mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
feat(desktop): refine inbox signal and layout
Made-with: Cursor
This commit is contained in:
@@ -10,12 +10,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
|
||||
|
||||
type ChatHeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
channelType?: ChannelType;
|
||||
visibility?: ChannelVisibility;
|
||||
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
@@ -73,7 +74,10 @@ export function ChatHeader({
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 pb-3 pt-8 sm:px-6"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-3 bg-background px-4 pb-3 pt-8 sm:px-6",
|
||||
mode === "home" ? "" : "border-b border-border/80",
|
||||
)}
|
||||
data-testid="chat-header"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
@@ -96,12 +100,14 @@ export function ChatHeader({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="chat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{description ? (
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="chat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
|
||||
@@ -2,19 +2,31 @@ import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import type {
|
||||
FeedItem,
|
||||
FeedItemCategory,
|
||||
HomeFeedResponse,
|
||||
} from "@/shared/api/types";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
|
||||
export type InboxFilter = "all" | "mention" | "needs_action";
|
||||
export type InboxFilter =
|
||||
| "all"
|
||||
| "mention"
|
||||
| "needs_action"
|
||||
| "activity"
|
||||
| "agent_activity";
|
||||
|
||||
export type InboxItem = {
|
||||
avatarUrl: string | null;
|
||||
id: string;
|
||||
item: FeedItem;
|
||||
categories: FeedItemCategory[];
|
||||
categoryLabel: string;
|
||||
channelLabel: string | null;
|
||||
fullTimestampLabel: string;
|
||||
isActionRequired: boolean;
|
||||
latestActivityAt: number;
|
||||
mentionNames: string[];
|
||||
preview: string;
|
||||
searchableText: string;
|
||||
@@ -126,6 +138,34 @@ function feedPreview(item: FeedItem) {
|
||||
return "No additional details were attached to this event.";
|
||||
}
|
||||
|
||||
function categoryLabelFor(category: FeedItemCategory) {
|
||||
return category === "needs_action"
|
||||
? "Needs Action"
|
||||
: category === "mention"
|
||||
? "Mention"
|
||||
: category === "agent_activity"
|
||||
? "Agent update"
|
||||
: "Activity";
|
||||
}
|
||||
|
||||
function categoryPriority(category: FeedItemCategory) {
|
||||
switch (category) {
|
||||
case "needs_action":
|
||||
return 0;
|
||||
case "mention":
|
||||
return 1;
|
||||
case "agent_activity":
|
||||
return 2;
|
||||
case "activity":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function getInboxThreadKey(item: FeedItem) {
|
||||
const thread = getThreadReference(item.tags);
|
||||
return thread.rootId ?? thread.parentId ?? item.id;
|
||||
}
|
||||
|
||||
function formatInboxTimestamp(unixSeconds: number) {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
const now = new Date();
|
||||
@@ -155,7 +195,7 @@ export function groupInboxItems(items: InboxItem[]): InboxGroup[] {
|
||||
const now = new Date();
|
||||
|
||||
for (const item of items) {
|
||||
const date = new Date(item.item.createdAt * 1_000);
|
||||
const date = new Date(item.latestActivityAt * 1_000);
|
||||
const dayDiff = diffInDays(now, date);
|
||||
const label =
|
||||
dayDiff === 0
|
||||
@@ -190,46 +230,86 @@ export function buildInboxItems({
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = [...feed.feed.mentions, ...feed.feed.needsAction].sort(
|
||||
(left, right) => right.createdAt - left.createdAt,
|
||||
);
|
||||
const feedItems = [
|
||||
...feed.feed.mentions,
|
||||
...feed.feed.needsAction,
|
||||
...feed.feed.activity,
|
||||
...feed.feed.agentActivity,
|
||||
];
|
||||
|
||||
return items.map((item) => {
|
||||
const senderLabel = resolveUserLabel({
|
||||
pubkey: item.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const subject = feedHeadline(item);
|
||||
const preview = feedPreview(item);
|
||||
const mentionNames = resolveMentionNames(item.tags, profiles) ?? [];
|
||||
const channelLabel = item.channelName.trim() || null;
|
||||
const categoryLabel =
|
||||
item.category === "needs_action" ? "Needs Action" : "Mention";
|
||||
const threadGroups = new Map<
|
||||
string,
|
||||
{
|
||||
items: FeedItem[];
|
||||
latestActivityAt: number;
|
||||
rootItem: FeedItem | null;
|
||||
}
|
||||
>();
|
||||
|
||||
return {
|
||||
avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null,
|
||||
id: item.id,
|
||||
item,
|
||||
categoryLabel,
|
||||
channelLabel,
|
||||
fullTimestampLabel: formatInboxFullTimestamp(item.createdAt),
|
||||
isActionRequired: item.category === "needs_action",
|
||||
mentionNames,
|
||||
preview,
|
||||
searchableText: [
|
||||
for (const item of feedItems) {
|
||||
const threadKey = getInboxThreadKey(item);
|
||||
const group = threadGroups.get(threadKey) ?? {
|
||||
items: [],
|
||||
latestActivityAt: 0,
|
||||
rootItem: null,
|
||||
};
|
||||
|
||||
group.items.push(item);
|
||||
group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt);
|
||||
if (item.id === threadKey) {
|
||||
group.rootItem = item;
|
||||
}
|
||||
|
||||
threadGroups.set(threadKey, group);
|
||||
}
|
||||
|
||||
return [...threadGroups.values()]
|
||||
.sort((left, right) => right.latestActivityAt - left.latestActivityAt)
|
||||
.map((group) => {
|
||||
const latestItem = group.items.reduce((latest, current) =>
|
||||
current.createdAt > latest.createdAt ? current : latest,
|
||||
);
|
||||
const item = group.rootItem ?? latestItem;
|
||||
const categories = [
|
||||
...new Set(group.items.map((groupItem) => groupItem.category)),
|
||||
].sort((left, right) => categoryPriority(left) - categoryPriority(right));
|
||||
const senderLabel = resolveUserLabel({
|
||||
pubkey: item.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const subject = feedHeadline(item);
|
||||
const preview = feedPreview(item);
|
||||
const mentionNames = resolveMentionNames(item.tags, profiles) ?? [];
|
||||
const channelLabel = item.channelName.trim() || null;
|
||||
const categoryLabel = categoryLabelFor(categories[0] ?? item.category);
|
||||
|
||||
return {
|
||||
avatarUrl: profiles?.[item.pubkey.toLowerCase()]?.avatarUrl ?? null,
|
||||
id: item.id,
|
||||
item,
|
||||
categories,
|
||||
categoryLabel,
|
||||
channelLabel,
|
||||
fullTimestampLabel: formatInboxFullTimestamp(item.createdAt),
|
||||
isActionRequired: categories.includes("needs_action"),
|
||||
latestActivityAt: group.latestActivityAt,
|
||||
mentionNames,
|
||||
preview,
|
||||
searchableText: [
|
||||
senderLabel,
|
||||
subject,
|
||||
preview,
|
||||
...group.items.map(feedPreview),
|
||||
channelLabel ?? "",
|
||||
categoryLabel,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase(),
|
||||
senderLabel,
|
||||
subject,
|
||||
preview,
|
||||
channelLabel ?? "",
|
||||
categoryLabel,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase(),
|
||||
senderLabel,
|
||||
subject,
|
||||
timestampLabel: formatInboxTimestamp(item.createdAt),
|
||||
};
|
||||
});
|
||||
timestampLabel: formatInboxTimestamp(group.latestActivityAt),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import { useHomeFeedQuery } from "@/features/home/hooks";
|
||||
import { HomeView } from "@/features/home/ui/HomeView";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type HomeScreenProps = {
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
@@ -19,45 +15,22 @@ export function HomeScreen({
|
||||
const homeFeedQuery = useHomeFeedQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
actions={
|
||||
<Button
|
||||
className="h-9 rounded-full px-3"
|
||||
onClick={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${homeFeedQuery.isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<HomeView
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
errorMessage={
|
||||
homeFeedQuery.error instanceof Error
|
||||
? homeFeedQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
description="Personalized inbox for mentions, reminders, and approvals."
|
||||
mode="home"
|
||||
title="Inbox"
|
||||
feed={homeFeedQuery.data}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<HomeView
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
errorMessage={
|
||||
homeFeedQuery.error instanceof Error
|
||||
? homeFeedQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
feed={homeFeedQuery.data}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
type InboxFilter,
|
||||
type InboxReply,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
import { useFeedItemState } from "@/features/home/useFeedItemState";
|
||||
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
|
||||
import { InboxListPane } from "@/features/home/ui/InboxListPane";
|
||||
import { useChannelMessagesQuery } from "@/features/messages/hooks";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
|
||||
@@ -17,6 +20,14 @@ import type { HomeFeedResponse } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
function matchesInboxFilter(item: { categories: InboxFilter[] }, filter: InboxFilter) {
|
||||
if (filter === "all") {
|
||||
return item.categories.some((category) => category !== "activity");
|
||||
}
|
||||
|
||||
return item.categories.includes(filter);
|
||||
}
|
||||
|
||||
function HomeLoadingState() {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
@@ -77,18 +88,58 @@ export function HomeView({
|
||||
>({});
|
||||
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
|
||||
const feedItems = React.useMemo(
|
||||
() => (feed ? [...feed.feed.mentions, ...feed.feed.needsAction] : []),
|
||||
() =>
|
||||
feed
|
||||
? [
|
||||
...feed.feed.mentions,
|
||||
...feed.feed.needsAction,
|
||||
...feed.feed.activity,
|
||||
...feed.feed.agentActivity,
|
||||
]
|
||||
: [],
|
||||
[feed],
|
||||
);
|
||||
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data;
|
||||
const selectedChannelIdCandidate = React.useMemo(() => {
|
||||
if (!selectedItemId) return null;
|
||||
const match = feedItems.find((item) => item.id === selectedItemId);
|
||||
return match?.channelId ?? null;
|
||||
}, [feedItems, selectedItemId]);
|
||||
const selectedChannel = React.useMemo(() => {
|
||||
if (!selectedChannelIdCandidate || !channels) return null;
|
||||
return (
|
||||
channels.find((channel) => channel.id === selectedChannelIdCandidate) ??
|
||||
null
|
||||
);
|
||||
}, [channels, selectedChannelIdCandidate]);
|
||||
|
||||
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
|
||||
const channelMessages = channelMessagesQuery.data;
|
||||
const threadEvents = React.useMemo(() => {
|
||||
if (!selectedItemId || !channelMessages) return [];
|
||||
return channelMessages
|
||||
.filter((event) => {
|
||||
if (event.id === selectedItemId) return false;
|
||||
const ref = getThreadReference(event.tags);
|
||||
return (
|
||||
ref.parentId === selectedItemId || ref.rootId === selectedItemId
|
||||
);
|
||||
})
|
||||
.sort((a, b) => a.created_at - b.created_at);
|
||||
}, [channelMessages, selectedItemId]);
|
||||
|
||||
const feedProfilePubkeys = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set([
|
||||
...feedItems.map((item) => item.pubkey),
|
||||
...threadEvents.map((event) => event.pubkey),
|
||||
...(currentPubkey ? [currentPubkey] : []),
|
||||
]),
|
||||
],
|
||||
[currentPubkey, feedItems],
|
||||
[currentPubkey, feedItems, threadEvents],
|
||||
);
|
||||
const feedProfilesQuery = useUsersBatchQuery(
|
||||
feedProfilePubkeys,
|
||||
@@ -110,8 +161,7 @@ export function HomeView({
|
||||
const normalizedQuery = searchValue.trim().toLowerCase();
|
||||
|
||||
return inboxItems.filter((item) => {
|
||||
const matchesFilter =
|
||||
filter === "all" ? true : item.item.category === filter;
|
||||
const matchesFilter = matchesInboxFilter(item, filter);
|
||||
const matchesQuery =
|
||||
normalizedQuery.length === 0 ||
|
||||
item.searchableText.includes(normalizedQuery);
|
||||
@@ -121,9 +171,32 @@ export function HomeView({
|
||||
}, [filter, inboxItems, searchValue]);
|
||||
const selectedItem =
|
||||
filteredItems.find((item) => item.id === selectedItemId) ?? null;
|
||||
const selectedItemReplies = selectedItem
|
||||
? localRepliesByItemId[selectedItem.id] ?? []
|
||||
: [];
|
||||
const threadReplies = React.useMemo<InboxReply[]>(
|
||||
() =>
|
||||
threadEvents.map((event) => ({
|
||||
id: event.id,
|
||||
authorLabel: resolveUserLabel({
|
||||
pubkey: event.pubkey,
|
||||
currentPubkey,
|
||||
profiles: feedProfiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
}),
|
||||
avatarUrl:
|
||||
feedProfiles?.[event.pubkey.toLowerCase()]?.avatarUrl ?? null,
|
||||
content: event.content,
|
||||
fullTimestampLabel: formatInboxFullTimestamp(event.created_at),
|
||||
})),
|
||||
[currentPubkey, feedProfiles, threadEvents],
|
||||
);
|
||||
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
|
||||
if (!selectedItem) return [];
|
||||
const localReplies = localRepliesByItemId[selectedItem.id] ?? [];
|
||||
const remoteIds = new Set(threadReplies.map((reply) => reply.id));
|
||||
const pendingLocals = localReplies.filter(
|
||||
(reply) => !remoteIds.has(reply.id),
|
||||
);
|
||||
return [...threadReplies, ...pendingLocals];
|
||||
}, [localRepliesByItemId, selectedItem, threadReplies]);
|
||||
React.useEffect(() => {
|
||||
if (filteredItems.length === 0) {
|
||||
setSelectedItemId(null);
|
||||
@@ -136,6 +209,7 @@ export function HomeView({
|
||||
}, [filteredItems, selectedItemId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void selectedItemId;
|
||||
setIsDeletingMessage(false);
|
||||
setIsSendingReply(false);
|
||||
}, [selectedItemId]);
|
||||
@@ -208,7 +282,10 @@ export function HomeView({
|
||||
items={filteredItems}
|
||||
onFilterChange={setFilter}
|
||||
onSearchChange={setSearchValue}
|
||||
onSelect={setSelectedItemId}
|
||||
onSelect={(itemId) => {
|
||||
setSelectedItemId(itemId);
|
||||
markDone(itemId);
|
||||
}}
|
||||
searchValue={searchValue}
|
||||
selectedId={selectedItemId}
|
||||
/>
|
||||
@@ -225,12 +302,7 @@ export function HomeView({
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isSendingReply={isSendingReply}
|
||||
item={selectedItem}
|
||||
localReplies={selectedItemReplies}
|
||||
onArchive={() => {
|
||||
if (selectedItem) {
|
||||
handleToggleDone(selectedItem.id);
|
||||
}
|
||||
}}
|
||||
replies={selectedItemReplies}
|
||||
onDelete={() => {
|
||||
if (!selectedItem || !canDelete) {
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Archive,
|
||||
CheckCheck,
|
||||
CircleDot,
|
||||
Mail,
|
||||
MailOpen,
|
||||
Forward,
|
||||
MoreHorizontal,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -22,7 +19,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -42,8 +38,7 @@ type InboxDetailPaneProps = {
|
||||
isDeletingMessage?: boolean;
|
||||
isSendingReply?: boolean;
|
||||
item: InboxItem | null;
|
||||
localReplies?: InboxReply[];
|
||||
onArchive: () => void;
|
||||
replies?: InboxReply[];
|
||||
onDelete: () => void;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onSendReply: (
|
||||
@@ -63,8 +58,7 @@ export function InboxDetailPane({
|
||||
isDeletingMessage = false,
|
||||
isSendingReply = false,
|
||||
item,
|
||||
localReplies = [],
|
||||
onArchive,
|
||||
replies = [],
|
||||
onDelete,
|
||||
onOpenChannel,
|
||||
onSendReply,
|
||||
@@ -152,22 +146,15 @@ export function InboxDetailPane({
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<HeaderIconAction
|
||||
label="Reply"
|
||||
onClick={focusComposer}
|
||||
icon={<Reply className="h-4 w-4" />}
|
||||
/>
|
||||
<HeaderIconAction
|
||||
label="Reply all"
|
||||
onClick={focusComposer}
|
||||
icon={<ReplyAll className="h-4 w-4" />}
|
||||
/>
|
||||
<HeaderIconAction
|
||||
label="Forward"
|
||||
icon={<Forward className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
{canReply ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<HeaderIconAction
|
||||
label="Reply"
|
||||
onClick={focusComposer}
|
||||
icon={<Reply className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{canOpenChannel && channelId ? (
|
||||
<HeaderIconAction
|
||||
@@ -188,20 +175,16 @@ export function InboxDetailPane({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HeaderMoreMenu
|
||||
canDelete={canDelete}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
{canDelete ? (
|
||||
<HeaderMoreMenu
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{item.subject}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-6">
|
||||
@@ -214,10 +197,18 @@ export function InboxDetailPane({
|
||||
tight
|
||||
/>
|
||||
</div>
|
||||
{localReplies.map((reply) => (
|
||||
{replies.length > 0 ? (
|
||||
<div
|
||||
className="border-t border-border/60 px-6 py-5"
|
||||
data-testid="home-inbox-local-reply"
|
||||
className="border-t border-border/60 px-6 pt-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"
|
||||
data-testid="home-inbox-replies-header"
|
||||
>
|
||||
{replies.length === 1 ? "1 reply" : `${replies.length} replies`}
|
||||
</div>
|
||||
) : null}
|
||||
{replies.map((reply) => (
|
||||
<div
|
||||
className="px-6 py-5"
|
||||
data-testid="home-inbox-reply"
|
||||
key={reply.id}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
@@ -249,12 +240,14 @@ export function InboxDetailPane({
|
||||
<MessageComposer
|
||||
channelId={item.item.channelId}
|
||||
channelName={item.channelLabel ?? "channel"}
|
||||
containerClassName="px-6 pb-4 sm:px-6"
|
||||
disabled={!canReply}
|
||||
draftKey={`inbox-reply:${item.id}`}
|
||||
isSending={isSendingReply}
|
||||
onSend={onSendReply}
|
||||
placeholder={
|
||||
canReply
|
||||
? `Reply to ${item.senderLabel}${item.channelLabel ? ` in #${item.channelLabel}` : ""}`
|
||||
? `Send reply to ${item.channelLabel ? `#${item.channelLabel} thread` : "channel thread"}`
|
||||
: (disabledReplyReason ?? "Replies are not available for this item.")
|
||||
}
|
||||
/>
|
||||
@@ -293,14 +286,10 @@ function HeaderIconAction({
|
||||
}
|
||||
|
||||
function HeaderMoreMenu({
|
||||
canDelete,
|
||||
isDeletingMessage,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: {
|
||||
canDelete: boolean;
|
||||
isDeletingMessage: boolean;
|
||||
onArchive: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const trigger = (
|
||||
@@ -324,14 +313,9 @@ function HeaderMoreMenu({
|
||||
<TooltipContent>More actions</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onArchive}>
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive for now
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={!canDelete || isDeletingMessage}
|
||||
disabled={isDeletingMessage}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { Inbox, Search } from "lucide-react";
|
||||
|
||||
import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox";
|
||||
import { groupInboxItems } from "@/features/home/lib/inbox";
|
||||
@@ -11,6 +11,8 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "mention", label: "Mentions" },
|
||||
{ value: "needs_action", label: "Needs Action" },
|
||||
{ value: "activity", label: "Activity" },
|
||||
{ value: "agent_activity", label: "Agents" },
|
||||
];
|
||||
|
||||
type InboxListPaneProps = {
|
||||
@@ -38,7 +40,19 @@ export function InboxListPane({
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-border/70 bg-background">
|
||||
<div className="border-b border-border/70 px-4 py-4">
|
||||
<div className="border-b border-border/70 px-4 pb-4 pt-8" data-tauri-drag-region>
|
||||
<div
|
||||
className="mb-4 flex min-w-0 items-center gap-2"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<Inbox className="h-5 w-5 shrink-0 text-primary" />
|
||||
<h1
|
||||
className="min-w-0 truncate text-lg font-semibold tracking-tight"
|
||||
data-testid="chat-title"
|
||||
>
|
||||
Inbox
|
||||
</h1>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -101,7 +115,6 @@ export function InboxListPane({
|
||||
isSelected
|
||||
? "border-l-primary bg-muted/30"
|
||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||
isDone ? "opacity-60" : "",
|
||||
)}
|
||||
data-testid={`home-inbox-item-${item.id}`}
|
||||
key={item.id}
|
||||
@@ -124,7 +137,12 @@ export function InboxListPane({
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm text-foreground",
|
||||
isDone ? "font-normal" : "font-bold",
|
||||
)}
|
||||
>
|
||||
{item.senderLabel}
|
||||
</p>
|
||||
{item.isActionRequired ? (
|
||||
@@ -134,18 +152,35 @@ export function InboxListPane({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{item.timestampLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-0.5 line-clamp-2 text-sm leading-5 text-muted-foreground">
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 line-clamp-2 text-sm leading-5",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.preview}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.channelLabel ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
#{item.channelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/features/messages/lib/normalizeMentionClipboard";
|
||||
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
|
||||
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
import { ComposerAttachments } from "./ComposerAttachments";
|
||||
@@ -29,6 +30,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar";
|
||||
type MessageComposerProps = {
|
||||
channelId?: string | null;
|
||||
channelName: string;
|
||||
containerClassName?: string;
|
||||
disabled?: boolean;
|
||||
draftKey?: string;
|
||||
editTarget?: {
|
||||
@@ -58,6 +60,7 @@ type MessageComposerProps = {
|
||||
export function MessageComposer({
|
||||
channelId = null,
|
||||
channelName,
|
||||
containerClassName,
|
||||
disabled = false,
|
||||
draftKey,
|
||||
editTarget = null,
|
||||
@@ -87,6 +90,8 @@ export function MessageComposer({
|
||||
const drafts = useDrafts();
|
||||
const effectiveDraftKey = draftKey ?? channelId;
|
||||
const previousDraftKeyRef = React.useRef<string | null>(null);
|
||||
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
|
||||
effectiveDraftKeyRef.current = effectiveDraftKey;
|
||||
|
||||
const mentions = useMentions(channelId);
|
||||
const channelLinks = useChannelLinks();
|
||||
@@ -107,13 +112,11 @@ export function MessageComposer({
|
||||
const onSendRef = React.useRef(onSend);
|
||||
const onEditSaveRef = React.useRef(onEditSave);
|
||||
const editTargetRef = React.useRef(editTarget);
|
||||
const channelIdRef = React.useRef(channelId);
|
||||
disabledRef.current = disabled;
|
||||
isSendingRef.current = isSending;
|
||||
onSendRef.current = onSend;
|
||||
onEditSaveRef.current = onEditSave;
|
||||
editTargetRef.current = editTarget;
|
||||
channelIdRef.current = channelId;
|
||||
|
||||
// ── Refs consumed by Tiptap's submitOnEnter extension ──────────────
|
||||
const isAutocompleteOpenRef = React.useRef(false);
|
||||
@@ -352,11 +355,11 @@ export function MessageComposer({
|
||||
channelLinks.clearChannels();
|
||||
setIsEmojiPickerOpen(false);
|
||||
|
||||
const sendChannelId = channelIdRef.current;
|
||||
const sentDraftKey = effectiveDraftKeyRef.current;
|
||||
try {
|
||||
await onSendRef.current(finalContent, pubkeys, mediaTags);
|
||||
if (sendChannelId) {
|
||||
drafts.clearDraft(sendChannelId);
|
||||
if (sentDraftKey) {
|
||||
drafts.clearDraft(sentDraftKey);
|
||||
}
|
||||
} catch {
|
||||
setContent(savedContent);
|
||||
@@ -498,7 +501,12 @@ export function MessageComposer({
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<footer className="relative z-10 -mt-4 px-8 pb-0 pt-0 sm:px-10">
|
||||
<footer
|
||||
className={cn(
|
||||
"relative z-10 -mt-4 px-8 pb-0 pt-0 sm:px-10",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<form
|
||||
className="relative rounded-2xl border border-input bg-card px-4 pb-3 pt-4"
|
||||
@@ -559,16 +567,18 @@ export function MessageComposer({
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="h-7 w-7 shrink-0 px-0"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
{onCancelReply ? (
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="h-7 w-7 shrink-0 px-0"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user