fix(desktop): align inbox layout and draft details (#2143)

Approved
This commit is contained in:
thomaspblock
2026-07-20 00:53:53 +02:00
committed by GitHub
parent 9a251fbef1
commit 7f769b2439
14 changed files with 871 additions and 266 deletions

View File

@@ -255,7 +255,12 @@ async fn init_session(h: &mut Harness, mcp_servers: Value) -> String {
.await;
r["result"]["sessionId"]
.as_str()
.expect("sessionId")
.unwrap_or_else(|| {
panic!(
"session/new did not return sessionId: response={r}, stderr={}",
h.stderr_text()
)
})
.to_owned()
}
@@ -1570,7 +1575,23 @@ async fn cancel_kills_inflight_tool_via_mcp_notification() {
// the buzz-agent test binary (they share the same target dir).
let self_bin = std::path::PathBuf::from(env!("CARGO_BIN_EXE_buzz-agent"));
let dev_mcp_bin = self_bin.parent().unwrap().join("buzz-dev-mcp");
if !dev_mcp_bin.exists() {
let dev_mcp_is_executable = std::fs::metadata(&dev_mcp_bin)
.map(|metadata| {
if !metadata.is_file() || metadata.len() == 0 {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
})
.unwrap_or(false);
if !dev_mcp_is_executable {
eprintln!(
"SKIP: buzz-dev-mcp not built at {}; run `cargo build -p buzz-dev-mcp` first",
dev_mcp_bin.display()

View File

@@ -23,6 +23,7 @@ import {
toInboxContextMessage,
} from "@/features/home/lib/inboxViewHelpers";
import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState";
import { useHomeDrafts } from "@/features/home/useHomeDrafts";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import {
type ProfilePanelTab,
@@ -41,6 +42,7 @@ import {
import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState";
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
import { InboxListPane } from "@/features/home/ui/InboxListPane";
import { DraftDetailPane } from "@/features/messages/ui/DraftDetailPane";
import {
useChannelMessagesQuery,
useToggleReactionMutation,
@@ -52,7 +54,6 @@ import {
import { formatTime } from "@/features/messages/lib/dateFormatters";
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useActiveDraftCount } from "@/features/messages/ui/DraftsPanel";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
@@ -143,10 +144,18 @@ export function HomeView({
const isMessagesMode = !isReminders && !isDrafts;
const remindersQuery = useRemindersQuery(currentPubkey);
const dueReminderCount = countDueReminders(remindersQuery.data ?? []);
// Active draft count for the badge. When the Drafts panel is closed,
// rootStatusMap is empty so orphaned drafts are counted optimistically.
// They drop from the count once the panel opens and relay confirms deletion.
const activeDraftCount = useActiveDraftCount(new Map());
const {
activeCount: activeDraftCount,
deleteDraft: handleDeleteDraft,
items: draftItems,
selectedItem: selectedDraftItem,
selectedKey: selectedDraftKey,
selectDraft: setSelectedDraftKey,
} = useHomeDrafts({
isDrafts,
isNarrowHomeViewport,
viewportWidthPx: homeInboxWidthPx,
});
// `?item=` is Messages-mode-only machinery: a reminder never enters the
// FeedItem selection model, so reload while in Reminders mode keeps a stale
// `?item=` unconsumed and does not snap back to a feed-item detail view.
@@ -645,11 +654,19 @@ export function HomeView({
isNarrowHomeViewport &&
selectedEventId !== null &&
!isSinglePanelAuxiliaryView;
const showListPane = !isSinglePanelDetailView && !isSinglePanelAuxiliaryView;
const isSinglePanelDraftDetailView =
isDrafts &&
isNarrowHomeViewport &&
selectedDraftItem !== null &&
!isSinglePanelAuxiliaryView;
const showListPane =
!isSinglePanelDetailView &&
!isSinglePanelDraftDetailView &&
!isSinglePanelAuxiliaryView;
const showDetailPane =
isMessagesMode &&
!isSinglePanelAuxiliaryView &&
(!isNarrowHomeViewport || isSinglePanelDetailView);
((isMessagesMode && (!isNarrowHomeViewport || isSinglePanelDetailView)) ||
(isDrafts && (!isNarrowHomeViewport || isSinglePanelDraftDetailView)));
const auxiliaryPaneWidthPx = isSinglePanelAuxiliaryView
? homeInboxWidthPx
: threadPanelWidthPx;
@@ -695,15 +712,25 @@ export function HomeView({
} as React.CSSProperties
}
>
{showListPane || showDetailPane ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-30 h-13 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55"
data-testid="home-inbox-shared-header-backdrop"
/>
) : null}
{showListPane ? (
<InboxListPane
activeReminderEventIds={activeReminderEventIds}
agentPubkeys={inboxAgentPubkeys}
activeDraftCount={activeDraftCount}
draftItems={draftItems}
doneSet={effectiveDoneSet}
dueReminderCount={dueReminderCount}
filter={filter}
items={filteredItems}
onDeleteDraft={handleDeleteDraft}
onFilterChange={setFilter}
onMarkRead={markItemRead}
onMarkUnread={markItemUnread}
@@ -734,9 +761,11 @@ export function HomeView({
handleUserSelectItem(itemId);
markItemRead(itemId);
}}
onSelectDraft={setSelectedDraftKey}
onUnreadOnlyChange={setUnreadOnly}
reminderPubkey={currentPubkey}
selectedConversationId={selectedConversationId}
selectedDraftKey={selectedDraftKey}
showRightDivider={showListPane && showDetailPane}
unreadOnly={unreadOnly}
/>
@@ -765,7 +794,7 @@ export function HomeView({
<span className="absolute bottom-0 left-1/2 top-0 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
</button>
{showDetailPane ? (
{showDetailPane && isMessagesMode ? (
<InboxDetailPane
agentPubkeys={inboxAgentPubkeys}
canDelete={canDelete}
@@ -902,6 +931,18 @@ export function HomeView({
replies={selectedItemReplies}
/>
) : null}
{showDetailPane && isDrafts ? (
<DraftDetailPane
item={selectedDraftItem}
key={selectedDraftItem?.entry.key ?? "empty"}
onBack={
isSinglePanelDraftDetailView
? () => setSelectedDraftKey(null)
: undefined
}
onDelete={handleDeleteDraft}
/>
) : null}
{profilePanelPubkey ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}

View File

@@ -23,6 +23,7 @@ import { getThreadReference } from "@/features/messages/lib/threading";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import type { Channel, UserProfileSummary } from "@/shared/api/types";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
@@ -130,6 +131,7 @@ export function InboxDetailPane({
// Refs for the shared anchored-scroll hook's container and content roots.
const scrollContainerRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const composerWrapperRef = React.useRef<HTMLDivElement | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [isFocusHighlightVisible, setIsFocusHighlightVisible] =
React.useState(true);
@@ -319,6 +321,12 @@ export function InboxDetailPane({
setCapturedDefaultParentId(fallback);
}, [conversationId, selectedEventId, item, latchedDefaultParentId]);
useComposerHeightPadding(
scrollContainerRef,
composerWrapperRef,
conversationId,
);
if (!item) {
return (
<section
@@ -379,7 +387,7 @@ export function InboxDetailPane({
ref={detailPaneRef}
>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<TopChromeInsetHeader flush>
<TopChromeInsetHeader flush transparent>
<div className="px-5 py-2">
<div className="flex min-h-9 min-w-0 items-center justify-between gap-3">
<div
@@ -468,7 +476,8 @@ export function InboxDetailPane({
<div
aria-busy={isThreadContextLoading}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32 [overflow-anchor:none]"
className="-mt-13 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32 pt-13 [overflow-anchor:none]"
data-testid="home-inbox-detail-scroll"
onScroll={onScroll}
ref={scrollContainerRef}
>
@@ -488,27 +497,48 @@ export function InboxDetailPane({
);
return (
<React.Fragment key={message.id}>
{isAfterSeparator ? (
<div className="mx-6 my-3 border-t border-border/60" />
) : null}
<InboxMessageRow
agentPubkeys={agentPubkeys}
canReply={canReply}
channelId={item.item.channelId}
isContinuation={isContinuation}
isFocusHighlightVisible={isFocusHighlightVisible}
message={message}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
/>
</React.Fragment>
<InboxMessageRow
agentPubkeys={agentPubkeys}
canReply={canReply}
channelId={item.item.channelId}
isContinuation={isContinuation}
isFirst={index === 0}
isFocusHighlightVisible={isFocusHighlightVisible}
key={message.id}
message={message}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
/>
);
})}
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:h-12 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:h-4 after:bg-background after:content-['']"
data-testid="home-inbox-detail-composer-overlay"
ref={composerWrapperRef}
>
<span
aria-hidden="true"
className="absolute bottom-4 left-4 h-4 w-4 bg-background"
style={{
maskImage:
"radial-gradient(circle at top right, transparent 0 1rem, black calc(1rem + 0.5px))",
WebkitMaskImage:
"radial-gradient(circle at top right, transparent 0 1rem, black calc(1rem + 0.5px))",
}}
/>
<span
aria-hidden="true"
className="absolute bottom-4 right-4 h-4 w-4 bg-background"
style={{
maskImage:
"radial-gradient(circle at top left, transparent 0 1rem, black calc(1rem + 0.5px))",
WebkitMaskImage:
"radial-gradient(circle at top left, transparent 0 1rem, black calc(1rem + 0.5px))",
}}
/>
<div className="pointer-events-auto">
<MessageComposer
audienceContext={{

View File

@@ -1,8 +1,8 @@
import {
ChevronDown,
Clock,
Ellipsis,
ExternalLink,
ListFilter,
MailOpen,
} from "lucide-react";
import * as React from "react";
@@ -13,7 +13,10 @@ import {
type InboxItem,
type InboxTypeLabel,
} from "@/features/home/lib/inbox";
import { DraftsPanel } from "@/features/messages/ui/DraftsPanel";
import {
DraftsPanel,
type DraftViewItem,
} from "@/features/messages/ui/DraftsPanel";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
@@ -102,17 +105,21 @@ type InboxListPaneProps = {
activeReminderEventIds?: ReadonlySet<string>;
agentPubkeys?: ReadonlySet<string>;
activeDraftCount: number;
draftItems: DraftViewItem[];
doneSet: ReadonlySet<string>;
filter: InboxFilter;
items: InboxItem[];
onFilterChange: (filter: InboxFilter) => void;
onDeleteDraft: (draftKey: string) => void;
onMarkRead: (itemId: string) => void;
onMarkUnread: (itemId: string) => void;
onOpenDirect: (item: InboxItem) => void;
onRemindLater: (item: InboxItem) => void;
onSelect: (itemId: string) => void;
onSelectDraft: (draftKey: string) => void;
onUnreadOnlyChange: (checked: boolean) => void;
selectedConversationId: string | null;
selectedDraftKey: string | null;
showRightDivider?: boolean;
dueReminderCount: number;
reminderPubkey?: string;
@@ -123,17 +130,21 @@ export function InboxListPane({
activeReminderEventIds,
agentPubkeys,
activeDraftCount,
draftItems,
doneSet,
filter,
items,
onFilterChange,
onDeleteDraft,
onMarkRead,
onMarkUnread,
onOpenDirect,
onRemindLater,
onSelect,
onSelectDraft,
onUnreadOnlyChange,
selectedConversationId,
selectedDraftKey,
showRightDivider = false,
dueReminderCount,
reminderPubkey,
@@ -142,6 +153,12 @@ export function InboxListPane({
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
const isReminders = filter === "reminders";
const isDrafts = filter === "drafts";
const inboxStatusLabel =
dueReminderCount > 0
? `${dueReminderCount} due reminder${dueReminderCount === 1 ? "" : "s"}`
: activeDraftCount > 0
? `${activeDraftCount} active draft${activeDraftCount === 1 ? "" : "s"}`
: null;
const scrollRef = React.useRef<HTMLDivElement>(null);
const unreadVisibleItemCount = React.useMemo(
() =>
@@ -156,7 +173,7 @@ export function InboxListPane({
}
}, [doneSet, items, onMarkRead]);
const renderItem = (item: InboxItem, index: number) => {
const renderItem = (item: InboxItem) => {
const isSelected = item.conversationId === selectedConversationId;
const isDone = doneSet.has(item.id);
const hasActiveReminder = activeReminderEventIds?.has(item.id) ?? false;
@@ -191,17 +208,14 @@ export function InboxListPane({
>
<button
aria-label={`Open inbox item from ${item.senderLabel}`}
className={cn(
"absolute inset-0 z-0 block w-full border-l border-l-transparent text-left after:pointer-events-none after:absolute after:bottom-0 after:left-[3.625rem] after:right-3 after:h-px after:bg-border/45 after:content-['']",
index === items.length - 1 && "after:hidden",
)}
className="absolute inset-0 z-0 block w-full border-l border-l-transparent text-left"
onClick={() => onSelect(item.id)}
type="button"
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-0 left-0 right-3 transition-colors",
"pointer-events-none absolute inset-y-0 left-0 right-0 transition-colors",
isSelected
? "bg-[var(--inbox-row-highlight-bg)]"
: "group-hover/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-focus-within/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-active/inbox-item:bg-muted/40",
@@ -387,89 +401,90 @@ export function InboxListPane({
showRightDivider && INBOX_PANE_RIGHT_DIVIDER_CLASS,
)}
>
<TopChromeInsetHeader flush>
<TopChromeInsetHeader flush transparent>
<div className="px-5 py-2">
<div className="flex min-h-9 w-full min-w-0 items-center justify-between gap-3">
<Popover>
<PopoverTrigger asChild>
<button
aria-label="Inbox options"
className={cn(INBOX_HEADER_ICON_BUTTON_CLASS, "-ml-4")}
data-testid="inbox-options-trigger"
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-2">
<div
className={cn(
"flex min-h-9 items-center justify-between gap-3 rounded-lg px-2 py-1.5",
(isReminders || isDrafts) && "opacity-50",
)}
>
<label
className="text-sm font-medium text-foreground"
htmlFor="inbox-unread-only-switch"
<div className="order-2 ml-auto flex shrink-0 items-center justify-end">
<Popover>
<PopoverTrigger asChild>
<button
aria-label="Inbox options"
className={cn(INBOX_HEADER_ICON_BUTTON_CLASS, "-mr-4")}
data-testid="inbox-options-trigger"
type="button"
>
Show unread
</label>
<Switch
checked={unreadOnly}
className="shadow-none [&>span]:shadow-none"
data-testid="inbox-unread-only-toggle"
disabled={isReminders || isDrafts}
id="inbox-unread-only-switch"
onCheckedChange={onUnreadOnlyChange}
/>
</div>
<Separator className="my-1 bg-muted" />
<button
className="flex min-h-9 w-full items-center rounded-lg px-2 py-2 text-left text-sm transition-colors hover:bg-muted/50 disabled:pointer-events-none disabled:opacity-50"
disabled={unreadVisibleItemCount === 0}
onClick={handleMarkAllRead}
type="button"
>
<span>Mark all as read</span>
{unreadVisibleItemCount > 0 ? (
<span className="ml-auto text-xs text-muted-foreground">
{unreadVisibleItemCount}
</span>
) : null}
</button>
</PopoverContent>
</Popover>
<div className="ml-auto flex shrink-0 items-center justify-end">
<Ellipsis className="h-4 w-4" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-60 p-2">
<div
className={cn(
"flex min-h-9 items-center justify-between gap-3 rounded-lg px-2 py-1.5",
(isReminders || isDrafts) && "opacity-50",
)}
>
<label
className="text-sm font-medium text-foreground"
htmlFor="inbox-unread-only-switch"
>
Show unread
</label>
<Switch
checked={unreadOnly}
className="shadow-none [&>span]:shadow-none"
data-testid="inbox-unread-only-toggle"
disabled={isReminders || isDrafts}
id="inbox-unread-only-switch"
onCheckedChange={onUnreadOnlyChange}
/>
</div>
<Separator className="my-1 bg-muted" />
<button
className="flex min-h-9 w-full items-center rounded-lg px-2 py-2 text-left text-sm transition-colors hover:bg-muted/50 disabled:pointer-events-none disabled:opacity-50"
disabled={unreadVisibleItemCount === 0}
onClick={handleMarkAllRead}
type="button"
>
<span>Mark all as read</span>
{unreadVisibleItemCount > 0 ? (
<span className="ml-auto text-xs text-muted-foreground">
{unreadVisibleItemCount}
</span>
) : null}
</button>
</PopoverContent>
</Popover>
</div>
<div className="order-1 flex shrink-0 items-center justify-start">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Filter inbox: ${activeFilter?.label ?? "All"}`}
aria-label={`Filter inbox: ${activeFilter?.label ?? "All"}${inboxStatusLabel ? `. ${inboxStatusLabel}` : ""}`}
className={cn(
INBOX_HEADER_ICON_BUTTON_CLASS,
"relative -mr-4",
"relative -ml-2 w-auto gap-1 px-2 text-sm font-medium text-foreground",
)}
data-testid="inbox-filter-trigger"
type="button"
>
<ListFilter className="h-4 w-4" />
<span>{activeFilter?.label ?? "All"}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
{dueReminderCount > 0 ? (
<span
className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-background bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
aria-hidden="true"
className="absolute right-1.5 top-0 h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-background"
data-testid="inbox-reminder-badge"
>
{dueReminderCount}
</span>
/>
) : activeDraftCount > 0 ? (
<span
className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-background bg-primary px-1 text-2xs font-semibold leading-none text-primary-foreground"
aria-hidden="true"
className="absolute right-1.5 top-0 h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-background"
data-testid="inbox-draft-badge"
>
{activeDraftCount}
</span>
/>
) : null}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuRadioGroup
onValueChange={(value) =>
onFilterChange(value as InboxFilter)
@@ -513,7 +528,7 @@ export function InboxListPane({
{isReminders ? (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
className="-mt-13 flex min-h-0 flex-1 flex-col overflow-hidden pt-13"
data-testid="home-inbox-reminders"
>
{reminderPubkey ? (
@@ -522,14 +537,19 @@ export function InboxListPane({
</div>
) : isDrafts ? (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
className="-mt-13 flex min-h-0 flex-1 flex-col overflow-hidden pt-13"
data-testid="home-inbox-drafts"
>
<DraftsPanel />
<DraftsPanel
items={draftItems}
onDeleteDraft={onDeleteDraft}
onSelectDraft={onSelectDraft}
selectedDraftKey={selectedDraftKey}
/>
</div>
) : (
<div
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
className="-mt-13 min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pt-13"
data-testid="home-inbox-list"
ref={scrollRef}
>

View File

@@ -27,6 +27,7 @@ type InboxMessageRowProps = {
/** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */
channelId?: string | null;
isContinuation?: boolean;
isFirst?: boolean;
isFocusHighlightVisible: boolean;
message: InboxDisplayMessage;
onSelectReplyTarget: (message: InboxDisplayMessage) => void;
@@ -42,6 +43,7 @@ export function InboxMessageRow({
canReply,
channelId = null,
isContinuation = false,
isFirst = false,
isFocusHighlightVisible,
message,
onSelectReplyTarget,
@@ -111,7 +113,12 @@ export function InboxMessageRow({
}
>
{canReply || canToggleReactions ? (
<div className="absolute right-2 top-1 z-10 sm:top-0 sm:-translate-y-1/2">
<div
className={cn(
"absolute right-2 top-1 z-10",
!isFirst && "sm:top-0 sm:-translate-y-1/2",
)}
>
<MessageActionBar
channelId={channelId}
message={timelineMessage}

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import { clearDraftEntry } from "@/features/messages/lib/useDrafts";
import {
useActiveDraftCount,
useDraftViewItems,
} from "@/features/messages/ui/DraftsPanel";
type UseHomeDraftsOptions = {
isDrafts: boolean;
isNarrowHomeViewport: boolean;
viewportWidthPx: number;
};
export function useHomeDrafts({
isDrafts,
isNarrowHomeViewport,
viewportWidthPx,
}: UseHomeDraftsOptions) {
const items = useDraftViewItems(isDrafts);
const optimisticActiveCount = useActiveDraftCount(new Map());
const activeCount = isDrafts
? items.filter((item) => item.rootStatus !== "deleted").length
: optimisticActiveCount;
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const selectedItem =
items.find((item) => item.entry.key === selectedKey) ?? null;
React.useEffect(() => {
if (!isDrafts) {
setSelectedKey(null);
return;
}
if (viewportWidthPx === 0) {
return;
}
if (
selectedKey !== null &&
items.some((item) => item.entry.key === selectedKey)
) {
return;
}
setSelectedKey(isNarrowHomeViewport ? null : (items[0]?.entry.key ?? null));
}, [isDrafts, isNarrowHomeViewport, items, selectedKey, viewportWidthPx]);
const deleteDraft = React.useCallback(
(draftKey: string) => {
clearDraftEntry(draftKey);
if (selectedKey === draftKey) {
setSelectedKey(null);
}
},
[selectedKey],
);
return {
activeCount,
deleteDraft,
items,
selectedItem,
selectedKey,
selectDraft: setSelectedKey,
};
}

View File

@@ -0,0 +1,268 @@
import {
AlertTriangle,
ArrowLeft,
FileText,
Lock,
Pencil,
Send,
Trash2,
} from "lucide-react";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
canOpenDraft,
canSendDraft,
formatDraftCreatedAt,
getDraftPreview,
openDraftEntry,
SendConfirmDialog,
sendDraftEntry,
type DraftViewItem,
} from "@/features/messages/ui/DraftsPanel";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type DraftDetailPaneProps = {
item: DraftViewItem | null;
onBack?: () => void;
onDelete: (draftKey: string) => void;
};
export function DraftDetailPane({
item,
onBack,
onDelete,
}: DraftDetailPaneProps) {
const { goChannel } = useAppNavigation();
const [sendDialogOpen, setSendDialogOpen] = React.useState(false);
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-draft-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">
<FileText className="h-6 w-6" />
</div>
<p className="mt-4 text-base font-semibold">Select a draft</p>
<p className="mt-1 text-sm text-muted-foreground">
Pick a draft to preview it and choose what to do next.
</p>
</div>
</section>
);
}
const { entry, rootStatus, source } = item;
const isOrphaned = rootStatus === "deleted";
const isPrivate = source.channel?.visibility === "private";
const isDm = source.channel?.channelType === "dm";
const channelLabel = source.channel
? isDm
? source.label
: `#${source.label}`
: "Unknown channel";
const openEnabled = canOpenDraft(entry.draft, source) && !isOrphaned;
const sendEnabled = canSendDraft(entry.draft, source, rootStatus);
const content = entry.draft.content.trim();
const attachmentCount = entry.draft.pendingImeta.length;
return (
<section
className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60"
data-testid="home-inbox-draft-detail"
>
<TopChromeInsetHeader flush transparent>
<div className="px-5 py-2">
<div className="flex min-h-9 min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1">
{onBack ? (
<Button
aria-label="Back to drafts list"
className="rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft />
</Button>
) : null}
<div className="flex min-w-0 items-center gap-1.5">
{isPrivate ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<h2 className="truncate text-sm font-semibold leading-5 tracking-tight text-foreground">
{channelLabel}
</h2>
</div>
</div>
</div>
</div>
</TopChromeInsetHeader>
<div className="-mt-13 min-h-0 flex-1 overflow-y-auto pb-8 pt-15">
{isOrphaned ? (
<div
className="mx-5 mb-3 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive"
data-testid="home-inbox-draft-orphaned-notice"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
The original thread was deleted. This draft can no longer be
opened or sent.
</span>
</div>
) : null}
<div className="relative px-2">
<article className="group/message relative z-10 mx-1 flex items-start gap-2.5 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50">
<DraftActionBar
canOpen={openEnabled}
canSend={sendEnabled}
onDelete={() => onDelete(entry.key)}
onOpen={() => void openDraftEntry(entry, goChannel)}
onSend={() => setSendDialogOpen(true)}
/>
<UserAvatar
avatarUrl={null}
className="h-9 w-9 shrink-0"
displayName="You"
size="md"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
<span className="text-sm font-semibold text-foreground">
You
</span>
<span className="text-xs font-medium text-muted-foreground">
Draft
</span>
<span className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
{formatDraftCreatedAt(entry.draft)}
</span>
</div>
<div className="mt-0.5 text-base leading-6 text-foreground">
<Markdown
className="inbox-preview-markdown text-inherit leading-6"
content={content || getDraftPreview(entry.draft)}
interactive={false}
/>
{attachmentCount > 0 && content ? (
<p className="mt-2 text-sm text-muted-foreground">
{attachmentCount} attachment
{attachmentCount === 1 ? "" : "s"}
</p>
) : null}
</div>
</div>
</article>
</div>
</div>
{sendDialogOpen ? (
<SendConfirmDialog
channelLabel={source.label}
isDm={isDm}
onCancel={() => setSendDialogOpen(false)}
onConfirm={() => {
setSendDialogOpen(false);
void sendDraftEntry(entry, goChannel);
}}
open={true}
/>
) : null}
</section>
);
}
function DraftActionBar({
canOpen,
canSend,
onDelete,
onOpen,
onSend,
}: {
canOpen: boolean;
canSend: boolean;
onDelete: () => void;
onOpen: () => void;
onSend: () => void;
}) {
return (
<div className="absolute right-2 top-1 z-10">
<div
className="-m-1 p-1 opacity-100 transition-opacity duration-150 ease-out sm:pointer-events-none sm:opacity-0 sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100 sm:group-focus-within/message:pointer-events-auto sm:group-focus-within/message:opacity-100"
data-testid="home-inbox-draft-action-bar"
>
<div className="overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85">
<div className="flex items-center gap-0.5 p-1">
<DraftActionButton
disabled={!canOpen}
label="Open draft"
onClick={onOpen}
>
<Pencil className="h-4 w-4" />
</DraftActionButton>
<DraftActionButton
disabled={!canSend}
label="Send"
onClick={onSend}
>
<Send className="h-4 w-4" />
</DraftActionButton>
<DraftActionButton destructive label="Delete" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
</DraftActionButton>
</div>
</div>
</div>
</div>
);
}
function DraftActionButton({
children,
destructive = false,
disabled = false,
label,
onClick,
}: {
children: React.ReactNode;
destructive?: boolean;
disabled?: boolean;
label: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
className={
destructive
? "h-8 w-8 rounded-full p-0 text-destructive hover:text-destructive"
: "h-8 w-8 rounded-full p-0"
}
disabled={disabled}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}

View File

@@ -4,7 +4,6 @@ import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
clearDraftEntry,
getActiveDraftEntries,
renameDraftEntry,
useDraftsSnapshot,
@@ -45,22 +44,22 @@ const SENT_DRAFT_PREFIX = "sent:";
const THREAD_DRAFT_PREFIX = "thread:";
const UNKNOWN_CHANNEL_LABEL = "Unknown channel";
type DraftListEntry = {
export type DraftListEntry = {
draft: DraftState;
key: string;
};
type DraftSection = {
entries: DraftListEntry[];
label: string;
status: DraftState["status"];
};
type DraftSource = {
export type DraftSource = {
channel: Channel | null;
label: string;
};
export type DraftViewItem = {
entry: DraftListEntry;
rootStatus: RootStatus;
source: DraftSource;
};
const UNKNOWN_DRAFT_SOURCE: DraftSource = {
channel: null,
label: UNKNOWN_CHANNEL_LABEL,
@@ -78,7 +77,7 @@ function parseDraftTime(value: string): number {
return Number.isFinite(time) ? time : 0;
}
function formatDraftCreatedAt(draft: DraftState): string {
export function formatDraftCreatedAt(draft: DraftState): string {
const time = parseDraftTime(draft.createdAt);
return time === 0
? "Unknown time"
@@ -97,7 +96,7 @@ function getOriginalDraftKey(draftKey: string): string {
: sentPayload;
}
function getThreadRootId(draftKey: string): string | null {
export function getThreadRootId(draftKey: string): string | null {
const originalDraftKey = getOriginalDraftKey(draftKey);
if (!originalDraftKey.startsWith(THREAD_DRAFT_PREFIX)) {
return null;
@@ -113,7 +112,7 @@ function isVisibleDraft(entry: DraftListEntry): boolean {
return content.length > 0 || attachmentCount > 0;
}
function getDraftPreview(draft: DraftState): string {
export function getDraftPreview(draft: DraftState): string {
const content = draft.content.trim();
if (content.length > 0) {
return content;
@@ -129,17 +128,6 @@ function getDraftPreview(draft: DraftState): string {
return "Empty draft";
}
function readDraftSections(): DraftSection[] {
const active = getActiveDraftEntries().filter(isVisibleDraft);
const sections: DraftSection[] = [];
if (active.length > 0) {
sections.push({ label: "Drafts", status: "active", entries: active });
}
return sections;
}
function resolveDraftSources({
channels,
currentPubkey,
@@ -236,6 +224,65 @@ export function canSendDraft(
return true;
}
type GoChannel = ReturnType<typeof useAppNavigation>["goChannel"];
export async function openDraftEntry(
entry: DraftListEntry,
goChannel: GoChannel,
): Promise<void> {
if (!entry.draft.channelId) return;
if (entry.key.startsWith(INBOX_REPLY_PREFIX)) {
const migrated = await migrateInboxReplyDraft(entry.key, entry.draft, {
getEventById,
getChannelIdFromTags,
getThreadReference,
renameDraftEntry,
});
if (!migrated) return;
await goChannel(migrated.channelId, {
messageId: migrated.conversationId,
threadRootId: migrated.conversationId,
});
return;
}
const threadRootId = getThreadRootId(entry.key);
await goChannel(
entry.draft.channelId,
threadRootId ? { messageId: threadRootId, threadRootId } : undefined,
);
}
export async function sendDraftEntry(
entry: DraftListEntry,
goChannel: GoChannel,
): Promise<void> {
if (!entry.draft.channelId) return;
if (entry.key.startsWith(INBOX_REPLY_PREFIX)) {
const migrated = await migrateInboxReplyDraft(entry.key, entry.draft, {
getEventById,
getChannelIdFromTags,
getThreadReference,
renameDraftEntry,
});
if (!migrated) return;
await goChannel(migrated.channelId, {
autoSend: migrated.newDraftKey,
messageId: migrated.conversationId,
threadRootId: migrated.conversationId,
});
return;
}
const threadRootId = getThreadRootId(entry.key);
await goChannel(entry.draft.channelId, {
...(threadRootId ? { messageId: threadRootId, threadRootId } : {}),
autoSend: entry.key,
});
}
// ── Send confirmation dialog ──────────────────────────────────────────────────
type SendConfirmDialogProps = {
@@ -246,7 +293,7 @@ type SendConfirmDialogProps = {
open: boolean;
};
function SendConfirmDialog({
export function SendConfirmDialog({
channelLabel,
isDm,
onCancel,
@@ -289,15 +336,19 @@ function DraftRow({
entry,
onDelete,
onOpen,
onSelect,
onSend,
rootStatus,
selected,
source,
}: {
entry: DraftListEntry;
onDelete: (draftKey: string) => void;
onOpen: (entry: DraftListEntry) => void;
onSelect: () => void;
onSend: (entry: DraftListEntry) => void;
rootStatus: RootStatus;
selected: boolean;
source: DraftSource;
}) {
const isSent = entry.draft.status === "sent";
@@ -316,15 +367,15 @@ function DraftRow({
<div
className={cn(
"group/draft-row relative rounded-md border border-border/70 bg-background transition-colors hover:bg-muted/40 focus-within:bg-muted/40",
selected && "border-primary/30 bg-muted/60",
isOrphaned && "opacity-50",
)}
data-testid={`home-draft-item-${entry.key}`}
>
<button
aria-label={`Open draft in ${channelLabel}`}
aria-label={`View draft in ${channelLabel}`}
className="block w-full min-w-0 px-3 py-3 text-left disabled:cursor-default"
disabled={!canOpen}
onClick={() => onOpen(entry)}
onClick={onSelect}
type="button"
>
<div className="min-w-0 pr-0 transition-[padding] group-hover/draft-row:pr-20 group-focus-within/draft-row:pr-20">
@@ -458,41 +509,28 @@ export function useActiveDraftCount(
return deriveActiveDraftCount(activeDrafts, rootStatusMap);
}
// ── DraftsPanel ──────────────────────────────────────────────────────────────
// ── Shared draft view model ──────────────────────────────────────────────────
export function DraftsPanel() {
const { goChannel } = useAppNavigation();
export function useDraftViewItems(enabled: boolean): DraftViewItem[] {
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
const channelsQuery = useChannelsQuery();
// Collapse the old `sections` state + `refreshDrafts` pattern onto a
// reactive snapshot: every draft write re-renders via useSyncExternalStore.
useDraftsSnapshot();
const sections = readDraftSections();
const drafts = getActiveDraftEntries().filter(isVisibleDraft);
const drafts = React.useMemo(
() => sections.flatMap((section) => section.entries),
[sections],
);
// Collect unique thread-root IDs from active drafts only (sent drafts cannot
// be sent/orphaned). Deduplicated on root id — multiple drafts can share one.
const threadRootIds = React.useMemo(() => {
const ids = new Set<string>();
for (const entry of sections.flatMap((s) =>
s.status === "active" ? s.entries : [],
)) {
for (const entry of drafts) {
const rootId = getThreadRootId(entry.key);
if (rootId) {
ids.add(rootId);
}
}
return [...ids];
}, [sections]);
}, [drafts]);
// Panel is always mounted when visible; `isOpen=true` enables root queries.
const rootStatusMap = useDraftRootStatus(threadRootIds, true);
const rootStatusMap = useDraftRootStatus(threadRootIds, enabled);
const profilePubkeys = React.useMemo(
() => [
@@ -522,6 +560,36 @@ export function DraftsPanel() {
[channelsQuery.data, currentPubkey, drafts, profiles],
);
return drafts.map((entry) => {
const threadRootId = getThreadRootId(entry.key);
return {
entry,
rootStatus:
threadRootId !== null
? (rootStatusMap.get(threadRootId) ?? "checking")
: "available",
source: sources.get(entry.key) ?? UNKNOWN_DRAFT_SOURCE,
};
});
}
// ── DraftsPanel ──────────────────────────────────────────────────────────────
type DraftsPanelProps = {
items: DraftViewItem[];
onDeleteDraft: (draftKey: string) => void;
onSelectDraft: (draftKey: string) => void;
selectedDraftKey: string | null;
};
export function DraftsPanel({
items,
onDeleteDraft,
onSelectDraft,
selectedDraftKey,
}: DraftsPanelProps) {
const { goChannel } = useAppNavigation();
// Send confirmation dialog state.
const [sendTarget, setSendTarget] = React.useState<DraftListEntry | null>(
null,
@@ -529,52 +597,17 @@ export function DraftsPanel() {
const handleOpen = React.useCallback(
(entry: DraftListEntry) => {
if (!entry.draft.channelId) {
return;
}
// Legacy inbox-reply:<eventId> keys were written by the old InboxDetailPane
// before stable conversation IDs were introduced. Migrate them on open:
// resolve the embedded event to derive the thread root, verify the channel
// tag matches the stored channelId, re-key to thread:<conversationId>, and
// navigate to the correct thread composer with the migrated content.
if (entry.key.startsWith(INBOX_REPLY_PREFIX)) {
void (async () => {
const migrated = await migrateInboxReplyDraft(
entry.key,
entry.draft,
{
getEventById,
getChannelIdFromTags,
getThreadReference,
renameDraftEntry,
},
);
// Navigate only on successful migration; any failure leaves the
// legacy draft untouched and the user in place.
if (!migrated) return;
void goChannel(migrated.channelId, {
messageId: migrated.conversationId,
threadRootId: migrated.conversationId,
});
})();
return;
}
const threadRootId = getThreadRootId(entry.key);
void goChannel(
entry.draft.channelId,
threadRootId ? { messageId: threadRootId, threadRootId } : undefined,
);
void openDraftEntry(entry, goChannel);
},
[goChannel],
);
const handleDelete = React.useCallback((draftKey: string) => {
clearDraftEntry(draftKey);
// No manual refresh needed — clearDraftEntry notifies subscribers and
// useDraftsSnapshot() causes DraftsPanel to re-render automatically.
}, []);
const handleDelete = React.useCallback(
(draftKey: string) => {
onDeleteDraft(draftKey);
},
[onDeleteDraft],
);
const handleSendRequest = React.useCallback((entry: DraftListEntry) => {
setSendTarget(entry);
@@ -588,44 +621,18 @@ export function DraftsPanel() {
if (!sendTarget) return;
const entry = sendTarget;
setSendTarget(null);
if (!entry.draft.channelId) return;
// Legacy inbox-reply: keys: migrate first, then auto-send with new key.
if (entry.key.startsWith(INBOX_REPLY_PREFIX)) {
void (async () => {
const migrated = await migrateInboxReplyDraft(entry.key, entry.draft, {
getEventById,
getChannelIdFromTags,
getThreadReference,
renameDraftEntry,
});
if (!migrated) return;
void goChannel(migrated.channelId, {
messageId: migrated.conversationId,
threadRootId: migrated.conversationId,
autoSend: migrated.newDraftKey,
});
})();
return;
}
const threadRootId = getThreadRootId(entry.key);
void goChannel(entry.draft.channelId, {
...(threadRootId ? { messageId: threadRootId, threadRootId } : {}),
autoSend: entry.key,
});
void sendDraftEntry(entry, goChannel);
}, [sendTarget, goChannel]);
const sendDialogSource = sendTarget
? (sources.get(sendTarget.key) ?? UNKNOWN_DRAFT_SOURCE)
: UNKNOWN_DRAFT_SOURCE;
const sendDialogSource =
items.find((item) => item.entry.key === sendTarget?.key)?.source ??
UNKNOWN_DRAFT_SOURCE;
const sendDialogIsDm = sendDialogSource.channel?.channelType === "dm";
const sendDialogChannelLabel = sendDialogSource.channel
? sendDialogSource.label
: UNKNOWN_CHANNEL_LABEL;
if (sections.length === 0) {
if (items.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<FileText className="h-8 w-8 text-muted-foreground/50" />
@@ -636,31 +643,25 @@ export function DraftsPanel() {
return (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{sections.map((section) => (
<div className="space-y-2" key={section.status}>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{section.label}
</h3>
{section.entries.map((entry) => {
const threadRootId = getThreadRootId(entry.key);
const rootStatus: RootStatus =
threadRootId !== null
? (rootStatusMap.get(threadRootId) ?? "checking")
: "available";
return (
<DraftRow
entry={entry}
key={entry.key}
onDelete={handleDelete}
onOpen={handleOpen}
onSend={handleSendRequest}
rootStatus={rootStatus}
source={sources.get(entry.key) ?? UNKNOWN_DRAFT_SOURCE}
/>
);
})}
</div>
<div
className="flex-1 space-y-2 overflow-y-auto p-4"
data-testid="home-inbox-drafts-list"
>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Drafts
</h3>
{items.map(({ entry, rootStatus, source }) => (
<DraftRow
entry={entry}
key={entry.key}
onDelete={handleDelete}
onOpen={handleOpen}
onSelect={() => onSelectDraft(entry.key)}
onSend={handleSendRequest}
rootStatus={rootStatus}
selected={entry.key === selectedDraftKey}
source={source}
/>
))}
</div>

View File

@@ -6,6 +6,8 @@ import { cn } from "@/shared/lib/cn";
type TopChromeInsetHeaderProps = React.ComponentProps<"div"> & {
/** Keep the header flush with its parent content row. */
flush?: boolean;
/** Render without a local backdrop when a parent supplies shared chrome. */
transparent?: boolean;
};
/**
@@ -16,12 +18,13 @@ export function TopChromeInsetHeader({
className,
children,
flush = false,
transparent = false,
...props
}: TopChromeInsetHeaderProps) {
return (
<div
className={cn(
topChromeInset.headerBase,
transparent ? "relative z-40 shrink-0" : topChromeInset.headerBase,
!flush && topChromeInset.padding,
!flush && topChromeInset.divider,
className,

View File

@@ -33,7 +33,7 @@ export const topChromeInset = {
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-border/35 before:content-['']",
/** Shared header backdrop and bottom border below the inset row. */
headerBase:
"relative z-40 shrink-0 bg-background/75 backdrop-blur-md supports-backdrop-filter:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/35",
"relative z-40 shrink-0 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
/** Vertical pane divider starting at the top of the content row. */
verticalDivider:
"after:pointer-events-none after:absolute after:bottom-0 after:right-0 after:top-0 after:z-40 after:w-px after:bg-border/35 after:content-['']",

View File

@@ -2530,9 +2530,14 @@ test("home inbox manage affordance opens management without leaving home", async
}
expect(detailBox.x + detailBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
expect(sheetBox.width).toBeGreaterThanOrEqual(300);
await page
.getByTestId("home-inbox-list")
.click({ position: { x: 24, y: 80 } });
const inboxList = page.getByTestId("home-inbox-list");
const inboxListBox = await inboxList.boundingBox();
if (!inboxListBox) {
throw new Error("Expected the home inbox list box.");
}
await inboxList.click({
position: { x: 24, y: inboxListBox.height - 24 },
});
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();

View File

@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/drafts";
@@ -164,14 +165,44 @@ test.describe("drafts screenshots", () => {
// Section heading should be "DRAFTS"
await expect(panel.getByText("Drafts", { exact: true })).toBeVisible();
// Small settle before screenshot
await page.waitForTimeout(200);
const detail = page.getByTestId("home-inbox-draft-detail");
await expect(detail).toBeVisible();
await expect(detail.getByText("1 attachment")).toBeVisible();
await expect(detail.getByText("You", { exact: true })).toBeVisible();
await expect(detail.getByText("Draft", { exact: true })).toBeVisible();
await panel.screenshot({
await detail.locator("article").hover();
await expect(
detail.getByTestId("home-inbox-draft-action-bar"),
).toBeVisible();
await waitForAnimations(page);
await page.getByTestId("home-inbox").screenshot({
path: `${SHOTS}/01-drafts-section-populated.png`,
});
});
test("02 — narrow draft list opens the detail view", async ({ page }) => {
await page.setViewportSize({ width: 520, height: 900 });
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
const draftRow = panel.locator(
`[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`,
);
await draftRow.getByRole("button", { name: /View draft/ }).click();
const detail = page.getByTestId("home-inbox-draft-detail");
await expect(detail).toBeVisible();
await expect(panel).not.toBeVisible();
await detail.getByRole("button", { name: "Back to drafts list" }).click();
await expect(panel).toBeVisible();
await expect(detail).not.toBeVisible();
});
test("03 — hover actions visible", async ({ page }) => {
await installMockBridge(page);
await patchCommunityPubkey(page);
@@ -204,8 +235,7 @@ test.describe("drafts screenshots", () => {
await expect(sendMessageBtn).toBeVisible({ timeout: 4_000 });
await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 });
await page.waitForTimeout(200);
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/03-hover-actions.png` });
});
@@ -218,8 +248,7 @@ test.describe("drafts screenshots", () => {
// Empty state: FileText icon + "No drafts" text
await expect(panel.getByText("No drafts")).toBeVisible({ timeout: 5_000 });
await page.waitForTimeout(200);
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` });
});
@@ -286,7 +315,7 @@ test.describe("drafts screenshots", () => {
// Confirm dialog body names the channel.
await expect(dialog.getByText(/general/i)).toBeVisible();
await page.waitForTimeout(200);
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/05-thread-draft-send-dialog.png`,
});
@@ -320,7 +349,7 @@ test.describe("drafts screenshots", () => {
page,
}) => {
// Captures both badge placements for the PR screenshot:
// 1. The numeric badge on the inbox filter trigger button.
// 1. The status dot on the inbox filter trigger button.
// 2. The badge next to "Drafts" in the filter dropdown.
// Two active drafts are seeded so the count is 2.
await installMockBridge(page);
@@ -332,10 +361,14 @@ test.describe("drafts screenshots", () => {
timeout: 10_000,
});
// Badge should be visible on the filter trigger with count = 2.
// The trigger uses a compact dot while retaining the count accessibly.
const triggerBadge = page.getByTestId("inbox-draft-badge");
await expect(triggerBadge).toBeVisible({ timeout: 6_000 });
await expect(triggerBadge).toHaveText("2");
await expect(triggerBadge).toBeEmpty();
await expect(page.getByTestId("inbox-filter-trigger")).toHaveAttribute(
"aria-label",
"Filter inbox: All. 2 active drafts",
);
// Open the filter dropdown so the badge-option is visible too.
await page.getByTestId("inbox-filter-trigger").click();
@@ -343,7 +376,7 @@ test.describe("drafts screenshots", () => {
await expect(dropdownBadge).toBeVisible({ timeout: 4_000 });
await expect(dropdownBadge).toHaveText("2");
await page.waitForTimeout(200);
await waitForAnimations(page);
// Capture the full inbox header area including the open dropdown.
await page.getByTestId("home-inbox").screenshot({
@@ -406,7 +439,7 @@ test.describe("drafts screenshots", () => {
draftRow.getByRole("button", { name: "Delete draft" }),
).toBeVisible({ timeout: 4_000 });
await page.waitForTimeout(200);
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/07-thread-deleted-state.png`,

View File

@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
test.describe("home inbox chrome", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
});
test("puts the filter on the left and options on the right", async ({
page,
}) => {
const filter = page.getByTestId("inbox-filter-trigger");
const options = page.getByTestId("inbox-options-trigger");
await expect(filter).toBeVisible();
await expect(filter).toContainText("All");
await expect(options).toBeVisible();
const [filterBox, optionsBox] = await Promise.all([
filter.boundingBox(),
options.boundingBox(),
]);
expect(filterBox).not.toBeNull();
expect(optionsBox).not.toBeNull();
expect(filterBox?.x ?? 0).toBeLessThan(optionsBox?.x ?? 0);
});
test("shares a blurred header backdrop across list and detail", async ({
page,
}) => {
await expect(page.getByTestId("home-inbox-detail")).toBeVisible();
const homeInbox = page.getByTestId("home-inbox");
const listScroller = page.getByTestId("home-inbox-list");
const detailScroller = page.getByTestId("home-inbox-detail-scroll");
const sharedBackdrop = page.getByTestId(
"home-inbox-shared-header-backdrop",
);
const [homeBox, backdropBox, listBox, detailBox, backdropFilter] =
await Promise.all([
homeInbox.boundingBox(),
sharedBackdrop.boundingBox(),
listScroller.boundingBox(),
detailScroller.boundingBox(),
sharedBackdrop.evaluate(
(element) => getComputedStyle(element).backdropFilter,
),
]);
expect(homeBox).not.toBeNull();
expect(backdropBox).not.toBeNull();
expect(listBox).not.toBeNull();
expect(detailBox).not.toBeNull();
expect(Math.round(backdropBox?.x ?? 0)).toBe(Math.round(homeBox?.x ?? 0));
expect(Math.round(backdropBox?.width ?? 0)).toBe(
Math.round(homeBox?.width ?? 0),
);
expect(Math.round(listBox?.y ?? 0)).toBe(Math.round(backdropBox?.y ?? 0));
expect(Math.round(detailBox?.y ?? 0)).toBe(Math.round(backdropBox?.y ?? 0));
expect(backdropFilter).not.toBe("none");
await waitForAnimations(page);
});
test("reserves the measured composer height and masks content behind it", async ({
page,
}) => {
const detailScroller = page.getByTestId("home-inbox-detail-scroll");
const composerOverlay = page.getByTestId(
"home-inbox-detail-composer-overlay",
);
await expect(composerOverlay).toBeVisible();
await expect
.poll(async () => {
const [paddingBottom, overlayBox] = await Promise.all([
detailScroller.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).paddingBottom),
),
composerOverlay.boundingBox(),
]);
return Math.abs(paddingBottom - (overlayBox?.height ?? 0));
})
.toBeLessThanOrEqual(1);
const overlayMaskStyles = await composerOverlay.evaluate((element) => ({
gradient: getComputedStyle(element, "::before").backgroundImage,
solidBackground: getComputedStyle(element, "::after").backgroundColor,
}));
expect(overlayMaskStyles.gradient).not.toBe("none");
expect(overlayMaskStyles.solidBackground).not.toBe("rgba(0, 0, 0, 0)");
});
});

View File

@@ -140,6 +140,18 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
await expect(selectedMessage.getByLabel("Toggle ❤️ reaction")).toBeVisible();
await selectedMessage.hover();
const actionBar = page.getByTestId(`message-action-bar-${replyEvent.id}`);
const [actionBarBox, selectedMessageBox] = await Promise.all([
actionBar.boundingBox(),
selectedMessage.boundingBox(),
]);
expect(actionBarBox).not.toBeNull();
expect(selectedMessageBox).not.toBeNull();
if (!actionBarBox || !selectedMessageBox) {
throw new Error("Inbox message action bar bounds were unavailable.");
}
expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y);
await selectedMessage
.getByRole("button", { name: "React with :+1:" })
.click();