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.