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( , ); }