mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
fix(desktop): keep inbox composer editable while loading
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
46
desktop/src/features/home/ui/LoadingInboxComposer.tsx
Normal file
46
desktop/src/features/home/ui/LoadingInboxComposer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
desktop/src/features/home/useInboxLoadingDraftHandoff.ts
Normal file
49
desktop/src/features/home/useInboxLoadingDraftHandoff.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user