diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bd793ac3..10ab30126 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,6 +164,8 @@ jobs: key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop lint and format run: just desktop-check + - name: Desktop unit tests + run: just desktop-test - name: Desktop build run: just desktop-build - name: Desktop smoke e2e @@ -172,6 +174,10 @@ jobs: run: just desktop-tauri-check env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri tests + run: just desktop-tauri-test + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Upload desktop e2e artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/desktop/package.json b/desktop/package.json index 36bf94d25..993144ca4 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,6 +11,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes", "format": "biome format --write .", + "test": "node --test 'src/**/*.test.mjs'", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build && playwright test", diff --git a/desktop/src-tauri/src/commands/social.rs b/desktop/src-tauri/src/commands/social.rs index 4efd38183..1782ca02b 100644 --- a/desktop/src-tauri/src/commands/social.rs +++ b/desktop/src-tauri/src/commands/social.rs @@ -1,14 +1,55 @@ -use nostr::EventId; +use std::collections::{HashMap, HashSet}; + +use nostr::{Event, EventId, Tag}; use tauri::State; use crate::{ app_state::AppState, events, - models::{ContactEntry, ContactListResponse, UserNoteInfo, UserNotesResponse}, + models::{ + ContactEntry, ContactListResponse, NoteReactionSummary, UserNoteInfo, UserNotesResponse, + }, nostr_convert, relay::{query_relay, submit_event, SubmitEventResponse}, }; +fn e_tag_id(tag: &Tag) -> Option<&String> { + let values = tag.as_slice(); + match (values.first().map(String::as_str), values.get(1)) { + (Some("e"), Some(id)) => Some(id), + _ => None, + } +} + +fn deleted_event_ids(events: &[Event]) -> HashSet { + events + .iter() + .flat_map(|event| event.tags.iter().filter_map(e_tag_id).cloned()) + .collect() +} + +fn last_event_tag_id(event: &Event) -> Option { + event.tags.iter().rev().find_map(e_tag_id).cloned() +} + +fn last_matching_event_tag_id(event: &Event, targets: &HashSet) -> Option { + event + .tags + .iter() + .rev() + .filter_map(e_tag_id) + .find(|id| targets.contains(*id)) + .cloned() +} + +fn reaction_emoji(event: &Event) -> String { + if event.content.is_empty() { + "+".to_string() + } else { + event.content.clone() + } +} + /// Publish a global kind:1 text note (NIP-01). #[tauri::command] pub async fn publish_note( @@ -44,11 +85,17 @@ pub async fn get_contact_list( ) .await?; - events - .first() - .map(nostr_convert::contact_list_from_event) - .transpose()? - .ok_or_else(|| "contact list not found".to_string()) + if let Some(event) = events.first() { + return nostr_convert::contact_list_from_event(event); + } + + Ok(ContactListResponse { + id: String::new(), + pubkey, + created_at: 0, + tags: Vec::new(), + content: String::new(), + }) } /// Replace the full contact list (kind:3, NIP-02). Read-before-write required @@ -73,6 +120,230 @@ pub async fn set_contact_list( submit_event(builder, &state).await } +/// Fetch global NIP-01 kind:1 notes without an author filter. +#[tauri::command] +pub async fn get_global_notes( + limit: Option, + before: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result { + let _ = before_id; + let mut filter = serde_json::Map::new(); + filter.insert("kinds".to_string(), serde_json::json!([1])); + filter.insert( + "limit".to_string(), + serde_json::json!(limit.unwrap_or(50).min(200)), + ); + if let Some(t) = before { + filter.insert("until".to_string(), serde_json::json!(t)); + } + + let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + Ok(nostr_convert::user_notes_from_events(&events)) +} + +fn validate_note_id(note_id: &str) -> Result<(), String> { + if note_id.len() == 64 && note_id.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(()) + } else { + Err("invalid note id".to_string()) + } +} + +/// Fetch a single NIP-01 kind:1 note by event id. +#[tauri::command] +pub async fn get_note( + note_id: String, + state: State<'_, AppState>, +) -> Result, String> { + validate_note_id(¬e_id)?; + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [1], + "ids": [note_id], + "limit": 1, + })], + ) + .await?; + + Ok(nostr_convert::user_notes_from_events(&events) + .notes + .into_iter() + .next()) +} + +const MAX_NOTE_IDS: usize = 200; + +/// Fetch and fold kind:7 reactions for visible Pulse notes. +#[tauri::command] +pub async fn get_note_reactions( + note_ids: Vec, + state: State<'_, AppState>, +) -> Result, String> { + if note_ids.is_empty() { + return Ok(Vec::new()); + } + if note_ids.len() > MAX_NOTE_IDS { + return Err(format!( + "too many note ids (max {MAX_NOTE_IDS}, got {})", + note_ids.len() + )); + } + for note_id in ¬e_ids { + validate_note_id(note_id)?; + } + + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [7], + "#e": note_ids, + "limit": 500, + })], + ) + .await?; + + let reaction_ids: Vec = events.iter().map(|event| event.id.to_hex()).collect(); + let deletion_events = if reaction_ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ + "kinds": [5], + "#e": reaction_ids, + "limit": 500, + })], + ) + .await? + }; + let deleted_reaction_ids = deleted_event_ids(&deletion_events); + + let targets: HashSet = note_ids.into_iter().collect(); + let mut by_note_and_emoji = HashMap::<(String, String), HashSet>::new(); + for event in events { + if deleted_reaction_ids.contains(&event.id.to_hex()) { + continue; + } + + let Some(target_id) = last_matching_event_tag_id(&event, &targets) else { + continue; + }; + + let emoji = reaction_emoji(&event); + by_note_and_emoji + .entry((target_id, emoji)) + .or_default() + .insert(event.pubkey.to_hex()); + } + + let mut summaries: Vec = by_note_and_emoji + .into_iter() + .map(|((note_id, emoji), pubkey_set)| { + let mut pubkeys: Vec = pubkey_set.into_iter().collect(); + pubkeys.sort(); + NoteReactionSummary { + note_id, + emoji, + count: pubkeys.len(), + pubkeys, + } + }) + .collect(); + summaries.sort_by(|left, right| { + left.note_id + .cmp(&right.note_id) + .then_with(|| left.emoji.cmp(&right.emoji)) + }); + + Ok(summaries) +} + +/// Fetch notes liked by a user, excluding deleted reaction events. +#[tauri::command] +pub async fn get_liked_notes( + author_pubkey: String, + limit: Option, + state: State<'_, AppState>, +) -> Result { + let cap = limit.unwrap_or(50).min(MAX_NOTE_IDS as u32) as usize; + let reaction_fetch_limit = (cap * 4).min(1000); + let mut reactions = query_relay( + &state, + &[serde_json::json!({ + "kinds": [7], + "authors": [author_pubkey], + "limit": reaction_fetch_limit, + })], + ) + .await?; + reactions.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + + let reaction_ids: Vec = reactions.iter().map(|event| event.id.to_hex()).collect(); + let deletions = if reaction_ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ + "kinds": [5], + "authors": [author_pubkey], + "#e": reaction_ids, + "limit": 500, + })], + ) + .await? + }; + let deleted_reaction_ids = deleted_event_ids(&deletions); + + let mut target_ids = Vec::::new(); + let mut target_liked_at = HashMap::::new(); + let mut seen_targets = HashSet::::new(); + for reaction in reactions { + if target_ids.len() >= cap { + break; + } + if deleted_reaction_ids.contains(&reaction.id.to_hex()) { + continue; + } + let Some(target_id) = last_event_tag_id(&reaction) else { + continue; + }; + if seen_targets.insert(target_id.clone()) { + target_liked_at.insert(target_id.clone(), reaction.created_at.as_secs() as i64); + target_ids.push(target_id); + } + } + + if target_ids.is_empty() { + return Ok(UserNotesResponse { + notes: Vec::new(), + next_cursor: None, + }); + } + + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [1], + "ids": target_ids, + "limit": cap, + })], + ) + .await?; + let mut response = nostr_convert::user_notes_from_events(&events); + response.notes.sort_by(|left, right| { + target_liked_at + .get(&right.id) + .unwrap_or(&0) + .cmp(target_liked_at.get(&left.id).unwrap_or(&0)) + }); + response.notes.truncate(cap); + Ok(response) +} + /// Maximum number of pubkeys per timeline request to keep filter size bounded. const MAX_TIMELINE_PUBKEYS: usize = 100; @@ -119,6 +390,7 @@ pub async fn get_notes_timeline( pubkey: ev.pubkey.to_hex(), created_at: ev.created_at.as_secs() as i64, content: ev.content.clone(), + tags: ev.tags.iter().map(|tag| tag.as_slice().to_vec()).collect(), }) .collect(); @@ -131,3 +403,74 @@ pub async fn get_notes_timeline( next_cursor: None, }) } + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn tag(values: &[&str]) -> Tag { + Tag::parse(values.iter().copied()).expect("parse tag") + } + + fn event(tags: Vec, content: &str) -> Event { + EventBuilder::new(Kind::Custom(7), content) + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign event") + } + + #[test] + fn e_tag_id_returns_only_event_tag_values() { + let e = tag(&["e", "a"]); + let p = tag(&["p", "b"]); + assert_eq!(e_tag_id(&e), Some(&"a".to_string())); + assert_eq!(e_tag_id(&p), None); + } + + #[test] + fn last_event_tag_id_uses_last_e_tag() { + let ev = event( + vec![tag(&["e", "a"]), tag(&["p", "x"]), tag(&["e", "b"])], + "+", + ); + assert_eq!(last_event_tag_id(&ev), Some("b".to_string())); + } + + #[test] + fn last_matching_event_tag_id_uses_last_visible_target() { + let ev = event( + vec![tag(&["e", "x"]), tag(&["e", "y"]), tag(&["e", "z"])], + "+", + ); + let targets = HashSet::from(["y".to_string(), "z".to_string()]); + assert_eq!( + last_matching_event_tag_id(&ev, &targets), + Some("z".to_string()) + ); + } + + #[test] + fn deleted_event_ids_collects_all_e_tags() { + let first = event(vec![tag(&["e", "a"]), tag(&["e", "b"])], ""); + let second = event(vec![tag(&["p", "ignored"]), tag(&["e", "c"])], ""); + let deleted = deleted_event_ids(&[first, second]); + assert!(deleted.contains("a")); + assert!(deleted.contains("b")); + assert!(deleted.contains("c")); + assert!(!deleted.contains("ignored")); + } + + #[test] + fn reaction_emoji_defaults_empty_content_to_plus() { + assert_eq!(reaction_emoji(&event(Vec::new(), "")), "+"); + assert_eq!(reaction_emoji(&event(Vec::new(), "πŸ”₯")), "πŸ”₯"); + } + + #[test] + fn validate_note_id_requires_hex64() { + assert!(validate_note_id(&"a".repeat(64)).is_ok()); + assert!(validate_note_id(&"g".repeat(64)).is_err()); + assert!(validate_note_id(&"a".repeat(63)).is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a8029911f..85df4cfd9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -453,15 +453,11 @@ pub fn run() { try_regenerate_nest(&app_handle); - // Pre-download voice models in the background so they're ready - // when the user starts their first huddle. Idempotent β€” no-op if - // already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS). if let Some(mgr) = huddle::models::global_model_manager() { mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); } - // Register PTT global shortcut (Ctrl+Space). // Non-fatal: huddle works without the shortcut (user can switch to VAD mode). #[cfg(desktop)] { @@ -614,6 +610,10 @@ pub fn run() { get_contact_list, set_contact_list, get_notes_timeline, + get_global_notes, + get_note, + get_note_reactions, + get_liked_notes, start_huddle, join_huddle, leave_huddle, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 5ce672401..02cd5af3a 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -51,6 +51,15 @@ pub struct UserNoteInfo { pub pubkey: String, pub created_at: i64, pub content: String, + pub tags: Vec>, +} + +#[derive(Serialize, Deserialize)] +pub struct NoteReactionSummary { + pub note_id: String, + pub emoji: String, + pub count: usize, + pub pubkeys: Vec, } #[derive(Serialize, Deserialize)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 6c3d00516..bf126aaea 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -465,6 +465,7 @@ pub fn user_notes_from_events(events: &[Event]) -> UserNotesResponse { pubkey: ev.pubkey.to_hex(), created_at: ev.created_at.as_secs() as i64, content: ev.content.clone(), + tags: ev.tags.iter().map(|tag| tag.as_slice().to_vec()).collect(), }) .collect(); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7f672adb3..fac3afc33 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -17,46 +17,13 @@ import { } from "@/features/channels/ui/BotActivityBar"; import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import { Button } from "@/shared/ui/button"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import type { useChannelFind } from "@/features/search/useChannelFind"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; -const THREAD_PANEL_DEFAULT_WIDTH_PX = 380; -const THREAD_PANEL_MIN_WIDTH_PX = 320; -const THREAD_PANEL_MAX_WIDTH_PX = 720; -const THREAD_PANEL_WIDTH_SESSION_KEY = "sprout.desktop.thread-panel-width"; - -function clampThreadPanelWidth(width: number): number { - return Math.max( - THREAD_PANEL_MIN_WIDTH_PX, - Math.min(THREAD_PANEL_MAX_WIDTH_PX, width), - ); -} - -function getInitialThreadPanelWidth(): number { - if (typeof window === "undefined") { - return THREAD_PANEL_DEFAULT_WIDTH_PX; - } - - try { - const raw = window.sessionStorage.getItem(THREAD_PANEL_WIDTH_SESSION_KEY); - if (!raw) { - return THREAD_PANEL_DEFAULT_WIDTH_PX; - } - - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return THREAD_PANEL_DEFAULT_WIDTH_PX; - } - - return clampThreadPanelWidth(parsed); - } catch { - return THREAD_PANEL_DEFAULT_WIDTH_PX; - } -} - type ChannelPaneProps = { activeChannel: Channel | null; activityAgents?: BotActivityAgent[]; @@ -174,66 +141,17 @@ export const ChannelPane = React.memo(function ChannelPane({ threadReplyTargetMessage, typingPubkeys, }: ChannelPaneProps) { - const [threadPanelWidthPx, setThreadPanelWidthPx] = React.useState( - () => getInitialThreadPanelWidth(), - ); + const { + canReset: canResetThreadPanelWidth, + onResetWidth: handleThreadPanelWidthReset, + onResizeStart: handleThreadPanelResizeStart, + widthPx: threadPanelWidthPx, + } = useThreadPanelWidth(); const timelineScrollRef = React.useRef(null); const composerWrapperRef = React.useRef(null); useComposerHeightPadding(timelineScrollRef, composerWrapperRef); - React.useEffect(() => { - if (typeof window === "undefined") { - return; - } - - try { - window.sessionStorage.setItem( - THREAD_PANEL_WIDTH_SESSION_KEY, - String(threadPanelWidthPx), - ); - } catch { - // Ignore storage failures and keep in-memory width for this session. - } - }, [threadPanelWidthPx]); - - const handleThreadPanelResizeStart = React.useCallback( - (event: React.PointerEvent) => { - event.preventDefault(); - - const startX = event.clientX; - const startWidth = threadPanelWidthPx; - const previousCursor = document.body.style.cursor; - const previousUserSelect = document.body.style.userSelect; - - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - - const handlePointerMove = (moveEvent: PointerEvent) => { - const deltaX = startX - moveEvent.clientX; - const nextWidth = clampThreadPanelWidth(startWidth + deltaX); - setThreadPanelWidthPx(nextWidth); - }; - - const handlePointerUp = () => { - document.body.style.cursor = previousCursor; - document.body.style.userSelect = previousUserSelect; - window.removeEventListener("pointermove", handlePointerMove); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp, { once: true }); - }, - [threadPanelWidthPx], - ); - - const handleThreadPanelWidthReset = React.useCallback(() => { - setThreadPanelWidthPx(THREAD_PANEL_DEFAULT_WIDTH_PX); - }, []); - - const canResetThreadPanelWidth = - threadPanelWidthPx !== THREAD_PANEL_DEFAULT_WIDTH_PX; - // Scope the edit target to the correct composer: if the message being edited // lives inside the open thread (thread head or a reply), show the editing UI // only in the thread panel; otherwise show it in the main channel composer. diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 0d6e7a99a..3e40b187a 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -159,6 +159,26 @@ function categoryLabelFor(category: FeedItemCategory) { : "Activity"; } +export function formatInboxTypeLabel(item: InboxItem) { + const channelName = item.channelLabel; + const channelSuffix = channelName ? ` in #${channelName}` : ""; + + if (item.item.channelType === "dm") { + return item.senderLabel ? `DM from ${item.senderLabel}` : "DM"; + } + + const category = item.categories[0] ?? item.item.category; + if (category === "mention") { + return channelName ? `Mentioned in #${channelName}` : "Mentioned"; + } + + if (category === "needs_action") { + return channelName ? `Needs action in #${channelName}` : "Needs action"; + } + + return `${feedHeadline(item.item)}${channelSuffix}`; +} + function categoryPriority(category: FeedItemCategory) { switch (category) { case "needs_action": diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 70da049c0..7875c3ab6 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -12,6 +12,7 @@ import type { InboxItem, InboxReply, } from "@/features/home/lib/inbox"; +import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; import { type InboxDisplayMessage, InboxMessageRow, @@ -193,8 +194,8 @@ export function InboxDetailPane({ : null; const channelContextName = contextChannelName ?? item.channelLabel; const contextLabel = channelContextName - ? `#${channelContextName}` - : item.categoryLabel; + ? formatInboxTypeLabel({ ...item, channelLabel: channelContextName }) + : formatInboxTypeLabel(item); const contextChannelId = item.item.channelId; const handleSelectReplyTarget = (message: InboxDisplayMessage) => { diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index e2a560b1d..0f8fe1048 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,8 @@ -import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; +import { + formatInboxTypeLabel, + type InboxFilter, + type InboxItem, +} from "@/features/home/lib/inbox"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -31,20 +35,22 @@ export function InboxListPane({ return (
-
- {FILTER_OPTIONS.map((option) => ( - - ))} +
+
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
@@ -68,6 +74,7 @@ export function InboxListPane({ {items.map((item) => { const isSelected = item.id === selectedId; const isDone = doneSet.has(item.id); + const typeLabel = formatInboxTypeLabel(item); return (
diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index cb99ce507..f0c89e86d 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -8,6 +8,8 @@ import { getUsersBatch, updateProfile, } from "@/shared/api/tauri"; +import { getContactList, setContactList } from "@/shared/api/social"; +import type { ContactListResponse } from "@/shared/api/socialTypes"; import type { Profile, UpdateProfileInput, @@ -16,6 +18,9 @@ import type { } from "@/shared/api/types"; export const profileQueryKey = ["profile"] as const; +export const contactListQueryKey = (pubkey: string) => + ["contact-list", pubkey] as const; +export const allPulseTimelinesQueryKey = ["pulse-timeline"] as const; export function useProfileQuery(enabled = true) { return useQuery({ @@ -26,6 +31,71 @@ export function useProfileQuery(enabled = true) { }); } +export function useContactListQuery(pubkey?: string) { + return useQuery({ + queryKey: contactListQueryKey(pubkey ?? ""), + // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey + queryFn: () => getContactList(pubkey!), + enabled: !!pubkey, + staleTime: 60_000, + gcTime: 5 * 60_000, + }); +} + +/** + * Follow mutation re-fetches the contact list inside the mutationFn to prevent + * race conditions when clicking Follow on multiple users quickly. The kind:3 + * contact list is a full-snapshot replaceable event β€” stale reads cause data loss. + */ +export function useFollowMutation(currentPubkey?: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (targetPubkey: string) => { + if (!currentPubkey) throw new Error("No identity"); + const current = await getContactList(currentPubkey); + if (current.contacts.some((c) => c.pubkey === targetPubkey)) { + return; + } + const updated = [...current.contacts, { pubkey: targetPubkey }]; + return setContactList(updated); + }, + onSuccess: () => { + if (currentPubkey) { + void queryClient.invalidateQueries({ + queryKey: contactListQueryKey(currentPubkey), + }); + void queryClient.invalidateQueries({ + queryKey: allPulseTimelinesQueryKey, + }); + } + }, + }); +} + +export function useUnfollowMutation(currentPubkey?: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (targetPubkey: string) => { + if (!currentPubkey) throw new Error("No identity"); + const current = await getContactList(currentPubkey); + const updated = current.contacts.filter((c) => c.pubkey !== targetPubkey); + return setContactList(updated); + }, + onSuccess: () => { + if (currentPubkey) { + void queryClient.invalidateQueries({ + queryKey: contactListQueryKey(currentPubkey), + }); + void queryClient.invalidateQueries({ + queryKey: allPulseTimelinesQueryKey, + }); + } + }, + }); +} + export function useUserProfileQuery(pubkey?: string) { return useQuery({ enabled: typeof pubkey === "string" && pubkey.length > 0, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 7c8670577..7c507a318 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -9,7 +9,12 @@ import { } from "lucide-react"; import { toast } from "sonner"; -import { useUserProfileQuery } from "@/features/profile/hooks"; +import { + useContactListQuery, + useFollowMutation, + useUnfollowMutation, + useUserProfileQuery, +} from "@/features/profile/hooks"; import { useRelayAgentsQuery, useManagedAgentsQuery, @@ -102,6 +107,9 @@ export function UserProfilePanel({ pubkey.toLowerCase() !== currentPubkey.toLowerCase(), ); const isArchived = useIsIdentityArchived(pubkey); + const contactListQuery = useContactListQuery(currentPubkey); + const followMutation = useFollowMutation(currentPubkey); + const unfollowMutation = useUnfollowMutation(currentPubkey); const archiveMutation = useArchiveIdentityMutation(); const unarchiveMutation = useUnarchiveIdentityMutation(); const { onOpenAgentSession } = useAgentSession(); @@ -121,6 +129,12 @@ export function UserProfilePanel({ const isSelf = currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isBot && Boolean(onOpenAgentSession); + const isFollowing = + !isSelf && + (contactListQuery.data?.contacts.some( + (contact) => contact.pubkey.toLowerCase() === pubkeyLower, + ) ?? + false); // NIP-IA gates. Button shows when ANY of: self path (acting on own pubkey), // admin path (current user is owner/admin in relay_members), or owner path @@ -323,6 +337,43 @@ export function UserProfilePanel({ {/* Actions */}
+ {!isSelf ? ( + isFollowing ? ( + + ) : ( + + ) + ) : null} {onOpenDm && !isSelf ? ( +
diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 5e459dbab..e5dd0f9bd 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -1,36 +1,116 @@ import { - ALargeSmall, - AtSign, - Bookmark, Bot, + Heart, MessageCircle, PenSquare, - Paperclip, - SmilePlus, SquareArrowOutUpRight, - ThumbsUp, } from "lucide-react"; import * as React from "react"; +import { ForumComposer } from "@/features/forum/ui/ForumComposer"; +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { useNoteByIdQuery } from "@/features/pulse/hooks"; +import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies"; import type { UserNote } from "@/shared/api/socialTypes"; -import type { UserProfileSummary } from "@/shared/api/types"; +import type { ChannelMember, UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +export type NoteCardActions = { + reply?: ( + note: UserNote, + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + share?: (note: UserNote) => void; + startDm?: (pubkey: string) => void; + toggleUpvote?: (note: UserNote, remove: boolean) => Promise; +}; + type NoteCardProps = { note: UserNote; profile?: UserProfileSummary | null; currentUserDisplayName?: string; currentUserProfile?: UserProfileSummary | null; + composerProfiles?: Record; + isReplySending?: boolean; + reactionCount?: number; + isUpvotePending?: boolean; + isUpvoted?: boolean; + members?: ChannelMember[]; isAgent?: boolean; isOwnNote: boolean; - isFollowing: boolean; - onFollow?: (pubkey: string) => void; - onReply?: (note: UserNote) => void; - onShare?: (note: UserNote) => void; - onUnfollow?: (pubkey: string) => void; + actions?: NoteCardActions; }; +function ReplyParentContext({ + parentId, + profiles, +}: { + parentId: string; + profiles: Record; +}) { + const parentNoteQuery = useNoteByIdQuery(parentId); + const parentNote = parentNoteQuery.data ?? null; + const cachedProfile = parentNote + ? profiles[parentNote.pubkey.toLowerCase()] + : null; + const parentProfileQuery = useUserProfileQuery( + parentNote && !cachedProfile ? parentNote.pubkey : undefined, + ); + const fetchedProfile = parentProfileQuery.data ?? null; + const parentDisplayName = parentNote + ? (cachedProfile?.displayName ?? + fetchedProfile?.displayName ?? + `${parentNote.pubkey.slice(0, 8)}...`) + : null; + const parentAvatarUrl = + cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; + const parentSnippet = parentNote ? noteSnippet(parentNote.content) : null; + + return ( +
+ {parentNote ? ( +
+ + + + + + + + : {parentSnippet || "No text"} + +
+ ) : parentNoteQuery.isLoading ? ( + "Loading reply context…" + ) : ( + "Replying to an unavailable note" + )} +
+ ); +} + function formatRelativeTime(unixSeconds: number): string { const now = Date.now() / 1_000; const diff = now - unixSeconds; @@ -51,57 +131,66 @@ export function NoteCard({ profile, currentUserDisplayName = "You", currentUserProfile, + composerProfiles = {}, isAgent, isOwnNote, - isFollowing, - onFollow, - onReply, - onShare, - onUnfollow, + isReplySending = false, + reactionCount = 0, + isUpvotePending = false, + isUpvoted = false, + members = [], + actions, }: NoteCardProps) { const displayName = profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`; const avatarUrl = profile?.avatarUrl ?? null; - const [isUpvoted, setIsUpvoted] = React.useState(false); - const [isBookmarked, setIsBookmarked] = React.useState(false); const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); - const [replyDraft, setReplyDraft] = React.useState(""); - const replyInputRef = React.useRef(null); const actionButtonClass = "inline-flex min-w-7 items-center gap-1.5 text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"; - const activeActionClass = "text-foreground"; + const activeActionClass = "text-primary"; const countPlaceholder = ; + const reactionCountLabel = + reactionCount > 0 ? ( + {reactionCount} + ) : null; const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null; - - React.useEffect(() => { - if (!isReplyComposerOpen) return; - replyInputRef.current?.focus(); - }, [isReplyComposerOpen]); - - const handleReplySubmit = (event: React.FormEvent) => { - event.preventDefault(); - if (replyDraft.trim().length === 0) return; - setReplyDraft(""); - setIsReplyComposerOpen(false); - }; + const replyParentId = getReplyParent(note); return (
-
- - {isAgent ? ( - - ) : null} -
+ + +
- - {displayName} - + + + {isAgent ? ( bot @@ -117,156 +206,119 @@ export function NoteCard({
+ {replyParentId ? ( + + ) : null} +
- - - - - {!isOwnNote ? ( - isFollowing ? ( + + - ) : ( + + {isUpvoted ? "Unlike" : "Like"} + + + - ) - ) : null} -
- -
- {isReplyComposerOpen ? ( -
- -
-