mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
**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>
125 lines
4.5 KiB
TypeScript
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));
|
|
}
|