mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Redesign Home as an inbox (#582)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user