Files
buzz/desktop/src/shared/api/tauriMedia.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

125 lines
4.5 KiB
TypeScript

import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core";
import { type BlobDescriptor, invokeTauri } from "./tauri";
function encodeRawIpcHeader(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return window
.btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/, "");
}
/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */
export async function uploadMediaFile(
file: File,
progressId?: string,
signal?: AbortSignal,
onDispatch?: () => void,
): Promise<BlobDescriptor> {
const headers: Record<string, string> = {
"x-buzz-filename": encodeRawIpcHeader(file.name),
};
if (progressId) {
headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId);
}
if (signal?.aborted) throw new Error("upload cancelled");
const bytes = new Uint8Array(await file.arrayBuffer());
if (signal?.aborted) throw new Error("upload cancelled");
onDispatch?.();
return invokeTauriRaw<BlobDescriptor>("upload_media_bytes_raw", bytes, {
headers,
});
}
/** Stop the native HTTP request associated with a background media upload. */
export async function cancelMediaUpload(progressId: string): Promise<void> {
await invokeTauri("cancel_media_upload", { progressId });
}
/** Release the renderer's cancellation ownership after an upload settles. */
export async function releaseMediaUpload(progressId: string): Promise<void> {
await invokeTauri("release_media_upload", { progressId });
}
/**
* Open a native single-file picker constrained to images and upload the
* chosen file. Non-image files are rejected in Rust (via MIME sniffing)
* before the bytes leave the client, so discarded/non-image selections never
* reach the relay. Resolves to `null` when the user cancels the dialog.
*/
export async function pickAndUploadImage(): Promise<BlobDescriptor | null> {
return invokeTauri<BlobDescriptor | null>("pick_and_upload_image", {});
}
/**
* Fetch relay media bytes over IPC (Rust reqwest, VPN-tunneled).
*
* Used by the composer image editor: wrapping the bytes in a same-origin
* `blob:` URL gives the canvas pixel access without CORS, so the media
* proxy needs no special headers. The Rust side enforces the same URL
* validation and size cap as the download commands.
*/
export async function fetchMediaBytes(
url: string,
): Promise<Uint8Array<ArrayBuffer>> {
// The Rust command replies with `tauri::ipc::Response`, so the bytes
// arrive as a raw ArrayBuffer rather than a JSON number array.
const bytes = await invokeTauri<ArrayBuffer>("fetch_media_bytes", { url });
return new Uint8Array(bytes);
}
/** Read plain text without depending on embedded-webview clipboard grants. */
export async function readTextFromSystemClipboard(): Promise<string> {
// E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's
// `isTauri()` marker remains false. Exercise the packaged-app command path in
// that build so tests detect accidental regressions to permission-gated DOM
// clipboard reads.
if (isTauri() || import.meta.env.MODE === "e2e") {
return invokeTauri<string>("read_clipboard_text");
}
const clipboard = navigator.clipboard;
if (!clipboard?.readText) {
throw new Error("Clipboard text reading is unavailable");
}
return clipboard.readText();
}
/** Write text through the native clipboard after an asynchronous workflow. */
export async function copyTextToSystemClipboard(
text: string,
html?: string,
): Promise<void> {
await invokeTauri("copy_text_to_clipboard", { html, text });
}
/**
* Fetch an agent snapshot attachment in memory, verifying size, SHA-256, and
* snapshot decode before returning the bytes.
*
* Inputs come directly from the message's imeta fields; validation is
* performed on the Rust side (same-relay URL, format-specific size cap,
* hash + size integrity, and snapshot decode). Returns the raw bytes as a
* number array so they can be passed to the existing preview/confirm APIs.
*
* Throws a human-readable error string on any validation failure.
*/
export async function fetchSnapshotBytes(args: {
url: string;
filename: string;
expectedSha256: string;
expectedSize: number;
}): Promise<number[]> {
const buffer = await invokeTauri<ArrayBuffer>("fetch_snapshot_bytes", {
url: args.url,
filename: args.filename,
expectedSha256: args.expectedSha256,
expectedSize: args.expectedSize,
});
return Array.from(new Uint8Array(buffer));
}