diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ccaff90af..43016f26a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -179,6 +179,14 @@ struct SearchQueryParams<'a> { limit: Option, } +#[derive(Serialize)] +struct SendChannelMessageBody<'a> { + content: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + parent_event_id: Option<&'a str>, + broadcast_to_channel: bool, +} + #[derive(Serialize)] struct MintTokenBody<'a> { name: &'a str, @@ -274,6 +282,15 @@ pub struct SearchResponse { pub found: u64, } +#[derive(Serialize, Deserialize)] +pub struct SendChannelMessageResponse { + pub event_id: String, + pub parent_event_id: Option, + pub root_event_id: Option, + pub depth: u32, + pub created_at: i64, +} + fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -793,6 +810,25 @@ async fn search_messages( send_json_request(request).await } +#[tauri::command] +async fn send_channel_message( + channel_id: String, + content: String, + parent_event_id: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}/messages"); + let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?.json( + &SendChannelMessageBody { + content: content.trim(), + parent_event_id: parent_event_id.as_deref(), + broadcast_to_channel: false, + }, + ); + + send_json_request(request).await +} + #[tauri::command] async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result { let request = build_authed_request( @@ -963,6 +999,7 @@ pub fn run() { leave_channel, get_feed, search_messages, + send_channel_message, get_event, list_tokens, mint_token, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 9e49925ff..ebfe13fd0 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -23,6 +23,10 @@ import { collectMessageAuthorPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; import { usePresenceQuery, usePresenceSession, @@ -73,6 +77,7 @@ export function AppShell() { >(null); const [searchAnchorEvent, setSearchAnchorEvent] = React.useState(null); + const [replyTargetId, setReplyTargetId] = React.useState(null); const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); @@ -91,6 +96,7 @@ export function AppShell() { ); const createChannelMutation = useCreateChannelMutation(); const activeChannel = selectedView === "channel" ? selectedChannel : null; + const activeChannelId = activeChannel?.id ?? null; const { unreadChannelIds } = useUnreadChannels(channels, activeChannel); const activeDmParticipantPubkeys = React.useMemo(() => { if (!activeChannel || activeChannel.channelType !== "dm") { @@ -170,6 +176,11 @@ export function AppShell() { resolvedMessages, ], ); + const replyTargetMessage = React.useMemo( + () => + timelineMessages.find((message) => message.id === replyTargetId) ?? null, + [replyTargetId, timelineMessages], + ); const channelDescription = activeChannel ? [ @@ -196,6 +207,11 @@ export function AppShell() { const isTimelineLoading = messagesQuery.isLoading && resolvedMessages.length === 0; + const requestedAncestorIdsRef = React.useRef>(new Set()); + const previousActiveChannelIdRef = React.useRef( + activeChannelId, + ); + const resolveChannel = React.useCallback( async (channelId: string): Promise => { const cachedChannels = @@ -282,6 +298,85 @@ export function AppShell() { [handleOpenChannel], ); + React.useEffect(() => { + if (previousActiveChannelIdRef.current === activeChannelId) { + return; + } + + previousActiveChannelIdRef.current = activeChannelId; + setReplyTargetId(null); + requestedAncestorIdsRef.current.clear(); + }, [activeChannelId]); + + React.useEffect(() => { + if (replyTargetId && !replyTargetMessage) { + setReplyTargetId(null); + } + }, [replyTargetId, replyTargetMessage]); + + React.useEffect(() => { + if (!activeChannel || activeChannel.channelType === "forum") { + return; + } + + const knownEvents = new Map( + resolvedMessages.map((message) => [message.id, message]), + ); + const missingAncestorIds = new Set(); + + for (const message of resolvedMessages) { + const thread = getThreadReference(message.tags); + + for (const eventId of [thread.parentId, thread.rootId]) { + if ( + !eventId || + knownEvents.has(eventId) || + requestedAncestorIdsRef.current.has(eventId) + ) { + continue; + } + + missingAncestorIds.add(eventId); + } + } + + if (missingAncestorIds.size === 0) { + return; + } + + for (const eventId of missingAncestorIds) { + requestedAncestorIdsRef.current.add(eventId); + } + + let isCancelled = false; + + void Promise.all( + [...missingAncestorIds].map(async (eventId) => { + try { + const event = await getEventById(eventId); + + if ( + isCancelled || + getChannelIdFromTags(event.tags) !== activeChannel.id + ) { + return; + } + + queryClient.setQueryData( + ["channel-messages", activeChannel.id], + (current = []) => mergeMessages(current, event), + ); + } catch (error) { + console.error("Failed to load ancestor event", eventId, error); + } + }), + ); + + return () => { + isCancelled = true; + }; + }, [activeChannel, queryClient, resolvedMessages]); + React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = @@ -435,10 +530,11 @@ export function AppShell() { ) : ( <> { + setReplyTargetId((current) => + current === message.id ? null : message.id, + ); + }} onTargetReached={(messageId) => { setSearchAnchor((current) => current?.eventId === messageId ? null : current, @@ -473,11 +574,16 @@ export function AppShell() { } isSending={sendMessageMutation.isPending} key={activeChannel?.id ?? "no-channel"} + onCancelReply={() => { + setReplyTargetId(null); + }} onSend={async (content, mentionPubkeys) => { await sendMessageMutation.mutateAsync({ content, mentionPubkeys, + parentEventId: replyTargetId, }); + setReplyTargetId(null); }} placeholder={ activeChannel?.archivedAt @@ -490,6 +596,15 @@ export function AppShell() { ? `Message #${activeChannel.name}` : "Select a channel" } + replyTarget={ + replyTargetMessage + ? { + author: replyTargetMessage.author, + body: replyTargetMessage.body, + id: replyTargetMessage.id, + } + : null + } /> )} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 756473943..afc4ce4f5 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -2,7 +2,12 @@ import { useEffect, useEffectEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { updateChannelLastMessageAt } from "@/features/channels/hooks"; +import { + buildReplyTags, + resolveReplyRootId, +} from "@/features/messages/lib/threading"; import { relayClient } from "@/shared/api/relayClient"; +import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; type MessageQueryContext = { @@ -49,11 +54,26 @@ function createOptimisticMessage( channelId: string, content: string, identity: Identity, + currentMessages: RelayEvent[], mentionPubkeys: string[] = [], + parentEventId: string | null = null, ): RelayEvent { - const tags: string[][] = [["h", channelId]]; - for (const pubkey of mentionPubkeys) { - tags.push(["p", pubkey]); + const tags: string[][] = []; + + if (parentEventId) { + tags.push( + ...buildReplyTags( + channelId, + identity.pubkey, + parentEventId, + resolveReplyRootId(parentEventId, currentMessages), + ), + ); + } else { + tags.push(["h", channelId]); + for (const pubkey of mentionPubkeys) { + tags.push(["p", pubkey]); + } } return { @@ -77,7 +97,7 @@ export function useChannelMessagesQuery(channel: Channel | null) { throw new Error("No channel selected."); } - const history = await relayClient.fetchChannelHistory(channel.id); + const history = await relayClient.fetchChannelHistory(channel.id, 200); return dedupeMessagesById(history); }, staleTime: Number.POSITIVE_INFINITY, @@ -150,17 +170,53 @@ export function useSendMessageMutation( return useMutation< RelayEvent, Error, - { content: string; mentionPubkeys?: string[] }, + { + content: string; + mentionPubkeys?: string[]; + parentEventId?: string | null; + }, MessageQueryContext | undefined >({ - mutationFn: async ({ content, mentionPubkeys }) => { + mutationFn: async ({ content, mentionPubkeys, parentEventId }) => { if (!channel || channel.channelType === "forum") { throw new Error("This channel does not support message sending yet."); } + if (!identity) { + throw new Error("No identity available for sending messages."); + } + + if (parentEventId) { + const cachedMessages = + queryClient.getQueryData([ + "channel-messages", + channel.id, + ]) ?? []; + const result = await sendChannelMessage( + channel.id, + content, + parentEventId, + ); + + return { + id: result.eventId, + pubkey: identity.pubkey, + created_at: result.createdAt, + kind: 4_0001, + tags: buildReplyTags( + channel.id, + identity.pubkey, + parentEventId, + resolveReplyRootId(parentEventId, cachedMessages), + ), + content: content.trim(), + sig: "", + }; + } + return relayClient.sendMessage(channel.id, content, mentionPubkeys ?? []); }, - onMutate: async ({ content, mentionPubkeys }) => { + onMutate: async ({ content, mentionPubkeys, parentEventId }) => { if (!channel || !identity || channel.channelType === "forum") { return undefined; } @@ -174,7 +230,9 @@ export function useSendMessageMutation( channel.id, content.trim(), identity, + previousMessages, mentionPubkeys ?? [], + parentEventId ?? null, ); queryClient.setQueryData( diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 4208cc070..5bc47b1f4 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -1,6 +1,7 @@ import type { Channel, RelayEvent } from "@/shared/api/types"; import type { TimelineMessage } from "@/features/messages/types"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { resolveUserLabel, type UserProfileLookup, @@ -70,13 +71,71 @@ export function formatTimelineMessages( currentUserAvatarUrl: string | null, profiles?: UserProfileLookup, ): TimelineMessage[] { - return events.map((event) => { + const eventsById = new Map(events.map((event) => [event.id, event])); + const authorPubkeyByEventId = new Map(); + const authorLabelByEventId = new Map(); + const depthByEventId = new Map(); + const resolvingEventIds = new Set(); + + function getAuthorLabel(event: RelayEvent) { + const cached = authorLabelByEventId.get(event.id); + if (cached) { + return cached; + } + const authorPubkey = getEffectiveAuthorPubkey(event); + const author = formatMessageAuthor(event, channel, currentPubkey, profiles); + + authorPubkeyByEventId.set(event.id, authorPubkey); + authorLabelByEventId.set(event.id, author); + return author; + } + + function getDepth(event: RelayEvent): number { + const cached = depthByEventId.get(event.id); + if (cached !== undefined) { + return cached; + } + + if (resolvingEventIds.has(event.id)) { + return 0; + } + + const thread = getThreadReference(event.tags); + if (!thread.parentId) { + depthByEventId.set(event.id, 0); + return 0; + } + + const parent = eventsById.get(thread.parentId); + if (!parent) { + const fallbackDepth = + thread.rootId && thread.rootId !== thread.parentId ? 2 : 1; + depthByEventId.set(event.id, fallbackDepth); + return fallbackDepth; + } + + resolvingEventIds.add(event.id); + const depth = getDepth(parent) + 1; + resolvingEventIds.delete(event.id); + depthByEventId.set(event.id, depth); + return depth; + } + + return events.map((event) => { + const author = getAuthorLabel(event); + const authorPubkey = + authorPubkeyByEventId.get(event.id) ?? getEffectiveAuthorPubkey(event); + const thread = getThreadReference(event.tags); + const parentEvent = thread.parentId + ? eventsById.get(thread.parentId) + : undefined; return { id: event.id, + createdAt: event.created_at, pubkey: authorPubkey, - author: formatMessageAuthor(event, channel, currentPubkey, profiles), + author, avatarUrl: getAuthorAvatarUrl({ authorPubkey, currentPubkey, @@ -88,6 +147,11 @@ export function formatTimelineMessages( minute: "2-digit", }).format(new Date(event.created_at * 1_000)), body: event.content, + parentId: thread.parentId, + rootId: thread.rootId, + depth: getDepth(event), + replyToAuthor: parentEvent ? getAuthorLabel(parentEvent) : null, + replyToSnippet: parentEvent?.content ?? null, accent: currentPubkey === authorPubkey, pending: event.pending, kind: event.kind, diff --git a/desktop/src/features/messages/lib/threading.ts b/desktop/src/features/messages/lib/threading.ts new file mode 100644 index 000000000..ced2ed205 --- /dev/null +++ b/desktop/src/features/messages/lib/threading.ts @@ -0,0 +1,77 @@ +import type { RelayEvent } from "@/shared/api/types"; + +export type ThreadReference = { + parentId: string | null; + rootId: string | null; +}; + +function getEventTags(tags: string[][]) { + return tags.filter((tag) => tag[0] === "e" && typeof tag[1] === "string"); +} + +export function getChannelIdFromTags(tags: string[][]) { + return tags.find((tag) => tag[0] === "h")?.[1] ?? null; +} + +export function getThreadReference(tags: string[][]): ThreadReference { + const eventTags = getEventTags(tags); + + if (eventTags.length === 0) { + return { + parentId: null, + rootId: null, + }; + } + + const rootTag = eventTags.find((tag) => tag[3] === "root"); + const replyTag = + [...eventTags].reverse().find((tag) => tag[3] === "reply") ?? null; + + if (!replyTag) { + return { + parentId: null, + rootId: null, + }; + } + + const parentId = replyTag[1] ?? null; + + return { + parentId, + rootId: rootTag?.[1] ?? parentId, + }; +} + +export function buildReplyTags( + channelId: string, + authorPubkey: string, + parentEventId: string, + rootEventId: string, +) { + const tags: string[][] = [ + ["p", authorPubkey], + ["h", channelId], + ]; + + if (parentEventId === rootEventId) { + tags.push(["e", rootEventId, "", "reply"]); + return tags; + } + + tags.push(["e", rootEventId, "", "root"]); + tags.push(["e", parentEventId, "", "reply"]); + return tags; +} + +export function resolveReplyRootId( + parentEventId: string, + events: RelayEvent[], +) { + const parent = events.find((event) => event.id === parentEventId); + if (!parent) { + return parentEventId; + } + + const thread = getThreadReference(parent.tags); + return thread.rootId ?? parent.id; +} diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index 34eb127f1..dc97dda04 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -1,11 +1,17 @@ export type TimelineMessage = { id: string; + createdAt: number; pubkey?: string; author: string; avatarUrl?: string | null; role?: string; time: string; body: string; + parentId?: string | null; + rootId?: string | null; + depth: number; + replyToAuthor?: string | null; + replyToSnippet?: string | null; accent?: boolean; pending?: boolean; highlighted?: boolean; diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0b6f80793..f3a80476d 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -14,8 +14,14 @@ type MessageComposerProps = { channelName: string; disabled?: boolean; isSending?: boolean; + onCancelReply?: () => void; onSend: (content: string, mentionPubkeys: string[]) => Promise; placeholder?: string; + replyTarget?: { + author: string; + body: string; + id: string; + } | null; }; const MAX_TEXTAREA_ROWS = 4; @@ -45,8 +51,10 @@ export function MessageComposer({ channelName, disabled = false, isSending = false, + onCancelReply, onSend, placeholder, + replyTarget = null, }: MessageComposerProps) { const [content, setContent] = React.useState(""); const textareaRef = React.useRef(null); @@ -258,6 +266,14 @@ export function MessageComposer({ } }); + React.useEffect(() => { + if (!replyTarget || disabled) { + return; + } + + textareaRef.current?.focus(); + }, [disabled, replyTarget]); + return (