fix(desktop): keep inbox composer editable while loading

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-11 23:22:03 -07:00
parent 4b3570671e
commit 9403a7f39c
9 changed files with 278 additions and 6 deletions

View File

@@ -1,8 +1,15 @@
import type { Ref } from "react";
import { Skeleton } from "@/shared/ui/skeleton";
import { Spinner } from "@/shared/ui/spinner";
import { LoadingInboxComposer } from "@/features/home/ui/LoadingInboxComposer";
export function HomeLoadingState() {
export function HomeLoadingState({
containerRef,
}: {
containerRef?: Ref<HTMLDivElement>;
}) {
return (
<div className="min-h-0 flex-1 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="relative overflow-hidden bg-background/60 after:absolute after:bottom-0 after:right-0 after:top-10 after:w-px after:bg-border/70 after:content-['']">
<div className="px-5 py-2">
@@ -100,11 +107,15 @@ export function HomeLoadingState() {
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto px-4 pb-4 sm:px-4">
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
<Skeleton className="h-5 w-48" />
<LoadingInboxComposer />
<div className="mt-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
<Spinner
aria-label="Loading Inbox"
className="ml-auto h-5 w-5 border-2 text-muted-foreground"
data-testid="home-loading-inbox-spinner"
/>
</div>
</div>
</div>

View File

@@ -26,6 +26,7 @@ import { resolveInboxFilterSelection } from "@/features/home/lib/inboxSelection"
import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState";
import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelection";
import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages";
import { useInboxLoadingDraftHandoff } from "@/features/home/useInboxLoadingDraftHandoff";
import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import {
@@ -456,6 +457,13 @@ export function HomeView({
}
return null;
}, [filteredItems, selectedConversationId, selectedEventId]);
const isInboxDraftHandoffPending = useInboxLoadingDraftHandoff({
feedReady: Boolean(feed),
hasVisibleItems: filteredItems.length > 0,
isMessagesMode,
isNarrowViewport: isNarrowHomeViewport,
selectedItem,
});
const deleteInboxMessage = React.useCallback(
async (eventId: string) => {
const channelId = selectedItem?.item.channelId;
@@ -573,8 +581,8 @@ export function HomeView({
],
);
if (isLoading && !feed) {
return <HomeLoadingState />;
if ((isLoading && !feed) || isInboxDraftHandoffPending) {
return <HomeLoadingState containerRef={homeInboxRef} />;
}
if (!feed) {

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import {
INBOX_LOADING_DRAFT_KEY,
loadDraftEntry,
persistDraftEntry,
} from "@/features/messages/lib/useDrafts";
const PLACEHOLDER = "Write a reply…";
/** Draft-backed input shown while Inbox resolves the conversation to reply to. */
export function LoadingInboxComposer() {
const initialDraftRef = React.useRef(loadDraftEntry(INBOX_LOADING_DRAFT_KEY));
const [content, setContent] = React.useState(
() => initialDraftRef.current?.content ?? "",
);
const persistContent = React.useCallback((nextContent: string) => {
const initialDraft = initialDraftRef.current;
persistDraftEntry(
INBOX_LOADING_DRAFT_KEY,
nextContent,
"",
initialDraft?.pendingImeta ?? [],
initialDraft?.spoileredAttachmentUrls ?? [],
initialDraft?.mentionRefs ?? [],
);
}, []);
return (
<textarea
aria-label={PLACEHOLDER}
className="block min-h-5 w-full resize-none overflow-hidden border-0 bg-transparent p-0 text-sm leading-5 text-foreground outline-hidden placeholder:text-muted-foreground"
data-testid="home-loading-inbox-composer"
onChange={(event) => {
const nextContent = event.currentTarget.value;
setContent(nextContent);
persistContent(nextContent);
}}
placeholder={PLACEHOLDER}
rows={1}
spellCheck="true"
value={content}
/>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import type { InboxItem } from "@/features/home/lib/inbox";
import {
handoffInboxLoadingDraft,
INBOX_LOADING_DRAFT_KEY,
loadDraftEntry,
useDraftsSnapshot,
} from "@/features/messages/lib/useDrafts";
type InboxLoadingDraftHandoffOptions = {
feedReady: boolean;
hasVisibleItems: boolean;
isMessagesMode: boolean;
isNarrowViewport: boolean;
selectedItem: InboxItem | null;
};
/** Moves the provisional loading draft once desktop Inbox resolves a target. */
export function useInboxLoadingDraftHandoff({
feedReady,
hasVisibleItems,
isMessagesMode,
isNarrowViewport,
selectedItem,
}: InboxLoadingDraftHandoffOptions): boolean {
useDraftsSnapshot();
const provisionalDraft = loadDraftEntry(INBOX_LOADING_DRAFT_KEY);
const channelId = selectedItem?.item.channelId ?? null;
const destinationKey = selectedItem
? selectedItem.item.channelType === "dm"
? (channelId ?? selectedItem.conversationId)
: `thread:${selectedItem.conversationId}`
: null;
React.useEffect(() => {
if (!feedReady || !provisionalDraft || !channelId || !destinationKey) {
return;
}
handoffInboxLoadingDraft(destinationKey, channelId);
}, [channelId, destinationKey, feedReady, provisionalDraft]);
return Boolean(
feedReady &&
provisionalDraft &&
isMessagesMode &&
(selectedItem || (!isNarrowViewport && hasVisibleItems)),
);
}

View File

@@ -55,6 +55,8 @@ import {
getActiveDraftEntries,
getAllDraftEntries,
getSentDraftEntries,
handoffInboxLoadingDraft,
INBOX_LOADING_DRAFT_KEY,
initDraftStore,
loadDraftEntry,
markDraftSentEntry,
@@ -334,6 +336,56 @@ test("getAllDraftEntries_returns_empty_array_when_no_drafts", () => {
assert.deepEqual(getAllDraftEntries(), []);
});
test("Inbox loading draft stays internal and migrates to the resolved thread", () => {
setup("pubkey-inbox-loading");
persistDraftEntry(
INBOX_LOADING_DRAFT_KEY,
"Typed while Inbox loads",
"",
[],
[],
);
assert.deepEqual(getAllDraftEntries(), []);
assert.equal(
handoffInboxLoadingDraft("thread:root-1", "channel-1"),
"migrated",
);
assert.equal(loadDraftEntry(INBOX_LOADING_DRAFT_KEY), undefined);
const migrated = loadDraftEntry("thread:root-1");
assert.ok(migrated);
assert.equal(migrated.channelId, "channel-1");
assert.equal(migrated.content, "Typed while Inbox loads");
});
test("Inbox loading handoff merges with an existing local thread draft", () => {
setup("pubkey-inbox-loading-merge");
persistDraftEntry(
"thread:root-2",
"Existing local draft",
"channel-2",
[IMG_A],
[],
);
persistDraftEntry(
INBOX_LOADING_DRAFT_KEY,
"Typed during loading",
"",
[],
[],
);
assert.equal(
handoffInboxLoadingDraft("thread:root-2", "channel-2"),
"merged",
);
const merged = loadDraftEntry("thread:root-2");
assert.ok(merged);
assert.equal(merged.content, "Existing local draft\n\nTyped during loading");
assert.deepEqual(merged.pendingImeta, [IMG_A]);
assert.equal(loadDraftEntry(INBOX_LOADING_DRAFT_KEY), undefined);
});
// ── channelId correctness on key switch ──────────────────────────────────────
// Regression: composer effect body was re-persisting prevKey with the incoming
// channel's id, corrupting the outgoing draft's channelId metadata.

View File

@@ -84,6 +84,9 @@ const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v2";
const LEGACY_DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1";
const MAX_DRAFTS = 100;
/** Internal draft used while Inbox is still resolving its selected thread. */
export const INBOX_LOADING_DRAFT_KEY = "__buzz-internal__:inbox-loading";
/**
* Canonicalize a relay URL for use as a storage key scope.
* Unlike the shared `normalizeRelayUrl` (which lowercases the entire URL),
@@ -423,6 +426,79 @@ export function renameDraftEntry(
return "migrated";
}
/**
* Move the provisional Inbox loading draft into a resolved conversation.
*
* The write is atomic so the full composer cannot mount between clearing the
* provisional key and saving the canonical key. If a canonical draft already
* exists, append the provisional text so neither local draft is lost.
*/
export function handoffInboxLoadingDraft(
destinationKey: string,
channelId: string,
): "merged" | "migrated" | "noop" {
const map = readStore();
const provisional = map.get(INBOX_LOADING_DRAFT_KEY);
if (!provisional) return "noop";
const destination = map.get(destinationKey);
if (!destination) {
map.set(destinationKey, { ...provisional, channelId });
map.delete(INBOX_LOADING_DRAFT_KEY);
flushStore(map);
notifySubscribers();
return "migrated";
}
const provisionalContent = provisional.content.trim();
const destinationContent = destination.content.trim();
const content =
provisionalContent.length === 0 || provisionalContent === destinationContent
? destination.content
: destinationContent.length === 0
? provisional.content
: `${destination.content}\n\n${provisional.content}`;
const updatedAt =
provisional.updatedAt > destination.updatedAt
? provisional.updatedAt
: destination.updatedAt;
const pendingImeta = [
...destination.pendingImeta,
...provisional.pendingImeta,
];
const spoileredAttachmentUrls = [
...new Set([
...destination.spoileredAttachmentUrls,
...provisional.spoileredAttachmentUrls,
]),
];
const mentionRefs = [
...(destination.mentionRefs ?? []),
...(provisional.mentionRefs ?? []).filter(
(candidate) =>
!(destination.mentionRefs ?? []).some(
(existing) => existing.pubkey === candidate.pubkey,
),
),
];
map.set(destinationKey, {
...destination,
channelId,
content,
selectionEnd: content.length,
selectionStart: content.length,
updatedAt,
pendingImeta,
mentionRefs,
spoileredAttachmentUrls,
});
map.delete(INBOX_LOADING_DRAFT_KEY);
flushStore(map);
notifySubscribers();
return "merged";
}
/**
* Convenience: save if content or attachments are non-empty, otherwise clear.
* Preserves existing createdAt on updates; sets it on first save.
@@ -466,6 +542,7 @@ export function getAllDraftEntries(): Array<{
draft: DraftState;
}> {
return [...readStore().entries()]
.filter(([key]) => key !== INBOX_LOADING_DRAFT_KEY)
.sort((a, b) => b[1].updatedAt.localeCompare(a[1].updatedAt))
.map(([key, draft]) => ({ key, draft }));
}

View File

@@ -311,6 +311,7 @@ type E2eConfig = {
channelsReadDelayMs?: number;
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
deepHistoryMessageCount?: number;
feedReadDelayMs?: number;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace` so e2e tests can observe the
@@ -7067,6 +7068,10 @@ async function handleGetFeed(
},
config: E2eConfig | undefined,
): Promise<RawHomeFeedResponse> {
const feedReadDelayMs = config?.mock?.feedReadDelayMs ?? 0;
if (feedReadDelayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, feedReadDelayMs));
}
const feedReadError = config?.mock?.feedReadError;
if (feedReadError) {
throw new Error(feedReadError);

View File

@@ -360,6 +360,29 @@ test("send a message and see it in timeline", async ({ page }) => {
);
});
test("Inbox loading composer persists and hands off its draft", async ({
page,
}) => {
const draft = `Inbox draft while loading ${Date.now()}`;
await installMockBridge(page, { feedReadDelayMs: 2_000 });
await page.goto("/");
const loadingInput = page.getByTestId("home-loading-inbox-composer");
await expect(page.getByTestId("home-loading-inbox-spinner")).toBeVisible();
await expect(loadingInput).toHaveAttribute("placeholder", "Write a reply…");
await expect(loadingInput).toBeEditable();
await loadingInput.fill(draft);
await expect(loadingInput).toHaveValue(draft);
await page.reload();
const restoredLoadingInput = page.getByTestId("home-loading-inbox-composer");
await expect(restoredLoadingInput).toHaveValue(draft);
const detail = page.getByTestId("home-inbox-detail");
await expect(detail.getByTestId("message-input")).toHaveText(draft);
await expect(restoredLoadingInput).toHaveCount(0);
});
test("long autolink wraps without widening the timeline", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 600 });

View File

@@ -269,6 +269,7 @@ type MockBridgeOptions = {
joinChannelErrors?: string[];
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
deepHistoryMessageCount?: number;
feedReadDelayMs?: number;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */