Files
buzz/desktop/src/features/communities/useCommunityInit.ts
Taylor Ho f086eb6544 fix(link-previews): send while previews finish in background (#5697)
**Category:** fix
**User Impact:** Messages send immediately after submission while link
previews finish in the background, with an option to skip delayed
preview preparation.

**Problem:** Waiting for link-preview metadata or snapshot uploads kept
the composer occupied after users pressed Send, while races between
completion, timeout, and cancellation risked inconsistent payloads.
**Solution:** Freeze and promote speculative preview work into a bounded
background send task, clear the composer immediately, and
publish exactly once with prepared previews or gracefully without them
when skipped, failed, or timed out.



https://github.com/user-attachments/assets/987d2f2c-679f-473a-965f-dfb279951e52



<details>
<summary>File changes</summary>

**desktop/src/features/communities/useCommunityInit.ts**
Resets pending link-preview preparation when community context changes
so work cannot cross community boundaries.

**desktop/src/features/messages/lib/linkPreviewPreparationStore.ts**
Adds the coordinator-owned preparation state machine, bounded fallback,
Skip behavior, and exactly-once terminal publication handling.

**desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx**
Extends floating background progress UI to include link-preview
preparation.

**desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx**
Adds the preparing-link-preview label and Skip action to the progress
pill.

**desktop/src/features/messages/ui/MessageComposer.tsx**
Hands submitted preview work to the background coordinator and clears
the composer immediately.

**desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs**
Updates auto-submit unit coverage for coordinator-owned preview
preparation.

**desktop/src/features/messages/ui/messageComposerAutoSubmit.ts**
Allows submit to promote unfinished preview work instead of blocking
composer submission.

**desktop/src/features/messages/ui/useComposerLinkPreviews.tsx**
Starts preview work speculatively and exposes frozen preparation jobs
for adoption by the send flow.

**desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts**
Carries prepared preview tags through the mention and media payload
helpers.

**desktop/src/features/messages/ui/useMentionSendFlow.ts**
Integrates prepared preview tags into final message publication.

**desktop/src/shared/lib/useResolvedLinkPreviews.ts**
Exposes the in-flight metadata promise so promoted work can be adopted
rather than restarted.

**desktop/tests/e2e/messaging.spec.ts**
Covers immediate submit, upload handoff, Skip/completion races, failure
fallback, auto-send, and exactly-once publication.

</details>

## Reproduction steps

1. Enter a supported link and press Send while preview metadata or
snapshot upload is still pending.
2. Confirm the composer clears immediately and the floating progress UI
shows **Preparing link preview · Skip**.
3. Let preparation finish and confirm one message is published with its
preview.
4. Repeat and choose **Skip**; confirm one message is published without
waiting for the preview.
5. Simulate preview failure or timeout and confirm the message still
publishes once without preview tags.

## Validation

- TypeScript, Biome/format, file-size, px-text, and pubkey checks
- Full desktop unit suite: 4,734 passed
- Focused Playwright messaging suite: 5 passed
- Push hooks at `86c0aa7de2ff81b79286c99bf23db12345adc6ca`: desktop
check, typecheck, and tests passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-14 12:16:36 -07:00

346 lines
13 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { isTauri } from "@tauri-apps/api/core";
import { isMacPlatform } from "@/shared/lib/platform";
import { relayClient } from "@/shared/api/relayClient";
import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate";
import {
applyCommunity,
autoConnectDefaultRelayEnabled,
getDefaultRelayUrl,
} from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { clearTrayAgentActivity } from "@/shared/api/trayMenu";
import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { resetNavigationDeepLinkDrain } from "@/shared/deep-link";
import {
clearAllDrafts,
initDraftStore,
} from "@/features/messages/lib/useDrafts";
import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions";
import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore";
import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore";
import {
resetActiveAgentTurnsStore,
saveActiveAgentTurnsForCommunity,
restoreActiveAgentTurnsForCommunity,
} from "@/features/agents/activeAgentTurnsStore";
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import {
initFirstCommunity,
shouldAutoConnectDefaultRelay,
} from "./communityStorage";
import type { Community } from "./types";
/**
* Tear down all community-scoped module singletons so the new
* community starts with a clean slate. Hook-managed singletons
* (e.g. ChannelMuteSyncManager, ChannelSectionSyncManager) are
* destroyed via effect cleanup and do not need entries here.
* See AGENTS.md "Community Switching" for the full contract.
*/
async function resetCommunityState({
resetAvatarState,
}: {
resetAvatarState: boolean;
}): Promise<void> {
relayClient.disconnect();
await resetNavigationDeepLinkDrain();
resetRateLimitGate();
clearAllDrafts();
resetAgentObserverStore();
resetActiveAgentTurnsStore();
resetAgentWorkingSignal();
if (isTauri() && isMacPlatform()) {
void clearTrayAgentActivity();
}
if (resetAvatarState) {
resetAvatarProfileSync();
resetAvatarPresentations();
}
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetLinkPreviewMetadataCache();
resetVideoPlayerState();
resetRenderScopedReactionHydration();
resetBackgroundMediaUploads();
resetLinkPreviewPreparations();
clearSearchHitEventCache();
clearMarkdownNodeCache();
}
type CommunityInitResult =
| { isReady: true; needsSetup: false; appliedKey: string }
| {
isReady: false;
needsSetup: true;
defaultRelayUrl: string;
}
| { isReady: false; needsSetup: false; appliedKey: string | null }
| { isReady: false; needsSetup: false; appliedKey: null; error: string };
/**
* Applies the active community config to the Tauri backend and resets
* all community-scoped module singletons when the community changes.
*
* Returns a discriminated union — only render the app after the
* community is applied. When `needsSetup` is true, the caller
* should show a first-run welcome screen.
*/
export function useCommunityInit(
activeCommunity: Community | null,
communityKey: string,
isSharedIdentity: boolean,
suppressAutoConnect = false,
): CommunityInitResult {
const [result, setResult] = useState<CommunityInitResult>({
isReady: false,
needsSetup: false,
appliedKey: null,
});
// Track whether this is the initial mount or a community switch.
// On the initial mount we skip resetting singletons (they're fresh).
const hasInitializedRef = useRef(false);
// Track the previously-applied community ID so we can save its turn state
// before resetting when the user switches to a different community.
const prevCommunityIdRef = useRef<string | null>(null);
// Deferred avatar work owns the relay captured when it was queued. A
// same-relay reconnect during onboarding must not cancel that work, while an
// actual relay boundary must clear both the queue and its presentation probe.
const appliedRelayUrlRef = useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes
useEffect(() => {
let cancelled = false;
async function init() {
if (!activeCommunity) {
if (hasInitializedRef.current) {
if (prevCommunityIdRef.current) {
saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
prevCommunityIdRef.current = null;
}
try {
await resetCommunityState({ resetAvatarState: true });
} catch (error) {
console.error("Failed to reset community state:", error);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? `Could not safely leave community: ${error.message}`
: "Could not safely leave community",
});
}
return;
}
appliedRelayUrlRef.current = null;
hasInitializedRef.current = false;
}
try {
const defaultRelayUrl = await getDefaultRelayUrl();
const autoConnectDefaultRelay =
await autoConnectDefaultRelayEnabled();
// Internal builds explicitly opt into treating their reviewed default
// relay as the first community. Public builds retain community
// selection even when BUZZ_RELAY_URL is overridden at runtime.
if (
!suppressAutoConnect &&
(isSharedIdentity ||
(autoConnectDefaultRelay &&
shouldAutoConnectDefaultRelay(defaultRelayUrl)))
) {
const identity = await getIdentity();
if (cancelled) return;
const community = initFirstCommunity(
defaultRelayUrl,
identity.pubkey,
);
if (community && !cancelled) {
window.location.reload();
return;
}
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl,
});
}
return;
}
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl,
});
}
} catch {
if (!cancelled) {
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl: "ws://localhost:3000",
});
}
}
return;
}
// Mark this community config as pending while it is applied to the
// backend. App.tsx also checks appliedKey against the active communityKey,
// which prevents rendering community-scoped UI for a new community until
// that exact config has finished applying.
setResult({
isReady: false,
needsSetup: false,
appliedKey: communityKey,
});
// On community switch (not initial mount), reset module singletons
// so the new tree starts with a clean slate.
if (hasInitializedRef.current) {
// Save the outgoing community's turn state before wiping the store so
// timers survive a round-trip (A → B → A keeps A's elapsed time).
if (prevCommunityIdRef.current) {
saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
// Null out immediately so a rapid community switch (A→B→C before
// B's applyCommunity resolves) doesn't re-save the now-empty
// store under the outgoing community ID and delete its snapshot.
prevCommunityIdRef.current = null;
}
try {
await resetCommunityState({
resetAvatarState:
appliedRelayUrlRef.current !== activeCommunity.relayUrl,
});
} catch (error) {
console.error("Failed to reset community state:", error);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? `Could not safely switch communities: ${error.message}`
: "Could not safely switch communities",
});
}
return;
}
}
hasInitializedRef.current = true;
appliedRelayUrlRef.current = activeCommunity.relayUrl;
// Apply community config to the Tauri backend.
//
// Note: we deliberately do NOT pass an nsec here. The persisted
// `identity.key` file (resolved at startup by `resolve_persisted_identity`,
// and updated atomically by `import_identity`) is the single source of
// truth for the active key. Older builds stored the nsec in localStorage
// and re-applied it on every reload, which silently overwrote any
// imported key. `loadCommunities()` strips lingering `nsec` fields from
// legacy entries; this site refuses to apply one even if present.
try {
await applyCommunity(
activeCommunity.relayUrl,
undefined,
activeCommunity.token,
activeCommunity.reposDir,
getOverrides().agentManagedProfiles === true,
);
} catch (error) {
// A bad `repos_dir` no longer reaches here — `apply_workspace` treats
// it as non-fatal (relay/keys apply, bad value not persisted, REPOS
// falls back to a real dir, a `repos-dir-error` toast surfaces it) and
// returns Ok, so the app boots into a working state where the user can
// fix the value in community settings. This catch now only fires on a
// genuine relay/key apply failure (e.g. an invalid nsec or a poisoned
// lock). For those, marking the community ready would render
// community-scoped UI against a backend that never applied — park on
// the loading gate (isReady:false, no appliedKey) instead.
console.error("Failed to apply community to backend:", error);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? error.message
: "Failed to apply community configuration",
});
}
return;
}
if (!cancelled) {
// Refresh relay-derived media state only after the backend has installed
// this community's relay override. On cold launch, mediaUrl.ts may have
// eagerly cached the default relay origin before applyCommunity ran;
// leaving that stale value makes authenticated relay media look external
// and bypass the localhost proxy.
resetMediaCaches();
try {
const identity = await getIdentity();
if (cancelled) return;
initDraftStore(identity.pubkey, activeCommunity.relayUrl);
} catch (err) {
if (cancelled) return;
console.error(
"[useCommunityInit] getIdentity failed, draft store uninitialized:",
err,
);
}
// Restore any turn state saved for this community (a prior A→B round-
// trip). This runs after applyCommunity succeeds and before the app
// renders so components see the restored timers on first render.
restoreActiveAgentTurnsForCommunity(activeCommunity.id);
// Prime the ref so the NEXT switch saves this community's state.
prevCommunityIdRef.current = activeCommunity.id;
setResult({
isReady: true,
needsSetup: false,
appliedKey: communityKey,
});
}
}
void init();
return () => {
cancelled = true;
};
}, [
activeCommunity?.id,
activeCommunity?.relayUrl,
activeCommunity?.token,
activeCommunity?.reposDir,
isSharedIdentity,
suppressAutoConnect,
communityKey,
]);
return result;
}