feat(desktop): refine inbox signal and layout

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-28 22:00:54 -04:00
parent be33211d2c
commit d0c818d320
7 changed files with 336 additions and 176 deletions

View File

@@ -10,12 +10,13 @@ import {
} from "lucide-react";
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
type ChatHeaderProps = {
actions?: React.ReactNode;
title: string;
description: string;
description?: string;
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
@@ -73,7 +74,10 @@ export function ChatHeader({
}: ChatHeaderProps) {
return (
<header
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 pb-3 pt-8 sm:px-6"
className={cn(
"flex min-w-0 items-center gap-3 bg-background px-4 pb-3 pt-8 sm:px-6",
mode === "home" ? "" : "border-b border-border/80",
)}
data-testid="chat-header"
data-tauri-drag-region
>
@@ -96,12 +100,14 @@ export function ChatHeader({
</div>
) : null}
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="chat-description"
>
{description}
</p>
{description ? (
<p
className="truncate text-sm text-muted-foreground"
data-testid="chat-description"
>
{description}
</p>
) : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}

View File

@@ -2,19 +2,31 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { getThreadReference } from "@/features/messages/lib/threading";
import type {
FeedItem,
FeedItemCategory,
HomeFeedResponse,
} from "@/shared/api/types";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
export type InboxFilter = "all" | "mention" | "needs_action";
export type InboxFilter =
| "all"
| "mention"
| "needs_action"
| "activity"
| "agent_activity";
export type InboxItem = {
avatarUrl: string | null;
id: string;
item: FeedItem;
categories: FeedItemCategory[];
categoryLabel: string;
channelLabel: string | null;
fullTimestampLabel: string;
isActionRequired: boolean;
latestActivityAt: number;
mentionNames: string[];
preview: string;
searchableText: string;
@@ -126,6 +138,34 @@ function feedPreview(item: FeedItem) {
return "No additional details were attached to this event.";
}
function categoryLabelFor(category: FeedItemCategory) {
return category === "needs_action"
? "Needs Action"
: category === "mention"
? "Mention"
: category === "agent_activity"
? "Agent update"
: "Activity";
}
function categoryPriority(category: FeedItemCategory) {
switch (category) {
case "needs_action":
return 0;
case "mention":
return 1;
case "agent_activity":
return 2;
case "activity":
return 3;
}
}
function getInboxThreadKey(item: FeedItem) {
const thread = getThreadReference(item.tags);
return thread.rootId ?? thread.parentId ?? item.id;
}
function formatInboxTimestamp(unixSeconds: number) {
const date = new Date(unixSeconds * 1_000);
const now = new Date();
@@ -155,7 +195,7 @@ export function groupInboxItems(items: InboxItem[]): InboxGroup[] {
const now = new Date();
for (const item of items) {
const date = new Date(item.item.createdAt * 1_000);
const date = new Date(item.latestActivityAt * 1_000);
const dayDiff = diffInDays(now, date);
const label =
dayDiff === 0
@@ -190,46 +230,86 @@ export function buildInboxItems({
return [];
}
const items = [...feed.feed.mentions, ...feed.feed.needsAction].sort(
(left, right) => right.createdAt - left.createdAt,
);
const feedItems = [
...feed.feed.mentions,
...feed.feed.needsAction,
...feed.feed.activity,
...feed.feed.agentActivity,
];
return items.map((item) => {
const senderLabel = resolveUserLabel({
pubkey: item.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
});
const subject = feedHeadline(item);
const preview = feedPreview(item);
const mentionNames = resolveMentionNames(item.tags, profiles) ?? [];
const channelLabel = item.channelName.trim() || null;
const categoryLabel =
item.category === "needs_action" ? "Needs Action" : "Mention";
const threadGroups = new Map<
string,
{
items: FeedItem[];
latestActivityAt: number;
rootItem: FeedItem | null;
}
>();
return {
avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null,
id: item.id,
item,
categoryLabel,
channelLabel,
fullTimestampLabel: formatInboxFullTimestamp(item.createdAt),
isActionRequired: item.category === "needs_action",
mentionNames,
preview,
searchableText: [
for (const item of feedItems) {
const threadKey = getInboxThreadKey(item);
const group = threadGroups.get(threadKey) ?? {
items: [],
latestActivityAt: 0,
rootItem: null,
};
group.items.push(item);
group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt);
if (item.id === threadKey) {
group.rootItem = item;
}
threadGroups.set(threadKey, group);
}
return [...threadGroups.values()]
.sort((left, right) => right.latestActivityAt - left.latestActivityAt)
.map((group) => {
const latestItem = group.items.reduce((latest, current) =>
current.createdAt > latest.createdAt ? current : latest,
);
const item = group.rootItem ?? latestItem;
const categories = [
...new Set(group.items.map((groupItem) => groupItem.category)),
].sort((left, right) => categoryPriority(left) - categoryPriority(right));
const senderLabel = resolveUserLabel({
pubkey: item.pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
});
const subject = feedHeadline(item);
const preview = feedPreview(item);
const mentionNames = resolveMentionNames(item.tags, profiles) ?? [];
const channelLabel = item.channelName.trim() || null;
const categoryLabel = categoryLabelFor(categories[0] ?? item.category);
return {
avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null,
id: item.id,
item,
categories,
categoryLabel,
channelLabel,
fullTimestampLabel: formatInboxFullTimestamp(item.createdAt),
isActionRequired: categories.includes("needs_action"),
latestActivityAt: group.latestActivityAt,
mentionNames,
preview,
searchableText: [
senderLabel,
subject,
preview,
...group.items.map(feedPreview),
channelLabel ?? "",
categoryLabel,
]
.join(" ")
.toLowerCase(),
senderLabel,
subject,
preview,
channelLabel ?? "",
categoryLabel,
]
.join(" ")
.toLowerCase(),
senderLabel,
subject,
timestampLabel: formatInboxTimestamp(item.createdAt),
};
});
timestampLabel: formatInboxTimestamp(group.latestActivityAt),
};
});
}

View File

@@ -1,9 +1,5 @@
import { RefreshCcw } from "lucide-react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import { Button } from "@/shared/ui/button";
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
@@ -19,45 +15,22 @@ export function HomeScreen({
const homeFeedQuery = useHomeFeedQuery();
return (
<>
<ChatHeader
actions={
<Button
className="h-9 rounded-full px-3"
onClick={() => {
void homeFeedQuery.refetch();
}}
type="button"
variant="outline"
>
<RefreshCcw
className={`h-4 w-4 ${homeFeedQuery.isFetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
description="Personalized inbox for mentions, reminders, and approvals."
mode="home"
title="Inbox"
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
</div>
</>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { RefreshCcw } from "lucide-react";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
type InboxFilter,
type InboxReply,
@@ -10,6 +11,8 @@ import {
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
import { InboxListPane } from "@/features/home/ui/InboxListPane";
import { useChannelMessagesQuery } from "@/features/messages/hooks";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
@@ -17,6 +20,14 @@ import type { HomeFeedResponse } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
function matchesInboxFilter(item: { categories: InboxFilter[] }, filter: InboxFilter) {
if (filter === "all") {
return item.categories.some((category) => category !== "activity");
}
return item.categories.includes(filter);
}
function HomeLoadingState() {
return (
<div className="flex-1 overflow-hidden">
@@ -77,18 +88,58 @@ export function HomeView({
>({});
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
const feedItems = React.useMemo(
() => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []),
() =>
feed
? [
...feed.feed.mentions,
...feed.feed.needsAction,
...feed.feed.activity,
...feed.feed.agentActivity,
]
: [],
[feed],
);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data;
const selectedChannelIdCandidate = React.useMemo(() => {
if (!selectedItemId) return null;
const match = feedItems.find((item) => item.id === selectedItemId);
return match?.channelId ?? null;
}, [feedItems, selectedItemId]);
const selectedChannel = React.useMemo(() => {
if (!selectedChannelIdCandidate || !channels) return null;
return (
channels.find((channel) => channel.id === selectedChannelIdCandidate) ??
null
);
}, [channels, selectedChannelIdCandidate]);
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const channelMessages = channelMessagesQuery.data;
const threadEvents = React.useMemo(() => {
if (!selectedItemId || !channelMessages) return [];
return channelMessages
.filter((event) => {
if (event.id === selectedItemId) return false;
const ref = getThreadReference(event.tags);
return (
ref.parentId === selectedItemId || ref.rootId === selectedItemId
);
})
.sort((a, b) => a.created_at - b.created_at);
}, [channelMessages, selectedItemId]);
const feedProfilePubkeys = React.useMemo(
() =>
[
...new Set([
...feedItems.map((item) => item.pubkey),
...threadEvents.map((event) => event.pubkey),
...(currentPubkey ? [currentPubkey] : []),
]),
],
[currentPubkey, feedItems],
[currentPubkey, feedItems, threadEvents],
);
const feedProfilesQuery = useUsersBatchQuery(
feedProfilePubkeys,
@@ -110,8 +161,7 @@ export function HomeView({
const normalizedQuery = searchValue.trim().toLowerCase();
return inboxItems.filter((item) => {
const matchesFilter =
filter === "all" ? true : item.item.category === filter;
const matchesFilter = matchesInboxFilter(item, filter);
const matchesQuery =
normalizedQuery.length === 0 ||
item.searchableText.includes(normalizedQuery);
@@ -121,9 +171,32 @@ export function HomeView({
}, [filter, inboxItems, searchValue]);
const selectedItem =
filteredItems.find((item) => item.id === selectedItemId) ?? null;
const selectedItemReplies = selectedItem
? localRepliesByItemId[selectedItem.id] ?? []
: [];
const threadReplies = React.useMemo<InboxReply[]>(
() =>
threadEvents.map((event) => ({
id: event.id,
authorLabel: resolveUserLabel({
pubkey: event.pubkey,
currentPubkey,
profiles: feedProfiles,
preferResolvedSelfLabel: true,
}),
avatarUrl:
feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null,
content: event.content,
fullTimestampLabel: formatInboxFullTimestamp(event.created_at),
})),
[currentPubkey, feedProfiles, threadEvents],
);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
const localReplies = localRepliesByItemId[selectedItem.id] ?? [];
const remoteIds = new Set(threadReplies.map((reply) => reply.id));
const pendingLocals = localReplies.filter(
(reply) => !remoteIds.has(reply.id),
);
return [...threadReplies, ...pendingLocals];
}, [localRepliesByItemId, selectedItem, threadReplies]);
React.useEffect(() => {
if (filteredItems.length === 0) {
setSelectedItemId(null);
@@ -136,6 +209,7 @@ export function HomeView({
}, [filteredItems, selectedItemId]);
React.useEffect(() => {
void selectedItemId;
setIsDeletingMessage(false);
setIsSendingReply(false);
}, [selectedItemId]);
@@ -208,7 +282,10 @@ export function HomeView({
items={filteredItems}
onFilterChange={setFilter}
onSearchChange={setSearchValue}
onSelect={setSelectedItemId}
onSelect={(itemId) => {
setSelectedItemId(itemId);
markDone(itemId);
}}
searchValue={searchValue}
selectedId={selectedItemId}
/>
@@ -225,12 +302,7 @@ export function HomeView({
isDeletingMessage={isDeletingMessage}
isSendingReply={isSendingReply}
item={selectedItem}
localReplies={selectedItemReplies}
onArchive={() => {
if (selectedItem) {
handleToggleDone(selectedItem.id);
}
}}
replies={selectedItemReplies}
onDelete={() => {
if (!selectedItem || !canDelete) {
return;

View File

@@ -1,14 +1,11 @@
import {
ArrowUpRight,
Archive,
CheckCheck,
CircleDot,
Mail,
MailOpen,
Forward,
MoreHorizontal,
Reply,
ReplyAll,
Trash2,
} from "lucide-react";
import * as React from "react";
@@ -22,7 +19,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
@@ -42,8 +38,7 @@ type InboxDetailPaneProps = {
isDeletingMessage?: boolean;
isSendingReply?: boolean;
item: InboxItem | null;
localReplies?: InboxReply[];
onArchive: () => void;
replies?: InboxReply[];
onDelete: () => void;
onOpenChannel: (channelId: string) => void;
onSendReply: (
@@ -63,8 +58,7 @@ export function InboxDetailPane({
isDeletingMessage = false,
isSendingReply = false,
item,
localReplies = [],
onArchive,
replies = [],
onDelete,
onOpenChannel,
onSendReply,
@@ -152,22 +146,15 @@ export function InboxDetailPane({
<div className="flex shrink-0 items-center gap-4">
<TooltipProvider delayDuration={200}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-0.5">
<HeaderIconAction
label="Reply"
onClick={focusComposer}
icon={<Reply className="h-4 w-4" />}
/>
<HeaderIconAction
label="Reply all"
onClick={focusComposer}
icon={<ReplyAll className="h-4 w-4" />}
/>
<HeaderIconAction
label="Forward"
icon={<Forward className="h-4 w-4" />}
/>
</div>
{canReply ? (
<div className="flex items-center gap-0.5">
<HeaderIconAction
label="Reply"
onClick={focusComposer}
icon={<Reply className="h-4 w-4" />}
/>
</div>
) : null}
<div className="flex items-center gap-0.5">
{canOpenChannel && channelId ? (
<HeaderIconAction
@@ -188,20 +175,16 @@ export function InboxDetailPane({
}
/>
</div>
<HeaderMoreMenu
canDelete={canDelete}
isDeletingMessage={isDeletingMessage}
onArchive={onArchive}
onDelete={onDelete}
/>
{canDelete ? (
<HeaderMoreMenu
isDeletingMessage={isDeletingMessage}
onDelete={onDelete}
/>
) : null}
</div>
</TooltipProvider>
</div>
</div>
<div className="mt-5">
<h2 className="text-2xl font-semibold tracking-tight">{item.subject}</h2>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-6">
@@ -214,10 +197,18 @@ export function InboxDetailPane({
tight
/>
</div>
{localReplies.map((reply) => (
{replies.length > 0 ? (
<div
className="border-t border-border/60 px-6 py-5"
data-testid="home-inbox-local-reply"
className="border-t border-border/60 px-6 pt-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"
data-testid="home-inbox-replies-header"
>
{replies.length === 1 ? "1 reply" : `${replies.length} replies`}
</div>
) : null}
{replies.map((reply) => (
<div
className="px-6 py-5"
data-testid="home-inbox-reply"
key={reply.id}
>
<div className="mb-3 flex items-center gap-3">
@@ -249,12 +240,14 @@ export function InboxDetailPane({
<MessageComposer
channelId={item.item.channelId}
channelName={item.channelLabel ?? "channel"}
containerClassName="px-6 pb-4 sm:px-6"
disabled={!canReply}
draftKey={`inbox-reply:${item.id}`}
isSending={isSendingReply}
onSend={onSendReply}
placeholder={
canReply
? `Reply to ${item.senderLabel}${item.channelLabel ? ` in #${item.channelLabel}` : ""}`
? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}`
: (disabledReplyReason ?? "Replies are not available for this item.")
}
/>
@@ -293,14 +286,10 @@ function HeaderIconAction({
}
function HeaderMoreMenu({
canDelete,
isDeletingMessage,
onArchive,
onDelete,
}: {
canDelete: boolean;
isDeletingMessage: boolean;
onArchive: () => void;
onDelete: () => void;
}) {
const trigger = (
@@ -324,14 +313,9 @@ function HeaderMoreMenu({
<TooltipContent>More actions</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onArchive}>
<Archive className="h-4 w-4" />
Archive for now
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={!canDelete || isDeletingMessage}
disabled={isDeletingMessage}
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />

View File

@@ -1,4 +1,4 @@
import { Search } from "lucide-react";
import { Inbox, Search } from "lucide-react";
import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox";
import { groupInboxItems } from "@/features/home/lib/inbox";
@@ -11,6 +11,8 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
{ value: "all", label: "All" },
{ value: "mention", label: "Mentions" },
{ value: "needs_action", label: "Needs Action" },
{ value: "activity", label: "Activity" },
{ value: "agent_activity", label: "Agents" },
];
type InboxListPaneProps = {
@@ -38,7 +40,19 @@ export function InboxListPane({
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background">
<div className="border-b border-border/70 px-4 py-4">
<div className="border-b border-border/70 px-4 pb-4 pt-8" data-tauri-drag-region>
<div
className="mb-4 flex min-w-0 items-center gap-2"
data-tauri-drag-region
>
<Inbox className="h-5 w-5 shrink-0 text-primary" />
<h1
className="min-w-0 truncate text-lg font-semibold tracking-tight"
data-testid="chat-title"
>
Inbox
</h1>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -101,7 +115,6 @@ export function InboxListPane({
isSelected
? "border-l-primary bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
isDone ? "opacity-60" : "",
)}
data-testid={`home-inbox-item-${item.id}`}
key={item.id}
@@ -124,7 +137,12 @@ export function InboxListPane({
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-foreground">
<p
className={cn(
"truncate text-sm text-foreground",
isDone ? "font-normal" : "font-bold",
)}
>
{item.senderLabel}
</p>
{item.isActionRequired ? (
@@ -134,18 +152,35 @@ export function InboxListPane({
) : null}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{item.timestampLabel}
</span>
</div>
<p className="mt-0.5 line-clamp-2 text-sm leading-5 text-muted-foreground">
<p
className={cn(
"mt-0.5 line-clamp-2 text-sm leading-5",
isDone
? "font-normal text-muted-foreground"
: "font-semibold text-foreground",
)}
>
{item.preview}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{item.channelLabel ? (
<span className="text-[11px] font-medium text-muted-foreground">
<span
className={cn(
"text-[11px] text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
#{item.channelLabel}
</span>
) : null}

View File

@@ -17,6 +17,7 @@ import {
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerAttachments } from "./ComposerAttachments";
@@ -29,6 +30,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar";
type MessageComposerProps = {
channelId?: string | null;
channelName: string;
containerClassName?: string;
disabled?: boolean;
draftKey?: string;
editTarget?: {
@@ -58,6 +60,7 @@ type MessageComposerProps = {
export function MessageComposer({
channelId = null,
channelName,
containerClassName,
disabled = false,
draftKey,
editTarget = null,
@@ -87,6 +90,8 @@ export function MessageComposer({
const drafts = useDrafts();
const effectiveDraftKey = draftKey ?? channelId;
const previousDraftKeyRef = React.useRef<string | null>(null);
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
effectiveDraftKeyRef.current = effectiveDraftKey;
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
@@ -107,13 +112,11 @@ export function MessageComposer({
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const editTargetRef = React.useRef(editTarget);
const channelIdRef = React.useRef(channelId);
disabledRef.current = disabled;
isSendingRef.current = isSending;
onSendRef.current = onSend;
onEditSaveRef.current = onEditSave;
editTargetRef.current = editTarget;
channelIdRef.current = channelId;
// ── Refs consumed by Tiptap's submitOnEnter extension ──────────────
const isAutocompleteOpenRef = React.useRef(false);
@@ -352,11 +355,11 @@ export function MessageComposer({
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
const sendChannelId = channelIdRef.current;
const sentDraftKey = effectiveDraftKeyRef.current;
try {
await onSendRef.current(finalContent, pubkeys, mediaTags);
if (sendChannelId) {
drafts.clearDraft(sendChannelId);
if (sentDraftKey) {
drafts.clearDraft(sentDraftKey);
}
} catch {
setContent(savedContent);
@@ -498,7 +501,12 @@ export function MessageComposer({
// ── Render ──────────────────────────────────────────────────────────
return (
<footer className="relative z-10 -mt-4 px-8 pb-0 pt-0 sm:px-10">
<footer
className={cn(
"relative z-10 -mt-4 px-8 pb-0 pt-0 sm:px-10",
containerClassName,
)}
>
<div className="flex w-full flex-col gap-3">
<form
className="relative rounded-2xl border border-input bg-card px-4 pb-3 pt-4"
@@ -559,16 +567,18 @@ export function MessageComposer({
{replyTarget.body}
</p>
</div>
<Button
aria-label="Cancel reply"
className="h-7 w-7 shrink-0 px-0"
onClick={onCancelReply}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
{onCancelReply ? (
<Button
aria-label="Cancel reply"
className="h-7 w-7 shrink-0 px-0"
onClick={onCancelReply}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
) : null}