mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
64
desktop/src/features/home/useHomeDrafts.ts
Normal file
64
desktop/src/features/home/useHomeDrafts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
268
desktop/src/features/messages/ui/DraftDetailPane.tsx
Normal file
268
desktop/src/features/messages/ui/DraftDetailPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-['']",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
|
||||
100
desktop/tests/e2e/home-collapsed-top-chrome.spec.ts
Normal file
100
desktop/tests/e2e/home-collapsed-top-chrome.spec.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user