From d4e71c6f377537e4e6d6a615a2a09e048cb85c85 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 13 Apr 2026 07:46:44 -0400 Subject: [PATCH] feat(desktop): add scoped reply thread sidebars Keep forum and channel thread panes anchored to the selected branch, scope typing indicators to the active composer, and surface clearer relay errors when staging auth returns HTML. Made-with: Cursor --- crates/sprout-acp/src/main.rs | 46 +- crates/sprout-acp/src/relay.rs | 25 +- desktop/scripts/check-file-sizes.mjs | 3 +- desktop/src-tauri/src/relay.rs | 147 ++++- .../src/features/channels/ui/ChannelPane.tsx | 205 +++--- .../features/channels/ui/ChannelScreen.tsx | 218 ++++++- desktop/src/features/forum/hooks.ts | 3 +- desktop/src/features/forum/lib/threadTree.ts | 45 ++ .../src/features/forum/ui/ForumComposer.tsx | 34 + .../features/forum/ui/ForumThreadPanel.tsx | 597 +++++++++++------- .../forum/ui/ForumThreadPanelSections.tsx | 418 ++++++++++++ desktop/src/features/forum/ui/ForumView.tsx | 8 +- .../features/forum/useForumThreadTyping.ts | 177 ++++++ .../features/messages/lib/collapsedThreads.ts | 74 +++ .../src/features/messages/lib/threadBranch.ts | 80 +++ .../src/features/messages/lib/threading.ts | 15 + .../features/messages/ui/MessageComposer.tsx | 31 +- .../src/features/messages/ui/MessageRow.tsx | 70 +- .../messages/ui/MessageThreadSidebar.tsx | 149 +++++ .../features/messages/ui/MessageTimeline.tsx | 9 + .../messages/ui/TimelineMessageList.tsx | 9 + .../messages/useChannelThreadTyping.ts | 236 +++++++ .../features/messages/useTypingBroadcast.ts | 23 +- desktop/src/shared/api/relayClientSession.ts | 15 +- 24 files changed, 2226 insertions(+), 411 deletions(-) create mode 100644 desktop/src/features/forum/lib/threadTree.ts create mode 100644 desktop/src/features/forum/ui/ForumThreadPanelSections.tsx create mode 100644 desktop/src/features/forum/useForumThreadTyping.ts create mode 100644 desktop/src/features/messages/lib/collapsedThreads.ts create mode 100644 desktop/src/features/messages/lib/threadBranch.ts create mode 100644 desktop/src/features/messages/ui/MessageThreadSidebar.tsx create mode 100644 desktop/src/features/messages/useChannelThreadTyping.ts diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index 27cf7cad72..dbace019c8 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -21,7 +21,7 @@ use nostr::ToBech32; use pool::{ AgentPool, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState, }; -use queue::{EventQueue, QueuedEvent}; +use queue::{parse_thread_tags, EventQueue, QueuedEvent}; use relay::HarnessRelay; use sprout_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, @@ -805,7 +805,9 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashSet = HashSet::new(); + // Channels where an agent turn is in flight, mapped to the NIP-10 parent of + // the prompt's first event (for scoped kind:20002), or `None` if unknown. + let mut typing_channels: HashMap> = HashMap::new(); let mut presence_task: Option> = None; // ── Step 6d: Maintenance (slot refill + queue compaction) ──────────────── @@ -931,7 +933,9 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx)); + for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(ch, parent); + } } } @@ -962,7 +966,9 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx)); + for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(ch, parent); + } } // Borrow result_rx and join_set simultaneously via split-borrow helper. @@ -1272,7 +1278,9 @@ async fn tokio_main() -> Result<()> { } } // ── End mode gate ──────────────────────────────── - typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx)); + for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(ch, parent); + } } None => { tracing::warn!("relay event stream ended — requesting reconnect"); @@ -1294,7 +1302,9 @@ async fn tokio_main() -> Result<()> { let _ = result_rx; if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); - typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx)); + for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(ch, parent); + } } else if pool.any_idle() { dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); } else { @@ -1331,8 +1341,8 @@ async fn tokio_main() -> Result<()> { // Use try_publish (non-blocking) for typing indicators — // they're ephemeral and must not block the main loop during // relay reconnection (#35). - for &ch in &typing_channels { - if let Ok(event) = relay.build_typing_event(ch) { + for (ch, reply_parent) in &typing_channels { + if let Ok(event) = relay.build_typing_event(*ch, reply_parent.as_deref()) { if let Err(e) = relay.try_publish_event(event) { tracing::debug!("typing indicator dropped for {ch}: {e}"); } @@ -1381,7 +1391,9 @@ async fn tokio_main() -> Result<()> { { break; } - typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx)); + for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(ch, parent); + } } Some(PoolEvent::Panic(join_error)) => { tracing::error!("agent task panicked: {join_error}"); @@ -1401,7 +1413,9 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx)); + for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(ch, parent); + } } None => {} // relay/heartbeat/shutdown branches handled inline above } @@ -1540,7 +1554,7 @@ fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, -) -> Vec { +) -> Vec<(Uuid, Option)> { let mut dispatched_channels = Vec::new(); loop { let batch = match queue.flush_next() { @@ -1548,6 +1562,10 @@ fn dispatch_pending( None => break, }; let channel_id = batch.channel_id; + let reply_parent = batch + .events + .first() + .and_then(|be| parse_thread_tags(&be.event).parent_event_id); let affinity_hit = pool.has_session_for(channel_id); let agent = match pool.try_claim(Some(channel_id)) { Some(a) => a, @@ -1595,7 +1613,7 @@ fn dispatch_pending( cancel_tx: Some(cancel_tx), }, ); - dispatched_channels.push(channel_id); + dispatched_channels.push((channel_id, reply_parent)); } tracing::debug!( dispatched = dispatched_channels.len(), @@ -1779,7 +1797,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashSet, + typing_channels: &mut HashMap>, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -1863,7 +1881,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashSet, + typing_channels: &mut HashMap>, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index ec42f55c1d..94ee56673a 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -564,11 +564,30 @@ impl HarnessRelay { } /// Build a typing indicator event (kind:20002) for a channel. - pub fn build_typing_event(&self, channel_id: Uuid) -> Result { + /// + /// When `reply_parent_event_id_hex` is set, adds `["e", id, "", "reply"]` so + /// clients can align typing with the composer's NIP-10 parent (matches desktop). + pub fn build_typing_event( + &self, + channel_id: Uuid, + reply_parent_event_id_hex: Option<&str>, + ) -> Result { let h_tag = Tag::parse(&["h", &channel_id.to_string()]) .map_err(|e| RelayError::AuthFailed(e.to_string()))?; - let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", [h_tag]) - .sign_with_keys(&self.keys)?; + let event = match reply_parent_event_id_hex { + Some(pid) => { + let e_tag = Tag::parse(&["e", pid, "", "reply"]) + .map_err(|e| RelayError::AuthFailed(e.to_string()))?; + EventBuilder::new( + Kind::Custom(KIND_TYPING_INDICATOR as u16), + "", + [h_tag, e_tag], + ) + .sign_with_keys(&self.keys)? + } + None => EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", [h_tag]) + .sign_with_keys(&self.keys)?, + }; Ok(event) } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d33a485bd9..6bd3941925 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + ["src/shared/api/relayClientSession.ts", 805], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator (optional e tag) + fetchChannelHistoryBefore ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 570], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + persona pack commands ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests @@ -54,6 +54,7 @@ const overrides = new Map([ ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + ["src/features/forum/ui/ForumThreadPanel.tsx", 520], // two-column thread + branch panel + scoped typing hook wiring ["src/shared/api/types.ts", 540], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack ]); diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 14abec4c29..ea1a76a06e 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -1,6 +1,6 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use reqwest::Method; +use reqwest::{header::CONTENT_TYPE, Method, StatusCode}; use serde::de::DeserializeOwned; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -245,6 +245,77 @@ pub async fn relay_error_message(response: reqwest::Response) -> String { format!("relay returned {status}: {body}") } +fn summarize_response_body(body: &str) -> String { + let trimmed = body.trim(); + let preview: String = trimmed.chars().take(160).collect(); + if trimmed.chars().count() > 160 { + format!("{preview}...") + } else { + preview + } +} + +fn cloudflare_access_error(final_url: &str, body: &str) -> Option { + let lower_body = body.to_ascii_lowercase(); + if final_url.contains("cloudflareaccess.com") + || lower_body.contains("warp client") + || lower_body.contains("cdn-cgi/access/login") + { + return Some( + "Relay returned a Cloudflare Access login page instead of JSON. Connect via the Warp client or use a local relay (`just relay` + `just dev`).".to_string(), + ); + } + None +} + +fn decode_json_body( + status: StatusCode, + content_type: Option<&str>, + final_url: &str, + body: &str, + operation: &str, +) -> Result +where + T: DeserializeOwned, +{ + if let Some(message) = cloudflare_access_error(final_url, body) { + return Err(message); + } + + serde_json::from_str::(body).map_err(|error| { + let content_type = content_type.unwrap_or("unknown"); + let preview = summarize_response_body(body); + format!( + "{operation} failed to decode JSON (status {status}, content-type {content_type}). Response preview: {preview}. Error: {error}" + ) + }) +} + +async fn parse_json_response(response: reqwest::Response, operation: &str) -> Result +where + T: DeserializeOwned, +{ + let status = response.status(); + let final_url = response.url().to_string(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response + .text() + .await + .map_err(|error| format!("{operation} failed to read response body: {error}"))?; + + decode_json_body( + status, + content_type.as_deref(), + &final_url, + &body, + operation, + ) +} + pub async fn send_json_request(request: reqwest::RequestBuilder) -> Result where T: DeserializeOwned, @@ -258,10 +329,7 @@ where return Err(relay_error_message(response).await); } - response - .json::() - .await - .map_err(|error| format!("parse failed: {error}")) + parse_json_response(response, "Relay response").await } pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> { @@ -279,7 +347,14 @@ pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), #[cfg(test)] mod tests { - use super::{api_path, validate_api_path}; + use super::{api_path, cloudflare_access_error, decode_json_body, validate_api_path}; + use reqwest::StatusCode; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestPayload { + ok: bool, + } #[test] fn api_path_encodes_path_segments() { @@ -297,6 +372,60 @@ mod tests { fn validate_api_path_allows_encoded_segments() { assert!(validate_api_path("/api/tokens/..%2Fadmin").is_ok()); } + + #[test] + fn cloudflare_access_error_detects_login_redirect() { + let message = cloudflare_access_error( + "https://sqprod.cloudflareaccess.com/cdn-cgi/access/login/example", + "Please authenticate via the warp client", + ); + assert!(message.is_some()); + } + + #[test] + fn decode_json_body_parses_valid_json() { + let parsed: TestPayload = decode_json_body( + StatusCode::OK, + Some("application/json"), + "https://sprout.example/api/channels", + r#"{"ok":true}"#, + "Relay response", + ) + .expect("valid JSON should parse"); + + assert_eq!(parsed, TestPayload { ok: true }); + } + + #[test] + fn decode_json_body_surfaces_cloudflare_hint() { + let error = decode_json_body::( + StatusCode::OK, + Some("text/html"), + "https://sqprod.cloudflareaccess.com/cdn-cgi/access/login/example", + "Please authenticate via the warp client", + "Relay response", + ) + .expect_err("Cloudflare Access HTML should not parse"); + + assert!(error.contains("Cloudflare Access")); + assert!(error.contains("Warp client")); + } + + #[test] + fn decode_json_body_includes_preview_for_non_json() { + let error = decode_json_body::( + StatusCode::OK, + Some("text/plain"), + "https://sprout.example/api/channels", + "not json", + "Relay response", + ) + .expect_err("plain text should fail"); + + assert!(error.contains("failed to decode JSON")); + assert!(error.contains("content-type text/plain")); + assert!(error.contains("Response preview: not json")); + } } // ── Signed-event submission ────────────────────────────────────────────────── @@ -352,10 +481,8 @@ pub async fn submit_event( return Err(relay_error_message(response).await); } - let result: SubmitEventResponse = response - .json() - .await - .map_err(|e| format!("failed to parse response: {e}"))?; + let result: SubmitEventResponse = + parse_json_response(response, "Relay submit response").await?; if !result.accepted { return Err(format!("relay rejected event: {}", result.message)); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index e8693019c7..e9cfcaf10e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,6 +1,8 @@ import * as React from "react"; +import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { MessageThreadSidebar } from "@/features/messages/ui/MessageThreadSidebar"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import type { TimelineMessage } from "@/features/messages/types"; @@ -10,6 +12,7 @@ import type { Channel } from "@/shared/api/types"; type ChannelPaneProps = { activeChannel: Channel | null; currentPubkey?: string; + collapsedThreadSummaryByMessageId?: Map; editTarget?: { author: string; body: string; @@ -21,11 +24,15 @@ type ChannelPaneProps = { isSending: boolean; isTimelineLoading: boolean; messages: TimelineMessage[]; + activeReplyTargetId: string | null; onCancelEdit?: () => void; onCancelReply: () => void; + onCancelThreadReply: () => void; + onCloseThread: () => void; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditSave?: (content: string) => Promise; + onOpenThread?: (message: TimelineMessage) => void; onReply: (message: TimelineMessage) => void; onSend: ( content: string, @@ -33,6 +40,12 @@ type ChannelPaneProps = { mediaTags?: string[][], ) => Promise; onTargetReached?: (messageId: string) => void; + onThreadReply: (message: TimelineMessage) => void; + onThreadSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -41,14 +54,18 @@ type ChannelPaneProps = { /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; profiles?: UserProfileLookup; - replyTargetId: string | null; replyTargetMessage: TimelineMessage | null; targetMessageId: string | null; + threadHeadMessage: TimelineMessage | null; + threadMessages: TimelineMessage[]; + threadReplyTargetMessage: TimelineMessage | null; + threadTypingPubkeys: string[]; typingPubkeys: string[]; }; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, + activeReplyTargetId, currentPubkey, editTarget = null, fetchOlder, @@ -57,97 +74,131 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending, isTimelineLoading, messages, + collapsedThreadSummaryByMessageId, onCancelEdit, onCancelReply, + onCancelThreadReply, + onCloseThread, onDelete, onEdit, onEditSave, + onOpenThread, onReply, onSend, onTargetReached, + onThreadReply, + onThreadSend, onToggleReaction, personaLookup, profiles, - replyTargetId, replyTargetMessage, targetMessageId, + threadHeadMessage, + threadMessages, + threadReplyTargetMessage, + threadTypingPubkeys, typingPubkeys, }: ChannelPaneProps) { + const composerDisabled = + !activeChannel || + !activeChannel.isMember || + activeChannel.archivedAt !== null || + activeChannel.channelType === "forum" || + isSending; + return ( - <> - - - - +
+
+ + + +
+ + {threadHeadMessage ? ( + + ) : null} +
); }); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 97434bda93..6e89b569ef 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -27,12 +27,15 @@ import { collectMessageAuthorPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; +import { buildCollapsedThreadTimeline } from "@/features/messages/lib/collapsedThreads"; import { getChannelIdFromTags, getThreadReference, } from "@/features/messages/lib/threading"; +import { collectThreadBranch } from "@/features/messages/lib/threadBranch"; +import type { TimelineMessage } from "@/features/messages/types"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; -import { useChannelTyping } from "@/features/messages/useChannelTyping"; +import { useChannelThreadTyping } from "@/features/messages/useChannelThreadTyping"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; @@ -83,6 +86,12 @@ export function ChannelScreen({ const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [replyTargetId, setReplyTargetId] = React.useState(null); const [editTargetId, setEditTargetId] = React.useState(null); + const [focusedThreadHeadId, setFocusedThreadHeadId] = React.useState< + string | null + >(null); + const [threadReplyTargetId, setThreadReplyTargetId] = React.useState< + string | null + >(null); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; @@ -134,14 +143,61 @@ export function ChannelScreen({ () => resolvedMessages[resolvedMessages.length - 1] ?? null, [resolvedMessages], ); - const typingPubkeys = useChannelTyping( - activeChannel, - currentPubkey, - latestMessageEvent, - ); + const openThreadMessageIds = React.useMemo(() => { + if (!focusedThreadHeadId) { + return null; + } + + const childrenByParent = new Map(); + for (const event of resolvedMessages) { + const thread = getThreadReference(event.tags); + if (!thread.parentId) { + continue; + } + const children = childrenByParent.get(thread.parentId) ?? []; + children.push(event.id); + childrenByParent.set(thread.parentId, children); + } + + const ids = new Set([focusedThreadHeadId]); + const stack = [focusedThreadHeadId]; + while (stack.length > 0) { + const parentId = stack.pop(); + if (!parentId) { + continue; + } + const children = childrenByParent.get(parentId) ?? []; + for (const childId of children) { + if (ids.has(childId)) { + continue; + } + ids.add(childId); + stack.push(childId); + } + } + + return ids; + }, [focusedThreadHeadId, resolvedMessages]); + const { mainComposerTypingPubkeys, threadComposerTypingPubkeys } = + useChannelThreadTyping( + activeChannel, + currentPubkey, + latestMessageEvent, + openThreadMessageIds, + ); const messageProfilePubkeys = React.useMemo( - () => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])], - [messageAuthorPubkeys, typingPubkeys], + () => [ + ...new Set([ + ...messageAuthorPubkeys, + ...mainComposerTypingPubkeys, + ...threadComposerTypingPubkeys, + ]), + ], + [ + messageAuthorPubkeys, + mainComposerTypingPubkeys, + threadComposerTypingPubkeys, + ], ); const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { enabled: messageProfilePubkeys.length > 0, @@ -213,16 +269,44 @@ export function ChannelScreen({ resolvedMessages, ], ); + const messageById = React.useMemo( + () => new Map(timelineMessages.map((message) => [message.id, message])), + [timelineMessages], + ); const replyTargetMessage = React.useMemo( - () => - timelineMessages.find((message) => message.id === replyTargetId) ?? null, - [replyTargetId, timelineMessages], + () => (replyTargetId ? (messageById.get(replyTargetId) ?? null) : null), + [messageById, replyTargetId], ); const editTargetMessage = React.useMemo( - () => - timelineMessages.find((message) => message.id === editTargetId) ?? null, - [editTargetId, timelineMessages], + () => (editTargetId ? (messageById.get(editTargetId) ?? null) : null), + [editTargetId, messageById], ); + const threadBranch = React.useMemo( + () => collectThreadBranch(timelineMessages, focusedThreadHeadId), + [focusedThreadHeadId, timelineMessages], + ); + const { + summaryByMessageId: collapsedThreadSummaryByMessageId, + visibleMessages, + } = React.useMemo( + () => buildCollapsedThreadTimeline(timelineMessages), + [timelineMessages], + ); + const { headMessage: threadHeadMessage, messageIds: threadMessageIds } = + threadBranch; + const threadMessages = threadBranch.messages; + const threadReplyTargetMessage = React.useMemo(() => { + if ( + !threadReplyTargetId || + !threadHeadMessage || + threadReplyTargetId === threadHeadMessage.id || + !threadMessageIds.has(threadReplyTargetId) + ) { + return null; + } + + return messageById.get(threadReplyTargetId) ?? null; + }, [messageById, threadHeadMessage, threadMessageIds, threadReplyTargetId]); const { handleCancelEdit, @@ -230,7 +314,6 @@ export function ChannelScreen({ handleDelete, handleEdit, handleEditSave, - handleReply, handleSend, handleToggleReaction, } = useChannelPaneHandlers({ @@ -243,6 +326,63 @@ export function ChannelScreen({ setReplyTargetId, toggleReactionMutation, }); + const handleCloseThread = React.useCallback(() => { + setFocusedThreadHeadId(null); + setThreadReplyTargetId(null); + }, []); + const handleReply = React.useCallback((message: TimelineMessage) => { + if (message.depth < 1) { + setFocusedThreadHeadId(null); + setThreadReplyTargetId(null); + setReplyTargetId((current) => + current === message.id ? null : message.id, + ); + setEditTargetId(null); + return; + } + + setReplyTargetId(null); + setEditTargetId(null); + setFocusedThreadHeadId(message.id); + setThreadReplyTargetId(message.id); + }, []); + const handleOpenThread = React.useCallback((message: TimelineMessage) => { + setReplyTargetId(null); + setEditTargetId(null); + setFocusedThreadHeadId(message.id); + setThreadReplyTargetId(message.id); + }, []); + const handleThreadReply = React.useCallback((message: TimelineMessage) => { + setThreadReplyTargetId(message.id); + }, []); + const handleCancelThreadReply = React.useCallback(() => { + setThreadReplyTargetId((current) => focusedThreadHeadId ?? current); + }, [focusedThreadHeadId]); + const handleThreadSend = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + const parentEventId = threadReplyTargetId ?? focusedThreadHeadId; + if (!parentEventId) { + return; + } + + await sendMessageMutation.mutateAsync({ + content, + mentionPubkeys, + mediaTags, + parentEventId, + }); + setThreadReplyTargetId(focusedThreadHeadId ?? parentEventId); + }, + [focusedThreadHeadId, sendMessageMutation, threadReplyTargetId], + ); + const activeReplyTargetId = + focusedThreadHeadId && threadReplyTargetId !== focusedThreadHeadId + ? threadReplyTargetId + : replyTargetId; const canReact = activeChannel !== null && activeChannel.archivedAt === null; const effectiveToggleReaction = React.useMemo( @@ -275,6 +415,8 @@ export function ChannelScreen({ (_channelId: string | null) => { setReplyTargetId(null); setEditTargetId(null); + setFocusedThreadHeadId(null); + setThreadReplyTargetId(null); }, [], ); @@ -296,12 +438,38 @@ export function ChannelScreen({ if (editTargetId && !editTargetMessage) { setEditTargetId(null); } - }, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]); + if (focusedThreadHeadId && !threadHeadMessage) { + setFocusedThreadHeadId(null); + setThreadReplyTargetId(null); + } else if ( + threadReplyTargetId && + focusedThreadHeadId && + threadReplyTargetId !== focusedThreadHeadId && + !threadReplyTargetMessage + ) { + setThreadReplyTargetId(focusedThreadHeadId); + } + }, [ + editTargetId, + editTargetMessage, + focusedThreadHeadId, + replyTargetId, + replyTargetMessage, + threadHeadMessage, + threadReplyTargetId, + threadReplyTargetMessage, + ]); React.useEffect(() => { resetRequestedAncestors(activeChannelId); }, [activeChannelId, resetRequestedAncestors]); + React.useEffect(() => { + if (threadHeadMessage && !threadReplyTargetId) { + setThreadReplyTargetId(threadHeadMessage.id); + } + }, [threadHeadMessage, threadReplyTargetId]); + React.useEffect(() => { if (!activeChannel || activeChannel.channelType === "forum") { return; @@ -437,6 +605,7 @@ export function ChannelScreen({ }> ) diff --git a/desktop/src/features/forum/hooks.ts b/desktop/src/features/forum/hooks.ts index d70c906f81..e77c1aa932 100644 --- a/desktop/src/features/forum/hooks.ts +++ b/desktop/src/features/forum/hooks.ts @@ -129,6 +129,7 @@ export function useCreateForumReplyMutation(channel: Channel | null) { }: { content: string; parentEventId: string; + rootEventId: string; mentionPubkeys?: string[]; }) => { if (!channel) { @@ -147,7 +148,7 @@ export function useCreateForumReplyMutation(channel: Channel | null) { onSuccess: (_data, variables) => { if (channel) { void queryClient.invalidateQueries({ - queryKey: forumThreadQueryKey(channel.id, variables.parentEventId), + queryKey: forumThreadQueryKey(channel.id, variables.rootEventId), }); void queryClient.invalidateQueries({ queryKey: forumPostsQueryKey(channel.id), diff --git a/desktop/src/features/forum/lib/threadTree.ts b/desktop/src/features/forum/lib/threadTree.ts new file mode 100644 index 0000000000..6906020c01 --- /dev/null +++ b/desktop/src/features/forum/lib/threadTree.ts @@ -0,0 +1,45 @@ +import type { ThreadReply } from "@/shared/api/types"; + +export type ThreadNode = ThreadReply & { children: ThreadNode[] }; + +/** + * Groups flat thread replies into a tree by NIP-10 parent (`parentEventId`), + * attaching orphans to `rootPostId`. + */ +export function buildReplyTree( + replies: ThreadReply[], + rootPostId: string, +): ThreadNode[] { + const byParent = new Map(); + for (const reply of replies) { + const pid = reply.parentEventId ?? rootPostId; + const list = byParent.get(pid) ?? []; + list.push(reply); + byParent.set(pid, list); + } + + function nest(parentId: string): ThreadNode[] { + return (byParent.get(parentId) ?? []).map((reply) => ({ + ...reply, + children: nest(reply.eventId), + })); + } + + return nest(rootPostId); +} + +export function findNodeInTree( + nodes: ThreadNode[], + id: string, +): ThreadNode | null { + for (const n of nodes) { + if (n.eventId === id) { + return n; + } + const inner = findNodeInTree(n.children, id); + if (inner) { + return inner; + } + } + return null; +} diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index c376404df0..eef26a3056 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete"; import { MentionAutocomplete } from "@/features/messages/ui/MentionAutocomplete"; import { Button } from "@/shared/ui/button"; @@ -15,6 +16,11 @@ type ForumComposerProps = { disabled?: boolean; isSending?: boolean; onCancel?: () => void; + /** When set, shows a compact “replying to …” strip with cancel. */ + replyToAuthorLabel?: string | null; + onCancelReplyTo?: () => void; + /** Passed to kind 20002 as the `e` reply target — same as the next message parent. */ + typingReplyParentId?: string | null; onSubmit: (content: string, mentionPubkeys: string[]) => void; }; @@ -25,6 +31,9 @@ export function ForumComposer({ disabled, isSending, onCancel, + replyToAuthorLabel = null, + onCancelReplyTo, + typingReplyParentId = null, onSubmit, }: ForumComposerProps) { const [value, setValue] = React.useState(""); @@ -32,6 +41,7 @@ export function ForumComposer({ const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); + const notifyTyping = useTypingBroadcast(channelId, typingReplyParentId); function handleSubmit(event: React.FormEvent) { event.preventDefault(); @@ -49,6 +59,9 @@ export function ForumComposer({ setValue(next); mentions.updateMentionQuery(next, event.target.selectionStart); channelLinks.updateChannelQuery(next, event.target.selectionStart); + if (next.trim().length > 0) { + notifyTyping(); + } } function handleKeyDown(event: React.KeyboardEvent) { @@ -105,6 +118,27 @@ export function ForumComposer({ return (
+ {replyToAuthorLabel ? ( +
+ + Replying to{" "} + + {replyToAuthorLabel} + + + {onCancelReplyTo ? ( + + ) : null} +
+ ) : null} { const textarea = textareaRef.current; diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index c92682456f..6433ba7202 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -1,4 +1,10 @@ -import { ArrowLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react"; +import { + ArrowLeft, + MessageSquare, + MoreHorizontal, + Trash2, + X, +} from "lucide-react"; import * as React from "react"; import { @@ -10,16 +16,6 @@ import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -30,18 +26,36 @@ import { import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; +import { + buildReplyTree, + findNodeInTree, + type ThreadNode, +} from "../lib/threadTree"; import { formatRelativeTime } from "../lib/time"; +import { useForumThreadTyping } from "../useForumThreadTyping"; import { ForumComposer } from "./ForumComposer"; +import { + BranchReplyList, + DeleteConfirmDialog, + ForumTypingStrip, + MainColumnReplyRow, +} from "./ForumThreadPanelSections"; type ForumThreadPanelProps = { thread: ForumThreadResponse | undefined; isLoading: boolean; isSendingReply: boolean; channelId: string; + /** Thread root (forum post) event id — used for reply tree and cache keys. */ + threadRootEventId: string; currentPubkey?: string; profiles?: UserProfileLookup; onBack: () => void; - onReply: (content: string, mentionPubkeys: string[]) => void; + onReply: ( + content: string, + mentionPubkeys: string[], + parentEventId: string, + ) => void; onDeletePost?: (eventId: string) => void; onDeleteReply?: (eventId: string) => void; onTargetReached?: (eventId: string) => void; @@ -50,138 +64,12 @@ type ForumThreadPanelProps = { targetEventId?: string | null; }; -function canDeleteReply( - reply: ThreadReply, - currentPubkey: string | undefined, -): boolean { - if (!currentPubkey) return false; - return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase(); -} - -function DeleteConfirmDialog({ - open, - onOpenChange, - onConfirm, - label, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - label: string; -}) { - return ( - - - - Delete {label}? - - This will permanently delete this {label} and cannot be undone. - - - - - - - - - - - - - ); -} - -function ReplyRow({ - reply, - currentPubkey, - profiles, - channelNames, - onDelete, -}: { - reply: ThreadReply; - currentPubkey?: string; - profiles?: UserProfileLookup; - channelNames?: string[]; - onDelete?: (eventId: string) => void; -}) { - const [isDeleteOpen, setIsDeleteOpen] = React.useState(false); - const replyAuthorLabel = resolveUserLabel({ - pubkey: reply.pubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }); - const replyAvatarUrl = - profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null; - const showDelete = onDelete && canDeleteReply(reply, currentPubkey); - const replyMentionNames = resolveMentionNames(reply.tags, profiles); - - return ( -
-
- - - {replyAuthorLabel} - - - {formatRelativeTime(reply.createdAt)} - - - {showDelete ? ( -
- - - - - - setIsDeleteOpen(true)} - > - - Delete reply - - - - onDelete(reply.eventId)} - onOpenChange={setIsDeleteOpen} - open={isDeleteOpen} - /> -
- ) : null} -
-
- -
-
- ); -} - export function ForumThreadPanel({ thread, isLoading, isSendingReply, channelId, + threadRootEventId, currentPubkey, profiles, onBack, @@ -195,6 +83,17 @@ export function ForumThreadPanel({ }: ForumThreadPanelProps) { const scrollRef = React.useRef(null); const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false); + const [focusedBranchId, setFocusedBranchId] = React.useState( + null, + ); + const [branchReplyParentId, setBranchReplyParentId] = React.useState< + string | null + >(null); + /** When set, main composer replies to this event id (else thread root). */ + const [mainReplyParentId, setMainReplyParentId] = React.useState< + string | null + >(null); + const { channels } = useChannelNavigation(); const channelNames = React.useMemo( () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), @@ -218,6 +117,78 @@ export function ForumThreadPanel({ onTargetReached?.(targetEventId); }, [onTargetReached, targetEventId, thread]); + const tree = React.useMemo(() => { + if (!thread) { + return []; + } + return buildReplyTree(thread.replies, threadRootEventId); + }, [thread, threadRootEventId]); + + const branchNodes = React.useMemo(() => { + if (!focusedBranchId || tree.length === 0) { + return []; + } + const stack = [...tree]; + while (stack.length > 0) { + const n = stack.pop(); + if (!n) break; + if (n.eventId === focusedBranchId) { + return n.children; + } + for (const c of n.children) { + stack.push(c); + } + } + return []; + }, [focusedBranchId, tree]); + + const focusedHead = React.useMemo(() => { + if (!focusedBranchId || !thread) { + return null; + } + const findIn = (nodes: ThreadNode[]): ThreadNode | null => { + for (const n of nodes) { + if (n.eventId === focusedBranchId) { + return n; + } + const inner = findIn(n.children); + if (inner) { + return inner; + } + } + return null; + }; + return findIn(tree); + }, [focusedBranchId, thread, tree]); + + React.useEffect(() => { + if (focusedBranchId) { + setBranchReplyParentId((prev) => prev ?? focusedBranchId); + } else { + setBranchReplyParentId(null); + } + }, [focusedBranchId]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset reply target when opening a different thread + React.useEffect(() => { + setMainReplyParentId(null); + }, [threadRootEventId]); + + const effectiveMainParentId = mainReplyParentId ?? threadRootEventId; + const branchTypingParentForHook = + focusedBranchId !== null ? (branchReplyParentId ?? focusedBranchId) : null; + + const { branchComposerTypingPubkeys, mainComposerTypingPubkeys } = + useForumThreadTyping( + channelId, + currentPubkey, + Boolean(!isLoading && thread), + threadRootEventId, + effectiveMainParentId, + focusedBranchId !== null, + branchTypingParentForHook, + ); + if (isLoading || !thread) { return (
@@ -252,8 +223,54 @@ export function ForumThreadPanel({ const postAvatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null; + /** Open the right thread column and reply under this comment (Slack-style: Reply opens the panel). */ + const handleOpenThreadForReply = (reply: ThreadReply) => { + setFocusedBranchId(reply.eventId); + setBranchReplyParentId(reply.eventId); + setMainReplyParentId(null); + }; + + const handlePressReplyBranch = (reply: ThreadReply) => { + setBranchReplyParentId(reply.eventId); + }; + + const branchHeadLabel = focusedHead + ? resolveUserLabel({ + pubkey: focusedHead.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : ""; + + const mainReplyParent = mainReplyParentId + ? findNodeInTree(tree, mainReplyParentId) + : null; + const mainReplyAuthorLabel = mainReplyParent + ? resolveUserLabel({ + pubkey: mainReplyParent.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : null; + + const branchReplyParentNode = + branchReplyParentId && branchReplyParentId !== focusedBranchId + ? findNodeInTree(tree, branchReplyParentId) + : null; + const branchReplyAuthorLabel = + branchReplyParentNode && focusedBranchId + ? resolveUserLabel({ + pubkey: branchReplyParentNode.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : null; + return ( -
+
-
- {/* Original post */} -
-
- -
- - {postAuthorLabel} - - - {formatRelativeTime(post.createdAt)} - -
+
+
+
+
+
+ +
+ + {postAuthorLabel} + + + {formatRelativeTime(post.createdAt)} + +
- {canDeletePost && onDeletePost ? ( -
- - - - - - setIsDeletePostOpen(true)} - > - - Delete post - - - - onDeletePost(post.eventId)} - onOpenChange={setIsDeletePostOpen} - open={isDeletePostOpen} + {canDeletePost && onDeletePost ? ( +
+ + + + + + setIsDeletePostOpen(true)} + > + + Delete post + + + + onDeletePost(post.eventId)} + onOpenChange={setIsDeletePostOpen} + open={isDeletePostOpen} + /> +
+ ) : null} +
+
+
- ) : null} -
-
- -
-
- - {/* Replies header */} -
- - {replies.length} {replies.length === 1 ? "reply" : "replies"} -
- - {/* Reply list */} -
- {replies.map((reply) => ( - - ))} - - {replies.length === 0 ? ( -
- No replies yet. Be the first to respond.
- ) : null} -
-
- {/* Reply composer */} -
- +
+ + {replies.length} {replies.length === 1 ? "reply" : "replies"} +
+ +
+ {tree.map((node) => ( + + ))} + + {replies.length === 0 ? ( +
+ No replies yet. Be the first to respond. +
+ ) : null} +
+
+ +
+ {mainComposerTypingPubkeys.length > 0 ? ( + + ) : null} + { + setMainReplyParentId(null); + } + : undefined + } + onSubmit={(content, mentionPubkeys) => { + onReply(content, mentionPubkeys, effectiveMainParentId); + setMainReplyParentId(null); + }} + placeholder="Reply to this post..." + replyToAuthorLabel={mainReplyAuthorLabel} + submitLabel="Reply" + typingReplyParentId={effectiveMainParentId} + /> +
+
+ + {focusedBranchId && focusedHead ? ( + + ) : null}
); diff --git a/desktop/src/features/forum/ui/ForumThreadPanelSections.tsx b/desktop/src/features/forum/ui/ForumThreadPanelSections.tsx new file mode 100644 index 0000000000..961ca3709e --- /dev/null +++ b/desktop/src/features/forum/ui/ForumThreadPanelSections.tsx @@ -0,0 +1,418 @@ +import { + CornerUpLeft, + MoreHorizontal, + PanelRightClose, + Trash2, +} from "lucide-react"; +import * as React from "react"; + +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { ThreadReply } from "@/shared/api/types"; +import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Markdown } from "@/shared/ui/markdown"; + +import type { ThreadNode } from "../lib/threadTree"; +import { formatRelativeTime } from "../lib/time"; + +export function canDeleteReply( + reply: ThreadReply, + currentPubkey: string | undefined, +): boolean { + if (!currentPubkey) return false; + return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase(); +} + +export function DeleteConfirmDialog({ + open, + onOpenChange, + onConfirm, + label, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + label: string; +}) { + return ( + + + + Delete {label}? + + This will permanently delete this {label} and cannot be undone. + + + + + + + + + + + + + ); +} + +type ReplyRowProps = { + node: ThreadNode; + depth: number; + currentPubkey?: string; + profiles?: UserProfileLookup; + channelNames?: string[]; + onDelete?: (eventId: string) => void; + onPressReply: (reply: ThreadReply) => void; + onOpenBranch: (reply: ThreadReply) => void; + showThreadAction: boolean; +}; + +/** Nested replies under a parent — used in the branch column only. */ +export function ReplyRow({ + node, + depth, + currentPubkey, + profiles, + channelNames, + onDelete, + onPressReply, + onOpenBranch, + showThreadAction, +}: ReplyRowProps) { + const [isDeleteOpen, setIsDeleteOpen] = React.useState(false); + const reply = node; + const replyAuthorLabel = resolveUserLabel({ + pubkey: reply.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const replyAvatarUrl = + profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null; + const showDelete = onDelete && canDeleteReply(reply, currentPubkey); + const replyMentionNames = resolveMentionNames(reply.tags, profiles); + const visibleDepth = Math.min(depth, 6); + const indentPx = visibleDepth * 12; + + const hasChildren = node.children.length > 0; + + return ( +
+
+
+ + + {replyAuthorLabel} + + + {formatRelativeTime(reply.createdAt)} + + +
+ + {showThreadAction ? ( + + ) : null} + + {showDelete ? ( + + + + + + setIsDeleteOpen(true)} + > + + Delete reply + + + + ) : null} + {showDelete ? ( + onDelete?.(reply.eventId)} + onOpenChange={setIsDeleteOpen} + open={isDeleteOpen} + /> + ) : null} +
+
+
+ +
+
+ {node.children.map((child) => ( + + ))} +
+ ); +} + +/** Single top-level reply in the main column — nested replies appear in the branch panel. */ +export function MainColumnReplyRow({ + node, + currentPubkey, + profiles, + channelNames, + onDelete, + onPressReply, + onOpenBranch, +}: { + node: ThreadNode; + currentPubkey?: string; + profiles?: UserProfileLookup; + channelNames?: string[]; + onDelete?: (eventId: string) => void; + onPressReply: (reply: ThreadReply) => void; + onOpenBranch: (reply: ThreadReply) => void; +}) { + const [isDeleteOpen, setIsDeleteOpen] = React.useState(false); + const reply = node; + const replyAuthorLabel = resolveUserLabel({ + pubkey: reply.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const replyAvatarUrl = + profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null; + const showDelete = onDelete && canDeleteReply(reply, currentPubkey); + const replyMentionNames = resolveMentionNames(reply.tags, profiles); + const hasChildren = node.children.length > 0; + + return ( +
+
+
+ + + {replyAuthorLabel} + + + {formatRelativeTime(reply.createdAt)} + + +
+ + + + {showDelete ? ( + + + + + + setIsDeleteOpen(true)} + > + + Delete reply + + + + ) : null} + {showDelete ? ( + onDelete?.(reply.eventId)} + onOpenChange={setIsDeleteOpen} + open={isDeleteOpen} + /> + ) : null} +
+
+
+ +
+
+
+ ); +} + +/** Renders a flat list of nodes (no recursion into children) — for the branch column. */ +export function BranchReplyList({ + nodes, + currentPubkey, + profiles, + channelNames, + onDelete, + onPressReply, +}: { + nodes: ThreadNode[]; + currentPubkey?: string; + profiles?: UserProfileLookup; + channelNames?: string[]; + onDelete?: (eventId: string) => void; + onPressReply: (reply: ThreadReply) => void; +}) { + return ( +
+ {nodes.map((node) => ( + {}} + onPressReply={onPressReply} + node={node} + profiles={profiles} + showThreadAction={false} + /> + ))} +
+ ); +} + +export function ForumTypingStrip({ + currentPubkey, + profiles, + typingPubkeys, +}: { + currentPubkey?: string; + profiles?: UserProfileLookup; + typingPubkeys: string[]; +}) { + if (typingPubkeys.length === 0) { + return null; + } + + const names = typingPubkeys.map((pubkey) => + resolveUserLabel({ + pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }), + ); + const label = + names.length === 1 + ? `${names[0]} is typing...` + : names.length === 2 + ? `${names[0]} and ${names[1]} are typing...` + : `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`; + + return ( +
+ + {label} + +
+ ); +} diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 3b27cd86ed..e58024e9e9 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -114,6 +114,7 @@ export function ForumView({ return ( { deleteReplyMutation.mutate({ eventId }); }} - channelId={channel.id} - onReply={(content, mentionPubkeys) => { + onReply={(content, mentionPubkeys, parentEventId) => { createReplyMutation.mutate({ content, - parentEventId: selectedPostId, mentionPubkeys, + parentEventId, + rootEventId: selectedPostId, }); }} onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} thread={threadQuery.data} + threadRootEventId={selectedPostId} /> ); } diff --git a/desktop/src/features/forum/useForumThreadTyping.ts b/desktop/src/features/forum/useForumThreadTyping.ts new file mode 100644 index 0000000000..4dd2b8ed99 --- /dev/null +++ b/desktop/src/features/forum/useForumThreadTyping.ts @@ -0,0 +1,177 @@ +import { useEffect, useEffectEvent, useMemo, useState } from "react"; + +import { + getChannelIdFromTags, + getTypingReplyParentFromTags, +} from "@/features/messages/lib/threading"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_TYPING_INDICATOR } from "@/shared/constants/kinds"; + +type TypingEntry = { + expiresAt: number; + firstSeenAt: number; + /** `null` = legacy event without `e` tag — treat as “typing toward thread root only”. */ + replyParentId: string | null; +}; + +type TypingState = Record; + +const TYPING_INDICATOR_TTL_MS = 8_000; +const TYPING_PRUNE_INTERVAL_MS = 1_000; + +function pruneTypingState(state: TypingState, now = Date.now()) { + let changed = false; + const next: TypingState = {}; + + for (const [pubkey, entry] of Object.entries(state)) { + if (entry.expiresAt > now) { + next[pubkey] = entry; + continue; + } + + changed = true; + } + + return changed ? next : state; +} + +/** + * Subscribes to kind 20002 for a forum channel and splits typers by optional + * `["e", parent, "", "reply"]` tag so indicators can sit beside the matching composer. + */ +export function useForumThreadTyping( + channelId: string | null | undefined, + currentPubkey: string | undefined, + enabled: boolean, + threadRootEventId: string, + mainComposerParentId: string, + branchPanelOpen: boolean, + branchComposerParentId: string | null, +) { + const normalizedSelf = currentPubkey?.toLowerCase(); + const [typingByPubkey, setTypingByPubkey] = useState({}); + + const registerTyping = useEffectEvent((event: RelayEvent) => { + if (!channelId || event.kind !== KIND_TYPING_INDICATOR) { + return; + } + + if (getChannelIdFromTags(event.tags) !== channelId) { + return; + } + + const typingPubkey = event.pubkey.toLowerCase(); + if (normalizedSelf && typingPubkey === normalizedSelf) { + return; + } + + const replyParentId = getTypingReplyParentFromTags(event.tags); + + const now = Date.now(); + setTypingByPubkey((current) => { + const pruned = pruneTypingState(current, now); + const existing = pruned[typingPubkey]; + return { + ...pruned, + [typingPubkey]: { + expiresAt: now + TYPING_INDICATOR_TTL_MS, + firstSeenAt: existing?.firstSeenAt ?? now, + replyParentId, + }, + }; + }); + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset when channel or thread changes + useEffect(() => { + setTypingByPubkey({}); + }, [channelId, threadRootEventId]); + + useEffect(() => { + if (!channelId || !enabled) { + return; + } + + let isDisposed = false; + let cleanup: (() => Promise) | undefined; + + relayClient + .subscribeToTypingIndicators(channelId, (event) => { + if (!isDisposed) { + registerTyping(event); + } + }) + .then((dispose) => { + if (isDisposed) { + void dispose(); + return; + } + + cleanup = dispose; + }) + .catch((error) => { + console.error( + "Failed to subscribe to typing indicators", + channelId, + error, + ); + }); + + return () => { + isDisposed = true; + if (cleanup) { + void cleanup(); + } + }; + }, [channelId, enabled]); + + const hasActiveTypers = Object.keys(typingByPubkey).length > 0; + + useEffect(() => { + if (!hasActiveTypers) { + return; + } + + const interval = window.setInterval(() => { + setTypingByPubkey((current) => pruneTypingState(current)); + }, TYPING_PRUNE_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + }; + }, [hasActiveTypers]); + + const mainComposerTypingPubkeys = useMemo(() => { + const list: { pubkey: string; firstSeenAt: number }[] = []; + for (const [pubkey, entry] of Object.entries(typingByPubkey)) { + const { replyParentId } = entry; + const matchesMain = + replyParentId === mainComposerParentId || + (replyParentId === null && mainComposerParentId === threadRootEventId); + if (matchesMain) { + list.push({ pubkey, firstSeenAt: entry.firstSeenAt }); + } + } + return list + .sort((a, b) => a.firstSeenAt - b.firstSeenAt) + .map((x) => x.pubkey); + }, [mainComposerParentId, threadRootEventId, typingByPubkey]); + + const branchComposerTypingPubkeys = useMemo(() => { + if (!branchPanelOpen || !branchComposerParentId) { + return []; + } + const list: { pubkey: string; firstSeenAt: number }[] = []; + for (const [pubkey, entry] of Object.entries(typingByPubkey)) { + if (entry.replyParentId === branchComposerParentId) { + list.push({ pubkey, firstSeenAt: entry.firstSeenAt }); + } + } + return list + .sort((a, b) => a.firstSeenAt - b.firstSeenAt) + .map((x) => x.pubkey); + }, [branchComposerParentId, branchPanelOpen, typingByPubkey]); + + return { branchComposerTypingPubkeys, mainComposerTypingPubkeys }; +} diff --git a/desktop/src/features/messages/lib/collapsedThreads.ts b/desktop/src/features/messages/lib/collapsedThreads.ts new file mode 100644 index 0000000000..62890c8f15 --- /dev/null +++ b/desktop/src/features/messages/lib/collapsedThreads.ts @@ -0,0 +1,74 @@ +import type { TimelineMessage } from "@/features/messages/types"; + +export type CollapsedThreadPreview = { + participants: Array<{ + avatarUrl: string | null | undefined; + id: string; + label: string; + }>; + replyCount: number; +}; + +export function buildCollapsedThreadTimeline(messages: TimelineMessage[]) { + const messageById = new Map(messages.map((message) => [message.id, message])); + const visibleMessages: TimelineMessage[] = []; + const hiddenRepliesByAnchor = new Map(); + + for (const message of messages) { + if (message.depth < 2) { + visibleMessages.push(message); + continue; + } + + let anchorId = message.parentId ?? null; + while (anchorId) { + const ancestor = messageById.get(anchorId); + if (!ancestor) { + anchorId = null; + break; + } + if (ancestor.depth < 2) { + const group = hiddenRepliesByAnchor.get(ancestor.id) ?? []; + group.push(message); + hiddenRepliesByAnchor.set(ancestor.id, group); + break; + } + anchorId = ancestor.parentId ?? null; + } + + if (!anchorId) { + visibleMessages.push(message); + } + } + + const summaryByMessageId = new Map(); + for (const [messageId, hiddenReplies] of hiddenRepliesByAnchor) { + const participants = new Map< + string, + { avatarUrl: string | null | undefined; id: string; label: string } + >(); + + for (let index = hiddenReplies.length - 1; index >= 0; index -= 1) { + const reply = hiddenReplies[index]; + const key = + reply.pubkey?.toLowerCase() ?? `author:${reply.author.toLowerCase()}`; + if (!participants.has(key)) { + participants.set(key, { + avatarUrl: reply.avatarUrl, + id: key, + label: reply.author, + }); + } + if (participants.size >= 3) { + break; + } + } + + summaryByMessageId.set(messageId, { + participants: [...participants.values()], + replyCount: hiddenReplies.length, + }); + } + + return { summaryByMessageId, visibleMessages }; +} diff --git a/desktop/src/features/messages/lib/threadBranch.ts b/desktop/src/features/messages/lib/threadBranch.ts new file mode 100644 index 0000000000..e1f341cdf6 --- /dev/null +++ b/desktop/src/features/messages/lib/threadBranch.ts @@ -0,0 +1,80 @@ +import type { TimelineMessage } from "@/features/messages/types"; + +type ThreadBranch = { + headMessage: TimelineMessage | null; + messageIds: Set; + messages: TimelineMessage[]; +}; + +/** + * Collect the visible branch for a selected thread head. + * + * The underlying NIP-10 root may point to an older message, but the UI thread + * panel should anchor to the specific message the user chose to discuss and + * then show only descendants from that branch. + */ +export function collectThreadBranch( + messages: TimelineMessage[], + branchHeadId: string | null, +): ThreadBranch { + if (!branchHeadId) { + return { + headMessage: null, + messageIds: new Set(), + messages: [], + }; + } + + const headMessage = + messages.find((message) => message.id === branchHeadId) ?? null; + if (!headMessage) { + return { + headMessage: null, + messageIds: new Set(), + messages: [], + }; + } + + const childrenByParent = new Map(); + for (const message of messages) { + if (!message.parentId) { + continue; + } + const children = childrenByParent.get(message.parentId) ?? []; + children.push(message.id); + childrenByParent.set(message.parentId, children); + } + + const branchIds = new Set([branchHeadId]); + const stack = [branchHeadId]; + while (stack.length > 0) { + const parentId = stack.pop(); + if (!parentId) { + continue; + } + const children = childrenByParent.get(parentId) ?? []; + for (const childId of children) { + if (branchIds.has(childId)) { + continue; + } + branchIds.add(childId); + stack.push(childId); + } + } + + const depthOffset = headMessage.depth + 1; + const branchMessages = messages + .filter( + (message) => message.id !== branchHeadId && branchIds.has(message.id), + ) + .map((message) => ({ + ...message, + depth: Math.max(0, message.depth - depthOffset), + })); + + return { + headMessage, + messageIds: branchIds, + messages: branchMessages, + }; +} diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts index bfbd3a3a53..5713ea15f4 100644 --- a/desktop/src/features/messages/lib/threading.ts +++ b/desktop/src/features/messages/lib/threading.ts @@ -13,6 +13,21 @@ export function getChannelIdFromTags(tags: string[][]) { return tags.find((tag) => tag[0] === "h")?.[1] ?? null; } +/** NIP-10 `e` reply marker on kind 20002 — which composer surface the typer is using. */ +export function getTypingReplyParentFromTags( + tags: string[][] | undefined, +): string | null { + if (!tags) { + return null; + } + for (const tag of tags) { + if (tag[0] === "e" && tag[3] === "reply" && tag[1]) { + return tag[1]; + } + } + return null; +} + export function getThreadReference(tags: string[][]): ThreadReference { const eventTags = getEventTags(tags); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 14e2dedf54..b25c4f7aef 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -19,6 +19,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar"; type MessageComposerProps = { channelId?: string | null; channelName: string; + draftKey?: string | null; disabled?: boolean; editTarget?: { author: string; @@ -40,6 +41,7 @@ type MessageComposerProps = { body: string; id: string; } | null; + typingReplyParentId?: string | null; }; const MAX_TEXTAREA_ROWS = 4; @@ -47,6 +49,7 @@ const MAX_TEXTAREA_ROWS = 4; export function MessageComposer({ channelId = null, channelName, + draftKey = null, disabled = false, editTarget = null, isSending = false, @@ -56,6 +59,7 @@ export function MessageComposer({ onSend, placeholder, replyTarget = null, + typingReplyParentId = null, }: MessageComposerProps) { const [content, setContent] = React.useState(""); const contentRef = React.useRef(content); @@ -70,11 +74,12 @@ export function MessageComposer({ contentRef.current = content; const drafts = useDrafts(); - const previousChannelIdRef = React.useRef(null); + const draftStorageKey = draftKey ?? channelId; + const previousDraftKeyRef = React.useRef(null); const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); - const notifyTyping = useTypingBroadcast(channelId); + const notifyTyping = useTypingBroadcast(channelId, typingReplyParentId); const media = useMediaUpload(setContent); @@ -93,27 +98,29 @@ export function MessageComposer({ editTargetRef.current = editTarget; channelIdRef.current = channelId; - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — save draft for previous channel, restore draft for new channel, reset transient state + // biome-ignore lint/correctness/useExhaustiveDependencies: draftStorageKey is the sole trigger — save draft for previous scope, restore draft for new scope, reset transient state React.useEffect(() => { - // Save draft for the channel we're leaving - const prevId = previousChannelIdRef.current; - if (prevId) { + // Save draft for the composer scope we're leaving. + const previousDraftKey = previousDraftKeyRef.current; + if (previousDraftKey) { const currentContent = contentRef.current; const sel = draftSelectionRef.current; if (currentContent.trim().length > 0) { - drafts.saveDraft(prevId, { + drafts.saveDraft(previousDraftKey, { content: currentContent, selectionEnd: sel.end, selectionStart: sel.start, }); } else { - drafts.clearDraft(prevId); + drafts.clearDraft(previousDraftKey); } } - previousChannelIdRef.current = channelId; + previousDraftKeyRef.current = draftStorageKey; - // Restore draft for the channel we're entering - const saved = channelId ? drafts.loadDraft(channelId) : undefined; + // Restore draft for the composer scope we're entering. + const saved = draftStorageKey + ? drafts.loadDraft(draftStorageKey) + : undefined; if (saved) { setContent(saved.content); contentRef.current = saved.content; @@ -136,7 +143,7 @@ export function MessageComposer({ mentions.clearMentions(); channelLinks.clearChannels(); lineHeightRef.current = null; - }, [channelId]); + }, [draftStorageKey]); const applyMentionInsert = React.useCallback( (suggestion: MentionSuggestion) => { diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 710e9968c1..4dd6dd21bb 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,8 +1,11 @@ +import { MessageSquareReply } from "lucide-react"; import * as React from "react"; +import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads"; import type { TimelineMessage } from "@/features/messages/types"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; @@ -11,15 +14,9 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext" import { parseImetaTags } from "@/features/messages/lib/parseImeta"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; -import { BotIdenticon } from "./BotIdenticon"; import { MessageActionBar } from "./MessageActionBar"; import { MessageTimestamp } from "./MessageTimestamp"; -/** Returns true if this message is from a bot instance. */ -function isBotInstance(role?: string): boolean { - return role === "bot"; -} - const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -30,15 +27,18 @@ export const MessageRow = React.memo( message, onDelete, onEdit, + onOpenThread, onToggleReaction, onReply, profiles, + threadSummary = null, }: { activeReplyTargetId?: string | null; highlighted?: boolean; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -46,6 +46,7 @@ export const MessageRow = React.memo( ) => Promise; onReply?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; + threadSummary?: CollapsedThreadPreview | null; }) { const [expandedDiffId, setExpandedDiffId] = React.useState( null, @@ -70,8 +71,6 @@ export const MessageRow = React.memo( [channels], ); - const visibleDepth = Math.min(message.depth, 6); - const indentPx = visibleDepth * 28; const getTag = (name: string) => message.tags?.find((tag) => tag[0] === name)?.[1]; @@ -153,18 +152,7 @@ export const MessageRow = React.memo( ); return ( -
0 ? { paddingLeft: `${indentPx}px` } : undefined} - > - {message.depth > 0 ? ( -
- ) : null} - +
- {isBotInstance(message.role) ? ( - - ) : null} {message.pubkey ? ( + ) : null} {reactionErrorMessage ? (

{reactionErrorMessage} @@ -326,7 +339,8 @@ export const MessageRow = React.memo( prev.message.personaDisplayName === next.message.personaDisplayName && prev.highlighted === next.highlighted && prev.activeReplyTargetId === next.activeReplyTargetId && - prev.profiles === next.profiles, + prev.profiles === next.profiles && + prev.threadSummary === next.threadSummary, ); MessageRow.displayName = "MessageRow"; diff --git a/desktop/src/features/messages/ui/MessageThreadSidebar.tsx b/desktop/src/features/messages/ui/MessageThreadSidebar.tsx new file mode 100644 index 0000000000..de3bd93372 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageThreadSidebar.tsx @@ -0,0 +1,149 @@ +import { X } from "lucide-react"; + +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { MessageComposer } from "./MessageComposer"; +import { MessageRow } from "./MessageRow"; +import { TimelineMessageList } from "./TimelineMessageList"; +import { TypingIndicatorRow } from "./TypingIndicatorRow"; + +type MessageThreadSidebarProps = { + channel: Channel | null; + currentPubkey?: string; + headMessage: TimelineMessage; + isSending: boolean; + messages: TimelineMessage[]; + onCancelReply: () => void; + onClose: () => void; + onDelete?: (message: TimelineMessage) => void; + onReply: (message: TimelineMessage) => void; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; + personaLookup?: Map; + profiles?: UserProfileLookup; + replyTargetMessage: TimelineMessage | null; + typingPubkeys: string[]; +}; + +export function MessageThreadSidebar({ + channel, + currentPubkey, + headMessage, + isSending, + messages, + onCancelReply, + onClose, + onDelete, + onReply, + onSend, + onToggleReaction, + personaLookup, + profiles, + replyTargetMessage, + typingPubkeys, +}: MessageThreadSidebarProps) { + const threadReplyTarget = + replyTargetMessage && replyTargetMessage.id !== headMessage.id + ? { + author: replyTargetMessage.author, + body: replyTargetMessage.body, + id: replyTargetMessage.id, + } + : null; + const canReplyInChannel = + !!channel && + channel.isMember && + channel.archivedAt === null && + channel.channelType !== "forum"; + + return ( +

+ ); +} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 4697855038..3dcb9424ae 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { ArrowDown, Loader2 } from "lucide-react"; +import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; @@ -19,6 +20,7 @@ type MessageTimelineProps = { emptyTitle?: string; emptyDescription?: string; activeReplyTargetId?: string | null; + collapsedThreadSummaryByMessageId?: Map; currentPubkey?: string; fetchOlder?: () => Promise; hasOlderMessages?: boolean; @@ -28,6 +30,7 @@ type MessageTimelineProps = { profiles?: UserProfileLookup; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -45,6 +48,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ emptyTitle = "No messages yet", emptyDescription = "Send the first message to start the thread.", activeReplyTargetId = null, + collapsedThreadSummaryByMessageId, currentPubkey, fetchOlder, hasOlderMessages = true, @@ -53,6 +57,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ profiles, onDelete, onEdit, + onOpenThread, onReply, onToggleReaction, targetMessageId = null, @@ -154,11 +159,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {!isLoading && messages.length > 0 ? ( ; currentPubkey?: string; highlightedMessageId?: string | null; messages: TimelineMessage[]; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -31,11 +34,13 @@ type TimelineMessageListProps = { export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId = null, + collapsedThreadSummaryByMessageId, currentPubkey, highlightedMessageId = null, messages, onDelete, onEdit, + onOpenThread, onReply, onToggleReaction, personaLookup, @@ -86,8 +91,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ : undefined } onToggleReaction={onToggleReaction} + onOpenThread={onOpenThread} onReply={onReply} profiles={profiles} + threadSummary={ + collapsedThreadSummaryByMessageId?.get(message.id) ?? null + } />, ); } diff --git a/desktop/src/features/messages/useChannelThreadTyping.ts b/desktop/src/features/messages/useChannelThreadTyping.ts new file mode 100644 index 0000000000..d212fcc716 --- /dev/null +++ b/desktop/src/features/messages/useChannelThreadTyping.ts @@ -0,0 +1,236 @@ +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; + +import { + getChannelIdFromTags, + getTypingReplyParentFromTags, +} from "@/features/messages/lib/threading"; +import { relayClient } from "@/shared/api/relayClient"; +import type { Channel, RelayEvent } from "@/shared/api/types"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_DIFF, + KIND_TYPING_INDICATOR, +} from "@/shared/constants/kinds"; + +type TypingEntry = { + expiresAt: number; + firstSeenAt: number; + replyParentId: string | null; +}; + +type TypingState = Record; + +const TYPING_INDICATOR_TTL_MS = 8_000; +const TYPING_PRUNE_INTERVAL_MS = 1_000; +const TYPING_POST_MESSAGE_SUPPRESS_MS = 2_000; + +function pruneTypingState(state: TypingState, now = Date.now()) { + let changed = false; + const next: TypingState = {}; + + for (const [pubkey, entry] of Object.entries(state)) { + if (entry.expiresAt > now) { + next[pubkey] = entry; + continue; + } + + changed = true; + } + + return changed ? next : state; +} + +function isTypingCompletionEvent(event: RelayEvent | null | undefined) { + if (!event) { + return false; + } + + return ( + event.kind === KIND_STREAM_MESSAGE || + event.kind === KIND_STREAM_MESSAGE_DIFF + ); +} + +export function useChannelThreadTyping( + channel: Channel | null, + currentPubkey: string | undefined, + latestMessageEvent: RelayEvent | null | undefined, + openThreadMessageIds?: ReadonlySet | null, +) { + const channelId = channel?.id ?? null; + const channelType = channel?.channelType ?? null; + const [typingByPubkey, setTypingByPubkey] = useState({}); + const normalizedCurrentPubkey = currentPubkey?.toLowerCase(); + const typingSuppressUntilByPubkeyRef = useRef>({}); + const latestMessageCreatedAtByPubkeyRef = useRef>({}); + + const registerTyping = useEffectEvent((event: RelayEvent) => { + if (!channelId || event.kind !== KIND_TYPING_INDICATOR) { + return; + } + + if (getChannelIdFromTags(event.tags) !== channelId) { + return; + } + + const typingPubkey = event.pubkey.toLowerCase(); + if (normalizedCurrentPubkey && typingPubkey === normalizedCurrentPubkey) { + return; + } + + const suppressUntil = + typingSuppressUntilByPubkeyRef.current[typingPubkey] ?? 0; + if (suppressUntil > Date.now()) { + return; + } + if (suppressUntil > 0) { + delete typingSuppressUntilByPubkeyRef.current[typingPubkey]; + } + + const latestMessageCreatedAt = + latestMessageCreatedAtByPubkeyRef.current[typingPubkey] ?? 0; + if (event.created_at <= latestMessageCreatedAt) { + return; + } + + const replyParentId = getTypingReplyParentFromTags(event.tags); + const now = Date.now(); + setTypingByPubkey((current) => { + const pruned = pruneTypingState(current, now); + const existing = pruned[typingPubkey]; + return { + ...pruned, + [typingPubkey]: { + expiresAt: now + TYPING_INDICATOR_TTL_MS, + firstSeenAt: existing?.firstSeenAt ?? now, + replyParentId, + }, + }; + }); + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: channel changes should clear local typing state + useEffect(() => { + setTypingByPubkey({}); + typingSuppressUntilByPubkeyRef.current = {}; + latestMessageCreatedAtByPubkeyRef.current = {}; + }, [channelId]); + + useEffect(() => { + if ( + !channelId || + !latestMessageEvent || + !isTypingCompletionEvent(latestMessageEvent) + ) { + return; + } + + if (getChannelIdFromTags(latestMessageEvent.tags) !== channelId) { + return; + } + + const authorPubkey = latestMessageEvent.pubkey.toLowerCase(); + latestMessageCreatedAtByPubkeyRef.current[authorPubkey] = Math.max( + latestMessageCreatedAtByPubkeyRef.current[authorPubkey] ?? 0, + latestMessageEvent.created_at, + ); + typingSuppressUntilByPubkeyRef.current[authorPubkey] = + Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS; + setTypingByPubkey((current) => { + const next = pruneTypingState(current); + if (!(authorPubkey in next)) { + return next; + } + + const updated = { ...next }; + delete updated[authorPubkey]; + return updated; + }); + }, [channelId, latestMessageEvent]); + + useEffect(() => { + if (!channelId || channelType === "forum") { + return; + } + + let isDisposed = false; + let cleanup: (() => Promise) | undefined; + + relayClient + .subscribeToTypingIndicators(channelId, (event) => { + if (!isDisposed) { + registerTyping(event); + } + }) + .then((dispose) => { + if (isDisposed) { + void dispose(); + return; + } + + cleanup = dispose; + }) + .catch((error) => { + console.error( + "Failed to subscribe to typing indicators", + channelId, + error, + ); + }); + + return () => { + isDisposed = true; + if (cleanup) { + void cleanup(); + } + }; + }, [channelId, channelType]); + + const hasActiveTypers = Object.keys(typingByPubkey).length > 0; + + useEffect(() => { + if (!hasActiveTypers) { + return; + } + + const interval = window.setInterval(() => { + setTypingByPubkey((current) => pruneTypingState(current)); + }, TYPING_PRUNE_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + }; + }, [hasActiveTypers]); + + const orderedEntries = useMemo( + () => + Object.entries(typingByPubkey) + .map(([pubkey, entry]) => ({ pubkey, ...entry })) + .sort((left, right) => left.firstSeenAt - right.firstSeenAt), + [typingByPubkey], + ); + + const mainComposerTypingPubkeys = useMemo( + () => + orderedEntries + .filter((entry) => entry.replyParentId === null) + .map((entry) => entry.pubkey), + [orderedEntries], + ); + + const threadComposerTypingPubkeys = useMemo(() => { + if (!openThreadMessageIds || openThreadMessageIds.size === 0) { + return []; + } + + return orderedEntries + .filter( + (entry) => + entry.replyParentId !== null && + openThreadMessageIds.has(entry.replyParentId), + ) + .map((entry) => entry.pubkey); + }, [openThreadMessageIds, orderedEntries]); + + return { mainComposerTypingPubkeys, threadComposerTypingPubkeys }; +} diff --git a/desktop/src/features/messages/useTypingBroadcast.ts b/desktop/src/features/messages/useTypingBroadcast.ts index 80175318a1..0554fb8575 100644 --- a/desktop/src/features/messages/useTypingBroadcast.ts +++ b/desktop/src/features/messages/useTypingBroadcast.ts @@ -7,12 +7,21 @@ const TYPING_SEND_INTERVAL_MS = 3_000; /** * Publishes kind:20002 typing indicators for the current user, * throttled to at most once every 3 seconds per channel. + * + * @param replyParentEventId When set, adds `["e", id, "", "reply"]` so receivers can + * scope indicators to a composer (main vs sub-thread). */ -export function useTypingBroadcast(channelId: string | null | undefined) { +export function useTypingBroadcast( + channelId: string | null | undefined, + replyParentEventId?: string | null, +) { const lastSentRef = useRef(0); const lastChannelRef = useRef(channelId); + const lastScopeRef = useRef(undefined); const channelIdRef = useRef(channelId); + const replyParentRef = useRef(replyParentEventId); channelIdRef.current = channelId; + replyParentRef.current = replyParentEventId; const notifyTyping = useCallback(() => { const id = channelIdRef.current; @@ -20,9 +29,13 @@ export function useTypingBroadcast(channelId: string | null | undefined) { return; } - // Reset throttle when channel changes. - if (lastChannelRef.current !== id) { + // Reset throttle when channel or reply scope changes. + if ( + lastChannelRef.current !== id || + lastScopeRef.current !== replyParentRef.current + ) { lastChannelRef.current = id; + lastScopeRef.current = replyParentRef.current; lastSentRef.current = 0; } @@ -32,7 +45,9 @@ export function useTypingBroadcast(channelId: string | null | undefined) { } lastSentRef.current = now; - relayClient.sendTypingIndicator(id).catch(() => {}); + relayClient + .sendTypingIndicator(id, replyParentRef.current ?? undefined) + .catch(() => {}); }, []); return notifyTyping; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 66bdf1d740..9edb6d48c2 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -135,15 +135,26 @@ export class RelayClient { ); } - async sendTypingIndicator(channelId: string) { + /** + * @param replyParentEventId Optional NIP-10 reply target — same id you pass as `parentEventId` + * when sending a message. Enables scoped “is typing” in thread UIs. + */ + async sendTypingIndicator( + channelId: string, + replyParentEventId?: string | null, + ) { // Bail when disconnected — not worth triggering a reconnect for ephemeral typing events. if (this.wsId === null) { return; } + const tags: string[][] = [["h", channelId]]; + if (replyParentEventId) { + tags.push(["e", replyParentEventId, "", "reply"]); + } const event = await signRelayEvent({ kind: KIND_TYPING_INDICATOR, content: "", - tags: [["h", channelId]], + tags, }); // Fire-and-forget: no need to wait for relay acknowledgement.