mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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
This commit is contained in:
@@ -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<void>;
|
||||
onReply: (message: TimelineMessage) => void;
|
||||
onSend: (
|
||||
onSendChannel: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => Promise<void>;
|
||||
onSendThread: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
@@ -44,6 +52,7 @@ type ChannelPaneProps = {
|
||||
replyTargetId: string | null;
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
targetMessageId: string | null;
|
||||
threadHintsByRootId: Map<string, ThreadConversationHint>;
|
||||
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({
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<MessageTimeline
|
||||
activeThreadRootId={threadRootId}
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={replyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
onOpenThread={onReply}
|
||||
profiles={profiles}
|
||||
threadHintsByRootId={threadHintsByRootId}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
@@ -131,29 +145,27 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
{!threadRootId ? (
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={composerDisabled}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelReply}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={mainPlaceholder}
|
||||
replyTarget={
|
||||
replyTargetMessage
|
||||
? {
|
||||
author: replyTargetMessage.author,
|
||||
body: replyTargetMessage.body,
|
||||
id: replyTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={composerDisabled}
|
||||
editTarget={threadRootId ? null : editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={threadRootId ? undefined : onCancelReply}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSendChannel}
|
||||
placeholder={mainPlaceholder}
|
||||
replyTarget={
|
||||
threadRootId || !replyTargetMessage
|
||||
? null
|
||||
: {
|
||||
author: replyTargetMessage.author,
|
||||
body: replyTargetMessage.body,
|
||||
id: replyTargetMessage.id,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string, ThreadConversationHint>();
|
||||
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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-b border-border/40 px-3 py-2.5 last:border-b-0">
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
@@ -109,7 +108,6 @@ export function ChannelThreadPanel({
|
||||
profiles,
|
||||
rootEventId,
|
||||
onClose,
|
||||
replyTarget,
|
||||
onCancelReply,
|
||||
onSend,
|
||||
isSending,
|
||||
@@ -130,22 +128,42 @@ export function ChannelThreadPanel({
|
||||
enabled: Boolean(channel.id && rootEventId),
|
||||
});
|
||||
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
|
||||
const thread = threadQuery.data;
|
||||
const isLoading = threadQuery.isPending;
|
||||
|
||||
const implicitThreadAgentMention = React.useMemo(() => {
|
||||
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 (
|
||||
<aside
|
||||
className="flex h-full min-h-0 w-[min(100%,420px)] shrink-0 flex-col border-l border-border/60 bg-muted/20"
|
||||
className="relative z-10 flex h-full min-h-0 w-[min(100%,420px)] shrink-0 flex-col border-l border-border/60 bg-muted/20 pt-12"
|
||||
data-testid="channel-thread-panel"
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">Thread</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
#{channel.name}
|
||||
</p>
|
||||
</div>
|
||||
<header className="flex shrink-0 justify-end px-2 py-2">
|
||||
<Button
|
||||
aria-label="Close thread"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
@@ -166,7 +184,7 @@ export function ChannelThreadPanel({
|
||||
</div>
|
||||
) : thread ? (
|
||||
<>
|
||||
<div className="border-b border-border/50 bg-background/40 px-3 py-3">
|
||||
<div className="bg-background/40 px-3 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ProfileAvatar
|
||||
avatarUrl={
|
||||
@@ -232,19 +250,21 @@ export function ChannelThreadPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border/60 bg-background/80 p-2 backdrop-blur-sm">
|
||||
<div className="shrink-0 bg-background/80 p-2 backdrop-blur-sm">
|
||||
<MessageComposer
|
||||
channelId={channel.id}
|
||||
channelName={channel.name}
|
||||
disabled={disabledComposer}
|
||||
draftStorageKey={`${channel.id}:thread:${rootEventId}`}
|
||||
editTarget={editTarget}
|
||||
implicitThreadAgentMention={implicitThreadAgentMention}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelReply}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={`Reply in thread…`}
|
||||
replyTarget={replyTarget}
|
||||
placeholder="Reply in thread…"
|
||||
replyTarget={null}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const previousDraftKeyRef = React.useRef<string | null>(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<string | null>(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({
|
||||
<ComposerMentionOverlay
|
||||
channelNames={channelLinks.knownChannelNames}
|
||||
content={content}
|
||||
mentionNames={mentions.knownNames}
|
||||
mentionNames={mentionNamesForOverlay}
|
||||
scrollTop={composerScrollTop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<UserProfilePopover pubkey={pubkey}>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-5 shrink-0 overflow-hidden rounded-full border-2 border-background bg-muted text-[8px] font-semibold text-muted-foreground shadow-sm transition-transform hover:z-20 hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{ zIndex: 10 - stackIndex }}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{avatarUrl && !failed ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => {
|
||||
setFailed(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={rewriteRelayUrl(avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center">
|
||||
{threadParticipantInitials(label)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
threadHint?: ThreadConversationHint;
|
||||
}) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
@@ -294,6 +362,61 @@ export const MessageRow = React.memo(
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{message.depth === 0 &&
|
||||
threadHint &&
|
||||
threadHint.replyCount > 0 &&
|
||||
onOpenThread ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 inline-flex max-w-full flex-wrap items-center gap-2 rounded-md py-0.5 text-left text-[11px] transition-colors",
|
||||
"text-muted-foreground/90 hover:bg-muted/50 hover:text-foreground",
|
||||
activeThreadRootId === message.id
|
||||
? "bg-muted/40 text-foreground"
|
||||
: "",
|
||||
)}
|
||||
data-testid="message-thread-conversation-hint"
|
||||
>
|
||||
<button
|
||||
className="inline-flex items-center gap-x-1.5 rounded-md px-0 text-left font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenThread(message);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare
|
||||
aria-hidden
|
||||
className="h-3 w-3 shrink-0 opacity-70"
|
||||
/>
|
||||
<span>
|
||||
{threadHint.replyCount}{" "}
|
||||
{threadHint.replyCount === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
</button>
|
||||
{threadHint.participantPubkeys.length > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex items-center -space-x-1.5 pl-0.5"
|
||||
>
|
||||
{threadHint.participantPubkeys.map((pubkey, i) => {
|
||||
const label =
|
||||
threadHint.participantLabels[i] ?? pubkey.slice(0, 8);
|
||||
const resolved =
|
||||
profiles?.[normalizePubkey(pubkey)] ?? null;
|
||||
return (
|
||||
<ThreadParticipantAvatar
|
||||
avatarUrl={resolved?.avatarUrl ?? null}
|
||||
key={pubkey}
|
||||
label={label}
|
||||
pubkey={pubkey}
|
||||
stackIndex={i}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{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";
|
||||
|
||||
@@ -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<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
threadHintsByRootId?: Map<string, ThreadConversationHint>;
|
||||
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 ? (
|
||||
<TimelineMessageList
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
activeThreadRootId={activeThreadRootId}
|
||||
currentPubkey={currentPubkey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
messages={messages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onOpenThread={onOpenThread}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
threadHintsByRootId={threadHintsByRootId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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<string, ThreadConversationHint>;
|
||||
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(
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
activeThreadRootId={activeThreadRootId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
message={message}
|
||||
onDelete={
|
||||
@@ -81,9 +92,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onOpenThread={onOpenThread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
threadHint={threadHint}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user