mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
Merge remote-tracking branch 'origin/main' into feat/projects-copy-link
Signed-off-by: Thomas Petersen <thomasp@squareup.com> # Conflicts: # desktop/src/shared/lib/useResolvedLinkPreviews.ts
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"version": "0.5.8",
|
||||
"base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f",
|
||||
"previous_tag": "desktop-v0.5.7",
|
||||
"previous_base_sha": "74b913cff8512c015dc6f1a7473b253fa803f954",
|
||||
"previous_merge_sha": "13c9e900c84cac1e2c8eeb7551bd1510ecb544d3",
|
||||
"tag": "desktop-v0.5.8",
|
||||
"commit_count": 4
|
||||
"version": "0.5.9",
|
||||
"base_sha": "f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b",
|
||||
"previous_tag": "desktop-v0.5.8",
|
||||
"previous_base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f",
|
||||
"previous_merge_sha": "c815a9c6e1a1d1818a0547f60f894e4a5388761a",
|
||||
"tag": "desktop-v0.5.9",
|
||||
"commit_count": 29
|
||||
}
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## v0.5.9
|
||||
|
||||
### Desktop and shared changes
|
||||
|
||||
- Polish desktop onboarding flow ([#5310](https://github.com/block/buzz/pull/5310)) ([`3f2f32641f4093d087fd9506bfac1fa0329e8b2e`](https://github.com/block/buzz/commit/3f2f32641f4093d087fd9506bfac1fa0329e8b2e))
|
||||
- fix(desktop): quiesce renderer polling while hidden (#3677) ([#5490](https://github.com/block/buzz/pull/5490)) ([`07a3c768d619db31fee3f0590f9433cdd1213e8f`](https://github.com/block/buzz/commit/07a3c768d619db31fee3f0590f9433cdd1213e8f))
|
||||
- fix(channels): restore member invitations to private channels ([#5493](https://github.com/block/buzz/pull/5493)) ([`2777189d960fa5b1d863166f36d6e37ff8ce0819`](https://github.com/block/buzz/commit/2777189d960fa5b1d863166f36d6e37ff8ce0819))
|
||||
- fix(desktop): bound nine unbounded localStorage stores ([#5454](https://github.com/block/buzz/pull/5454)) ([`9c074bb89b290721f839bbc84fdf4701269e43a0`](https://github.com/block/buzz/commit/9c074bb89b290721f839bbc84fdf4701269e43a0))
|
||||
- feat(desktop): time-based sweep for stale localStorage caches ([#5453](https://github.com/block/buzz/pull/5453)) ([`bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead`](https://github.com/block/buzz/commit/bb9aae1065d4a77ae3dcb36b7b4a4e7ac8e68ead))
|
||||
- feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 ([#4000](https://github.com/block/buzz/pull/4000)) ([`5e4c05f90b062898e1827ba45cb826c6ff913741`](https://github.com/block/buzz/commit/5e4c05f90b062898e1827ba45cb826c6ff913741))
|
||||
- fix(desktop): resolve overlapping member mentions ([#5225](https://github.com/block/buzz/pull/5225)) ([`44456e200e3ca6a5d2882b58b447b80474041347`](https://github.com/block/buzz/commit/44456e200e3ca6a5d2882b58b447b80474041347))
|
||||
- chore(deps): update rust crate anyhow to v1.0.104 ([#4447](https://github.com/block/buzz/pull/4447)) ([`e1ff91ecc1269682a50c17da2c0708d1448b336f`](https://github.com/block/buzz/commit/e1ff91ecc1269682a50c17da2c0708d1448b336f))
|
||||
- fix(desktop): preserve Welcome banner dismissal ([#5406](https://github.com/block/buzz/pull/5406)) ([`97aa9e31856edb9d8abcdcb33c472027f5588890`](https://github.com/block/buzz/commit/97aa9e31856edb9d8abcdcb33c472027f5588890))
|
||||
- fix(agent): retry LLM completion on malformed 2xx JSON body ([#5351](https://github.com/block/buzz/pull/5351)) ([`5bf78671f45178f8de02ba18d3d321cbbf19cd1f`](https://github.com/block/buzz/commit/5bf78671f45178f8de02ba18d3d321cbbf19cd1f))
|
||||
- fix(desktop): welcome banner overlap and missing dismiss control ([#5330](https://github.com/block/buzz/pull/5330)) ([`f029deafae6ad3b63e13c29104f3be76122cb1df`](https://github.com/block/buzz/commit/f029deafae6ad3b63e13c29104f3be76122cb1df))
|
||||
- fix(desktop): prevent horizontal clipping in Prompt Context modal ([#5324](https://github.com/block/buzz/pull/5324)) ([`fbf89e3bed9adebc033a26b7c43362c004e816a2`](https://github.com/block/buzz/commit/fbf89e3bed9adebc033a26b7c43362c004e816a2))
|
||||
- fix(buzz-agent): recover from 400-shaped image rejections; unbound benchmark agent rounds ([#5318](https://github.com/block/buzz/pull/5318)) ([`261c46076166c6de5bb9a71fb4a0fd0b70aa1efa`](https://github.com/block/buzz/commit/261c46076166c6de5bb9a71fb4a0fd0b70aa1efa))
|
||||
|
||||
### Other repository changes
|
||||
|
||||
- feat(cli): add --visibility flag to channels update ([#5119](https://github.com/block/buzz/pull/5119)) ([`f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b`](https://github.com/block/buzz/commit/f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b))
|
||||
- perf(ci): experiment with sccache for relay builds ([#5224](https://github.com/block/buzz/pull/5224)) ([`5a3b3d23226474f835a1cf41d2ecc5f53cacb070`](https://github.com/block/buzz/commit/5a3b3d23226474f835a1cf41d2ecc5f53cacb070))
|
||||
- ci(release): gate OSS desktop auto-update promotion ([#5398](https://github.com/block/buzz/pull/5398)) ([`43573d114b5bfaf7cefa75eee7e219dc05cf1cd1`](https://github.com/block/buzz/commit/43573d114b5bfaf7cefa75eee7e219dc05cf1cd1))
|
||||
- fix(release): pin desktop PR operations to block/buzz ([#5212](https://github.com/block/buzz/pull/5212)) ([`c1e20a814bf694db2af959adacb375ced27af023`](https://github.com/block/buzz/commit/c1e20a814bf694db2af959adacb375ced27af023))
|
||||
- fix(search): surface exact short profile names ([#5480](https://github.com/block/buzz/pull/5480)) ([`3c76f682c3c2dfe2cd296c277c5e63799d3424f9`](https://github.com/block/buzz/commit/3c76f682c3c2dfe2cd296c277c5e63799d3424f9))
|
||||
- Reduce repeated ACP session context ([#5423](https://github.com/block/buzz/pull/5423)) ([`563e4346da37d0fb2e9ec1c95e7f1eba79f83040`](https://github.com/block/buzz/commit/563e4346da37d0fb2e9ec1c95e7f1eba79f83040))
|
||||
- chore(deps): update react monorepo ([#4441](https://github.com/block/buzz/pull/4441)) ([`119a84897f225c1e3213a09cd149abb37dcb3abc`](https://github.com/block/buzz/commit/119a84897f225c1e3213a09cd149abb37dcb3abc))
|
||||
- ci(security): allow retired relay pool advisory ([#5404](https://github.com/block/buzz/pull/5404)) ([`d2ebaa95a7d2565fb217fdfae56bafb9509be444`](https://github.com/block/buzz/commit/d2ebaa95a7d2565fb217fdfae56bafb9509be444))
|
||||
- chore(deps): update dependency @tanstack/react-virtual to v3.14.9 ([#4439](https://github.com/block/buzz/pull/4439)) ([`c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888`](https://github.com/block/buzz/commit/c923e89a4b6d43ae0c507dbb5e58f2bdd9ab7888))
|
||||
- chore(deps): update all non-major dependencies ([#3049](https://github.com/block/buzz/pull/3049)) ([`856cdb848b0a849e33620887b145b7e598dfd95c`](https://github.com/block/buzz/commit/856cdb848b0a849e33620887b145b7e598dfd95c))
|
||||
- chore(deps): update rust crate arc-swap to v1.9.2 ([#4448](https://github.com/block/buzz/pull/4448)) ([`08de85c592106ea2ffe22ba16e3a0fc10687db54`](https://github.com/block/buzz/commit/08de85c592106ea2ffe22ba16e3a0fc10687db54))
|
||||
- chore(deps): update rust crate async-trait to v0.1.91 ([#4458](https://github.com/block/buzz/pull/4458)) ([`12b1f566480d4feddc171739097f9359d3f255c1`](https://github.com/block/buzz/commit/12b1f566480d4feddc171739097f9359d3f255c1))
|
||||
- chore(deps): update rust crate diffy to v0.5.1 ([#4466](https://github.com/block/buzz/pull/4466)) ([`d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4`](https://github.com/block/buzz/commit/d7cc724fa5391b23e7fac99fc65dc28b79e4c5c4))
|
||||
- chore(deps): update rust crate async-compression to v0.4.43 ([#4456](https://github.com/block/buzz/pull/4456)) ([`7dd8791d0765e9f15fed3299b6948e2babbfd763`](https://github.com/block/buzz/commit/7dd8791d0765e9f15fed3299b6948e2babbfd763))
|
||||
- chore(deps): update rust crate clap to v4.6.6 ([#4465](https://github.com/block/buzz/pull/4465)) ([`e668c6bb4913e36e58d7f947dbaf982e704e9132`](https://github.com/block/buzz/commit/e668c6bb4913e36e58d7f947dbaf982e704e9132))
|
||||
- chore(release): release Buzz Relay version 0.2.1 ([#2856](https://github.com/block/buzz/pull/2856)) ([`6e5c462ac524de60d7edb46c66130fd779cc9006`](https://github.com/block/buzz/commit/6e5c462ac524de60d7edb46c66130fd779cc9006))
|
||||
|
||||
[Compare desktop-v0.5.8...desktop-v0.5.9](https://github.com/block/buzz/compare/desktop-v0.5.8...desktop-v0.5.9)
|
||||
|
||||
## v0.5.8
|
||||
|
||||
### Desktop and shared changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "buzz",
|
||||
"private": true,
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -62,6 +62,7 @@ export default defineConfig({
|
||||
"**/video-attachment.spec.ts",
|
||||
"**/spoiler.spec.ts",
|
||||
"**/composer-link-shortcut.spec.ts",
|
||||
"**/entity-link-recipient-cards.spec.ts",
|
||||
"**/composer-selection-formatting.spec.ts",
|
||||
"**/composer-tooltip-dismiss.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
|
||||
2
desktop/src-tauri/Cargo.lock
generated
2
desktop/src-tauri/Cargo.lock
generated
@@ -1062,7 +1062,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "buzz-desktop"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"]
|
||||
|
||||
[package]
|
||||
name = "buzz-desktop"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
description = "Buzz desktop app"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Buzz",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"identifier": "xyz.block.buzz.app",
|
||||
"build": {
|
||||
"beforeDevCommand": {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useComposerContentState } from "./useComposerContentState";
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
|
||||
import { submitMessageEdit } from "./submitMessageEdit";
|
||||
import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
|
||||
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit";
|
||||
import type { MessageComposerProps } from "./MessageComposer.types";
|
||||
function MessageComposerImpl({
|
||||
audienceContext = null,
|
||||
@@ -100,11 +101,13 @@ function MessageComposerImpl({
|
||||
syncContentRefFromEditorRef,
|
||||
} = useComposerContentState();
|
||||
const [previewContent, setPreviewContent] = React.useState("");
|
||||
const deferredPreviewContent = React.useDeferredValue(previewContent);
|
||||
const {
|
||||
previewList: composerLinkPreviews,
|
||||
getReadyTags: getReadyLinkPreviewTags,
|
||||
} = useComposerLinkPreviews(deferredPreviewContent);
|
||||
hasPendingSnapshots: hasPendingLinkPreviewSnapshots,
|
||||
// Ref lets the submit guard block Enter/form/auto-submit until snapshots settle.
|
||||
hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef,
|
||||
} = useComposerLinkPreviews(previewContent, editTarget == null);
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
|
||||
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
|
||||
@@ -198,6 +201,8 @@ function MessageComposerImpl({
|
||||
const disabledRef = React.useRef(disabled);
|
||||
const isSendingRef = React.useRef(isSending);
|
||||
const isUploadingRef = React.useRef(media.isUploading);
|
||||
// Sync lock: taken before any async send so rapid Enter can't double-submit.
|
||||
const isSubmitLockedRef = React.useRef(false);
|
||||
const onSendRef = React.useRef(onSend);
|
||||
const onEditSaveRef = React.useRef(onEditSave);
|
||||
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
|
||||
@@ -562,7 +567,9 @@ function MessageComposerImpl({
|
||||
(!trimmed && !hasMedia) ||
|
||||
disabledRef.current ||
|
||||
isSendingRef.current ||
|
||||
isSubmitLockedRef.current ||
|
||||
isUploadingRef.current ||
|
||||
hasPendingLinkPreviewSnapshotsRef.current ||
|
||||
mentionSendFlow.isPreparingMentionSend
|
||||
) {
|
||||
return;
|
||||
@@ -574,6 +581,7 @@ function MessageComposerImpl({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
isSubmitLockedRef.current = true;
|
||||
onPreparingMentionSendChange?.(true);
|
||||
persistentMentionHydration.beginSubmit();
|
||||
try {
|
||||
@@ -594,6 +602,7 @@ function MessageComposerImpl({
|
||||
audienceRevision: audienceScope ? persistentAudience.revision : null,
|
||||
});
|
||||
} finally {
|
||||
isSubmitLockedRef.current = false;
|
||||
persistentMentionHydration.endSubmit();
|
||||
onPreparingMentionSendChange?.(false);
|
||||
}
|
||||
@@ -604,6 +613,7 @@ function MessageComposerImpl({
|
||||
drafts.loadDraft,
|
||||
emojiAutocomplete.clearEmojis,
|
||||
getReadyLinkPreviewTags,
|
||||
hasPendingLinkPreviewSnapshotsRef,
|
||||
media.clearQueuedAttachments,
|
||||
media.pendingImetaRef,
|
||||
media.queuedAttachmentsRef,
|
||||
@@ -654,15 +664,10 @@ function MessageComposerImpl({
|
||||
// Clear the trigger BEFORE firing so any navigation from the send cannot
|
||||
// loop back with the param still present.
|
||||
onAutoSubmitCompleteRef.current?.();
|
||||
// Defer by one macrotask so the draft-persist lifecycle effect (which runs
|
||||
// synchronously after mount) has a chance to load the draft content into
|
||||
// the Tiptap editor before we try to submit.
|
||||
const timer = window.setTimeout(() => {
|
||||
submitMessageRef.current();
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
return scheduleSettleGatedAutoSubmit({
|
||||
isPending: () => hasPendingLinkPreviewSnapshotsRef.current,
|
||||
submit: () => submitMessageRef.current(),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // mount-only
|
||||
const handleSubmit = React.useCallback(
|
||||
@@ -802,23 +807,14 @@ function MessageComposerImpl({
|
||||
});
|
||||
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
|
||||
// ── Send button state ───────────────────────────────────────────────
|
||||
const sendDisabled = React.useMemo(
|
||||
() =>
|
||||
composerDisabled ||
|
||||
media.isUploading ||
|
||||
mentionSendFlow.isPreparingMentionSend ||
|
||||
(isContentEmpty &&
|
||||
media.pendingImeta.length === 0 &&
|
||||
media.queuedAttachments.length === 0),
|
||||
[
|
||||
composerDisabled,
|
||||
media.isUploading,
|
||||
mentionSendFlow.isPreparingMentionSend,
|
||||
isContentEmpty,
|
||||
media.pendingImeta.length,
|
||||
media.queuedAttachments.length,
|
||||
],
|
||||
);
|
||||
const sendDisabled =
|
||||
composerDisabled ||
|
||||
media.isUploading ||
|
||||
hasPendingLinkPreviewSnapshots ||
|
||||
mentionSendFlow.isPreparingMentionSend ||
|
||||
(isContentEmpty &&
|
||||
media.pendingImeta.length === 0 &&
|
||||
media.queuedAttachments.length === 0);
|
||||
const handleCaptureSelection = React.useCallback(() => {}, []);
|
||||
|
||||
const handlePaperclipClick = React.useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Unit tests for `scheduleSettleGatedAutoSubmit` — the auto-submit scheduler
|
||||
* that fires a ?autoSend draft submit exactly once, after link-preview settling
|
||||
* finishes.
|
||||
*
|
||||
* Imports and exercises the ACTUAL source helper. Regression guard for the
|
||||
* auto-send-drop blocker (PR #5245, Blocker A): a confirmed draft with a
|
||||
* supported link is normally still settling at mount, so an immediate submit
|
||||
* bails on the pending guard. The prior one-shot `setTimeout(0)` consumed the
|
||||
* trigger and silently dropped the draft. The scheduler must instead poll while
|
||||
* pending and submit exactly once when settling clears — never zero, never
|
||||
* twice.
|
||||
*
|
||||
* A controllable fake timer drives the poll deterministically, so there is no
|
||||
* real-time flakiness (the E2E form could not reliably send inside the ~350 ms
|
||||
* window headless).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit.ts";
|
||||
|
||||
// Minimal deterministic timer: records scheduled callbacks so the test can
|
||||
// advance them one "tick" at a time and assert exact call counts.
|
||||
function makeFakeTimers() {
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
return {
|
||||
set(fn, _ms) {
|
||||
const id = nextId++;
|
||||
pending.set(id, fn);
|
||||
return id;
|
||||
},
|
||||
clear(id) {
|
||||
pending.delete(id);
|
||||
},
|
||||
// Fire the earliest-scheduled still-pending callback.
|
||||
tick() {
|
||||
const [id, fn] = pending.entries().next().value ?? [];
|
||||
if (id === undefined) return false;
|
||||
pending.delete(id);
|
||||
fn();
|
||||
return true;
|
||||
},
|
||||
pendingCount() {
|
||||
return pending.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("submits once immediately when nothing is pending", () => {
|
||||
const timers = makeFakeTimers();
|
||||
let submits = 0;
|
||||
scheduleSettleGatedAutoSubmit({
|
||||
isPending: () => false,
|
||||
submit: () => submits++,
|
||||
timers,
|
||||
});
|
||||
timers.tick(); // fire the initial setTimeout(0)
|
||||
assert.equal(submits, 1);
|
||||
assert.equal(timers.pendingCount(), 0, "no retry should be scheduled");
|
||||
});
|
||||
|
||||
test("waits while settling then submits exactly once (the drop-guard)", () => {
|
||||
const timers = makeFakeTimers();
|
||||
let submits = 0;
|
||||
let pending = true; // still settling at mount
|
||||
scheduleSettleGatedAutoSubmit({
|
||||
isPending: () => pending,
|
||||
submit: () => submits++,
|
||||
timers,
|
||||
});
|
||||
timers.tick(); // initial attempt: pending → reschedules, does NOT submit
|
||||
assert.equal(submits, 0, "must not send while a snapshot is still pending");
|
||||
assert.equal(timers.pendingCount(), 1, "a retry must be scheduled");
|
||||
|
||||
timers.tick(); // retry: still pending
|
||||
assert.equal(submits, 0);
|
||||
|
||||
pending = false; // settling finished
|
||||
timers.tick(); // retry: fires the send
|
||||
assert.equal(submits, 1, "must send exactly once after settling clears");
|
||||
assert.equal(timers.pendingCount(), 0);
|
||||
});
|
||||
|
||||
test("cleanup before settling finishes cancels the submit (no orphan send)", () => {
|
||||
const timers = makeFakeTimers();
|
||||
let submits = 0;
|
||||
const cleanup = scheduleSettleGatedAutoSubmit({
|
||||
isPending: () => true,
|
||||
submit: () => submits++,
|
||||
timers,
|
||||
});
|
||||
timers.tick(); // initial attempt reschedules a retry
|
||||
assert.equal(timers.pendingCount(), 1);
|
||||
cleanup(); // unmount
|
||||
assert.equal(
|
||||
timers.pendingCount(),
|
||||
0,
|
||||
"cleanup must clear the pending retry",
|
||||
);
|
||||
assert.equal(submits, 0);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Auto-submit scheduler for a confirmed draft that arrived via ?autoSend. A
|
||||
// draft containing a supported link is normally still settling (350 ms
|
||||
// debounce + metadata/upload) at mount, so a submit fired immediately bails on
|
||||
// the pending-snapshot guard. A one-shot `setTimeout(0)` would consume the
|
||||
// trigger and silently drop the draft; instead poll until settling finishes
|
||||
// (bounded by the preview hook's own anti-trap cap) then submit exactly once.
|
||||
// The `didSubmit` guard prevents a double fire, and the initial defer lets the
|
||||
// draft-persist lifecycle effect load the draft into the editor first.
|
||||
//
|
||||
// Extracted from MessageComposer as a pure, timer-injectable helper so the
|
||||
// retry/one-shot contract is unit-testable without mounting the composer.
|
||||
export function scheduleSettleGatedAutoSubmit({
|
||||
isPending,
|
||||
submit,
|
||||
retryDelayMs = 50,
|
||||
timers = {
|
||||
set: (fn: () => void, ms: number) => window.setTimeout(fn, ms),
|
||||
clear: (id: number) => window.clearTimeout(id),
|
||||
},
|
||||
}: {
|
||||
isPending: () => boolean;
|
||||
submit: () => void;
|
||||
retryDelayMs?: number;
|
||||
timers?: {
|
||||
set: (fn: () => void, ms: number) => number;
|
||||
clear: (id: number) => void;
|
||||
};
|
||||
}): () => void {
|
||||
let didSubmit = false;
|
||||
let retryTimer = 0;
|
||||
const attempt = () => {
|
||||
if (didSubmit) return;
|
||||
if (isPending()) {
|
||||
retryTimer = timers.set(attempt, retryDelayMs);
|
||||
return;
|
||||
}
|
||||
didSubmit = true;
|
||||
submit();
|
||||
};
|
||||
const initialTimer = timers.set(attempt, 0);
|
||||
return () => {
|
||||
timers.clear(initialTimer);
|
||||
timers.clear(retryTimer);
|
||||
};
|
||||
}
|
||||
78
desktop/src/features/messages/ui/selectSubmitTags.test.mjs
Normal file
78
desktop/src/features/messages/ui/selectSubmitTags.test.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Unit tests for `selectSubmitTags` — the pure selector that decides which
|
||||
* link-preview snapshot tags a composer submit emits.
|
||||
*
|
||||
* These import and exercise the ACTUAL source helper (not a mirrored copy), so
|
||||
* they fail if the submit-tag selection ever regresses.
|
||||
*
|
||||
* Regression guard for the "removed-URL tag leak" defect (PR #5245, Blocker B):
|
||||
* a ready snapshot tag for URL A lingers in the tag map for the 350 ms
|
||||
* debounce window after A is deleted from the draft. Submit must key off the
|
||||
* LIVE hrefs in the content being sent — never that debounced set — so deleting
|
||||
* A and immediately sending replacement text can never attach A's tag (and its
|
||||
* media refs) to a body that no longer contains A.
|
||||
*
|
||||
* The E2E form of this test was flaky: sending inside the 350 ms window from a
|
||||
* headless browser did not reliably fire a submit, so it could not isolate the
|
||||
* leak. A pure unit test against the extracted selector is deterministic and
|
||||
* targets the fix logic directly.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { selectSubmitTags } from "./useComposerLinkPreviews.tsx";
|
||||
|
||||
const tagA = ["link-preview", "snapshot", "1", "https://a.example/x", "A"];
|
||||
const tagB = ["link-preview", "snapshot", "1", "https://b.example/y", "B"];
|
||||
|
||||
test("emits the tag for a live href that has a ready snapshot", () => {
|
||||
const tags = selectSubmitTags(
|
||||
["https://a.example/x"],
|
||||
{ "https://a.example/x": tagA },
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(tags, [tagA]);
|
||||
});
|
||||
|
||||
test("LEAK GUARD: a ready tag whose href is no longer live is NOT emitted", () => {
|
||||
// A resolved (tag still cached), but A was deleted from the draft and the
|
||||
// live content is now different — the debounced map still holds A's tag.
|
||||
const tags = selectSubmitTags(
|
||||
[], // live content no longer contains A
|
||||
{ "https://a.example/x": tagA },
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(tags, [], "removed URL A must never leak its snapshot tag");
|
||||
});
|
||||
|
||||
test("LEAK GUARD: replacing A with a live B emits only B's tag, never A's", () => {
|
||||
const tags = selectSubmitTags(
|
||||
["https://b.example/y"], // A deleted, B is what's live now
|
||||
{ "https://a.example/x": tagA, "https://b.example/y": tagB },
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(tags, [tagB]);
|
||||
});
|
||||
|
||||
test("a live href with no ready tag is omitted (sends as a bare link)", () => {
|
||||
const tags = selectSubmitTags(["https://a.example/x"], {}, false);
|
||||
assert.deepEqual(tags, []);
|
||||
});
|
||||
|
||||
test("preserves live href order for multiple ready tags", () => {
|
||||
const tags = selectSubmitTags(
|
||||
["https://a.example/x", "https://b.example/y"],
|
||||
{ "https://b.example/y": tagB, "https://a.example/x": tagA },
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(tags, [tagA, tagB]);
|
||||
});
|
||||
|
||||
test("suppressed emits only the 'none' marker, ignoring any ready tags", () => {
|
||||
const tags = selectSubmitTags(
|
||||
["https://a.example/x"],
|
||||
{ "https://a.example/x": tagA },
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(tags, [["link-preview", "none"]]);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { ImageOff, LoaderCircle, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri";
|
||||
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
|
||||
@@ -11,8 +12,12 @@ import {
|
||||
beginRelayOriginFetch,
|
||||
getCachedRelayOrigin,
|
||||
} from "@/shared/lib/mediaUrl";
|
||||
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import {
|
||||
isBuzzEntityPreview,
|
||||
type ResolvedLinkPreview,
|
||||
useResolvedLinkPreviews,
|
||||
withEntityFallbacks,
|
||||
} from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
@@ -24,6 +29,17 @@ import {
|
||||
} from "@/shared/ui/attachment";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
// Idle time after the last keystroke before link-preview resolution runs, so
|
||||
// typing a URL does not flicker a card per character (debounce, not throttle:
|
||||
// throttle would still fire mid-type).
|
||||
const LINK_PREVIEW_DEBOUNCE_MS = 350;
|
||||
|
||||
// Upper bound on how long Send stays disabled while a preview is still settling
|
||||
// (metadata resolving, or snapshot media uploading). Past this the button
|
||||
// re-enables even if the tag never lands, so a dead or slow link never traps
|
||||
// the composer — the message then sends as a bare link.
|
||||
const SNAPSHOT_SETTLE_DISABLE_CAP_MS = 2000;
|
||||
|
||||
function previewHostname(href: string): string {
|
||||
try {
|
||||
return new URL(href).hostname.replace(/^www\./, "");
|
||||
@@ -32,10 +48,32 @@ function previewHostname(href: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Pure selector for the snapshot tags emitted on submit. Keyed off `liveHrefs`
|
||||
// (the hrefs in the content being sent RIGHT NOW), never the debounced active
|
||||
// set — a ready tag for URL A lingers in `tagsByHref` for the 350 ms until the
|
||||
// debounce drops A, so keying off live hrefs is what stops "delete A, send
|
||||
// replacement text within the window" from leaking A's tag (and media refs)
|
||||
// onto a body that no longer contains A. When `suppressed`, emit only the
|
||||
// "none" marker. Live hrefs without a ready tag (dead/slow link past the
|
||||
// anti-trap cap) are omitted and the message sends as a bare link.
|
||||
export function selectSubmitTags(
|
||||
liveHrefs: readonly string[],
|
||||
tagsByHref: Record<string, string[]>,
|
||||
suppressed: boolean,
|
||||
): string[][] {
|
||||
if (suppressed) return [["link-preview", "none"]];
|
||||
return liveHrefs.flatMap((href) => {
|
||||
const tag = tagsByHref[href];
|
||||
return tag ? [tag] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function ComposerLinkPreviewCard({
|
||||
preview,
|
||||
tagReady,
|
||||
}: {
|
||||
preview: ResolvedLinkPreview;
|
||||
tagReady: boolean;
|
||||
}) {
|
||||
const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null;
|
||||
const [failedImageSrc, setFailedImageSrc] = React.useState<string | null>(
|
||||
@@ -43,6 +81,11 @@ function ComposerLinkPreviewCard({
|
||||
);
|
||||
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
|
||||
const hostname = previewHostname(preview.href);
|
||||
// External cards are send-ready only once their snapshot tag exists. Buzz
|
||||
// entities never snapshot; recipients resolve them from the relay, so they
|
||||
// are complete as soon as the recognized entity card exists.
|
||||
const snapshotTagReady = Boolean(preview.snapshotReady && tagReady);
|
||||
const done = snapshotTagReady || isBuzzEntityPreview(preview);
|
||||
let path = "";
|
||||
try {
|
||||
const url = new URL(preview.href);
|
||||
@@ -55,7 +98,8 @@ function ComposerLinkPreviewCard({
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
data-link-preview-composer-card=""
|
||||
state={preview.snapshotReady ? "done" : "processing"}
|
||||
data-snapshot-tag-ready={snapshotTagReady ? "true" : "false"}
|
||||
state={done ? "done" : "processing"}
|
||||
>
|
||||
<AttachmentMedia
|
||||
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
|
||||
@@ -86,10 +130,10 @@ function ComposerLinkPreviewCard({
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="line-clamp-1" data-link-preview-hostname="">
|
||||
{preview.snapshotReady ? preview.title : hostname}
|
||||
{done ? preview.title : hostname}
|
||||
</AttachmentTitle>
|
||||
<AttachmentDescription>
|
||||
{preview.snapshotReady
|
||||
{done
|
||||
? preview.provider || hostname
|
||||
: path && path !== "/"
|
||||
? path
|
||||
@@ -131,21 +175,81 @@ async function uploadDataUrl(
|
||||
return { url: uploaded.url, sha256: uploaded.sha256 };
|
||||
}
|
||||
|
||||
export function useComposerLinkPreviews(content: string) {
|
||||
// Upload one snapshot media (image or favicon) independently so a single
|
||||
// failure degrades gracefully instead of dropping the whole preview: on
|
||||
// failure we return empty url/sha256 (a valid "no media" snapshot field) and
|
||||
// report which media failed so the caller can toast the user once.
|
||||
async function uploadSnapshotMedia(
|
||||
dataUrl: string | null | undefined,
|
||||
filename: string,
|
||||
label: "thumbnail" | "favicon",
|
||||
): Promise<{ url: string; sha256: string; failed: null | typeof label }> {
|
||||
try {
|
||||
const { url, sha256 } = await uploadDataUrl(dataUrl, filename);
|
||||
return { url, sha256, failed: null };
|
||||
} catch {
|
||||
return { url: "", sha256: "", failed: dataUrl ? label : null };
|
||||
}
|
||||
}
|
||||
|
||||
export function useComposerLinkPreviews(content: string, enabled = true) {
|
||||
const [suppressed, setSuppressed] = React.useState(false);
|
||||
const candidates = React.useMemo(
|
||||
() =>
|
||||
extractSupportedLinkPreviews(content).filter((preview) =>
|
||||
preview.href.startsWith("buzz://")
|
||||
? true
|
||||
: isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
|
||||
),
|
||||
[content],
|
||||
);
|
||||
const previews = useResolvedLinkPreviews(suppressed ? [] : candidates);
|
||||
// Debounce the content that drives resolution so typing a URL character by
|
||||
// character does not churn a new candidate href (and a flickering card) per
|
||||
// keystroke. `content` is the live editor value; `debounced` is what actually
|
||||
// resolves. A fast paste-and-Enter before the debounce fires is held by
|
||||
// `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the
|
||||
// live candidates resolve — so no synchronous flush is needed at submit.
|
||||
const [debounced, setDebounced] = React.useState(content);
|
||||
const debouncedRef = React.useRef(debounced);
|
||||
debouncedRef.current = debounced;
|
||||
React.useEffect(() => {
|
||||
if (candidates.length === 0) setSuppressed(false);
|
||||
}, [candidates.length]);
|
||||
if (content === debouncedRef.current) return;
|
||||
const timer = window.setTimeout(
|
||||
() => setDebounced(content),
|
||||
LINK_PREVIEW_DEBOUNCE_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [content]);
|
||||
const extractCandidates = React.useCallback(
|
||||
(source: string) =>
|
||||
enabled
|
||||
? extractSupportedLinkPreviews(source).filter((preview) =>
|
||||
preview.href.startsWith("buzz://")
|
||||
? true
|
||||
: isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
|
||||
)
|
||||
: [],
|
||||
[enabled],
|
||||
);
|
||||
const candidates = React.useMemo(
|
||||
() => extractCandidates(debounced),
|
||||
[extractCandidates, debounced],
|
||||
);
|
||||
// Supported candidates in the LIVE content. When these differ from what has
|
||||
// resolved (debounce not yet fired after a paste/keystroke), Send must still
|
||||
// treat the preview as pending so a fast Enter cannot ship a bare link ahead
|
||||
// of resolution.
|
||||
const liveCandidatesRef = React.useRef<string[]>([]);
|
||||
liveCandidatesRef.current = extractCandidates(content).map(
|
||||
(preview) => preview.href,
|
||||
);
|
||||
const resolvedPreviews = useResolvedLinkPreviews(
|
||||
suppressed ? [] : candidates,
|
||||
);
|
||||
// Entity links resolve to null metadata when the relay lookup has nothing
|
||||
// for them; keep their safe fallback cards rather than dropping them.
|
||||
const previews = React.useMemo(
|
||||
() => withEntityFallbacks(suppressed ? [] : candidates, resolvedPreviews),
|
||||
[suppressed, candidates, resolvedPreviews],
|
||||
);
|
||||
// Clear a "hide previews" suppression as soon as the LIVE draft has no
|
||||
// supported candidates — not the debounced set, whose lag would otherwise let
|
||||
// a clear-then-retype race keep suppression stuck on after the draft changed.
|
||||
const liveCandidatesEmpty = liveCandidatesRef.current.length === 0;
|
||||
React.useEffect(() => {
|
||||
if (liveCandidatesEmpty) setSuppressed(false);
|
||||
}, [liveCandidatesEmpty]);
|
||||
const [readyTags, setReadyTags] = React.useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
@@ -184,12 +288,33 @@ export function useComposerLinkPreviews(content: string) {
|
||||
)
|
||||
continue;
|
||||
uploadsRef.current.add(preview.href);
|
||||
void Promise.all([
|
||||
uploadDataUrl(preview.imageDataUrl, "link-preview-image.png"),
|
||||
uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png"),
|
||||
// Upload image and favicon independently so one failure degrades to the
|
||||
// surviving media instead of dropping the whole preview. A snapshot tag
|
||||
// with empty media fields is valid (renders as text + favicon, or
|
||||
// text-only), so a partial or total media failure still ships a real
|
||||
// inline preview and the card never spins forever.
|
||||
const uploadPromise = Promise.all([
|
||||
uploadSnapshotMedia(
|
||||
preview.imageDataUrl,
|
||||
"link-preview-image.png",
|
||||
"thumbnail",
|
||||
),
|
||||
uploadSnapshotMedia(
|
||||
preview.faviconDataUrl,
|
||||
"link-preview-favicon.png",
|
||||
"favicon",
|
||||
),
|
||||
])
|
||||
.then(([image, favicon]) => {
|
||||
if (!activeHrefsRef.current.has(preview.href)) return;
|
||||
const failedMedia = [image.failed, favicon.failed].filter(
|
||||
(label): label is "thumbnail" | "favicon" => label !== null,
|
||||
);
|
||||
if (failedMedia.length > 0) {
|
||||
toast.error(
|
||||
`Something went wrong with the ${failedMedia.join(" and ")}`,
|
||||
);
|
||||
}
|
||||
const tag = buildLinkPreviewSnapshotTag({
|
||||
canonicalUrl: preview.href,
|
||||
title: preview.title,
|
||||
@@ -201,10 +326,18 @@ export function useComposerLinkPreviews(content: string) {
|
||||
faviconSha256: favicon.sha256,
|
||||
});
|
||||
if (!tag) return;
|
||||
// Update the ref alongside state so a submit reading
|
||||
// `readyTagsByHrefRef` sees the tag before the next render commits.
|
||||
readyTagsByHrefRef.current = {
|
||||
...readyTagsByHrefRef.current,
|
||||
[preview.href]: tag,
|
||||
};
|
||||
setReadyTags((current) => ({ ...current, [preview.href]: tag }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => uploadsRef.current.delete(preview.href));
|
||||
.finally(() => {
|
||||
uploadsRef.current.delete(preview.href);
|
||||
});
|
||||
void uploadPromise;
|
||||
}
|
||||
}, [previews, readyTags]);
|
||||
|
||||
@@ -213,17 +346,73 @@ export function useComposerLinkPreviews(content: string) {
|
||||
: candidates.flatMap((candidate) =>
|
||||
readyTags[candidate.href] ? [readyTags[candidate.href]] : [],
|
||||
);
|
||||
// A preview is "settling" from paste until its sendable tag exists: metadata
|
||||
// is still resolving, or it resolved and the snapshot media is uploading.
|
||||
// Send stays disabled across the whole window so the button never flickers
|
||||
// ready -> not-ready -> ready (buzz:// links never snapshot, so they never
|
||||
// report settling). `imageState === "none"` is terminal (no snapshot), so it
|
||||
// does not block. See the disable cap below for the dead/slow-link escape.
|
||||
const hasResolvingSnapshots =
|
||||
!suppressed &&
|
||||
previews.some(
|
||||
(preview) =>
|
||||
!preview.href.startsWith("buzz://") &&
|
||||
(preview.imageState === "pending" ||
|
||||
(preview.snapshotReady && !readyTags[preview.href])),
|
||||
);
|
||||
// A supported link in the LIVE content that resolution has not caught up to
|
||||
// yet (debounce pending, or resolved for an older revision) also counts as
|
||||
// settling — otherwise a paste-and-immediate-Enter would ship a bare link
|
||||
// before resolution even starts. buzz:// links never snapshot, so ignore them.
|
||||
const hasUnresolvedLiveCandidates =
|
||||
!suppressed &&
|
||||
liveCandidatesRef.current.some(
|
||||
(href) =>
|
||||
!href.startsWith("buzz://") &&
|
||||
!readyTags[href] &&
|
||||
!candidates.some((candidate) => candidate.href === href),
|
||||
);
|
||||
const hasSettlingSnapshots =
|
||||
hasResolvingSnapshots || hasUnresolvedLiveCandidates;
|
||||
// Re-enable Send once the disable cap elapses even if a preview is still
|
||||
// settling, so a link whose metadata or upload stalls never traps the
|
||||
// composer. Resets whenever settling ends or the live candidate set changes.
|
||||
const [settleDisableExpired, setSettleDisableExpired] = React.useState(false);
|
||||
const liveCandidatesKey = liveCandidatesRef.current.join("\n");
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: liveCandidatesKey intentionally restarts the anti-trap cap when the link set changes while still settling, so a replaced/added link gets a fresh disable window rather than inheriting the prior link's near-expired timer.
|
||||
React.useEffect(() => {
|
||||
if (!hasSettlingSnapshots) {
|
||||
setSettleDisableExpired(false);
|
||||
return;
|
||||
}
|
||||
setSettleDisableExpired(false);
|
||||
const timer = window.setTimeout(
|
||||
() => setSettleDisableExpired(true),
|
||||
SNAPSHOT_SETTLE_DISABLE_CAP_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [hasSettlingSnapshots, liveCandidatesKey]);
|
||||
const hasPendingSnapshots = hasSettlingSnapshots && !settleDisableExpired;
|
||||
// Ref mirror so a synchronous submit guard can read the pending state on any
|
||||
// entry point (Enter, form, auto-submit), not just the reactive button prop.
|
||||
const hasPendingSnapshotsRef = React.useRef(hasPendingSnapshots);
|
||||
hasPendingSnapshotsRef.current = hasPendingSnapshots;
|
||||
const hideAll = React.useCallback(() => setSuppressed(true), []);
|
||||
const previewList = previews.length ? (
|
||||
<div
|
||||
className="mb-2"
|
||||
data-composer-link-previews=""
|
||||
data-has-pending-snapshots={hasPendingSnapshots ? "true" : "false"}
|
||||
data-ready-snapshot-count={readyTagsRef.current.length}
|
||||
>
|
||||
<div className="flex max-w-full items-start gap-1">
|
||||
<AttachmentGroup className="max-w-full flex-row flex-wrap items-start overflow-visible pb-0">
|
||||
{previews.map((preview) => (
|
||||
<ComposerLinkPreviewCard key={preview.href} preview={preview} />
|
||||
<ComposerLinkPreviewCard
|
||||
key={preview.href}
|
||||
preview={preview}
|
||||
tagReady={Boolean(readyTags[preview.href])}
|
||||
/>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
<Button
|
||||
@@ -241,12 +430,25 @@ export function useComposerLinkPreviews(content: string) {
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
const getReadyTags = React.useCallback(() => {
|
||||
if (suppressedRef.current) return [["link-preview", "none"]];
|
||||
return [...activeHrefsRef.current].flatMap((href) => {
|
||||
const tag = readyTagsByHrefRef.current[href];
|
||||
return tag ? [tag] : [];
|
||||
});
|
||||
}, []);
|
||||
return { previewList, getReadyTags };
|
||||
// Snapshot tags for a submit, read synchronously at submit start from the
|
||||
// LIVE candidate set (liveCandidatesRef) via `selectSubmitTags` — so the tags
|
||||
// always correspond to the content actually being sent, never a debounced set
|
||||
// that still holds a just-removed URL. No await: Send is disabled until every
|
||||
// settling preview has its tag (or the anti-trap cap fires), so at submit time
|
||||
// the tags that will ever exist already exist.
|
||||
const getReadyTags = React.useCallback(
|
||||
() =>
|
||||
selectSubmitTags(
|
||||
liveCandidatesRef.current,
|
||||
readyTagsByHrefRef.current,
|
||||
suppressedRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
return {
|
||||
previewList,
|
||||
getReadyTags,
|
||||
hasPendingSnapshots,
|
||||
hasPendingSnapshotsRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,37 @@ test("parseSupportedLinkPreview parses GitHub pull request URLs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview strips the fragment from the preview href", () => {
|
||||
// A `#fragment` is a client-only anchor; the preview and its signed snapshot
|
||||
// canonical URL are of the page. Keeping it would fail the fragmentless
|
||||
// snapshot-URL guard and drop the preview entirely.
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview(
|
||||
"https://github.com/block/sprout/pull/1234#pullrequestreview-99",
|
||||
)?.href,
|
||||
"https://github.com/block/sprout/pull/1234",
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews collapses fragment variants of one page", () => {
|
||||
const previews = extractSupportedLinkPreviews(
|
||||
[
|
||||
"https://github.com/block/sprout/pull/1234#pullrequestreview-99",
|
||||
"https://github.com/block/sprout/pull/1234#issuecomment-1",
|
||||
"https://github.com/block/sprout/pull/5678",
|
||||
].join("\n"),
|
||||
);
|
||||
// Two anchors into the same page dedupe to one card at first occurrence; the
|
||||
// distinct second page keeps its own card.
|
||||
assert.deepEqual(
|
||||
previews.map((preview) => preview.href),
|
||||
[
|
||||
"https://github.com/block/sprout/pull/1234",
|
||||
"https://github.com/block/sprout/pull/5678",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview parses GitHub repository URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview("https://github.com/block/sprout"),
|
||||
|
||||
@@ -274,9 +274,17 @@ function createPreview(
|
||||
typeLabel: SupportedLinkPreview["typeLabel"],
|
||||
title: string,
|
||||
): SupportedLinkPreview {
|
||||
// Strip the `#fragment` from the preview identity. A fragment is a
|
||||
// client-only anchor into the page — the preview (and the signed snapshot's
|
||||
// canonicalUrl) is of the page itself. Keeping it would fail the
|
||||
// fragment-free snapshot-URL guard, so a link like `pull/3767#review-1`
|
||||
// would silently get no preview at all. The message body keeps the raw URL,
|
||||
// so click-through to the anchor is preserved.
|
||||
const canonical = new URL(parsed.href);
|
||||
canonical.hash = "";
|
||||
return {
|
||||
kind,
|
||||
href: parsed.href,
|
||||
href: canonical.href,
|
||||
provider,
|
||||
title,
|
||||
typeLabel,
|
||||
|
||||
@@ -3,7 +3,10 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
__linkPreviewMetadataTest,
|
||||
fetchBuzzEntityMetadata,
|
||||
isBuzzEntityPreview,
|
||||
resolveLinkPreview,
|
||||
withEntityFallbacks,
|
||||
} from "./useResolvedLinkPreviews.ts";
|
||||
|
||||
const preview = {
|
||||
@@ -27,13 +30,27 @@ function metadata(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("pending metadata reserves the image treatment", () => {
|
||||
test("pending external metadata reserves the image treatment", () => {
|
||||
assert.deepEqual(resolveLinkPreview(preview, undefined), {
|
||||
...preview,
|
||||
imageState: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
test("pending Buzz entity metadata remains image-less", () => {
|
||||
const entityPreview = {
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
|
||||
provider: "Buzz",
|
||||
title: "buzz",
|
||||
typeLabel: "repo",
|
||||
};
|
||||
assert.deepEqual(resolveLinkPreview(entityPreview, undefined), {
|
||||
...entityPreview,
|
||||
imageState: "none",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolved image metadata keeps the reserved image treatment", () => {
|
||||
const resolved = resolveLinkPreview(preview, {
|
||||
title: "A story",
|
||||
@@ -180,3 +197,292 @@ test("metadata loader coalesces fragment variants and bounds concurrency", async
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
|
||||
test("withEntityFallbacks re-adds previews dropped by null metadata", () => {
|
||||
const entityPreview = {
|
||||
kind: "buzz-pull-request",
|
||||
href: `buzz://pr?id=${"ab".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`,
|
||||
provider: "Buzz",
|
||||
title: `buzz #${"ab".repeat(4)}`,
|
||||
typeLabel: "PR",
|
||||
};
|
||||
|
||||
assert.deepEqual(withEntityFallbacks([entityPreview], []), [
|
||||
{ ...entityPreview, imageState: "none" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("withEntityFallbacks keeps resolved previews and preserves order", () => {
|
||||
const first = {
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
|
||||
provider: "Buzz",
|
||||
title: "buzz",
|
||||
typeLabel: "repo",
|
||||
};
|
||||
const second = {
|
||||
kind: "buzz-issue",
|
||||
href: `buzz://issue?id=${"ef".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`,
|
||||
provider: "Buzz",
|
||||
title: `buzz #${"ef".repeat(4)}`,
|
||||
typeLabel: "issue",
|
||||
};
|
||||
const resolvedSecond = {
|
||||
...second,
|
||||
title: "Fix the preview cards",
|
||||
imageState: "none",
|
||||
};
|
||||
|
||||
assert.deepEqual(withEntityFallbacks([first, second], [resolvedSecond]), [
|
||||
{ ...first, imageState: "none" },
|
||||
resolvedSecond,
|
||||
]);
|
||||
});
|
||||
|
||||
test("entity fallback eligibility is kind-scoped", () => {
|
||||
assert.equal(
|
||||
isBuzzEntityPreview({
|
||||
...preview,
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isBuzzEntityPreview({ ...preview, href: "buzz://future?id=example" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("withEntityFallbacks still drops unresolved external links", () => {
|
||||
assert.deepEqual(withEntityFallbacks([preview], []), []);
|
||||
assert.deepEqual(
|
||||
withEntityFallbacks([{ ...preview, href: "buzz://future?id=example" }], []),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
function relayEvent({
|
||||
id,
|
||||
kind,
|
||||
pubkey,
|
||||
content = "",
|
||||
tags = [],
|
||||
createdAt = 1,
|
||||
}) {
|
||||
return { id, kind, pubkey, created_at: createdAt, content, tags, sig: "" };
|
||||
}
|
||||
|
||||
test("Buzz PR metadata includes repository identity and trusted root context", async () => {
|
||||
const owner = "cd".repeat(32);
|
||||
const attacker = "ef".repeat(32);
|
||||
const id = "ab".repeat(32);
|
||||
const repoAddress = `30617:${owner}:buzz`;
|
||||
const commit = "1234567".padEnd(40, "0");
|
||||
const events = [
|
||||
relayEvent({
|
||||
id: "01".repeat(32),
|
||||
kind: 30617,
|
||||
pubkey: owner,
|
||||
tags: [
|
||||
["d", "buzz"],
|
||||
["name", "Buzz Desktop"],
|
||||
["default-branch", "main"],
|
||||
],
|
||||
}),
|
||||
relayEvent({
|
||||
id,
|
||||
kind: 1618,
|
||||
pubkey: owner,
|
||||
content: "Body",
|
||||
tags: [
|
||||
["a", repoAddress],
|
||||
["subject", "Restore entity cards"],
|
||||
["branch-name", "fix/cards"],
|
||||
["target-branch", "release"],
|
||||
["c", commit],
|
||||
],
|
||||
}),
|
||||
relayEvent({
|
||||
id: "02".repeat(32),
|
||||
kind: 1633,
|
||||
pubkey: attacker,
|
||||
createdAt: 20,
|
||||
tags: [["e", id]],
|
||||
}),
|
||||
relayEvent({
|
||||
id: "03".repeat(32),
|
||||
kind: 1630,
|
||||
pubkey: owner,
|
||||
createdAt: 10,
|
||||
tags: [["e", id]],
|
||||
}),
|
||||
...Array.from({ length: 25 }, (_, index) =>
|
||||
relayEvent({
|
||||
id: index.toString(16).padStart(64, "0"),
|
||||
kind: 1633,
|
||||
pubkey: owner,
|
||||
createdAt: 100 + index,
|
||||
tags: [["e", index.toString(16).padStart(64, "f")]],
|
||||
}),
|
||||
),
|
||||
];
|
||||
const fetchEvents = async (filter) =>
|
||||
events
|
||||
.filter(
|
||||
(event) =>
|
||||
(!filter.kinds || filter.kinds.includes(event.kind)) &&
|
||||
(!filter.ids || filter.ids.includes(event.id)) &&
|
||||
(!filter.authors || filter.authors.includes(event.pubkey)) &&
|
||||
(!filter["#d"] ||
|
||||
event.tags.some(
|
||||
(tag) => tag[0] === "d" && filter["#d"].includes(tag[1]),
|
||||
)) &&
|
||||
(!filter["#a"] ||
|
||||
event.tags.some(
|
||||
(tag) => tag[0] === "a" && filter["#a"].includes(tag[1]),
|
||||
)) &&
|
||||
(!filter["#e"] ||
|
||||
event.tags.some(
|
||||
(tag) => tag[0] === "e" && filter["#e"].includes(tag[1]),
|
||||
)),
|
||||
)
|
||||
.sort((left, right) => right.created_at - left.created_at)
|
||||
.slice(0, filter.limit);
|
||||
|
||||
const result = await fetchBuzzEntityMetadata(
|
||||
`buzz://pr?id=${id}&owner=${owner}&d=buzz`,
|
||||
fetchEvents,
|
||||
);
|
||||
assert.equal(result?.siteName, "Buzz Desktop");
|
||||
assert.equal(result?.title, "Restore entity cards");
|
||||
assert.equal(result?.description, "Open · fix/cards → release · 1234567");
|
||||
assert.equal(result?.faviconDataUrl, null);
|
||||
assert.equal(result?.imageDataUrl, null);
|
||||
});
|
||||
|
||||
test("Buzz entity roots reject ambiguous repository tags", async () => {
|
||||
const owner = "cd".repeat(32);
|
||||
const attacker = "ef".repeat(32);
|
||||
const targetAddress = `30617:${owner}:buzz`;
|
||||
const attackerAddress = `30617:${attacker}:other`;
|
||||
const repository = relayEvent({
|
||||
id: "01".repeat(32),
|
||||
kind: 30617,
|
||||
pubkey: owner,
|
||||
tags: [
|
||||
["d", "buzz"],
|
||||
["name", "Buzz Desktop"],
|
||||
["default-branch", "main"],
|
||||
],
|
||||
});
|
||||
|
||||
for (const [type, kind] of [
|
||||
["pr", 1618],
|
||||
["issue", 1621],
|
||||
]) {
|
||||
const id = (type === "pr" ? "ab" : "bc").repeat(32);
|
||||
const root = relayEvent({
|
||||
id,
|
||||
kind,
|
||||
pubkey: attacker,
|
||||
tags: [
|
||||
["a", attackerAddress],
|
||||
["a", targetAddress],
|
||||
["subject", "Misbound entity"],
|
||||
],
|
||||
});
|
||||
const result = await fetchBuzzEntityMetadata(
|
||||
`buzz://${type}?id=${id}&owner=${owner}&d=buzz`,
|
||||
async (filter) =>
|
||||
filter.kinds?.includes(30617)
|
||||
? [repository]
|
||||
: filter.ids?.includes(id)
|
||||
? [root]
|
||||
: [],
|
||||
);
|
||||
assert.equal(result, null, `${type} with multiple repository tags`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Buzz repository metadata stays image-less and exposes default branch", async () => {
|
||||
const owner = "cd".repeat(32);
|
||||
const result = await fetchBuzzEntityMetadata(
|
||||
`buzz://repo?owner=${owner}&d=relay-tools`,
|
||||
async () => [
|
||||
relayEvent({
|
||||
id: "01".repeat(32),
|
||||
kind: 30617,
|
||||
pubkey: owner,
|
||||
content: "Fallback description",
|
||||
tags: [
|
||||
["d", "relay-tools"],
|
||||
["name", "Relay Tools"],
|
||||
["description", "Operator tooling for relays"],
|
||||
["status", "active"],
|
||||
["default-branch", "trunk"],
|
||||
],
|
||||
}),
|
||||
],
|
||||
);
|
||||
assert.equal(result?.siteName, "Relay Tools");
|
||||
assert.equal(result?.title, "Operator tooling for relays");
|
||||
assert.equal(result?.description, "active · default: trunk");
|
||||
assert.equal(result?.faviconDataUrl, null);
|
||||
assert.equal(result?.imageDataUrl, null);
|
||||
assert.equal(result?.imageDomain, null);
|
||||
});
|
||||
|
||||
test("Buzz project metadata resolves from the 30621 announcement", async () => {
|
||||
const owner = "cd".repeat(32);
|
||||
const result = await fetchBuzzEntityMetadata(
|
||||
`buzz://project?owner=${owner}&d=pollinator`,
|
||||
async () => [
|
||||
relayEvent({
|
||||
id: "01".repeat(32),
|
||||
kind: 30621,
|
||||
pubkey: owner,
|
||||
tags: [
|
||||
["d", "pollinator"],
|
||||
["name", "Pollinator"],
|
||||
["description", "Cross-repo pollination pipeline"],
|
||||
["a", `30617:${owner}:flappy-bee`],
|
||||
["a", `30617:${owner}:hive-tools`],
|
||||
],
|
||||
}),
|
||||
],
|
||||
);
|
||||
assert.equal(result?.siteName, "Pollinator");
|
||||
assert.equal(result?.title, "Cross-repo pollination pipeline");
|
||||
assert.equal(result?.description, "2 repositories");
|
||||
assert.equal(result?.imageDataUrl, null);
|
||||
});
|
||||
|
||||
test("Buzz project metadata declines a missing or invalid announcement", async () => {
|
||||
const owner = "cd".repeat(32);
|
||||
assert.equal(
|
||||
await fetchBuzzEntityMetadata(
|
||||
`buzz://project?owner=${owner}&d=pollinator`,
|
||||
async () => [],
|
||||
),
|
||||
null,
|
||||
);
|
||||
// Two `d` tags fail NIP-MP envelope validation.
|
||||
assert.equal(
|
||||
await fetchBuzzEntityMetadata(
|
||||
`buzz://project?owner=${owner}&d=pollinator`,
|
||||
async () => [
|
||||
relayEvent({
|
||||
id: "01".repeat(32),
|
||||
kind: 30621,
|
||||
pubkey: owner,
|
||||
tags: [
|
||||
["d", "pollinator"],
|
||||
["d", "duplicate"],
|
||||
],
|
||||
}),
|
||||
],
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,20 @@ import * as React from "react";
|
||||
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
eventToExplicitProject,
|
||||
eventToRepository,
|
||||
} from "@/features/projects/projectModels";
|
||||
import { eventToProjectIssue } from "@/features/projects/projectIssues.mjs";
|
||||
import { eventToProjectPullRequest } from "@/features/projects/projectPullRequests.mjs";
|
||||
import {
|
||||
KIND_GIT_ISSUE,
|
||||
KIND_GIT_PULL_REQUEST,
|
||||
KIND_GIT_STATUS_CLOSED,
|
||||
KIND_GIT_STATUS_DRAFT,
|
||||
KIND_GIT_STATUS_MERGED,
|
||||
KIND_GIT_STATUS_OPEN,
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
@@ -202,91 +213,147 @@ function fetchLinkPreviewMetadata(
|
||||
const metadataLoader = createMetadataLoader({
|
||||
fetcher: fetchLinkPreviewMetadata,
|
||||
});
|
||||
function buzzEntityMetadata(
|
||||
title: string | null | undefined,
|
||||
description?: string | null,
|
||||
): LinkPreviewMetadata | null {
|
||||
return title
|
||||
? {
|
||||
title,
|
||||
siteName: "Buzz",
|
||||
description: description || null,
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
}
|
||||
: null;
|
||||
const ENTITY_STATUS_KINDS = [
|
||||
KIND_GIT_STATUS_OPEN,
|
||||
KIND_GIT_STATUS_MERGED,
|
||||
KIND_GIT_STATUS_CLOSED,
|
||||
KIND_GIT_STATUS_DRAFT,
|
||||
];
|
||||
|
||||
type EntityEventFetcher = (
|
||||
filter: Parameters<typeof relayClient.fetchEvents>[0],
|
||||
) => Promise<RelayEvent[]>;
|
||||
|
||||
function compactMetadata(
|
||||
parts: Array<string | null | undefined>,
|
||||
): string | null {
|
||||
const values = parts.filter((part): part is string => Boolean(part));
|
||||
return values.length > 0 ? values.join(" · ") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display name of an addressable coordinate (`kind:owner:d`) from
|
||||
* its announcement event. Returns null when the relay has no such event, which
|
||||
* drops the card rather than advertising a coordinate nobody can open.
|
||||
*/
|
||||
async function loadCoordinateMetadata(
|
||||
kind: number,
|
||||
owner: string,
|
||||
dtag: string,
|
||||
/** Resolve builder-focused metadata only from the active relay. */
|
||||
export async function fetchBuzzEntityMetadata(
|
||||
href: string,
|
||||
fetchEvents: EntityEventFetcher = (filter) => relayClient.fetchEvents(filter),
|
||||
): Promise<LinkPreviewMetadata | null> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [kind],
|
||||
const parsed = parseEntityLink(href);
|
||||
if (!parsed.ok) return null;
|
||||
|
||||
const { owner, dtag } = parsed.value;
|
||||
if (parsed.value.type === "project") {
|
||||
const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`;
|
||||
const projectEvents = await fetchEvents({
|
||||
kinds: [KIND_PROJECT_ANNOUNCEMENT],
|
||||
authors: [owner],
|
||||
"#d": [dtag],
|
||||
limit: 1,
|
||||
});
|
||||
// Repository maps are only needed to resolve a project's repository read
|
||||
// models, which the card does not show — empty maps still validate the
|
||||
// announcement envelope and resolve name/description.
|
||||
const project = projectEvents
|
||||
.map((event) => eventToExplicitProject(event, new Map(), new Map()))
|
||||
.find((candidate) => candidate?.projectAddress === projectAddress);
|
||||
if (!project) return null;
|
||||
|
||||
const repositoryCount = project.repositoryAddresses.length;
|
||||
return {
|
||||
siteName: project.name,
|
||||
faviconDataUrl: null,
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
title: project.description || project.name,
|
||||
description: compactMetadata([
|
||||
repositoryCount > 0
|
||||
? `${repositoryCount} ${repositoryCount === 1 ? "repository" : "repositories"}`
|
||||
: null,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`;
|
||||
const repoEvents = await fetchEvents({
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
authors: [owner],
|
||||
"#d": [dtag],
|
||||
limit: 1,
|
||||
});
|
||||
const event = events[0];
|
||||
if (!event) return null;
|
||||
const repository = repoEvents
|
||||
.map((event) => eventToRepository(event))
|
||||
.find((candidate) => candidate?.repoAddress === repoAddress);
|
||||
if (!repository) return null;
|
||||
|
||||
const tag = (name: string) =>
|
||||
event.tags.find((entry) => entry[0] === name)?.[1] || null;
|
||||
return buzzEntityMetadata(tag("name") ?? dtag, tag("description"));
|
||||
const base = {
|
||||
siteName: repository.name,
|
||||
faviconDataUrl: null,
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
};
|
||||
if (parsed.value.type === "repo") {
|
||||
return {
|
||||
...base,
|
||||
title: repository.description || repository.name,
|
||||
description: compactMetadata([
|
||||
repository.status,
|
||||
`default: ${repository.defaultBranch}`,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const { id, type } = parsed.value;
|
||||
const rootEvents = await fetchEvents({
|
||||
kinds: [type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE],
|
||||
ids: [id],
|
||||
limit: 1,
|
||||
});
|
||||
const root = rootEvents.find((event) => {
|
||||
const repositoryTags = event.tags.filter((tag) => tag[0] === "a");
|
||||
return (
|
||||
event.id === id &&
|
||||
repositoryTags.length === 1 &&
|
||||
repositoryTags[0][1] === repoAddress
|
||||
);
|
||||
});
|
||||
if (!root) return null;
|
||||
|
||||
const trustedAuthors = [...new Set([root.pubkey.toLowerCase(), owner])];
|
||||
const statusEvents = await fetchEvents({
|
||||
kinds: ENTITY_STATUS_KINDS,
|
||||
authors: trustedAuthors,
|
||||
"#e": [id],
|
||||
limit: 20,
|
||||
});
|
||||
if (type === "issue") {
|
||||
const issue = eventToProjectIssue(root, statusEvents);
|
||||
return {
|
||||
...base,
|
||||
title: issue.title,
|
||||
description: compactMetadata([issue.status, ...issue.labels.slice(0, 2)]),
|
||||
};
|
||||
}
|
||||
|
||||
const pullRequest = eventToProjectPullRequest(root, [], [], statusEvents);
|
||||
const source = pullRequest.branchName;
|
||||
const target = pullRequest.targetBranch ?? repository.defaultBranch;
|
||||
return {
|
||||
...base,
|
||||
title: pullRequest.title,
|
||||
description: compactMetadata([
|
||||
pullRequest.status,
|
||||
source ? `${source} → ${target}` : null,
|
||||
pullRequest.commit?.slice(0, 7),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const entityTitleLoader = createMetadataLoader({
|
||||
fetcher: async (href) => {
|
||||
const parsed = parseEntityLink(href);
|
||||
if (!parsed.ok) return null;
|
||||
|
||||
const link = parsed.value;
|
||||
if (link.type === "repo") {
|
||||
return loadCoordinateMetadata(
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
link.owner,
|
||||
link.dtag,
|
||||
);
|
||||
}
|
||||
if (link.type === "project") {
|
||||
return loadCoordinateMetadata(
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
link.owner,
|
||||
link.dtag,
|
||||
);
|
||||
}
|
||||
|
||||
const { id, owner, dtag } = link;
|
||||
const expectedCoordinate = `30617:${owner}:${dtag}`;
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [link.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE],
|
||||
ids: [id],
|
||||
limit: 1,
|
||||
});
|
||||
const event = events[0];
|
||||
if (
|
||||
!event?.tags.some(
|
||||
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
|
||||
return buzzEntityMetadata(subject || event.content.split("\n")[0]);
|
||||
},
|
||||
const entityMetadataLoader = createMetadataLoader({
|
||||
fetcher: fetchBuzzEntityMetadata,
|
||||
});
|
||||
|
||||
/** Clear ephemeral metadata when the active relay/community changes. */
|
||||
export function resetLinkPreviewMetadataCache(): void {
|
||||
metadataLoader.reset();
|
||||
entityTitleLoader.reset();
|
||||
entityMetadataLoader.reset();
|
||||
}
|
||||
|
||||
export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none";
|
||||
@@ -316,7 +383,10 @@ export function resolveLinkPreview(
|
||||
metadata: LinkPreviewMetadata | null | undefined,
|
||||
): ResolvedLinkPreview {
|
||||
if (metadata === undefined) {
|
||||
return { ...preview, imageState: "pending" };
|
||||
return {
|
||||
...preview,
|
||||
imageState: isBuzzEntityPreview(preview) ? "none" : "pending",
|
||||
};
|
||||
}
|
||||
if (metadata === null) {
|
||||
return { ...preview, imageState: "none" };
|
||||
@@ -337,7 +407,8 @@ export function resolveLinkPreview(
|
||||
description: metadata.description,
|
||||
faviconDataUrl: metadata.faviconDataUrl,
|
||||
provider:
|
||||
preview.kind === "generic-link" && metadata.siteName
|
||||
(preview.kind === "generic-link" || isBuzzEntityPreview(preview)) &&
|
||||
metadata.siteName
|
||||
? metadata.siteName
|
||||
: preview.provider,
|
||||
imageDataUrl: hasImage ? metadata.imageDataUrl : null,
|
||||
@@ -346,6 +417,38 @@ export function resolveLinkPreview(
|
||||
};
|
||||
}
|
||||
|
||||
export function isBuzzEntityPreview(preview: SupportedLinkPreview): boolean {
|
||||
return (
|
||||
preview.kind === "buzz-pull-request" ||
|
||||
preview.kind === "buzz-issue" ||
|
||||
preview.kind === "buzz-repository" ||
|
||||
preview.kind === "buzz-project"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient-side `buzz://` entity cards must render even when the relay
|
||||
* lookup yields no metadata: `useResolvedLinkPreviews` drops null-metadata
|
||||
* previews (correct for external links — no metadata means no card), but
|
||||
* entity links always carry a usable fallback title (the repo d-tag, or
|
||||
* `<dtag> #<id8>` for PRs/issues — see `buzzEntityFallbackTitle`). Re-adds
|
||||
* recognized entity previews on their fallback title; non-entity previews
|
||||
* keep the hook's drop behavior.
|
||||
*/
|
||||
export function withEntityFallbacks(
|
||||
previews: SupportedLinkPreview[],
|
||||
resolved: ResolvedLinkPreview[],
|
||||
): ResolvedLinkPreview[] {
|
||||
const byHref = new Map(resolved.map((preview) => [preview.href, preview]));
|
||||
return previews.flatMap((preview) => {
|
||||
const match = byHref.get(preview.href);
|
||||
if (match) return [match];
|
||||
return isBuzzEntityPreview(preview)
|
||||
? [{ ...preview, imageState: "none" as const }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolvedLinkPreviews(
|
||||
previews: SupportedLinkPreview[],
|
||||
): ResolvedLinkPreview[] {
|
||||
@@ -383,7 +486,7 @@ export function useResolvedLinkPreviews(
|
||||
const cancelScheduledLoads: Array<() => void> = [];
|
||||
for (const preview of previews) {
|
||||
const loader = preview.href.startsWith("buzz://")
|
||||
? entityTitleLoader
|
||||
? entityMetadataLoader
|
||||
: metadataLoader;
|
||||
const cached = loader.peek(preview.href);
|
||||
if (cached !== undefined) {
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
AttachmentTrigger,
|
||||
} from "@/shared/ui/attachment";
|
||||
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
|
||||
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
||||
|
||||
function getHostname(preview: ResolvedLinkPreview): string {
|
||||
if (preview.href.startsWith("buzz://")) return preview.provider;
|
||||
try {
|
||||
return new URL(preview.href).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
@@ -66,14 +68,18 @@ export function CompactLinkPreviewAttachment({
|
||||
const showFallback =
|
||||
preview.imageState === "fallback" || Boolean(imageSrc && !showImage);
|
||||
const hostname = getHostname(preview);
|
||||
const showBuzzMark =
|
||||
preview.kind === "buzz-pull-request" ||
|
||||
preview.kind === "buzz-issue" ||
|
||||
preview.kind === "buzz-repository";
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-96 max-w-full shrink-0", className)}>
|
||||
<Attachment
|
||||
className={cn(
|
||||
"h-21 min-h-21 max-h-21 w-full bg-transparent no-underline shadow-none hover:bg-transparent",
|
||||
"w-full bg-transparent no-underline shadow-none hover:bg-transparent",
|
||||
reserveImage
|
||||
? "gap-0 border-0 p-0 hover:border-transparent"
|
||||
? "h-21 min-h-21 max-h-21 gap-0 border-0 p-0 hover:border-transparent"
|
||||
: "rounded-none border-0 border-l-[3px] border-border px-0 py-1 pl-3 hover:border-border",
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
@@ -120,7 +126,15 @@ export function CompactLinkPreviewAttachment({
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{preview.faviconDataUrl ? (
|
||||
{showBuzzMark ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-3 shrink-0 items-center text-foreground/70"
|
||||
data-link-preview-hostname-buzz-mark=""
|
||||
>
|
||||
<BuzzMark className="h-auto w-full" />
|
||||
</span>
|
||||
) : preview.faviconDataUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -23,7 +23,6 @@ import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
|
||||
import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import { AttachmentGroup } from "@/shared/ui/attachment";
|
||||
@@ -114,6 +113,7 @@ import { MarkdownTable } from "./markdown/MarkdownTable";
|
||||
import { ProgressiveImage } from "./markdown/ProgressiveImage";
|
||||
import { MessageLinkPill } from "./markdown/MessageLinkPill";
|
||||
import { renderCachedMarkdown } from "./markdown/nodeCache";
|
||||
import { useMessageLinkPreviews } from "./markdown/useMessageLinkPreviews";
|
||||
import {
|
||||
MarkdownRuntimeContext,
|
||||
useMarkdownRuntime,
|
||||
@@ -1809,19 +1809,13 @@ function MarkdownInner({
|
||||
[goChannel],
|
||||
);
|
||||
const relayOrigin = useRelayOrigin();
|
||||
const resolvedLinkPreviews = React.useMemo(
|
||||
() =>
|
||||
interactive && !linkPreviewsSuppressed
|
||||
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
|
||||
: [],
|
||||
[
|
||||
content,
|
||||
interactive,
|
||||
linkPreviewTags,
|
||||
linkPreviewsSuppressed,
|
||||
relayOrigin,
|
||||
],
|
||||
);
|
||||
const resolvedLinkPreviews = useMessageLinkPreviews({
|
||||
content,
|
||||
interactive,
|
||||
linkPreviewTags,
|
||||
linkPreviewsSuppressed,
|
||||
relayOrigin,
|
||||
});
|
||||
const configNudge = React.useMemo(
|
||||
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
|
||||
[content, interactive, configNudgeAuthorPubkey],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { extractSupportedLinkPreviews } from "../../lib/linkPreview.ts";
|
||||
import { parseLinkPreviewSnapshots } from "../../lib/linkPreviewSnapshot.ts";
|
||||
import { mergeMessageLinkPreviews } from "./useMessageLinkPreviews.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const EVENT_ID = "b".repeat(64);
|
||||
const ENTITY_HREF = `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`;
|
||||
const EXTERNAL_HREF = "https://example.com/story";
|
||||
const RELAY_ORIGIN = "https://relay.example";
|
||||
|
||||
function snapshotTag(href, title, siteName) {
|
||||
return [
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
href,
|
||||
title,
|
||||
siteName,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
test("relay-resolved Buzz entities beat conflicting sender snapshots in content order", () => {
|
||||
const content = `${ENTITY_HREF} then ${EXTERNAL_HREF}`;
|
||||
const candidates = extractSupportedLinkPreviews(content, RELAY_ORIGIN);
|
||||
const snapshots = parseLinkPreviewSnapshots(
|
||||
[
|
||||
snapshotTag(ENTITY_HREF, "Forged sender title", "Definitely Real Buzz"),
|
||||
snapshotTag(EXTERNAL_HREF, "External story", "Example"),
|
||||
],
|
||||
content,
|
||||
RELAY_ORIGIN,
|
||||
);
|
||||
const entity = {
|
||||
...candidates[0],
|
||||
title: "Relay-authenticated PR title",
|
||||
imageState: "none",
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
mergeMessageLinkPreviews(candidates, snapshots, [entity]).map(
|
||||
({ href, title, provider }) => ({ href, title, provider }),
|
||||
),
|
||||
[
|
||||
{
|
||||
href: ENTITY_HREF,
|
||||
title: "Relay-authenticated PR title",
|
||||
provider: "Buzz",
|
||||
},
|
||||
{
|
||||
href: EXTERNAL_HREF,
|
||||
title: "External story",
|
||||
provider: "Example",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
102
desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts
Normal file
102
desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
extractSupportedLinkPreviews,
|
||||
type SupportedLinkPreview,
|
||||
} from "@/shared/lib/linkPreview";
|
||||
import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot";
|
||||
import {
|
||||
isBuzzEntityPreview,
|
||||
type ResolvedLinkPreview,
|
||||
useResolvedLinkPreviews,
|
||||
withEntityFallbacks,
|
||||
} from "@/shared/lib/useResolvedLinkPreviews";
|
||||
|
||||
/**
|
||||
* Resolve the link-preview cards for a rendered message.
|
||||
*
|
||||
* External URLs render exclusively from sender-authored
|
||||
* `["link-preview","snapshot",…]` tags (`parseLinkPreviewSnapshots`) — the
|
||||
* privacy model shipped in the rich-link-previews work: recipients never
|
||||
* contact external sites.
|
||||
*
|
||||
* `buzz://pr|issue|repo` entity links (and relay clone URLs, which normalize
|
||||
* onto `buzz://repo`) are the exception and are rendered recipient-side:
|
||||
* their metadata comes from the community relay itself via the entity metadata
|
||||
* loader, so the sender-snapshot privacy model does not apply — and senders
|
||||
* (CLI, agents, mobile) attach no snapshot tags for them. Recognized entity
|
||||
* cards win
|
||||
* over conflicting sender snapshots, and cards retain their first-seen order in
|
||||
* the message.
|
||||
*/
|
||||
export function mergeMessageLinkPreviews(
|
||||
candidates: SupportedLinkPreview[],
|
||||
snapshots: ResolvedLinkPreview[],
|
||||
entities: ResolvedLinkPreview[],
|
||||
): ResolvedLinkPreview[] {
|
||||
const snapshotsByHref = new Map(
|
||||
snapshots.map((preview) => [preview.href, preview]),
|
||||
);
|
||||
const entitiesByHref = new Map(
|
||||
entities.map((preview) => [preview.href, preview]),
|
||||
);
|
||||
|
||||
return candidates.flatMap((candidate) => {
|
||||
const preview = isBuzzEntityPreview(candidate)
|
||||
? entitiesByHref.get(candidate.href)
|
||||
: snapshotsByHref.get(candidate.href);
|
||||
return preview ? [preview] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function useMessageLinkPreviews({
|
||||
content,
|
||||
interactive,
|
||||
linkPreviewTags,
|
||||
linkPreviewsSuppressed,
|
||||
relayOrigin,
|
||||
}: {
|
||||
content: string;
|
||||
interactive: boolean;
|
||||
linkPreviewTags?: readonly (readonly string[])[];
|
||||
linkPreviewsSuppressed: boolean;
|
||||
relayOrigin: string | null;
|
||||
}): ResolvedLinkPreview[] {
|
||||
const linkPreviewCandidates = React.useMemo(
|
||||
() =>
|
||||
interactive && !linkPreviewsSuppressed
|
||||
? extractSupportedLinkPreviews(content, relayOrigin)
|
||||
: [],
|
||||
[content, interactive, linkPreviewsSuppressed, relayOrigin],
|
||||
);
|
||||
const entityLinkPreviews = React.useMemo(
|
||||
() => linkPreviewCandidates.filter(isBuzzEntityPreview),
|
||||
[linkPreviewCandidates],
|
||||
);
|
||||
const relayResolvedEntityLinkPreviews =
|
||||
useResolvedLinkPreviews(entityLinkPreviews);
|
||||
const resolvedEntityLinkPreviews = React.useMemo(
|
||||
() =>
|
||||
withEntityFallbacks(entityLinkPreviews, relayResolvedEntityLinkPreviews),
|
||||
[entityLinkPreviews, relayResolvedEntityLinkPreviews],
|
||||
);
|
||||
return React.useMemo(() => {
|
||||
const snapshots =
|
||||
interactive && !linkPreviewsSuppressed
|
||||
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
|
||||
: [];
|
||||
return mergeMessageLinkPreviews(
|
||||
linkPreviewCandidates,
|
||||
snapshots,
|
||||
resolvedEntityLinkPreviews,
|
||||
);
|
||||
}, [
|
||||
content,
|
||||
interactive,
|
||||
linkPreviewCandidates,
|
||||
linkPreviewTags,
|
||||
linkPreviewsSuppressed,
|
||||
relayOrigin,
|
||||
resolvedEntityLinkPreviews,
|
||||
]);
|
||||
}
|
||||
@@ -365,6 +365,13 @@ type E2eConfig = {
|
||||
linkPreviewMetadataDelayMs?: number;
|
||||
/** Simulates native cold-cache startup work before the async response. */
|
||||
linkPreviewMetadataStartBlockMs?: number;
|
||||
/** Delays link-preview snapshot media uploads so specs can exercise the
|
||||
* composer's settle-gated disabled state before the snapshot tag is ready. */
|
||||
linkPreviewUploadDelayMs?: number;
|
||||
/** Substrings of `link-preview-*` upload filenames whose `upload_media_bytes`
|
||||
* call should reject, so specs can drive a per-media snapshot upload failure
|
||||
* (e.g. `["link-preview-image"]` fails only the thumbnail, favicon survives). */
|
||||
linkPreviewUploadErrorFilenames?: string[];
|
||||
searchProfiles?: MockSearchProfileSeed[];
|
||||
updateAvailable?: boolean;
|
||||
updateChannelDelayMs?: number;
|
||||
@@ -8930,6 +8937,16 @@ async function resolveMockUploadDescriptorForBytes(
|
||||
args: { data: number[] | Uint8Array; filename?: string | null },
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawBlobDescriptor> {
|
||||
const uploadDelayMs = config?.mock?.linkPreviewUploadDelayMs ?? 0;
|
||||
if (args.filename?.startsWith("link-preview-")) {
|
||||
if (uploadDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, uploadDelayMs));
|
||||
}
|
||||
const errorFilenames = config?.mock?.linkPreviewUploadErrorFilenames;
|
||||
if (errorFilenames?.some((needle) => args.filename?.includes(needle))) {
|
||||
throw new Error(`mock upload failed for ${args.filename}`);
|
||||
}
|
||||
}
|
||||
const configured = config?.mock?.uploadDescriptors;
|
||||
if (configured !== undefined) {
|
||||
const descriptors = await resolveMockUploadDescriptors(config);
|
||||
|
||||
167
desktop/tests/e2e/entity-link-recipient-cards.spec.ts
Normal file
167
desktop/tests/e2e/entity-link-recipient-cards.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/entity-link-recipient-cards";
|
||||
|
||||
// Regression coverage for buzz:// entity links posted WITHOUT sender
|
||||
// snapshot tags (CLI / agent senders): #3818 moved external-link previews to
|
||||
// sender-authored snapshots, which silently killed the recipient-side
|
||||
// buzz://pr|issue|repo cards from #4695. These cards resolve their titles
|
||||
// from the active relay itself, so they must render for recipients even when
|
||||
// the message carries no link-preview tags.
|
||||
|
||||
const ALICE_PUBKEY = TEST_IDENTITIES.alice.pubkey;
|
||||
const REPO_ADDRESS = `30617:${ALICE_PUBKEY}:relay-tools`;
|
||||
const PR_ID = `e0${"ca4d".repeat(15)}ff`; // 64-hex event id
|
||||
const PR_SUBJECT = "Restore recipient-side entity cards";
|
||||
|
||||
test("agent-style message with bare buzz:// links renders entity cards without snapshot tags", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(
|
||||
({ repoAddress, prId, alicePubkey, subject }) => {
|
||||
window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
|
||||
{
|
||||
id: prId,
|
||||
kind: 1618, // KIND_GIT_PULL_REQUEST
|
||||
pubkey: alicePubkey,
|
||||
created_at: Math.floor(Date.now() / 1000) - 60,
|
||||
content: "PR body",
|
||||
tags: [
|
||||
["a", repoAddress],
|
||||
["subject", subject],
|
||||
["c", "abc123".padEnd(40, "0")],
|
||||
["branch-name", "fix/entity-cards"],
|
||||
["clone", "https://github.com/block/relay-tools.git"],
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
repoAddress: REPO_ADDRESS,
|
||||
prId: PR_ID,
|
||||
alicePubkey: ALICE_PUBKEY,
|
||||
subject: PR_SUBJECT,
|
||||
},
|
||||
);
|
||||
await installMockBridge(page);
|
||||
await page.setViewportSize({ width: 900, height: 800 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
// Simulate an agent/CLI sender: plain kind-9 message with bare buzz://
|
||||
// URLs in the content and NO link-preview snapshot tags.
|
||||
await page.evaluate(
|
||||
({ prId, alicePubkey }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
pubkey: alicePubkey,
|
||||
content: [
|
||||
"PR is up — review when you can:",
|
||||
`buzz://pr?id=${prId}&owner=${alicePubkey}&d=relay-tools`,
|
||||
`Repo: buzz://repo?owner=${alicePubkey}&d=relay-tools`,
|
||||
].join("\n"),
|
||||
});
|
||||
},
|
||||
{ prId: PR_ID, alicePubkey: ALICE_PUBKEY },
|
||||
);
|
||||
|
||||
const row = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "PR is up" })
|
||||
.last();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// The PR card resolves builder metadata from the signed root and repository.
|
||||
const prCard = row.locator('[data-link-preview="buzz-pull-request"]');
|
||||
await expect(prCard).toBeVisible();
|
||||
await expect(prCard).toContainText("relay-tools");
|
||||
await expect(prCard).toContainText(PR_SUBJECT);
|
||||
await expect(prCard).toContainText(
|
||||
"Open · fix/entity-cards → main · abc1230",
|
||||
);
|
||||
await expect(prCard).toHaveAttribute("data-image-state", "none");
|
||||
await expect(prCard.locator("[data-link-preview-thumbnail]")).toHaveCount(0);
|
||||
await expect(
|
||||
prCard.locator("[data-link-preview-hostname-buzz-mark]"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
prCard.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveCount(0);
|
||||
|
||||
// The repository card uses its signed announcement metadata and remains
|
||||
// image-less.
|
||||
const repoCard = row.locator('[data-link-preview="buzz-repository"]');
|
||||
await expect(repoCard).toBeVisible();
|
||||
await expect(repoCard).toContainText("relay-tools");
|
||||
await expect(repoCard).toContainText(
|
||||
"Operator tooling and admin CLI for relay deployments.",
|
||||
);
|
||||
await expect(repoCard).toContainText("active · default: main");
|
||||
await expect(repoCard).toHaveAttribute("data-image-state", "none");
|
||||
await expect(repoCard.locator("[data-link-preview-thumbnail]")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
repoCard.locator("[data-link-preview-hostname-buzz-mark]"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
repoCard.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
await repoCard.evaluate((card) => card.getBoundingClientRect().height),
|
||||
).toBeLessThan(84);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${SHOTS}/01-recipient-entity-cards.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop composer shows entity card and send is not blocked by missing snapshot", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.setViewportSize({ width: 900, height: 800 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const repoLink = `buzz://repo?owner=${ALICE_PUBKEY}&d=relay-tools`;
|
||||
await page.getByTestId("message-input").fill(`Check out ${repoLink}`);
|
||||
|
||||
// buzz:// links never produce snapshot tags, so the composer card must
|
||||
// show as done (not stuck "processing") with zero ready snapshots.
|
||||
const composerCard = page
|
||||
.locator("[data-composer-link-previews]")
|
||||
.locator('[data-link-preview="buzz-repository"]');
|
||||
await expect(composerCard).toBeVisible();
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute(
|
||||
"data-ready-snapshot-count",
|
||||
"0",
|
||||
);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${SHOTS}/02-composer-entity-card.png`,
|
||||
});
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const row = page.getByTestId("message-row").last();
|
||||
const repoCard = row.locator('[data-link-preview="buzz-repository"]');
|
||||
await expect(repoCard).toBeVisible();
|
||||
await expect(repoCard).toContainText("relay-tools");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${SHOTS}/03-sent-entity-card.png`,
|
||||
});
|
||||
});
|
||||
@@ -141,114 +141,185 @@ test.beforeEach(async ({ page }, testInfo) => {
|
||||
imageDomain: "pbs.twimg.com",
|
||||
},
|
||||
}
|
||||
: testInfo.title.includes("mixed link preview image outcomes")
|
||||
: testInfo.title.includes("fragment link previews")
|
||||
? {
|
||||
// Metadata is keyed by the canonical, fragment-less URL — the
|
||||
// shape a real OpenGraph/HTML fetch resolves against. A resolver
|
||||
// that fetches with the raw `#fragment` attached would miss these
|
||||
// keys and drop the card, which is exactly the bug under test.
|
||||
linkPreviewMetadataByHref: {
|
||||
"https://github.com/block/buzz/pull/4001": {
|
||||
title: "Loaded preview image",
|
||||
"https://github.com/block/buzz/pull/3767": {
|
||||
title: "Buzz pull request 3767",
|
||||
siteName: "GitHub",
|
||||
description: "The image request completed.",
|
||||
imageDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
imageFetchState: "image",
|
||||
imageRetryAfterMs: null,
|
||||
},
|
||||
"https://github.com/block/buzz/pull/4002": {
|
||||
title: "Rate-limited preview image",
|
||||
siteName: "GitHub",
|
||||
description: "Metadata remains available during cooldown.",
|
||||
description: "Fragment-bearing PR link.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
},
|
||||
"https://github.com/block/buzz/pull/3867": {
|
||||
title: "Buzz pull request 3867",
|
||||
siteName: "GitHub",
|
||||
description: "Plain PR link.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
imageFetchState: "transient_failure",
|
||||
imageRetryAfterMs: 900_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
: testInfo.title.includes("link preview browser image error")
|
||||
: testInfo.title.includes("mixed link preview image outcomes")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Invalid decoded preview image",
|
||||
siteName: "GitHub",
|
||||
description: "The browser should replace this image.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
imageFetchState: "rejected",
|
||||
imageRetryAfterMs: null,
|
||||
},
|
||||
}
|
||||
: testInfo.title.includes("link preview image geometry")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title:
|
||||
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
||||
linkPreviewMetadataByHref: {
|
||||
"https://github.com/block/buzz/pull/4001": {
|
||||
title: "Loaded preview image",
|
||||
siteName: "GitHub",
|
||||
description: "A polished, stable preview for shared links.",
|
||||
description: "The image request completed.",
|
||||
imageDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
faviconDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
imageFetchState: "image",
|
||||
imageRetryAfterMs: null,
|
||||
},
|
||||
"https://github.com/block/buzz/pull/4002": {
|
||||
title: "Rate-limited preview image",
|
||||
siteName: "GitHub",
|
||||
description: "Metadata remains available during cooldown.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
imageFetchState: "transient_failure",
|
||||
imageRetryAfterMs: 900_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
: testInfo.title.includes("link preview browser image error")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Invalid decoded preview image",
|
||||
siteName: "GitHub",
|
||||
description: "The browser should replace this image.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
imageFetchState: "rejected",
|
||||
imageRetryAfterMs: null,
|
||||
},
|
||||
linkPreviewMetadataDelayMs: 800,
|
||||
}
|
||||
: testInfo.title.includes("link preview no-image layout") ||
|
||||
testInfo.title.includes("composer no-image link embeds")
|
||||
: testInfo.title.includes("link preview image geometry")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz",
|
||||
title:
|
||||
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
||||
siteName: "GitHub",
|
||||
description:
|
||||
"Open-source collaboration for the Buzz app.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
"A polished, stable preview for shared links.",
|
||||
imageDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
faviconDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
},
|
||||
linkPreviewMetadataDelayMs: 2_000,
|
||||
linkPreviewMetadataDelayMs: 800,
|
||||
}
|
||||
: testInfo.title.includes(
|
||||
"rich link preview preserves description newlines",
|
||||
)
|
||||
: testInfo.title.includes("link preview no-image layout") ||
|
||||
testInfo.title.includes("composer no-image link embeds")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz pull request",
|
||||
title: "Buzz",
|
||||
siteName: "GitHub",
|
||||
description:
|
||||
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
|
||||
"Open-source collaboration for the Buzz app.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
faviconDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
},
|
||||
linkPreviewMetadataDelayMs: 2_000,
|
||||
}
|
||||
: testInfo.title.includes("link preview") ||
|
||||
testInfo.title.includes("supported Compact")
|
||||
: testInfo.title.includes(
|
||||
"rich link preview preserves description newlines",
|
||||
)
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz pull request",
|
||||
siteName: "GitHub",
|
||||
description: "A sender-authored preview snapshot.",
|
||||
description:
|
||||
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
},
|
||||
linkPreviewMetadataDelayMs: testInfo.title.includes(
|
||||
"loading card before cold resolver work",
|
||||
)
|
||||
? 10_000
|
||||
: testInfo.title.includes("style defaults") ||
|
||||
testInfo.title.includes("send does not wait") ||
|
||||
testInfo.title.includes("attachment-sized")
|
||||
? 1_500
|
||||
: undefined,
|
||||
linkPreviewMetadataStartBlockMs:
|
||||
testInfo.title.includes(
|
||||
"loading card before cold resolver work",
|
||||
)
|
||||
? 150
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
: testInfo.title.includes(
|
||||
"Enter during an in-flight snapshot upload",
|
||||
)
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz pull request",
|
||||
siteName: "GitHub",
|
||||
description: "A sender-authored preview snapshot.",
|
||||
imageDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
},
|
||||
linkPreviewMetadataDelayMs: 300,
|
||||
linkPreviewUploadDelayMs: 1_200,
|
||||
}
|
||||
: testInfo.title.includes(
|
||||
"snapshot thumbnail upload failure",
|
||||
)
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz pull request",
|
||||
siteName: "GitHub",
|
||||
description:
|
||||
"A sender-authored preview snapshot.",
|
||||
imageDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
faviconDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
},
|
||||
// Fail only the thumbnail upload; the favicon survives,
|
||||
// so the snapshot degrades to a favicon-only preview.
|
||||
linkPreviewUploadErrorFilenames: [
|
||||
"link-preview-image",
|
||||
],
|
||||
}
|
||||
: testInfo.title.includes("link preview") ||
|
||||
testInfo.title.includes("supported Compact")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz pull request",
|
||||
siteName: "GitHub",
|
||||
description:
|
||||
"A sender-authored preview snapshot.",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
},
|
||||
linkPreviewMetadataDelayMs:
|
||||
testInfo.title.includes(
|
||||
"loading card before cold resolver work",
|
||||
)
|
||||
? 10_000
|
||||
: testInfo.title.includes(
|
||||
"send does not wait",
|
||||
)
|
||||
? 3_000
|
||||
: testInfo.title.includes("draft auto-send")
|
||||
? 500
|
||||
: testInfo.title.includes(
|
||||
"style defaults",
|
||||
) ||
|
||||
testInfo.title.includes(
|
||||
"attachment-sized",
|
||||
)
|
||||
? 1_500
|
||||
: undefined,
|
||||
linkPreviewMetadataStartBlockMs:
|
||||
testInfo.title.includes(
|
||||
"loading card before cold resolver work",
|
||||
)
|
||||
? 150
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
const mock = testInfo.title.includes("unresolvable preview")
|
||||
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 150 }
|
||||
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 800 }
|
||||
: baseMock;
|
||||
await installMockBridge(page, mock);
|
||||
});
|
||||
@@ -613,14 +684,22 @@ test("rich link preview preserves description newlines after sending", async ({
|
||||
);
|
||||
});
|
||||
|
||||
test("completed link previews send when one URL has an unsnapshotable fragment", async ({
|
||||
test("completed link previews normalize a trailing-fragment URL and still send", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The third URL carries a trailing `#` (empty fragment). It is normalized to
|
||||
// its fragmentless canonical form for the preview and snapshot tag, so it now
|
||||
// gets a card like the others; the message body keeps the original URL.
|
||||
const previewUrls = [
|
||||
"https://twitter.com/tellaho",
|
||||
"https://github.com/block/buzz/pull/3246",
|
||||
"https://x.com/tellaho/status/1884289176381841506#",
|
||||
];
|
||||
const canonicalUrls = [
|
||||
"https://twitter.com/tellaho",
|
||||
"https://github.com/block/buzz/pull/3246",
|
||||
"https://x.com/tellaho/status/1884289176381841506",
|
||||
];
|
||||
const pastedText = previewUrls.join("\n");
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
@@ -640,11 +719,8 @@ test("completed link previews send when one URL has an unsnapshotable fragment",
|
||||
const composerPreviewCards = page.locator(
|
||||
"[data-link-preview-composer-card]",
|
||||
);
|
||||
await expect(composerPreviewCards).toHaveCount(2);
|
||||
await expect(
|
||||
composerPreviewCards.locator(`a[href="${previewUrls[2]}"]`),
|
||||
).toHaveCount(0);
|
||||
await waitForReadyComposerSnapshots(page, 2);
|
||||
await expect(composerPreviewCards).toHaveCount(3);
|
||||
await waitForReadyComposerSnapshots(page, 3);
|
||||
|
||||
const send = page.getByTestId("send-message");
|
||||
await expect(send).toBeEnabled();
|
||||
@@ -664,7 +740,7 @@ test("completed link previews send when one URL has an unsnapshotable fragment",
|
||||
(
|
||||
calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
||||
)?.linkPreviewTags?.map((tag) => tag[3]),
|
||||
).toEqual(previewUrls.slice(0, 2));
|
||||
).toEqual(canonicalUrls);
|
||||
});
|
||||
|
||||
test("unresolvable preview disappears after the terminal miss", async ({
|
||||
@@ -705,6 +781,20 @@ test("send does not wait for a pending link preview snapshot", async ({
|
||||
composerPreviews.locator('[data-link-preview="github-pull-request"]'),
|
||||
).toHaveAttribute("data-image-state", "pending");
|
||||
|
||||
// While metadata is still resolving Send is disabled so the button does not
|
||||
// flicker ready -> not-ready. But a link whose metadata stalls must not trap
|
||||
// the composer: past the disable cap Send re-enables even though the card is
|
||||
// still pending, and sending ships a bare link with no snapshot tag.
|
||||
await expect(page.getByTestId("send-message")).toBeDisabled();
|
||||
await expect(composerPreviews).toHaveAttribute(
|
||||
"data-has-pending-snapshots",
|
||||
"false",
|
||||
);
|
||||
await expect(
|
||||
composerPreviews.locator('[data-link-preview="github-pull-request"]'),
|
||||
).toHaveAttribute("data-image-state", "pending");
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
const row = page.getByTestId("message-row").last();
|
||||
await expect(row).toContainText(previewUrl);
|
||||
@@ -721,6 +811,314 @@ test("send does not wait for a pending link preview snapshot", async ({
|
||||
expect(linkPreviewTags ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
test("Enter during an in-flight snapshot upload cannot ship a bare link", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill(previewUrl);
|
||||
|
||||
const composerPreviews = page.locator("[data-composer-link-previews]");
|
||||
const card = composerPreviews.locator("[data-link-preview-composer-card]");
|
||||
await expect(card).toBeVisible();
|
||||
// Metadata resolves (image painted) but the sendable tag is not ready yet:
|
||||
// the snapshot media upload is still in flight (linkPreviewUploadDelayMs), so
|
||||
// the composer reports the preview as still pending.
|
||||
await expect(card).toHaveAttribute("data-image-state", "image");
|
||||
await expect(card).toHaveAttribute("data-snapshot-tag-ready", "false");
|
||||
await expect(composerPreviews).toHaveAttribute(
|
||||
"data-has-pending-snapshots",
|
||||
"true",
|
||||
);
|
||||
|
||||
// Drive Enter (not a disabled-button click, which the browser swallows on its
|
||||
// own) while the upload is deterministically in flight. The synchronous submit
|
||||
// guard must reject it: no send_channel_message call may occur before the tag
|
||||
// is ready, or the link would ship bare. This is the core Enter-bypass fix —
|
||||
// the disabled state is enforced on the keyboard path, not just the button.
|
||||
await expect(input).toBeFocused();
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
const sendsDuringUpload = await page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
).length,
|
||||
);
|
||||
expect(sendsDuringUpload).toBe(0);
|
||||
|
||||
// Once the upload settles the tag is captured and Send re-enables. Sending
|
||||
// now lands the preview snapshot matching the body.
|
||||
await expect(card).toHaveAttribute("data-snapshot-tag-ready", "true");
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||||
await input.press("Enter");
|
||||
const row = page.getByTestId("message-row").last();
|
||||
await expect(row).toContainText(previewUrl);
|
||||
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
||||
|
||||
const linkPreviewTags = await page.evaluate(() => {
|
||||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.command === "send_channel_message");
|
||||
return (
|
||||
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
||||
)?.linkPreviewTags;
|
||||
});
|
||||
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
|
||||
});
|
||||
|
||||
test("draft auto-send with a link preview waits for settling and sends exactly once", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Regression for the one-shot auto-submit blocker: a confirmed Drafts-panel
|
||||
// "Send message" for a draft containing a supported link is normally still
|
||||
// inside the preview settling window when the mount-only auto-submit effect
|
||||
// fires. The old effect cleared the ?autoSend trigger then fired submit once
|
||||
// at setTimeout(0); submit bailed at the pending-snapshot guard and the
|
||||
// one-shot never retried, so the confirmed draft was silently never sent.
|
||||
// The effect must instead wait until settling finishes, then send exactly
|
||||
// once — with the resolved snapshot tag attached.
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?draft=autosend";
|
||||
const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
|
||||
// Seed a channel draft under the legacy store key (migrated on startup). The
|
||||
// main composer keys its draft off the bare channel id, and the Drafts panel
|
||||
// navigates with ?autoSend=<that key>, so seeding under the bare id mirrors
|
||||
// the real "Send message" target exactly.
|
||||
await page.addInitScript(
|
||||
({ storeKey, draftKey, content, channel }) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
window.localStorage.setItem(
|
||||
storeKey,
|
||||
JSON.stringify({
|
||||
[draftKey]: {
|
||||
channelId: channel,
|
||||
content,
|
||||
createdAt: timestamp,
|
||||
pendingImeta: [],
|
||||
selectionEnd: content.length,
|
||||
selectionStart: content.length,
|
||||
spoileredAttachmentUrls: [],
|
||||
status: "active",
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
storeKey: `buzz-drafts.v1:${"deadbeef".repeat(8)}`,
|
||||
draftKey: channelId,
|
||||
content: previewUrl,
|
||||
channel: channelId,
|
||||
},
|
||||
);
|
||||
|
||||
// Drive the real Drafts-panel "Send message" confirm flow. This does an
|
||||
// in-app client navigation to the channel with ?autoSend=<draftKey>, arming
|
||||
// the main composer's auto-submit effect — the exact production path.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("inbox-filter-trigger").click();
|
||||
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const draftRow = page.locator(`[data-testid='home-draft-item-${channelId}']`);
|
||||
await expect(draftRow).toBeVisible({ timeout: 8_000 });
|
||||
await draftRow.hover();
|
||||
await draftRow
|
||||
.getByRole("button", { name: "Send message", exact: true })
|
||||
.click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 4_000 });
|
||||
await dialog.getByRole("button", { name: "Send", exact: true }).click();
|
||||
|
||||
// Exactly one send eventually fires (after the ~500 ms metadata settle), and
|
||||
// it carries the link preview snapshot tag — proving the draft was not
|
||||
// dropped during the settling window and did not double-send on retry.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
).length,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
const linkPreviewTags = await page.evaluate(() => {
|
||||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.command === "send_channel_message");
|
||||
return (
|
||||
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
||||
)?.linkPreviewTags;
|
||||
});
|
||||
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
|
||||
});
|
||||
|
||||
test("rapid Enter presses on a ready link preview send exactly once", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?rapid=1";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill(previewUrl);
|
||||
|
||||
// Wait until the snapshot is fully ready and Send is enabled, so the only
|
||||
// thing under test is the composer-local send lock — not preview settling.
|
||||
await waitForReadyComposerSnapshots(page);
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||||
|
||||
// Mash Enter. The synchronous submit lock (isSubmitLockedRef), acquired before
|
||||
// any await, must collapse these into exactly one send_channel_message so a
|
||||
// duplicate cannot clear shared prep/hydration state mid-send.
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
|
||||
const row = page.getByTestId("message-row").last();
|
||||
await expect(row).toContainText(previewUrl);
|
||||
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
).length,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
test("pasting a link preview and immediately pressing Enter waits for resolution", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?fast=send";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
// Fill the URL and press Enter within the debounce window, before resolution
|
||||
// has even started. The live-candidate guard must treat the unresolved link
|
||||
// as pending and reject the Enter, so the message cannot ship bare.
|
||||
await input.fill(previewUrl);
|
||||
await input.press("Enter");
|
||||
const sendsBeforeResolution = await page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
).length,
|
||||
);
|
||||
expect(sendsBeforeResolution).toBe(0);
|
||||
|
||||
// The debounce fires, resolution + upload complete, and only then does Send
|
||||
// become available. A press now lands the snapshot.
|
||||
await waitForReadyComposerSnapshots(page);
|
||||
await input.press("Enter");
|
||||
const row = page.getByTestId("message-row").last();
|
||||
await expect(row).toContainText(previewUrl);
|
||||
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
||||
|
||||
const linkPreviewTags = await page.evaluate(() => {
|
||||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.command === "send_channel_message");
|
||||
return (
|
||||
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
||||
)?.linkPreviewTags;
|
||||
});
|
||||
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
|
||||
});
|
||||
|
||||
test("a snapshot thumbnail upload failure toasts and still sends with the favicon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?upload=fail";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill(previewUrl);
|
||||
|
||||
// The thumbnail upload is configured to reject while the favicon succeeds.
|
||||
// The preview must degrade to the surviving favicon rather than dropping the
|
||||
// whole card or spinning forever: a tag still lands, Send still enables.
|
||||
await waitForReadyComposerSnapshots(page);
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Something went wrong with the thumbnail" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||||
|
||||
await input.press("Enter");
|
||||
const row = page.getByTestId("message-row").last();
|
||||
await expect(row).toContainText(previewUrl);
|
||||
await expect(row.locator("[data-link-preview]")).toBeVisible();
|
||||
|
||||
// The snapshot tag exists (survivor media) but carries no image url — proving
|
||||
// the graceful per-media degrade rather than a dropped or all-or-nothing tag.
|
||||
const imageUrl = await page.evaluate(() => {
|
||||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.command === "send_channel_message");
|
||||
const tags = (
|
||||
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
||||
)?.linkPreviewTags;
|
||||
const snapshot = tags?.find(
|
||||
(tag) => tag[0] === "link-preview" && tag[1] === "snapshot",
|
||||
);
|
||||
// Snapshot tag layout: ["link-preview","snapshot",<version>,<url>,...pairs].
|
||||
const pairs = snapshot?.slice(4) ?? [];
|
||||
const imageIndex = pairs.indexOf("image");
|
||||
return imageIndex >= 0 ? pairs[imageIndex + 1] : null;
|
||||
});
|
||||
expect(imageUrl).toBeFalsy();
|
||||
});
|
||||
|
||||
test("editing a message excludes link previews entirely", async ({ page }) => {
|
||||
const message = `Edit-me ${Date.now()}`;
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?edit=1";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
// Send a plain message with no link, then edit it to add a supported URL.
|
||||
await input.fill(message);
|
||||
await input.press("Enter");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
|
||||
await expect(input).toBeFocused();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(page.getByTestId("edit-target")).toBeVisible();
|
||||
|
||||
// Adding a link while editing must NOT resolve, upload, gate Save, or render a
|
||||
// composer preview card — edit mode does not persist snapshots (decision A).
|
||||
await input.fill(`${message} ${previewUrl}`);
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
||||
// No snapshot upload was attempted for the edited link.
|
||||
const uploadedPreviewMedia = await page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
|
||||
(entry) =>
|
||||
entry.command === "upload_media_bytes" &&
|
||||
typeof (entry.payload as { filename?: string })?.filename ===
|
||||
"string" &&
|
||||
(entry.payload as { filename: string }).filename.startsWith(
|
||||
"link-preview-",
|
||||
),
|
||||
).length,
|
||||
);
|
||||
expect(uploadedPreviewMedia).toBe(0);
|
||||
// Save is not blocked waiting on a snapshot the edit will never emit.
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -882,6 +1280,44 @@ test("mixed link preview image outcomes keep Compact and Rich fallbacks stable",
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("fragment link previews render a card per canonical URL", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Two links into the SAME page differing only by `#fragment`, plus a link
|
||||
// to a second page. The fragment variants collapse to one card (the preview
|
||||
// is of the page, not the anchor); the second page adds a second card — two
|
||||
// cards total. A resolver that keys previews on the raw fragment-bearing URL
|
||||
// drops the fragment cards entirely (the reported bug).
|
||||
const fragmentUrlA =
|
||||
"https://github.com/block/buzz/pull/3767#pullrequestreview-4857569498";
|
||||
const fragmentUrlB = "https://github.com/block/buzz/pull/3767#issuecomment-1";
|
||||
const plainUrl = "https://github.com/block/buzz/pull/3867";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page
|
||||
.getByTestId("message-input")
|
||||
.fill(`${fragmentUrlA}\n${fragmentUrlB}\n${plainUrl}`);
|
||||
|
||||
const composerCards = page
|
||||
.locator("[data-composer-link-previews]")
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(composerCards).toHaveCount(2);
|
||||
|
||||
await waitForReadyComposerSnapshots(page, 2);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const row = page.getByTestId("message-row").last();
|
||||
// Two preview cards: the fragment variants collapsed to the 3767 page, plus
|
||||
// the 3867 page.
|
||||
await expect(
|
||||
row.locator('[data-link-preview="github-pull-request"]'),
|
||||
).toHaveCount(2);
|
||||
// Both original fragment-bearing prose links survive intact and clickable —
|
||||
// the fragment is a navigation anchor, only the preview is normalized.
|
||||
await expect(row.locator(`a[href="${fragmentUrlA}"]`)).toBeVisible();
|
||||
await expect(row.locator(`a[href="${fragmentUrlB}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("link preview browser image errors render a fallback", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -321,6 +321,13 @@ type MockBridgeOptions = {
|
||||
linkPreviewMetadataDelayMs?: number;
|
||||
/** Simulates native cold-cache startup work before the async response. */
|
||||
linkPreviewMetadataStartBlockMs?: number;
|
||||
/** Delays link-preview snapshot media uploads so specs can drive an in-flight
|
||||
* snapshot upload. See e2eBridge mock.linkPreviewUploadDelayMs. */
|
||||
linkPreviewUploadDelayMs?: number;
|
||||
/** Substrings of `link-preview-*` upload filenames whose upload should reject,
|
||||
* so specs can drive a per-media snapshot upload failure. See e2eBridge
|
||||
* mock.linkPreviewUploadErrorFilenames. */
|
||||
linkPreviewUploadErrorFilenames?: string[];
|
||||
searchProfiles?: MockSearchProfileSeed[];
|
||||
updateAvailable?: boolean;
|
||||
updateChannelDelayMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user