Redesign Home as an inbox (#582)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-14 13:35:17 -07:00
committed by GitHub
co-authored by Cursor
parent 1858e98d5e
commit 8a2d24e4fd
12 changed files with 1716 additions and 390 deletions
+4 -4
View File
@@ -5,7 +5,7 @@ import {
FileText,
FolderGit2,
Hash,
Home,
House,
Lock,
Zap,
} from "lucide-react";
@@ -19,7 +19,7 @@ import { useSidebar } from "@/shared/ui/sidebar";
type ChatHeaderProps = {
actions?: React.ReactNode;
title: string;
description: string;
description?: string;
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
@@ -39,7 +39,7 @@ function ChannelIcon({
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
}) {
if (mode === "home") {
return <Home className={HEADER_ICON_CLASS} />;
return <House className={HEADER_ICON_CLASS} />;
}
if (mode === "agents") {
@@ -83,7 +83,7 @@ export function ChatHeader({
overlaysContent = false,
statusBadge,
}: ChatHeaderProps) {
const trimmedDescription = description.trim();
const trimmedDescription = description?.trim() ?? "";
const { state: sidebarState } = useSidebar();
const reserveGlobalControls = sidebarState === "collapsed";
+340
View File
@@ -0,0 +1,340 @@
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { TimelineReaction } from "@/features/messages/types";
import type {
FeedItem,
FeedItemCategory,
HomeFeedResponse,
RelayEvent,
} from "@/shared/api/types";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
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;
senderLabel: string;
subject: string;
timestampLabel: string;
};
export type InboxReply = {
authorLabel: string;
avatarUrl: string | null;
content: string;
depth?: number;
fullTimestampLabel: string;
id: string;
parentId?: string | null;
reactions?: TimelineReaction[];
rootId?: string | null;
};
export type InboxContextMessage = InboxReply & {
depth: number;
isSelected: boolean;
mentionNames: string[];
};
export type InboxGroup = {
label: string;
items: InboxItem[];
};
const listTimeFormatter = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
});
const fullTimeFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
const shortDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const weekdayFormatter = new Intl.DateTimeFormat("en-US", {
weekday: "long",
});
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function diffInDays(from: Date, to: Date) {
return Math.round(
(startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000,
);
}
function feedHeadline(item: FeedItem) {
switch (item.kind) {
case 40007:
return "Reminder";
case 43001:
return "Job requested";
case 43002:
return "Job accepted";
case 43003:
return "Progress update";
case 43004:
return "Job result";
case 43005:
return "Job cancelled";
case 43006:
return "Job failed";
case 45001:
return "Forum post";
case 45003:
return "Forum reply";
case 46010:
return "Approval requested";
default:
if (item.category === "mention") {
return "Mention";
}
if (item.category === "agent_activity") {
return "Agent update";
}
return "Channel update";
}
}
function feedPreview(item: FeedItem) {
const content = item.content.trim();
if (content.length > 0) {
return content;
}
if (item.kind === 46010) {
return "A workflow is waiting for approval.";
}
if (item.kind === 40007) {
return "A reminder is waiting for you.";
}
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();
const dayDiff = diffInDays(now, date);
if (dayDiff === 0) {
return listTimeFormatter.format(date);
}
if (dayDiff === 1) {
return "Yesterday";
}
if (now.getFullYear() === date.getFullYear()) {
return shortDateFormatter.format(date);
}
return shortDateWithYearFormatter.format(date);
}
export function formatInboxFullTimestamp(unixSeconds: number) {
return fullTimeFormatter.format(new Date(unixSeconds * 1_000));
}
export function relayEventFromFeedItem(item: FeedItem): RelayEvent {
return {
content: item.content,
created_at: item.createdAt,
id: item.id,
kind: item.kind,
pubkey: item.pubkey,
sig: "",
tags: item.tags,
};
}
export function groupInboxItems(items: InboxItem[]): InboxGroup[] {
const groups = new Map<string, InboxItem[]>();
const now = new Date();
for (const item of items) {
const date = new Date(item.latestActivityAt * 1_000);
const dayDiff = diffInDays(now, date);
const label =
dayDiff === 0
? "Today"
: dayDiff === 1
? "Yesterday"
: dayDiff < 7
? weekdayFormatter.format(date)
: shortDateWithYearFormatter.format(date);
const current = groups.get(label) ?? [];
current.push(item);
groups.set(label, current);
}
return [...groups.entries()].map(([label, groupedItems]) => ({
label,
items: groupedItems,
}));
}
export function buildInboxItems({
currentPubkey,
feed,
profiles,
}: {
currentPubkey?: string;
feed?: HomeFeedResponse;
profiles?: UserProfileLookup;
}): InboxItem[] {
if (!feed) {
return [];
}
const feedItems = [
...feed.feed.mentions.map((item) => ({
...item,
category: "mention" as const,
})),
...feed.feed.needsAction.map((item) => ({
...item,
category: "needs_action" as const,
})),
...feed.feed.activity.map((item) => ({
...item,
category: "activity" as const,
})),
...feed.feed.agentActivity.map((item) => ({
...item,
category: "agent_activity" as const,
})),
];
const threadGroups = new Map<
string,
{
items: FeedItem[];
latestActivityAt: number;
rootItem: FeedItem | null;
}
>();
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,
senderLabel,
subject,
timestampLabel: formatInboxTimestamp(group.latestActivityAt),
};
});
}
@@ -1,4 +1,3 @@
import type { FeedItem } from "@/shared/api/types";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
@@ -6,15 +5,11 @@ import { HomeView } from "@/features/home/ui/HomeView";
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenFeedItem: (item: FeedItem) => void;
onOpenPulse: () => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenFeedItem,
onOpenPulse,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
@@ -38,8 +33,6 @@ export function HomeScreen({
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenFeedItem={onOpenFeedItem}
onOpenPulse={onOpenPulse}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
+372 -195
View File
@@ -1,60 +1,102 @@
import * as React from "react";
import { Activity, AtSign, Bot, CircleAlert, RefreshCcw } from "lucide-react";
import { RefreshCcw } from "lucide-react";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
type InboxFilter,
type InboxContextMessage,
type InboxReply,
buildInboxItems,
formatInboxFullTimestamp,
} from "@/features/home/lib/inbox";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
import { InboxListPane } from "@/features/home/ui/InboxListPane";
import {
useChannelMessagesQuery,
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types";
import { KIND_REACTION } from "@/shared/constants/kinds";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
const FeedSection = React.lazy(async () => {
const module = await import("./FeedSection");
return { default: module.FeedSection };
});
function matchesInboxFilter(
item: { categories: InboxFilter[] },
filter: InboxFilter,
) {
if (filter === "all") {
return item.categories.some((category) => category !== "activity");
}
const RecentNotesSection = React.lazy(async () => {
const module = await import("./RecentNotesSection");
return { default: module.RecentNotesSection };
});
type FeedFilter =
| "all"
| "mention"
| "needs_action"
| "activity"
| "agent_activity";
return item.categories.includes(filter);
}
function HomeLoadingState() {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="grid gap-4">
{["mentions", "actions"].map((section) => (
<div key={section}>
<Skeleton className="mb-2 h-4 w-24" />
<div className="space-y-0 rounded-md border border-border/60">
{["a", "b", "c"].map((row) => (
<Skeleton className="h-16" key={row} />
))}
</div>
</div>
))}
<div className="flex-1 overflow-hidden">
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="overflow-hidden border-r border-border/70 bg-background/60">
<div className="border-b border-border/70 px-4 pb-4 pt-14">
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-2 h-4 w-28" />
<Skeleton className="mt-4 h-10 rounded-md" />
</div>
<div className="space-y-3 px-4 py-4">
{["a", "b", "c", "d"].map((row) => (
<Skeleton className="h-20 rounded-md" key={row} />
))}
</div>
</div>
<div className="overflow-hidden bg-background/60">
<div className="border-b border-border/70 px-5 pb-4 pt-14">
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-3 h-8 w-72" />
</div>
<div className="px-5 py-5">
<Skeleton className="h-64 rounded-md" />
</div>
</div>
</div>
</div>
);
}
const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "mention", label: "Mentions" },
{ value: "needs_action", label: "Needs Action" },
{ value: "activity", label: "Activity" },
{ value: "agent_activity", label: "Agent Updates" },
];
function getContextMessageDepth(
event: RelayEvent,
eventById: ReadonlyMap<string, RelayEvent>,
): number {
let depth = 0;
let parentId = getThreadReference(event.tags).parentId;
const seen = new Set<string>([event.id]);
while (parentId && eventById.has(parentId) && !seen.has(parentId)) {
depth += 1;
seen.add(parentId);
parentId = getThreadReference(eventById.get(parentId)?.tags ?? []).parentId;
}
return depth;
}
function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
if (tag?.[0] === "e" && typeof tag[1] === "string") {
return tag[1];
}
}
return null;
}
type HomeViewProps = {
feed?: HomeFeedResponse;
@@ -62,8 +104,6 @@ type HomeViewProps = {
errorMessage?: string;
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenFeedItem: (item: FeedItem) => void;
onOpenPulse: () => void;
onRefresh: () => void;
};
@@ -73,64 +113,170 @@ export function HomeView({
errorMessage,
currentPubkey,
availableChannelIds,
onOpenFeedItem,
onOpenPulse,
onRefresh,
}: HomeViewProps) {
const [filter, setFilter] = React.useState<FeedFilter>("all");
const [filter, setFilter] = React.useState<InboxFilter>("all");
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(
null,
);
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
const [isSendingReply, setIsSendingReply] = React.useState(false);
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
Record<string, InboxReply[]>
>({});
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
// Defer Pulse widget queries until the shell is interactive
const startupReady = useDeferredStartup();
const deferredPubkey = startupReady ? currentPubkey : undefined;
// Recent notes for the Pulse widget
const contactListQuery = useContactListQuery(deferredPubkey);
const contactPubkeys = React.useMemo(
() => (contactListQuery.data?.contacts ?? []).map((c) => c.pubkey),
[contactListQuery.data],
);
const notesPubkeys = React.useMemo(
const feedItems = React.useMemo(
() =>
deferredPubkey
? [...new Set([deferredPubkey, ...contactPubkeys])]
: contactPubkeys,
[deferredPubkey, contactPubkeys],
feed
? [
...feed.feed.mentions,
...feed.feed.needsAction,
...feed.feed.activity,
...feed.feed.agentActivity,
]
: [],
[feed],
);
const notesTimelineQuery = useTimelineQuery(
notesPubkeys,
notesPubkeys.length > 0,
);
const recentNotes = notesTimelineQuery.data?.notes?.slice(0, 5) ?? [];
const noteAuthorPubkeys = React.useMemo(
() => [...new Set(recentNotes.map((n) => n.pubkey))],
[recentNotes],
);
const noteProfilesQuery = useUsersBatchQuery(noteAuthorPubkeys, {
enabled: noteAuthorPubkeys.length > 0,
});
const noteProfiles = noteProfilesQuery.data?.profiles ?? {};
const relayAgentsQuery = useRelayAgentsQuery({ enabled: startupReady });
const agentPubkeySet = React.useMemo(
() => new Set((relayAgentsQuery.data ?? []).map((a) => a.pubkey)),
[relayAgentsQuery.data],
const selectedFeedItem =
feedItems.find((item) => item.id === selectedItemId) ?? null;
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data;
const selectedChannelIdCandidate = React.useMemo(() => {
return selectedFeedItem?.channelId ?? null;
}, [selectedFeedItem]);
const selectedChannel = React.useMemo(() => {
if (!selectedChannelIdCandidate || !channels) return null;
return (
channels.find((channel) => channel.id === selectedChannelIdCandidate) ??
null
);
}, [channels, selectedChannelIdCandidate]);
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const toggleReactionMutation = useToggleReactionMutation();
const channelMessages = channelMessagesQuery.data;
const threadContext = useInboxThreadContext(
selectedFeedItem,
channelMessages,
);
const feedItems = feed
? [
...feed.feed.mentions,
...feed.feed.needsAction,
...(feed.feed.activity ?? []),
...(feed.feed.agentActivity ?? []),
]
: [];
const feedProfilesQuery = useUsersBatchQuery(
feedItems.map((item) => item.pubkey),
{
enabled: feedItems.length > 0,
},
const feedProfilePubkeys = React.useMemo(
() => [
...new Set([
...feedItems.map((item) => item.pubkey),
...threadContext.events.map((event) => event.pubkey),
...(channelMessages ?? [])
.filter((event) => event.kind === KIND_REACTION)
.map((event) => event.pubkey),
...(currentPubkey ? [currentPubkey] : []),
]),
],
[channelMessages, currentPubkey, feedItems, threadContext.events],
);
const feedProfilesQuery = useUsersBatchQuery(feedProfilePubkeys, {
enabled: feedProfilePubkeys.length > 0,
});
const feedProfiles = feedProfilesQuery.data?.profiles;
const inboxItems = React.useMemo(
() =>
buildInboxItems({
currentPubkey,
feed,
profiles: feedProfiles,
}),
[currentPubkey, feed, feedProfiles],
);
const filteredItems = React.useMemo(() => {
return inboxItems.filter((item) => matchesInboxFilter(item, filter));
}, [filter, inboxItems]);
const selectedItem =
filteredItems.find((item) => item.id === selectedItemId) ?? null;
const contextMessages = React.useMemo<InboxContextMessage[]>(() => {
if (!selectedItem) {
return [];
}
const eventById = new Map(
threadContext.events.map((event) => [event.id, event]),
);
const contextEventIds = new Set(eventById.keys());
const reactionEvents = (channelMessages ?? []).filter((event) => {
if (event.kind !== KIND_REACTION) {
return false;
}
const targetId = getReactionTargetId(event.tags);
return Boolean(targetId && contextEventIds.has(targetId));
});
const currentUserAvatarUrl = currentPubkey
? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null)
: null;
const timelineMessages = formatTimelineMessages(
[...threadContext.events, ...reactionEvents],
selectedChannel,
currentPubkey,
currentUserAvatarUrl,
feedProfiles,
);
return timelineMessages.map((message) => {
const event = eventById.get(message.id);
return {
id: message.id,
authorLabel: message.author,
avatarUrl: message.avatarUrl ?? null,
content: message.body,
depth: event ? getContextMessageDepth(event, eventById) : message.depth,
fullTimestampLabel: formatInboxFullTimestamp(message.createdAt),
isSelected: message.id === selectedItem.id,
mentionNames:
resolveMentionNames(message.tags ?? [], feedProfiles) ?? [],
reactions: message.reactions,
};
});
}, [
channelMessages,
currentPubkey,
feedProfiles,
selectedChannel,
selectedItem,
threadContext.events,
]);
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
const localReplies = localRepliesByItemId[selectedItem.id] ?? [];
const contextIds = new Set(contextMessages.map((message) => message.id));
return localReplies.filter((reply) => !contextIds.has(reply.id));
}, [contextMessages, localRepliesByItemId, selectedItem]);
React.useEffect(() => {
if (filteredItems.length === 0) {
setSelectedItemId(null);
return;
}
if (!filteredItems.some((item) => item.id === selectedItemId)) {
setSelectedItemId(filteredItems[0]?.id ?? null);
}
}, [filteredItems, selectedItemId]);
React.useEffect(() => {
void selectedItemId;
setIsDeletingMessage(false);
setIsSendingReply(false);
}, [selectedItemId]);
const handleToggleDone = React.useCallback(
(itemId: string) => {
if (doneSet.has(itemId)) {
undoDone(itemId);
return;
}
markDone(itemId);
},
[doneSet, markDone, undoDone],
);
if (isLoading && !feed) {
return <HomeLoadingState />;
@@ -138,8 +284,8 @@ export function HomeView({
if (!feed) {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
<div className="flex-1 overflow-hidden px-4 pb-3 pt-14 sm:px-6">
<div className="flex w-full max-w-3xl flex-col gap-4">
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-5">
<p className="text-base font-semibold tracking-tight">
Home feed unavailable
@@ -157,111 +303,142 @@ export function HomeView({
);
}
const showMentions = filter === "all" || filter === "mention";
const showNeedsAction = filter === "all" || filter === "needs_action";
const showActivity = filter === "all" || filter === "activity";
const showAgentActivity = filter === "all" || filter === "agent_activity";
const canReply =
selectedItem !== null &&
selectedItem.item.channelId !== null &&
availableChannelIds.has(selectedItem.item.channelId) &&
selectedItem.item.kind !== 45001 &&
selectedItem.item.kind !== 45003;
const disabledReplyReason =
canReply || !selectedItem
? null
: selectedItem.item.channelId
? availableChannelIds.has(selectedItem.item.channelId)
? "This item does not support inline replies yet."
: "Open the linked channel to reply."
: "This inbox item does not have a reply target.";
const canDelete =
selectedItem !== null &&
currentPubkey?.trim().toLowerCase() ===
selectedItem.item.pubkey.trim().toLowerCase();
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="flex items-center gap-1.5">
{FILTER_OPTIONS.map((option) => (
<Button
key={option.value}
onClick={() => setFilter(option.value)}
size="sm"
type="button"
variant={filter === option.value ? "default" : "ghost"}
className="h-7 px-2.5 text-xs"
>
{option.label}
</Button>
))}
</div>
<div className="flex-1 overflow-hidden">
<div
className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]"
data-testid="home-inbox"
>
<InboxListPane
doneSet={doneSet}
filter={filter}
items={filteredItems}
onFilterChange={setFilter}
onSelect={(itemId) => {
setSelectedItemId(itemId);
markDone(itemId);
}}
selectedId={selectedItemId}
/>
{recentNotes.length > 0 ? (
<React.Suspense fallback={null}>
<RecentNotesSection
agentPubkeys={agentPubkeySet}
notes={recentNotes}
onOpenPulse={onOpenPulse}
profiles={noteProfiles}
/>
</React.Suspense>
) : null}
<InboxDetailPane
canDelete={canDelete}
canOpenChannel={Boolean(
selectedItem?.item.channelId &&
availableChannelIds.has(selectedItem.item.channelId),
)}
canReply={canReply}
disabledReplyReason={disabledReplyReason}
isDone={selectedItem ? doneSet.has(selectedItem.id) : false}
isDeletingMessage={isDeletingMessage}
isSendingReply={isSendingReply}
isThreadContextLoading={threadContext.isLoading}
item={selectedItem}
messages={contextMessages}
replies={selectedItemReplies}
onDelete={() => {
if (!selectedItem || !canDelete) {
return;
}
<React.Suspense fallback={null}>
<div className="grid gap-5">
{showMentions ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="When someone mentions you, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
/>
) : null}
{showNeedsAction ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
) : null}
{showActivity ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Recent channel messages and forum posts will show up here."
emptyTitle="No channel activity yet"
icon={Activity}
items={feed.feed.activity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Channel Activity"
/>
) : null}
{showAgentActivity ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Agent job requests, progress, and results will appear here."
emptyTitle="No agent updates yet"
icon={Bot}
items={feed.feed.agentActivity ?? []}
onMarkDone={markDone}
onOpenItem={onOpenFeedItem}
onUndoDone={undoDone}
showDoneAction={false}
title="Agent Updates"
/>
) : null}
</div>
</React.Suspense>
setIsDeletingMessage(true);
void deleteMessage(selectedItem.id)
.then(() => {
onRefresh();
})
.finally(() => {
setIsDeletingMessage(false);
});
}}
onSendReply={async ({
content,
mediaTags,
mentionPubkeys,
parentEventId,
}) => {
const channelId = selectedItem?.item.channelId;
if (!selectedItem || !channelId || !canReply) {
throw new Error("Replies are not available for this item.");
}
const itemToReply = selectedItem;
setIsSendingReply(true);
try {
const result = await sendChannelMessage(
channelId,
content,
parentEventId,
mediaTags,
mentionPubkeys,
);
const authorPubkey = currentPubkey ?? itemToReply.item.pubkey;
const reply: InboxReply = {
authorLabel: currentPubkey
? resolveUserLabel({
currentPubkey,
profiles: feedProfiles,
pubkey: authorPubkey,
})
: "You",
avatarUrl:
currentPubkey && feedProfiles
? (feedProfiles[currentPubkey.trim().toLowerCase()]
?.avatarUrl ?? null)
: null,
content,
depth: result.depth,
fullTimestampLabel: formatInboxFullTimestamp(result.createdAt),
id: result.eventId,
parentId: result.parentEventId,
rootId: result.rootEventId,
};
setLocalRepliesByItemId((current) => ({
...current,
[itemToReply.id]: [...(current[itemToReply.id] ?? []), reply],
}));
onRefresh();
} finally {
setIsSendingReply(false);
}
}}
onToggleDone={() => {
if (selectedItem) {
handleToggleDone(selectedItem.id);
}
}}
onToggleReaction={
canReply
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
await channelMessagesQuery.refetch();
onRefresh();
}
: undefined
}
/>
</div>
</div>
);
@@ -0,0 +1,470 @@
import {
CheckCheck,
Mail,
MailOpen,
MoreHorizontal,
Trash2,
} from "lucide-react";
import * as React from "react";
import type {
InboxContextMessage,
InboxItem,
InboxReply,
} from "@/features/home/lib/inbox";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type InboxDetailPaneProps = {
canDelete: boolean;
canOpenChannel: boolean;
canReply: boolean;
disabledReplyReason?: string | null;
isDone: boolean;
isDeletingMessage?: boolean;
isSendingReply?: boolean;
isThreadContextLoading?: boolean;
item: InboxItem | null;
messages?: InboxContextMessage[];
replies?: InboxReply[];
onDelete: () => void;
onSendReply: (input: {
content: string;
mediaTags?: string[][];
mentionPubkeys: string[];
parentEventId: string;
}) => Promise<void>;
onToggleDone: () => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
};
type InboxDisplayMessage = InboxContextMessage & {
depth: number;
};
function toActionBarMessage(message: InboxDisplayMessage): TimelineMessage {
return {
id: message.id,
author: message.authorLabel,
avatarUrl: message.avatarUrl,
body: message.content,
createdAt: 0,
depth: message.depth,
reactions: message.reactions ?? [],
time: message.fullTimestampLabel,
};
}
export function InboxDetailPane({
canDelete,
canOpenChannel,
canReply,
disabledReplyReason,
isDone,
isDeletingMessage = false,
isSendingReply = false,
isThreadContextLoading = false,
item,
messages = [],
replies = [],
onDelete,
onSendReply,
onToggleDone,
onToggleReaction,
}: InboxDetailPaneProps) {
const detailPaneRef = React.useRef<HTMLElement | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [isFocusHighlightVisible, setIsFocusHighlightVisible] =
React.useState(true);
const selectedItemId = item?.id ?? null;
const focusComposer = React.useCallback(() => {
window.requestAnimationFrame(() => {
const textarea =
detailPaneRef.current?.querySelector<HTMLTextAreaElement>(
'[data-testid="message-input"]',
);
textarea?.focus();
});
}, []);
React.useEffect(() => {
void selectedItemId;
setReplyTargetId(null);
}, [selectedItemId]);
React.useEffect(() => {
void selectedItemId;
setIsFocusHighlightVisible(true);
const timeoutId = window.setTimeout(() => {
setIsFocusHighlightVisible(false);
}, 1_200);
return () => {
window.clearTimeout(timeoutId);
};
}, [selectedItemId]);
if (!item) {
return (
<section
className="flex min-h-0 min-w-0 items-center justify-center bg-background/60 px-6 py-10 pt-20 text-center"
data-testid="home-inbox-detail-empty"
>
<div className="max-w-sm">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Mail className="h-6 w-6" />
</div>
<p className="mt-4 text-base font-semibold">Select a message</p>
<p className="mt-1 text-sm text-muted-foreground">
Pick an inbox item to see the full message and react to it.
</p>
</div>
</section>
);
}
const selectedMessage = messages.find((message) => message.isSelected);
const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({
...reply,
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
isSelected: false,
mentionNames: [],
}));
const displayMessages: InboxDisplayMessage[] =
messages.length > 0
? [...messages, ...pendingReplyMessages]
: [
{
authorLabel: item.senderLabel,
avatarUrl: item.avatarUrl,
content: item.preview,
depth: 0,
fullTimestampLabel: item.fullTimestampLabel,
id: item.id,
isSelected: true,
mentionNames: item.mentionNames,
},
...pendingReplyMessages,
];
const replyTarget =
displayMessages.find((message) => message.id === replyTargetId) ?? null;
const composerParentEventId = replyTarget?.id ?? item.id;
const composerReplyTarget =
replyTarget && replyTarget.id !== item.id
? {
author: replyTarget.authorLabel,
body: replyTarget.content,
id: replyTarget.id,
}
: null;
const handleSelectReplyTarget = (message: InboxDisplayMessage) => {
setReplyTargetId((currentReplyTargetId) =>
currentReplyTargetId === message.id ? null : message.id,
);
focusComposer();
};
return (
<section
className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60"
data-testid="home-inbox-detail"
ref={detailPaneRef}
>
{!canOpenChannel ? (
<div className="px-6 pb-4 pt-14">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8 rounded-xl"
displayName={item.senderLabel}
size="md"
/>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="truncate text-base font-semibold">
{item.senderLabel}
</p>
<span
className={cn(
"inline-flex items-center text-[10px] font-semibold uppercase tracking-[0.14em]",
item.isActionRequired
? "text-amber-600 dark:text-amber-300"
: "text-primary",
)}
>
{item.categoryLabel}
</span>
{item.channelLabel ? (
<span className="inline-flex items-center text-[11px] font-medium text-muted-foreground">
#{item.channelLabel}
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{item.fullTimestampLabel}</span>
<span>Inbox only</span>
</div>
</div>
</div>
<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={isDone ? "Mark unread" : "Mark done"}
onClick={onToggleDone}
icon={
isDone ? (
<MailOpen className="h-4 w-4" />
) : (
<CheckCheck className="h-4 w-4" />
)
}
/>
</div>
{canDelete ? (
<HeaderMoreMenu
isDeletingMessage={isDeletingMessage}
onDelete={onDelete}
/>
) : null}
</div>
</TooltipProvider>
</div>
</div>
</div>
) : null}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-14">
<div>
{isThreadContextLoading ? (
<div className="px-6 pb-3 text-[11px] text-muted-foreground">
Loading context...
</div>
) : null}
{displayMessages.map((message, index) => (
<React.Fragment key={message.id}>
{index === 1 ? (
<div className="mx-6 my-3 border-t border-border/60" />
) : null}
<div className="px-6 py-2">
<article
className={cn(
"group/message flex items-start gap-2.5 px-2 py-1 transition-colors duration-1000",
message.isSelected
? cn(
isFocusHighlightVisible
? "bg-primary/[0.07]"
: "bg-transparent",
)
: "hover:bg-muted/20",
)}
data-testid={
message.isSelected
? "home-inbox-selected-message"
: "home-inbox-context-message"
}
>
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-8 w-8 shrink-0 rounded-xl"
displayName={message.authorLabel}
size="md"
/>
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0">
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
{message.authorLabel}
</p>
{message.isSelected ? (
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-muted-foreground/70">
Inbox item
</span>
) : null}
<p className="ml-auto text-xs text-muted-foreground">
{message.fullTimestampLabel}
</p>
{canReply || onToggleReaction ? (
<div className="relative ml-1">
<div className="absolute right-0 top-1/2 -translate-y-1/2">
<MessageActionBar
activeReplyTargetId={replyTargetId}
message={toActionBarMessage(message)}
onReactionSelect={
onToggleReaction
? (emoji) => {
const actionBarMessage =
toActionBarMessage(message);
const remove =
actionBarMessage.reactions?.some(
(reaction) =>
reaction.emoji === emoji &&
reaction.reactedByCurrentUser,
) ?? false;
return onToggleReaction(
actionBarMessage,
emoji,
remove,
);
}
: undefined
}
onReply={
canReply
? () => handleSelectReplyTarget(message)
: undefined
}
reactions={message.reactions ?? []}
/>
</div>
</div>
) : null}
</div>
<div className="mt-1">
<Markdown
className="max-w-full text-left text-sm text-foreground"
content={message.content}
mentionNames={message.mentionNames}
tight
/>
</div>
</div>
</article>
</div>
</React.Fragment>
))}
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<MessageComposer
channelId={item.item.channelId}
channelName={item.channelLabel ?? "channel"}
containerClassName="px-6 pb-4 sm:px-6 [&>div]:max-w-none"
disabled={!canReply}
draftKey={`inbox-reply:${item.id}`}
isSending={isSendingReply}
onCancelReply={
composerReplyTarget ? () => setReplyTargetId(null) : undefined
}
onSend={(content, mentionPubkeys, mediaTags) =>
onSendReply({
content,
mediaTags,
mentionPubkeys,
parentEventId: composerParentEventId,
})
}
placeholder={
canReply
? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}`
: (disabledReplyReason ??
"Replies are not available for this item.")
}
replyTarget={composerReplyTarget}
/>
</div>
</div>
</div>
</section>
);
}
function HeaderIconAction({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick?: () => void;
}) {
const button = (
<Button
aria-label={label}
className="h-8 w-8 rounded-full p-0 text-muted-foreground"
onClick={onClick}
size="icon"
type="button"
variant="ghost"
>
{icon}
</Button>
);
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function HeaderMoreMenu({
isDeletingMessage,
onDelete,
}: {
isDeletingMessage: boolean;
onDelete: () => void;
}) {
const trigger = (
<Button
aria-label="More actions"
className="h-8 w-8 rounded-full p-0 text-muted-foreground"
size="icon"
type="button"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
);
return (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isDeletingMessage}
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
Delete message
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,153 @@
import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
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 = {
doneSet: ReadonlySet<string>;
filter: InboxFilter;
items: InboxItem[];
onFilterChange: (filter: InboxFilter) => void;
onSelect: (itemId: string) => void;
selectedId: string | null;
};
export function InboxListPane({
doneSet,
filter,
items,
onFilterChange,
onSelect,
selectedId,
}: InboxListPaneProps) {
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background/60">
<div className="px-4 pb-3 pt-14">
<div className="flex flex-nowrap gap-1">
{FILTER_OPTIONS.map((option) => (
<Button
className="h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-sm data-[active=true]:backdrop-blur-sm"
data-active={filter === option.value}
key={option.value}
onClick={() => onFilterChange(option.value)}
size="sm"
type="button"
variant="ghost"
>
{option.label}
</Button>
))}
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="home-inbox-list"
>
{items.length === 0 ? (
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
<div>
<p className="text-sm font-medium text-foreground">
No messages found
</p>
<p className="mt-1 text-sm text-muted-foreground">
Switch back to all mail to see more messages.
</p>
</div>
</div>
) : (
<div>
{items.map((item) => {
const isSelected = item.id === selectedId;
const isDone = doneSet.has(item.id);
return (
<button
className={cn(
"flex w-full items-start gap-2.5 border-l px-4 py-2 text-left transition-colors",
isSelected
? "border-l-primary bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
)}
data-testid={`home-inbox-item-${item.id}`}
key={item.id}
onClick={() => onSelect(item.id)}
type="button"
>
<div className="relative">
<UserAvatar
avatarUrl={item.avatarUrl}
className="h-8 w-8 rounded-xl"
displayName={item.senderLabel}
size="md"
/>
{!isDone ? (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
) : null}
</div>
<div className="min-w-0 flex-1">
<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">
{item.senderLabel}
</p>
{item.isActionRequired ? (
<span className="inline-flex shrink-0 items-center text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
Needs action
</span>
) : null}
</div>
</div>
<span
className={cn(
"shrink-0 text-xs text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
{item.timestampLabel}
</span>
</div>
<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={cn(
"text-[11px] text-muted-foreground",
isDone ? "font-normal" : "font-semibold",
)}
>
#{item.channelLabel}
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,179 @@
import * as React from "react";
import { relayEventFromFeedItem } from "@/features/home/lib/inbox";
import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { getEventById } from "@/shared/api/tauri";
import type { FeedItem, RelayEvent } from "@/shared/api/types";
import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds";
type InboxThreadContextResult = {
events: RelayEvent[];
isLoading: boolean;
};
const THREAD_CONTEXT_LIMIT = 100;
function dedupeEvents(events: RelayEvent[]): RelayEvent[] {
const eventsById = new Map<string, RelayEvent>();
for (const event of events) {
eventsById.set(event.id, event);
}
return [...eventsById.values()].sort((a, b) => a.created_at - b.created_at);
}
function getThreadRootId(event: RelayEvent): string {
const thread = getThreadReference(event.tags);
return thread.rootId ?? thread.parentId ?? event.id;
}
function isSameChannel(event: RelayEvent, channelId: string | null): boolean {
if (!channelId) {
return true;
}
return getChannelIdFromTags(event.tags) === channelId;
}
export function useInboxThreadContext(
item: FeedItem | null,
channelMessages: RelayEvent[] | undefined,
): InboxThreadContextResult {
const [fetchedEvents, setFetchedEvents] = React.useState<RelayEvent[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const selectedEvent = React.useMemo(
() => (item ? relayEventFromFeedItem(item) : null),
[item],
);
const selectedThreadRootId = selectedEvent
? getThreadRootId(selectedEvent)
: null;
const selectedParentId = selectedEvent
? getThreadReference(selectedEvent.tags).parentId
: null;
const selectedChannelId = item?.channelId ?? null;
React.useEffect(() => {
let isCancelled = false;
if (!selectedEvent || !selectedThreadRootId) {
setFetchedEvents([]);
setIsLoading(false);
return () => {
isCancelled = true;
};
}
async function loadContext() {
const targetEvent = selectedEvent;
const threadRootId = selectedThreadRootId;
if (!targetEvent || !threadRootId) {
return;
}
setIsLoading(true);
try {
const eventIds = new Set<string>([threadRootId]);
if (selectedParentId) {
eventIds.add(selectedParentId);
}
const ancestorEvents = await Promise.all(
[...eventIds]
.filter((eventId) => eventId !== targetEvent.id)
.map(async (eventId) => {
try {
return await getEventById(eventId);
} catch {
return null;
}
}),
);
const descendantEvents =
selectedChannelId && threadRootId
? await relayClient
.fetchEvents({
"#e": [threadRootId],
"#h": [selectedChannelId],
kinds: [...HOME_MENTION_EVENT_KINDS],
limit: THREAD_CONTEXT_LIMIT,
})
.catch(() => [])
: [];
if (isCancelled) {
return;
}
setFetchedEvents(
dedupeEvents(
[...ancestorEvents, ...descendantEvents].filter(
(event): event is RelayEvent =>
event !== null && isSameChannel(event, selectedChannelId),
),
),
);
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
}
void loadContext();
return () => {
isCancelled = true;
};
}, [
selectedChannelId,
selectedEvent,
selectedParentId,
selectedThreadRootId,
]);
const events = React.useMemo(() => {
if (!selectedEvent) {
return [];
}
const localContext = (channelMessages ?? []).filter((event) => {
if (!isSameChannel(event, selectedChannelId)) {
return false;
}
if (event.id === selectedEvent.id) {
return true;
}
const thread = getThreadReference(event.tags);
return (
event.id === selectedThreadRootId ||
event.id === selectedParentId ||
thread.rootId === selectedThreadRootId ||
thread.parentId === selectedThreadRootId ||
thread.parentId === selectedEvent.id
);
});
return dedupeEvents([selectedEvent, ...fetchedEvents, ...localContext]);
}, [
channelMessages,
fetchedEvents,
selectedChannelId,
selectedEvent,
selectedParentId,
selectedThreadRootId,
]);
return {
events,
isLoading,
};
}
@@ -34,6 +34,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar";
type MessageComposerProps = {
channelId?: string | null;
channelName: string;
containerClassName?: string;
disabled?: boolean;
draftKey?: string;
editTarget?: {
@@ -66,6 +67,7 @@ type MessageComposerProps = {
export function MessageComposer({
channelId = null,
channelName,
containerClassName,
disabled = false,
draftKey,
editTarget = null,
@@ -82,7 +84,6 @@ export function MessageComposer({
typingParentEventId = null,
typingRootEventId = null,
}: MessageComposerProps) {
// ── Markdown content state (synced from Tiptap on every update) ──────
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
contentRef.current = content;
@@ -98,6 +99,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 preEditContentRef = React.useRef<string | null>(null);
const mentions = useMentions(channelId, undefined, profiles);
const channelLinks = useChannelLinks();
@@ -108,24 +111,20 @@ export function MessageComposer({
typingRootEventId,
);
// ── Media upload ─────────────────────────────────────────────────────
// We pass a custom setter that both updates React state AND inserts
// markdown into the Tiptap editor when media upload completes.
const media = useMediaUpload();
// ── Stable refs for callbacks ────────────────────────────────────────
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
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;
const isAutocompleteOpenRef = React.useRef(false);
isAutocompleteOpenRef.current =
@@ -142,7 +141,6 @@ export function MessageComposer({
? `Reply to ${replyTarget.author} in #${channelName}`
: `Message #${channelName}`));
// ── Tiptap editor ───────────────────────────────────────────────────
const richText = useRichTextEditor({
placeholder: computedPlaceholder,
editable: !disabled,
@@ -166,7 +164,6 @@ export function MessageComposer({
},
});
// ── Channel switching: save/restore drafts ──────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
const prevKey = previousDraftKeyRef.current;
@@ -202,7 +199,6 @@ export function MessageComposer({
};
}, [effectiveDraftKey]);
// ── Edit mode: pre-fill content & restore on cancel ─────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
React.useEffect(() => {
if (editTarget) {
@@ -391,11 +387,11 @@ export function MessageComposer({
emojiAutocomplete.clearEmojis();
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);
@@ -550,8 +546,9 @@ export function MessageComposer({
return (
<footer
className={cn(
"relative shrink-0 bg-transparent px-4 pb-2 pt-0",
"relative z-10 shrink-0 bg-transparent px-4 pb-2 pt-0",
showTopBorder ? "border-t border-border/40 pt-3" : "",
containerClassName,
)}
>
<div
@@ -630,16 +627,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}