From 181e6d0be2e970fd1b571ff1e1bcc2004d2ccdcb Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 7 Apr 2026 11:01:14 +0200 Subject: [PATCH] desktop: thread sidebar UX and main-channel thread hints Show Slack-style thread hints on root messages (reply count and stacked participant avatars from thread replies). Wire hint data from channel timeline aggregation through MessageTimeline into MessageRow. Simplify the thread panel chrome: only a top-right close control, and remove horizontal rules between header, root post, replies, and composer. Includes channel pane / composer updates for the split channel and thread composers. Made-with: Cursor --- .../src/features/channels/ui/ChannelPane.tsx | 75 +++++----- .../features/channels/ui/ChannelScreen.tsx | 112 ++++++++++++--- desktop/src/features/messages/types.ts | 9 ++ .../messages/ui/ChannelThreadPanel.tsx | 62 +++++--- .../features/messages/ui/MessageComposer.tsx | 119 +++++++++++++--- .../src/features/messages/ui/MessageRow.tsx | 134 +++++++++++++++++- .../features/messages/ui/MessageTimeline.tsx | 14 +- .../messages/ui/TimelineMessageList.tsx | 15 +- 8 files changed, 446 insertions(+), 94 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index b7cf389355..5d3d6b296e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,7 +4,10 @@ import { ChannelThreadPanel } from "@/features/messages/ui/ChannelThreadPanel"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; -import type { TimelineMessage } from "@/features/messages/types"; +import type { + ThreadConversationHint, + TimelineMessage, +} from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; @@ -29,7 +32,12 @@ type ChannelPaneProps = { onEdit?: (message: TimelineMessage) => void; onEditSave?: (content: string) => Promise; onReply: (message: TimelineMessage) => void; - onSend: ( + onSendChannel: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + onSendThread: ( content: string, mentionPubkeys: string[], mediaTags?: string[][], @@ -44,6 +52,7 @@ type ChannelPaneProps = { replyTargetId: string | null; replyTargetMessage: TimelineMessage | null; targetMessageId: string | null; + threadHintsByRootId: Map; threadRootId: string | null; typingPubkeys: string[]; }; @@ -65,13 +74,15 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit, onEditSave, onReply, - onSend, + onSendChannel, + onSendThread, onTargetReached, onToggleReaction, profiles, replyTargetId, replyTargetMessage, targetMessageId, + threadHintsByRootId, threadRootId, typingPubkeys, }: ChannelPaneProps) { @@ -96,13 +107,16 @@ export const ChannelPane = React.memo(function ChannelPane({
- {!threadRootId ? ( - - ) : null} +
@@ -168,17 +180,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelReply={onCancelReply} onClose={onCloseThread} onEditSave={onEditSave} - onSend={onSend} + onSend={onSendThread} profiles={profiles} - replyTarget={ - replyTargetMessage - ? { - author: replyTargetMessage.author, - body: replyTargetMessage.body, - id: replyTargetMessage.id, - } - : null - } rootEventId={threadRootId} /> ) : null} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index f757a539ca..45f320db8b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -15,7 +15,10 @@ import { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; -import type { TimelineMessage } from "@/features/messages/types"; +import type { + ThreadConversationHint, + TimelineMessage, +} from "@/features/messages/types"; import { channelMessagesKey, channelThreadKey, @@ -32,7 +35,10 @@ import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages import { useChannelTyping } from "@/features/messages/useChannelTyping"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; +import { + mergeCurrentProfileIntoLookup, + resolveUserLabel, +} from "@/features/profile/lib/identity"; import { getEventById } from "@/shared/api/tauri"; import type { Channel, @@ -194,6 +200,56 @@ export function ChannelScreen({ }), [timelineMessages], ); + + /** Reply counts + participant labels for roots that have sidebar thread activity. */ + const threadHintsByRootId = React.useMemo(() => { + const aggregates = new Map< + string, + { replyCount: number; pubkeys: string[] } + >(); + + for (const m of timelineMessages) { + if (m.kind === KIND_SYSTEM_MESSAGE) { + continue; + } + if (m.depth === 0) { + continue; + } + const rootId = m.rootId; + if (!rootId) { + continue; + } + + const agg = aggregates.get(rootId) ?? { replyCount: 0, pubkeys: [] }; + agg.replyCount += 1; + if (m.pubkey) { + const pk = m.pubkey.toLowerCase(); + if (!agg.pubkeys.some((p) => p.toLowerCase() === pk)) { + agg.pubkeys.push(m.pubkey); + } + } + aggregates.set(rootId, agg); + } + + const out = new Map(); + for (const [rootId, agg] of aggregates) { + const participantPubkeys = agg.pubkeys.slice(0, 5); + const participantLabels = participantPubkeys.map((pk) => + resolveUserLabel({ + pubkey: pk, + currentPubkey, + profiles: messageProfiles, + preferResolvedSelfLabel: true, + }), + ); + out.set(rootId, { + replyCount: agg.replyCount, + participantPubkeys, + participantLabels, + }); + } + return out; + }, [currentPubkey, messageProfiles, timelineMessages]); const replyTargetMessage = React.useMemo( () => timelineMessages.find((message) => message.id === replyTargetId) ?? null, @@ -237,15 +293,41 @@ export function ChannelScreen({ setReplyTargetId(null); }, []); - const handleSend = React.useCallback( + /** Main channel composer — always posts to the channel timeline (not the open thread). */ + const handleSendChannel = React.useCallback( async ( content: string, mentionPubkeys: string[], mediaTags?: string[][], ) => { - const parentEventId = threadRootId - ? (replyTargetId ?? threadRootId) - : replyTargetId; + const parentEventId = threadRootId ? null : replyTargetId; + + await sendMessageMutation.mutateAsync({ + content, + mentionPubkeys, + parentEventId, + mediaTags, + }); + + if (!threadRootId) { + setReplyTargetId(null); + } + }, + [replyTargetId, sendMessageMutation, threadRootId], + ); + + /** Thread sidebar composer — replies inside the active thread. */ + const handleSendThread = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + if (!threadRootId) { + return; + } + + const parentEventId = replyTargetId ?? threadRootId; const result = await sendMessageMutation.mutateAsync({ content, @@ -254,15 +336,11 @@ export function ChannelScreen({ mediaTags, }); - if (threadRootId) { - setReplyTargetId(result.id); - if (activeChannelId) { - void queryClient.invalidateQueries({ - queryKey: channelThreadKey(activeChannelId, threadRootId), - }); - } - } else { - setReplyTargetId(null); + setReplyTargetId(result.id); + if (activeChannelId) { + void queryClient.invalidateQueries({ + queryKey: channelThreadKey(activeChannelId, threadRootId), + }); } }, [ @@ -478,12 +556,14 @@ export function ChannelScreen({ onEdit={handleEdit} onEditSave={handleEditSave} onReply={handleReplyOpenThread} - onSend={handleSend} + onSendChannel={handleSendChannel} + onSendThread={handleSendThread} onTargetReached={onTargetReached} onToggleReaction={effectiveToggleReaction} profiles={messageProfiles} replyTargetId={replyTargetId} replyTargetMessage={replyTargetMessage} + threadHintsByRootId={threadHintsByRootId} threadRootId={threadRootId} targetMessageId={ activeChannel && diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index b58957ecab..da2e6e2a37 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -5,6 +5,15 @@ export type TimelineReaction = { users: Array<{ pubkey: string; displayName: string }>; }; +/** Sidebar thread summary for a root message (main timeline). */ +export type ThreadConversationHint = { + replyCount: number; + /** Unique reply authors (up to 5) for avatar stack. */ + participantPubkeys: string[]; + /** Parallel display names (tooltips / initials fallback). */ + participantLabels: string[]; +}; + export type TimelineMessage = { id: string; createdAt: number; diff --git a/desktop/src/features/messages/ui/ChannelThreadPanel.tsx b/desktop/src/features/messages/ui/ChannelThreadPanel.tsx index 8d5d94e850..3b447c08e3 100644 --- a/desktop/src/features/messages/ui/ChannelThreadPanel.tsx +++ b/desktop/src/features/messages/ui/ChannelThreadPanel.tsx @@ -1,7 +1,11 @@ -import { MessageSquare, X } from "lucide-react"; +import { X } from "lucide-react"; import * as React from "react"; import { useQuery } from "@tanstack/react-query"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { channelThreadKey } from "@/features/messages/lib/messageQueryKeys"; import { getForumThread } from "@/shared/api/forum"; @@ -25,11 +29,6 @@ type ChannelThreadPanelProps = { profiles?: UserProfileLookup; rootEventId: string; onClose: () => void; - replyTarget: { - author: string; - body: string; - id: string; - } | null; onCancelReply: () => void; onSend: ( content: string, @@ -74,7 +73,7 @@ function ThreadReplyRow({ const mentionNames = resolveMentionNames(tags, profiles); return ( -
+
{ + const rootPk = thread?.post.pubkey; + if (!rootPk) { + return null; + } + + const lower = rootPk.toLowerCase(); + const managed = (managedAgentsQuery.data ?? []).find( + (a) => a.pubkey.toLowerCase() === lower, + ); + if (managed) { + return { displayName: managed.name, pubkey: managed.pubkey }; + } + + const relay = (relayAgentsQuery.data ?? []).find( + (a) => a.pubkey.toLowerCase() === lower, + ); + if (relay) { + return { displayName: relay.name, pubkey: relay.pubkey }; + } + + return null; + }, [managedAgentsQuery.data, relayAgentsQuery.data, thread?.post.pubkey]); + return ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9fda205b19..9a97d1e41e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -6,6 +6,7 @@ import { useDrafts } from "@/features/messages/lib/useDrafts"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; +import { escapeRegExp } from "@/shared/lib/mentionPattern"; import { Button } from "@/shared/ui/button"; import { Textarea } from "@/shared/ui/textarea"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; @@ -18,6 +19,8 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar"; type MessageComposerProps = { channelId?: string | null; + /** When set, drafts are keyed by this (e.g. thread sidebar) instead of channelId alone. */ + draftStorageKey?: string | null; channelName: string; disabled?: boolean; editTarget?: { @@ -40,12 +43,27 @@ type MessageComposerProps = { body: string; id: string; } | null; + /** + * Thread sidebar: when the thread is with an agent, we seed `@Name ` and ensure + * sends include a mention + p-tag without the user typing @ every time. + */ + implicitThreadAgentMention?: { + displayName: string; + pubkey: string; + } | null; }; const MAX_TEXTAREA_ROWS = 4; +function textAlreadyMentionsDisplayName(text: string, displayName: string) { + const escaped = escapeRegExp(displayName); + const pattern = new RegExp(`(?:^|\\s)@${escaped}(?=[\\s,;.!?:)\\]}]|$)`, "i"); + return pattern.test(text); +} + export function MessageComposer({ channelId = null, + draftStorageKey = null, channelName, disabled = false, editTarget = null, @@ -56,6 +74,7 @@ export function MessageComposer({ onSend, placeholder, replyTarget = null, + implicitThreadAgentMention = null, }: MessageComposerProps) { const [content, setContent] = React.useState(""); const contentRef = React.useRef(content); @@ -70,7 +89,7 @@ export function MessageComposer({ contentRef.current = content; const drafts = useDrafts(); - const previousChannelIdRef = React.useRef(null); + const previousDraftKeyRef = React.useRef(null); const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); @@ -86,34 +105,51 @@ export function MessageComposer({ const onEditSaveRef = React.useRef(onEditSave); const editTargetRef = React.useRef(editTarget); const channelIdRef = React.useRef(channelId); + const effectiveDraftKeyRef = React.useRef(null); + const implicitThreadAgentMentionRef = React.useRef( + implicitThreadAgentMention, + ); disabledRef.current = disabled; isSendingRef.current = isSending; onSendRef.current = onSend; onEditSaveRef.current = onEditSave; editTargetRef.current = editTarget; channelIdRef.current = channelId; + effectiveDraftKeyRef.current = draftStorageKey ?? channelId ?? null; + implicitThreadAgentMentionRef.current = implicitThreadAgentMention; - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — save draft for previous channel, restore draft for new channel, reset transient state + const mentionNamesForOverlay = React.useMemo(() => { + const list = [...mentions.knownNames]; + const extra = implicitThreadAgentMention?.displayName; + if (extra && !list.some((n) => n.toLowerCase() === extra.toLowerCase())) { + list.push(extra); + } + return list; + }, [mentions.knownNames, implicitThreadAgentMention?.displayName]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: draft key change saves/restores draft and resets transient state React.useEffect(() => { - // Save draft for the channel we're leaving - const prevId = previousChannelIdRef.current; - if (prevId) { + const nextKey = draftStorageKey ?? channelId ?? null; + + // Save draft for the storage key we're leaving + const prevKey = previousDraftKeyRef.current; + if (prevKey) { const currentContent = contentRef.current; const sel = draftSelectionRef.current; if (currentContent.trim().length > 0) { - drafts.saveDraft(prevId, { + drafts.saveDraft(prevKey, { content: currentContent, selectionEnd: sel.end, selectionStart: sel.start, }); } else { - drafts.clearDraft(prevId); + drafts.clearDraft(prevKey); } } - previousChannelIdRef.current = channelId; + previousDraftKeyRef.current = nextKey; - // Restore draft for the channel we're entering - const saved = channelId ? drafts.loadDraft(channelId) : undefined; + // Restore draft for the storage key we're entering + const saved = nextKey ? drafts.loadDraft(nextKey) : undefined; if (saved) { setContent(saved.content); contentRef.current = saved.content; @@ -136,7 +172,7 @@ export function MessageComposer({ mentions.clearMentions(); channelLinks.clearChannels(); lineHeightRef.current = null; - }, [channelId]); + }, [channelId, draftStorageKey]); const applyMentionInsert = React.useCallback( (suggestion: MentionSuggestion) => { @@ -257,7 +293,7 @@ export function MessageComposer({ ); const submitMessage = React.useCallback(async () => { - const trimmed = contentRef.current.trim(); + let trimmed = contentRef.current.trim(); // Edit mode: save the edit and return. if (editTargetRef.current && onEditSaveRef.current) { @@ -290,7 +326,22 @@ export function MessageComposer({ return; } - const pubkeys = mentions.extractMentionPubkeys(trimmed); + const implicit = implicitThreadAgentMentionRef.current; + if (implicit && !editTargetRef.current) { + if (!textAlreadyMentionsDisplayName(trimmed, implicit.displayName)) { + trimmed = + trimmed.length > 0 + ? `@${implicit.displayName} ${trimmed}` + : `@${implicit.displayName}`; + } + } + + let pubkeys = mentions.extractMentionPubkeys(trimmed); + if (implicit && !editTargetRef.current) { + const set = new Set(pubkeys); + set.add(implicit.pubkey); + pubkeys = [...set]; + } const mediaTags = currentPendingImeta.length > 0 @@ -316,11 +367,22 @@ export function MessageComposer({ channelLinks.clearChannels(); setIsEmojiPickerOpen(false); - const sendChannelId = channelIdRef.current; + const sendDraftKey = effectiveDraftKeyRef.current; try { await onSendRef.current(trimmed, pubkeys, mediaTags); - if (sendChannelId) { - drafts.clearDraft(sendChannelId); + if (sendDraftKey) { + drafts.clearDraft(sendDraftKey); + } + const implicitAfter = implicitThreadAgentMentionRef.current; + if (implicitAfter && !editTargetRef.current) { + const seed = `@${implicitAfter.displayName} `; + setContent(seed); + contentRef.current = seed; + draftSelectionRef.current = { + end: seed.length, + start: seed.length, + }; + pendingSelectionRef.current = seed.length; } } catch { setContent(savedContent); @@ -477,6 +539,29 @@ export function MessageComposer({ textareaRef.current?.focus(); }, [editTarget?.id]); + // Seed `@AgentName ` in agent threads so users need not type @ every time. + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-seed when implicit agent or edit mode changes + React.useEffect(() => { + const implicit = implicitThreadAgentMention; + if (!implicit || editTarget) { + return; + } + + if (contentRef.current.trim().length > 0) { + return; + } + + const seed = `@${implicit.displayName} `; + setContent(seed); + contentRef.current = seed; + draftSelectionRef.current = { end: seed.length, start: seed.length }; + pendingSelectionRef.current = seed.length; + }, [ + implicitThreadAgentMention?.pubkey, + implicitThreadAgentMention?.displayName, + editTarget?.id, + ]); + const sendDisabled = React.useMemo( () => disabled || @@ -588,7 +673,7 @@ export function MessageComposer({
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 01f1110f9f..ff3cfe3488 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,12 +1,17 @@ import * as React from "react"; +import { MessageSquare } from "lucide-react"; -import type { TimelineMessage } from "@/features/messages/types"; +import type { + ThreadConversationHint, + TimelineMessage, +} from "@/features/messages/types"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; @@ -16,22 +21,84 @@ import { MessageTimestamp } from "./MessageTimestamp"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); +function threadParticipantInitials(label: string) { + const parts = label.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) { + return "?"; + } + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + return `${parts[0][0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase(); +} + +function ThreadParticipantAvatar({ + avatarUrl, + label, + pubkey, + stackIndex, +}: { + avatarUrl?: string | null; + label: string; + pubkey: string; + stackIndex: number; +}) { + const [failed, setFailed] = React.useState(false); + + return ( + + + + ); +} + export const MessageRow = React.memo( function MessageRow({ activeReplyTargetId = null, + activeThreadRootId = null, highlighted = false, message, onDelete, onEdit, + onOpenThread, onToggleReaction, onReply, profiles, + threadHint, }: { activeReplyTargetId?: string | null; + activeThreadRootId?: string | null; highlighted?: boolean; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -39,6 +106,7 @@ export const MessageRow = React.memo( ) => Promise; onReply?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; + threadHint?: ThreadConversationHint; }) { const [hasAvatarError, setHasAvatarError] = React.useState(false); const [expandedDiffId, setExpandedDiffId] = React.useState( @@ -294,6 +362,61 @@ export const MessageRow = React.memo( }); }} /> + {message.depth === 0 && + threadHint && + threadHint.replyCount > 0 && + onOpenThread ? ( +
+ + {threadHint.participantPubkeys.length > 0 ? ( +
+ {threadHint.participantPubkeys.map((pubkey, i) => { + const label = + threadHint.participantLabels[i] ?? pubkey.slice(0, 8); + const resolved = + profiles?.[normalizePubkey(pubkey)] ?? null; + return ( + + ); + })} +
+ ) : null} +
+ ) : null} {reactionErrorMessage ? (

{reactionErrorMessage} @@ -340,7 +463,14 @@ export const MessageRow = React.memo( prev.message.role === next.message.role && prev.highlighted === next.highlighted && prev.activeReplyTargetId === next.activeReplyTargetId && - prev.profiles === next.profiles, + prev.activeThreadRootId === next.activeThreadRootId && + prev.profiles === next.profiles && + (prev.threadHint === next.threadHint || + (prev.threadHint?.replyCount === next.threadHint?.replyCount && + prev.threadHint?.participantPubkeys.join("\0") === + next.threadHint?.participantPubkeys.join("\0") && + prev.threadHint?.participantLabels.join("\0") === + next.threadHint?.participantLabels.join("\0"))), ); MessageRow.displayName = "MessageRow"; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b463981fcc..0e750a1e71 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,7 +1,10 @@ import * as React from "react"; import { ArrowDown, Loader2 } from "lucide-react"; -import type { TimelineMessage } from "@/features/messages/types"; +import type { + ThreadConversationHint, + TimelineMessage, +} from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { Separator } from "@/shared/ui/separator"; @@ -19,13 +22,16 @@ type MessageTimelineProps = { emptyTitle?: string; emptyDescription?: string; activeReplyTargetId?: string | null; + activeThreadRootId?: string | null; currentPubkey?: string; fetchOlder?: () => Promise; hasOlderMessages?: boolean; isFetchingOlder?: boolean; profiles?: UserProfileLookup; + threadHintsByRootId?: Map; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -43,13 +49,16 @@ export const MessageTimeline = React.memo(function MessageTimeline({ emptyTitle = "No messages yet", emptyDescription = "Send the first message to start the thread.", activeReplyTargetId = null, + activeThreadRootId = null, currentPubkey, fetchOlder, hasOlderMessages = true, isFetchingOlder = false, profiles, + threadHintsByRootId, onDelete, onEdit, + onOpenThread, onReply, onToggleReaction, targetMessageId = null, @@ -150,14 +159,17 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {!isLoading && messages.length > 0 ? ( ) : null} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index a802b110b4..1c50768fb1 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -4,7 +4,10 @@ import { formatDayHeading, isSameDay, } from "@/features/messages/lib/dateFormatters"; -import type { TimelineMessage } from "@/features/messages/types"; +import type { + ThreadConversationHint, + TimelineMessage, +} from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { DayDivider } from "./DayDivider"; @@ -13,11 +16,14 @@ import { SystemMessageRow } from "./SystemMessageRow"; type TimelineMessageListProps = { activeReplyTargetId?: string | null; + activeThreadRootId?: string | null; currentPubkey?: string; highlightedMessageId?: string | null; messages: TimelineMessage[]; + threadHintsByRootId?: Map; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -29,11 +35,14 @@ type TimelineMessageListProps = { export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId = null, + activeThreadRootId = null, currentPubkey, highlightedMessageId = null, messages, + threadHintsByRootId, onDelete, onEdit, + onOpenThread, onReply, onToggleReaction, profiles, @@ -65,10 +74,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ />, ); } else { + const threadHint = threadHintsByRootId?.get(message.id); elements.push( , ); }