Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions desktop/src/features/home/ui/HomeLoadingState.tsx
Original file line number Diff line number Diff line change
@@ -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">
Expand Down Expand Up @@ -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>
Expand Down
12 changes: 10 additions & 2 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -573,8 +581,8 @@ export function HomeView({
],
);

if (isLoading && !feed) {
return <HomeLoadingState />;
if ((isLoading && !feed) || isInboxDraftHandoffPending) {
return <HomeLoadingState containerRef={homeInboxRef} />;
}

if (!feed) {
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/home/ui/LoadingInboxComposer.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 49 additions & 0 deletions desktop/src/features/home/useInboxLoadingDraftHandoff.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
}
52 changes: 52 additions & 0 deletions desktop/src/features/messages/lib/useDrafts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import {
getActiveDraftEntries,
getAllDraftEntries,
getSentDraftEntries,
handoffInboxLoadingDraft,
INBOX_LOADING_DRAFT_KEY,
initDraftStore,
loadDraftEntry,
markDraftSentEntry,
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions desktop/src/features/messages/lib/useDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 }));
}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
1 change: 1 addition & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading