From be48ce98bd163899197b79a82ad5b2bcf0bc9b54 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 20:27:30 -0700 Subject: [PATCH 01/20] fix(link-preview): reliably render previews sent right after they resolve (#5245) ## Overview **Category:** fix **User impact:** Link previews no longer disappear when a message is sent while preview metadata or media is still settling. Fast Enter, rapid Enter, and confirmed-draft auto-send now preserve the preview without duplicate sends or stale tags. **Problem:** The composer could look ready before its sender-authored snapshot tag existed. Send paths could then race preview resolution/upload, while debounced preview state could attach a tag for a URL that had already been removed. The same timing also caused confirmed-draft auto-send to be consumed without sending. **Solution:** - Debounce preview resolution to avoid card flicker while typing, then disable every submit path while a supported external preview settles. A 2-second escape cap still permits a bare-link send if resolution stalls. - Keep submit synchronous: acquire a composer-local lock before asynchronous send work, read ready tags from the live URL set, and reject Enter/form submits while a snapshot is pending. - Retry confirmed-draft auto-submit until preview settling clears, then submit exactly once. - Upload thumbnail and favicon independently. A failed upload shows a toast and degrades to the surviving media (or text-only) rather than leaving the card spinning. - Exclude message-edit mode from preview resolution, upload, and Save gating. Edit-time preview snapshots remain follow-up #5273. - Canonicalize fragment-bearing URLs for preview lookup/snapshot identity while preserving the original fragment links in message text. ## Link preview state walkthrough Captured using PR #5245's actual public Open Graph metadata and artwork. The deterministic E2E bridge controls only upload timing so the transient disabled state can be captured reliably. | State | Expected behavior | Screenshot | | --- | --- | --- | | **1. Snapshot upload pending** | The real PR preview is visible, but Submit remains disabled until its sendable snapshot tag is ready. Click and Enter cannot send a bare link during the settling window. | ![PR 5245 pasted with its real preview visible and Submit disabled](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5245/01-real-pasted-submit-disabled.png) | | **2. Snapshot ready** | Once snapshot upload settles and the tag is ready, the same preview remains and Submit becomes active. | ![PR 5245 preview ready in the composer with Submit enabled](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5245/02-real-resolved-submit-enabled.png) | | **3. Message sent** | The sent event carries the snapshot tag and renders the PR title, description, and artwork inline instead of degrading to a bare URL. | ![PR 5245 real link preview rendered inline in the message list](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5245/03-real-sent-preview-inline.png) | ## Regression coverage - Enter during metadata resolution or snapshot upload cannot send early. - Paste-and-immediate-Enter sends after settling; rapid Enter submits exactly once. - Confirmed-draft auto-send waits for settling and fires exactly once. - Removed/replaced URLs cannot leak stale snapshot tags or media refs. - Thumbnail upload failure toasts and sends with the surviving favicon. - Edit mode does not resolve/upload previews or gate Save. - Fragment variants share a canonical preview while original fragment links remain clickable. - Existing ready-preview, suppression, bare-link fallback, and multi-preview behavior remains covered. ## Reproduction steps 1. Open a channel and paste a supported external URL into the composer. 2. Press Enter immediately, before preview metadata/media finishes settling. 3. Before this fix, the event could be sent without its preview snapshot (or confirmed-draft auto-send could be lost). With this fix, submit waits behind the disabled state and fires once with the matching snapshot tag. 4. Remove or replace the URL and press Enter inside the debounce window. The sent event contains tags only for URLs still present in the submitted content. ## Validation All required PR checks are green, including Desktop Core, Desktop Smoke E2E shards, Desktop E2E Integration shards, macOS build, security checks, and DCO. --------- Signed-off-by: Taylor Ho Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- .../features/messages/ui/MessageComposer.tsx | 52 +- .../ui/messageComposerAutoSubmit.test.mjs | 103 ++++ .../messages/ui/messageComposerAutoSubmit.ts | 45 ++ .../messages/ui/selectSubmitTags.test.mjs | 78 +++ .../messages/ui/useComposerLinkPreviews.tsx | 245 +++++++- desktop/src/shared/lib/linkPreview.test.mjs | 31 + desktop/src/shared/lib/linkPreview.ts | 10 +- desktop/src/testing/e2eBridge.ts | 17 + desktop/tests/e2e/messaging.spec.ts | 578 +++++++++++++++--- desktop/tests/helpers/bridge.ts | 7 + 10 files changed, 1036 insertions(+), 130 deletions(-) create mode 100644 desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs create mode 100644 desktop/src/features/messages/ui/messageComposerAutoSubmit.ts create mode 100644 desktop/src/features/messages/ui/selectSubmitTags.test.mjs diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0dc289eefaf..aec94c6f37a 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -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(() => { diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs new file mode 100644 index 00000000000..e9a62e42dea --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.test.mjs @@ -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); +}); diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts new file mode 100644 index 00000000000..f15422b93d1 --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts @@ -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); + }; +} diff --git a/desktop/src/features/messages/ui/selectSubmitTags.test.mjs b/desktop/src/features/messages/ui/selectSubmitTags.test.mjs new file mode 100644 index 00000000000..f61f4cbadd9 --- /dev/null +++ b/desktop/src/features/messages/ui/selectSubmitTags.test.mjs @@ -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"]]); +}); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 25ae7ada29e..3f251a719d1 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -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"; @@ -28,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\./, ""); @@ -36,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, + 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( @@ -47,10 +81,11 @@ function ComposerLinkPreviewCard({ ); const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); const hostname = previewHostname(preview.href); - // `buzz://` entity links never produce snapshot tags (recipients render - // them from message content against the relay), so they are "done" as soon - // as they exist — there is no snapshot to wait for. - const done = preview.snapshotReady || isBuzzEntityPreview(preview); + // 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); @@ -63,6 +98,7 @@ function ComposerLinkPreviewCard({ data-image-state={preview.imageState} data-link-preview={preview.kind} data-link-preview-composer-card="" + data-snapshot-tag-ready={snapshotTagReady ? "true" : "false"} state={done ? "done" : "processing"} > { + 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); + // 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 (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( - () => - extractSupportedLinkPreviews(content).filter((preview) => - isBuzzEntityPreview(preview) - ? true - : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), - ), - [content], + () => 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([]); + 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 (repo links always do — only PR/issue titles are fetched); keep - // their cards on the fallback title rather than dropping them. + // 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 (candidates.length === 0) setSuppressed(false); - }, [candidates.length]); + if (liveCandidatesEmpty) setSuppressed(false); + }, [liveCandidatesEmpty]); const [readyTags, setReadyTags] = React.useState>( {}, ); @@ -201,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, @@ -218,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]); @@ -230,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 ? (
{previews.map((preview) => ( - + ))}
) : 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, + }; } diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 4bf3245dbff..b4807f82fd3 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -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"), diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 58d8739ef14..4a7772580b1 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -271,9 +271,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, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ed988de259d..751b06f484f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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 { + 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); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index a0808e4a6af..2d7f3eacda9 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -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, + description: "Fragment-bearing PR link.", + imageDataUrl: null, + imageDomain: null, }, - "https://github.com/block/buzz/pull/4002": { - title: "Rate-limited preview image", + "https://github.com/block/buzz/pull/3867": { + title: "Buzz pull request 3867", siteName: "GitHub", - description: "Metadata remains available during cooldown.", + 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, + linkPreviewMetadataByHref: { + "https://github.com/block/buzz/pull/4001": { + title: "Loaded preview image", + 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.", + imageDataUrl: null, + imageDomain: null, + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }, }, } - : testInfo.title.includes("link preview image geometry") + : testInfo.title.includes("link preview browser image error") ? { linkPreviewMetadata: { - title: - "Ship a wider horizontal preview with a two-line title that wraps cleanly", + title: "Invalid decoded preview image", siteName: "GitHub", - description: "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=", + 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", + } + : testInfo.title.includes( + "Enter during an in-flight snapshot upload", ) - ? 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", + ? { + 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", ) - ? 150 - : undefined, - } - : undefined; + ? { + 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=, 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=, 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",,,...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, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index fe0c1bf2a6e..50f792447c0 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -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; From 5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 11 Aug 2026 09:58:15 -0400 Subject: [PATCH 02/20] fix(buzz-agent): harden Databricks OAuth token cache and callback (#5534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the Databricks PKCE OAuth code in `crates/buzz-agent/src/auth.rs`. Two fixes. ## Token cache is owner-only across its whole lifecycle, and race-safe The PKCE cache holds both the access and refresh tokens, but `save()` wrote it with a bare `fs::write` + `fs::rename`. Under a `022` umask the file landed world-readable, and the fixed `*.json.tmp` temp name races across concurrent savers sharing `$HOME` — one writer's `rename` can fail on another's half-written temp. **On write**, `write_private_cache()` creates a temp file with owner-only permissions from the moment it exists — mode `0o600` on Unix via `OpenOptions::mode` — writes and fsyncs it, then renames over the destination. The rename swaps the inode wholesale, so a pre-existing cache file with loose permissions is *replaced* by the new private inode rather than inheriting its mode. `unique_suffix()` (getrandom, timestamp fallback) gives each write a distinct temp name, and a drop guard removes the temp on any failure path. **On load**, owner-only is enforced as a cache lifecycle invariant, not just a write-path property. A world-readable cache left by an older buzz-agent was previously read straight into memory and returned on the fresh cache-hit path without ever invoking `save()`, so a token file with no advertised expiry could stay exposed indefinitely. `read_cache()` now funnels every load — initial and cross-process re-reads — through `read_private_cache()`, which on Unix opens with `O_NOFOLLOW` (kernel-level symlink refusal, no stat/open TOCTOU), requires a regular file, and `fchmod`s the pinned handle to `0o600` when any group/other bit is set. A cache that cannot be secured is treated as absent, so callers fail closed to a fresh flow rather than trusting an exposed file. ## OAuth callback no longer reflects untrusted input The localhost callback embedded the untrusted `error` query param straight into the HTML response — an XSS sink on the redirect page — and routed that same raw value into the error string that reaches the logs. `callback_outcome()` is now a pure function returning `(result, static_page)`: the browser always sees a fixed literal page that embeds no request parameter, and failure detail travels only through the result channel. `sanitize_callback_detail()` strips control characters (CR/LF log-line injection) and caps length before that detail enters the error string bound for the logs. ## Deferred: Windows owner-only ACLs Windows owner-only protection is out of scope for this change. The goose-parity route (`CreateFileW` with an owner-only SDDL `D:P(A;;FA;;;OW)`) requires `unsafe` FFI, which this crate's `#![forbid(unsafe_code)]` prohibits; reconciling that conflict is a separate decision. Both platform seams — `create_private_temp_file` (write) and `read_private_cache` (load) — have a `#[cfg(not(unix))]` branch that relies on the default per-user ACLs and is the drop-in point if Windows protection is added later. No new dependency and no `unsafe` are introduced here. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> --- crates/buzz-agent/src/auth.rs | 546 ++++++++++++++++++++++++++++++++-- 1 file changed, 522 insertions(+), 24 deletions(-) diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 3f43925de36..a78a499bdd1 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -16,7 +16,8 @@ //! calls hit the cache and silently refresh when expired. use std::fs; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,15 +189,16 @@ impl PkceOAuthTokenSource { } /// Persist a token to disk and the in-memory cell. + /// + /// The cache holds both the access and refresh tokens, so the on-disk + /// file is written owner-only (`0o600` on Unix) via an atomic + /// inode-swapping rename — see [`write_private_cache`]. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { let body = serde_json::to_vec_pretty(&token) .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - // Atomic rename so a concurrent reader never sees a partial write. - let tmp = self.cache_path.with_extension("json.tmp"); - fs::write(&tmp, &body) - .map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?; - fs::rename(&tmp, &self.cache_path) - .map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; *state = Some(token); Ok(()) } @@ -463,11 +465,162 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } -fn read_cache(path: &PathBuf) -> Option { - let body = fs::read(path).ok()?; +/// Load a cached token, enforcing the owner-only invariant on load. +/// +/// Owner-only permissions are a cache *lifecycle* invariant, not just a +/// write-path property: a world-readable cache left by an older buzz-agent +/// (or any tampering) must be tightened the moment we touch it, before the +/// tokens are used — otherwise a file that never expires stays exposed until +/// some future refresh happens to rewrite it. Every load path (initial and +/// cross-process re-reads) funnels through here, so the repair covers them +/// all. Returns `None` when the cache is absent, unreadable, unparseable, or +/// cannot be secured; the caller then falls through to refresh/browser. +fn read_cache(path: &Path) -> Option { + let body = read_private_cache(path).ok()?; serde_json::from_slice(&body).ok() } +/// Open the cache, reject symlinks, tighten loose permissions to `0o600`, and +/// return its bytes. +/// +/// On Unix `O_NOFOLLOW` rejects a symlinked cache path at the kernel level +/// (no stat/open TOCTOU), and `fchmod` on the already-open handle repairs a +/// loose mode against the pinned inode rather than re-resolving the path. +/// A cache that exists but cannot be secured is an error, so the caller fails +/// closed instead of using an exposed file. +#[cfg(unix)] +fn read_private_cache(path: &Path) -> io::Result> { + use std::io::Read; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + + let meta = file.metadata()?; + if !meta.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oauth cache is not a regular file", + )); + } + // Tighten in place on the open fd if any group/other bit is set. fchmod + // targets the inode we already hold, so no attacker can swap the path + // between the check and the repair. + if meta.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + let mut body = Vec::new(); + file.read_to_end(&mut body)?; + Ok(body) +} + +/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the +/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +#[cfg(not(unix))] +fn read_private_cache(path: &Path) -> io::Result> { + fs::read(path) +} + +/// Removes a temp file on drop unless it was already renamed away. Keeps a +/// failed/partial write from leaving a stray token file behind. +struct TmpFileGuard<'a>(&'a Path); + +impl Drop for TmpFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} + +/// A per-write-unique temp suffix so concurrent savers — sibling threads or +/// separate processes sharing `$HOME` — never collide on one temp path. +/// Falls back to a timestamp if the RNG is unavailable rather than panicking +/// mid-auth. +fn unique_suffix() -> String { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_ok() { + return hex::encode(bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +/// Write `body` to `path` as an owner-only file via an atomic rename. +/// +/// The cache holds both the refresh and access tokens, so it must never be +/// readable by other users. We create a uniquely-named temp file in the same +/// directory with owner-only protection at creation time — mode `0o600` on +/// Unix (see [`create_private_temp_file`]) — so it is never briefly +/// world/other readable, write and fsync it, then rename over the +/// destination. The rename swaps the inode/entry wholesale, so a pre-existing +/// cache file with loose permissions is *replaced* by the new private one; +/// its old mode never survives. `fs::rename` maps to +/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on +/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI +/// decision noted at the seam. +fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "oauth cache path has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("oauth-cache"); + let tmp = parent.join(format!(".{file_name}.{}.tmp", unique_suffix())); + let guard = TmpFileGuard(&tmp); + + let mut f = create_private_temp_file(&tmp)?; + f.write_all(body)?; + f.sync_all()?; + drop(f); + + fs::rename(&tmp, path)?; + // The rename consumed the temp path; nothing left to clean up. + std::mem::forget(guard); + Ok(()) +} + +/// Create `tmp` for writing with owner-only permissions from the moment it +/// exists. Fails if the file already exists (`create_new`), which the +/// per-write-unique suffix makes effectively impossible. +#[cfg(unix)] +fn create_private_temp_file(tmp: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(tmp) +} + +/// Non-Unix fallback: create the temp file if it does not already exist. +/// +/// On Windows the owner-only equivalent is an explicit DACL set at creation +/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's +/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids. +/// Reconciling the two — an isolated helper crate, a vetted safe dependency, +/// or descoping Windows — is an open decision escalated to the maintainer, so +/// this interim relies on the default per-user ACLs and drops the owner-only +/// implementation in behind this seam once the decision lands. `create_new` +/// fails if the file already exists. +#[cfg(not(unix))] +fn create_private_temp_file(tmp: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp) +} + /// Parse a token-endpoint JSON response. Fails loudly when `access_token` /// is missing or empty — without this, a malformed server response would /// be cached and `bearer()` would silently return `""` until the entry @@ -518,6 +671,47 @@ fn random_state() -> Result { Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } +/// Decide the OAuth callback result and the HTML page to serve. +/// +/// Returns `(result, page)`: `result` carries the auth code (or a detail +/// string on failure) to the waiting flow via the oneshot channel; `page` is +/// the *static* HTML shown in the browser. The page never embeds any request +/// parameter — the `error` query value is attacker-influenceable, so +/// reflecting it would be an XSS sink on the localhost callback. Failure +/// detail travels only through `result`, which surfaces in the process error +/// and logs, never in the served markup. +fn callback_outcome( + params: &std::collections::HashMap, + expected_state: &str, +) -> (Result, String) { + let result = match (params.get("code"), params.get("state")) { + (Some(code), Some(st)) if st == expected_state => Ok(code.clone()), + (Some(_), Some(_)) => Err("state mismatch".to_string()), + _ => Err(params + .get("error") + .map(|e| sanitize_callback_detail(e)) + .unwrap_or_else(|| "missing code".into())), + }; + let page = match result { + Ok(_) => "

Buzz: signed in

You can close this window.

", + Err(_) => "

Buzz auth failed

You can close this window and try again.

", + } + .to_string(); + (result, page) +} + +/// Neutralize an attacker-controllable OAuth `error` value before it enters +/// an error string that later reaches the logs. Control characters (CR/LF in +/// particular) enable log-line injection, and an unbounded value could flood +/// the logs — replace control chars with spaces and cap the length. +fn sanitize_callback_detail(raw: &str) -> String { + const MAX: usize = 200; + raw.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(MAX) + .collect() +} + /// Spin up a localhost callback server, open the authorize URL in a /// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then /// exchange the code for a token. @@ -544,23 +738,11 @@ async fn browser_pkce_flow( let tx = Arc::clone(&tx); let expected = expected_state.clone(); async move { - let result = match (params.get("code"), params.get("state")) { - (Some(code), Some(st)) if st == &expected => Ok(code.clone()), - (Some(_), Some(_)) => Err("state mismatch".to_string()), - _ => Err(params - .get("error") - .cloned() - .unwrap_or_else(|| "missing code".into())), - }; + let (result, page) = callback_outcome(¶ms, &expected); if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(result.clone()); - } - match result { - Ok(_) => Html( - "

Buzz: signed in

You can close this window.

".to_string(), - ), - Err(e) => Html(format!("

Buzz auth failed

{e}
")), + let _ = sender.send(result); } + Html(page) } }), ); @@ -844,4 +1026,320 @@ mod tests { ), } } + + // ---- callback HTML must never reflect input -------------------------- + + #[test] + fn test_callback_failure_page_omits_reflected_error_param() { + // A hostile `error` query value carrying markup must not appear in + // the served HTML — otherwise the localhost callback is an XSS sink. + let payload = ""; + let mut params = std::collections::HashMap::new(); + params.insert("error".to_string(), payload.to_string()); + + let (result, page) = callback_outcome(¶ms, "expected-state"); + + // The failure detail still reaches the waiting flow via `result`... + assert_eq!(result.as_ref().err().map(String::as_str), Some(payload)); + // ...but the browser page is static and inert. + assert!( + !page.contains(payload), + "callback page reflected the raw error param: {page}" + ); + assert!( + !page.contains(""; - let result = validate_file_content(html, &config); + // Sanity: this fixture is exactly the shape `infer` classifies as HTML. + assert_eq!(infer::get(html).map(|k| k.mime_type()), Some("text/html")); + let (mime, ext) = validate_file_content(html, &config).unwrap(); + assert_eq!(mime, "text/html"); + assert_eq!(ext, "html"); + assert!( + !serve_inline(&mime), + "text/html must never be served inline — it must force download" + ); + } + + #[test] + fn test_validate_file_executable_still_rejected() { + // Removing HTML from the deny-list must not weaken the executable + // block. `infer` classifies an ELF header as `application/x-executable`, + // which the generic path must still reject via the deny-list. + let config = test_config(); + // `infer`'s ELF matcher requires the magic plus >52 bytes of header. + let mut elf = b"\x7fELF".to_vec(); + elf.extend_from_slice(&[0u8; 60]); + assert_eq!( + infer::get(&elf).map(|k| k.mime_type()), + Some("application/x-executable") + ); assert!( - matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"), - "expected DisallowedContentType(text/html), got {result:?}" + matches!(validate_file_content(&elf, &config), Err(MediaError::DisallowedContentType(ref m)) if m == "application/x-executable"), + "ELF executable must still be rejected by the generic file path" ); } + #[test] + fn test_generic_deny_list_keeps_active_content_and_executables() { + // Static guard on the deny-list itself: HTML is intentionally gone, but + // SVG, JavaScript, XHTML, and the native-executable types remain. These + // are the entries that keep the inert-download boundary honest even if a + // future `infer` upgrade starts classifying more of them by content. + assert!(!BLOCKED_FILE_MIME_TYPES.contains(&"text/html")); + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-msdownload", + "application/x-executable", + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", + "application/x-msi", + "application/x-apple-diskimage", + ] { + assert!( + BLOCKED_FILE_MIME_TYPES.contains(&kept), + "{kept} must remain in the generic-file deny-list" + ); + } + } + #[test] fn test_validate_file_too_large_rejected() { let mut config = test_config(); diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 8a9283c0409..d8adfaed984 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -423,6 +423,72 @@ async fn test_upload_svg_accepted_as_text_xml() { println!("✅ SVG (XML declaration) → 200 as text/xml"); } +#[tokio::test] +#[ignore] +async fn test_upload_html_served_as_inert_attachment() { + // HTML is accepted on the generic file path and MUST be served as an inert + // download: the security property the whole feature relies on is that the + // relay returns `Content-Disposition: attachment` + `X-Content-Type-Options: + // nosniff` + `Content-Security-Policy: default-src 'none'` so the payload can + // never execute or render as active content. This response-level regression + // pins that end to end (upload → GET), not just the deny-list membership. + let client = http_client(); + let keys = Keys::generate(); + // Exactly the shape `infer` classifies as text/html (leading recognised tag). + let html = b""; + let resp = upload(&client, &keys, html).await; + let status = resp.status().as_u16(); + assert_eq!( + status, 200, + "HTML should upload via file path, got {status}" + ); + let desc: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "text/html"); + let url = desc["url"].as_str().unwrap(); + assert!( + url.ends_with(".html"), + "served URL must carry the .html extension, got {url}" + ); + let sha256 = desc["sha256"].as_str().unwrap(); + + let get_resp = client + .get(url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) + .send() + .await + .expect("GET request"); + assert_eq!(get_resp.status(), 200, "HTML GET roundtrip should succeed"); + + let header = |name: &str| { + get_resp + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + assert_eq!(header("content-type"), "text/html"); + assert_eq!( + header("content-disposition"), + "attachment", + "HTML must be forced to download, never rendered inline" + ); + assert_eq!( + header("x-content-type-options"), + "nosniff", + "nosniff must prevent MIME re-sniffing to an executable type" + ); + assert_eq!( + header("content-security-policy"), + "default-src 'none'", + "restrictive CSP must neutralise any active content" + ); + println!("✅ HTML → 200, served as inert attachment (disposition+nosniff+CSP)"); +} + #[tokio::test] #[ignore] async fn test_upload_pdf_accepted() { diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 070381f55e8..8da845c07d4 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -115,11 +115,10 @@ fn fd_real_path(_file: &std::fs::File) -> Result { /// MIME types blocked from upload — mirrors the server's generic-file deny-list. /// -/// Active-content XSS carriers and native executables. Everything else (images, -/// video, documents, archives, audio, text, data) is accepted; un-sniffable -/// files fall back to `application/octet-stream` and are served as downloads. +/// Active-content XSS carriers (JS, SVG) and native executables. Other types, +/// including HTML, are accepted as downloads; un-sniffable files fall back to +/// `application/octet-stream`. XHTML remains blocked in lockstep with the relay. const BLOCKED_MIME: &[&str] = &[ - "text/html", "application/xhtml+xml", "image/svg+xml", "application/javascript", @@ -895,9 +894,29 @@ mod tests { } #[test] - fn test_detect_and_validate_mime_rejects_html() { + fn test_detect_and_validate_mime_accepts_html_as_inert_download() { let html = b""; - assert!(detect_and_validate_mime(html).is_err()); + assert_eq!(detect_and_validate_mime(html).unwrap(), "text/html"); + } + + #[test] + fn test_detect_and_validate_mime_still_rejects_executable() { + let elf = [b"\x7fELF".as_slice(), &[0u8; 60]].concat(); + assert!(detect_and_validate_mime(&elf).is_err()); + } + + #[test] + fn test_blocked_mime_keeps_active_content_and_executables() { + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-executable", + "application/x-mach-binary", + ] { + assert!(BLOCKED_MIME.contains(&kept), "{kept} must stay blocked"); + } } #[test] diff --git a/desktop/src/features/messages/lib/useFilePicker.ts b/desktop/src/features/messages/lib/useFilePicker.ts new file mode 100644 index 00000000000..a4963513a5e --- /dev/null +++ b/desktop/src/features/messages/lib/useFilePicker.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +type FilePickerOptions = { + accept?: string; + multiple?: boolean; +}; + +/** + * Owns one mounted file input for the hook lifetime. Reusing the node avoids + * detached-input presentation races when a native picker is canceled and + * immediately reopened. + */ +export function useFilePicker() { + const inputRef = React.useRef(null); + + React.useEffect( + () => () => { + const input = inputRef.current; + if (input) { + input.onchange = null; + input.remove(); + } + inputRef.current = null; + }, + [], + ); + + return React.useCallback( + (options: FilePickerOptions, onFiles: (files: File[]) => void) => { + let input = inputRef.current; + if (!input) { + input = document.createElement("input"); + input.type = "file"; + input.hidden = true; + document.body.append(input); + inputRef.current = input; + } + + // Cancel emits no `change`, so replace rather than stack callbacks. Reset + // before opening (and after selection) to permit choosing the same file. + input.accept = options.accept ?? ""; + input.multiple = options.multiple ?? false; + input.value = ""; + input.onchange = (event) => { + const currentInput = event.currentTarget as HTMLInputElement; + const files = Array.from(currentInput.files ?? []); + currentInput.value = ""; + onFiles(files); + }; + input.click(); + }, + [], + ); +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 374d392818f..4b9d2c6cce7 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -8,6 +8,7 @@ import { import { uploadMediaFile } from "@/shared/api/tauriMedia"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; +import { useFilePicker } from "./useFilePicker"; import { isVideoFile, videoMimeForFile } from "./videoFileType"; /** @@ -617,21 +618,14 @@ export function useMediaUpload({ [fillSlot, onUploadError, reserveSlots, reserveUploadingPreview], ); + const openFilePicker = useFilePicker(); + const handlePaperclip = React.useCallback(async () => { if (queueUntilSend) { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - input.addEventListener( - "change", - () => { - const files = Array.from(input.files ?? []); - queueFiles(files.filter(shouldQueueFile)); - uploadFiles(files.filter((file) => !shouldQueueFile(file))); - }, - { once: true }, - ); - input.click(); + openFilePicker({ multiple: true }, (files) => { + queueFiles(files.filter(shouldQueueFile)); + uploadFiles(files.filter((file) => !shouldQueueFile(file))); + }); return; } @@ -661,6 +655,7 @@ export function useMediaUpload({ isUploadCanceled, isUploadStale, onUploadError, + openFilePicker, queueFiles, reserveUploadingPreview, shouldQueueFile, diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 0a79718e0b4..d5680b6c90d 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -72,6 +72,56 @@ async function choosePhoto(page: Page) { }); } +const PHOTO_FILE = { + buffer: Buffer.from("photo"), + mimeType: "image/png", + name: "photo.png", +}; + +async function uploadCommandCount(page: Page) { + return page.evaluate( + () => + ( + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [] + ).filter((command) => command === "upload_media_bytes_raw").length, + ); +} + +test("picker survives cancel, same-file retry, and multiple selection", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const attach = page.getByRole("button", { name: "Attach file" }); + + // Model cancel/no selection, then immediately reopen. The composer must + // reuse its one mounted input rather than creating competing detached ones. + const [canceledChooser] = await Promise.all([ + page.waitForEvent("filechooser"), + attach.click(), + ]); + await canceledChooser.setFiles([]); + + await choosePhoto(page); + await expect.poll(() => uploadCommandCount(page)).toBe(1); + + // Reset-before-open is load-bearing: without it browsers suppress `change` + // when the same path remains selected. + await choosePhoto(page); + await expect.poll(() => uploadCommandCount(page)).toBe(2); + + const [multipleChooser] = await Promise.all([ + page.waitForEvent("filechooser"), + attach.click(), + ]); + await multipleChooser.setFiles([ + PHOTO_FILE, + { ...PHOTO_FILE, buffer: Buffer.from("second photo"), name: "other.png" }, + ]); + await expect.poll(() => uploadCommandCount(page)).toBe(4); +}); + test("photos upload before Send without a queued spoiler control", async ({ page, }) => { From b0795a10ea0f63f2382f4028a1adc2bc3e039d79 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 11 Aug 2026 18:18:54 +0100 Subject: [PATCH 09/20] Add Send to channel for thread messages (#5305) ## Summary - Share eligible self-authored or owned-agent thread messages into the parent channel as new top-level messages. - Link the shared message back to the exact root thread with a semantic channel label and excerpt. - Add a dedicated channel-arrow icon plus ownership and navigation coverage. ## Validation - Desktop lint, size, and text guards - Desktop TypeScript build and all 4,543 unit tests - Focused Playwright send-to-channel and thread-link navigation tests --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Wes Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/commands/messages.rs | 19 +- desktop/src-tauri/src/egress_guard_tests.rs | 2 + desktop/src-tauri/src/events.rs | 168 +++++------ desktop/src-tauri/src/events/message_tags.rs | 140 +++++++++ desktop/src-tauri/src/huddle/pipeline.rs | 1 + .../src/features/channels/ui/ChannelPane.tsx | 6 +- .../features/channels/ui/ChannelPane.types.ts | 7 + .../features/channels/ui/ChannelScreen.tsx | 28 +- .../channels/useChannelPaneHandlers.ts | 31 ++ .../src/features/forum/ui/ForumComposer.tsx | 1 + .../src/features/home/ui/InboxDetailPane.tsx | 12 + .../src/features/home/ui/InboxListPane.tsx | 3 +- desktop/src/features/messages/hooks.ts | 54 +++- .../messages/lib/applyEditTagOverlay.mjs | 39 ++- .../messages/lib/applyEditTagOverlay.test.mjs | 64 ++++ .../messages/lib/canSendToChannel.test.mjs | 71 +++++ .../features/messages/lib/canSendToChannel.ts | 34 +++ .../lib/composerMessageLinkNode.test.mjs | 137 +++++++++ .../messages/lib/composerMessageLinkNode.ts | 242 +++++++++++++++ .../messages/lib/draftMentionRefs.test.mjs | 86 ++++++ .../features/messages/lib/draftMentionRefs.ts | 103 ++++++- .../messages/lib/messageGrouping.test.mjs | 12 + .../features/messages/lib/messageGrouping.ts | 16 + .../messages/lib/messageLinkLabel.test.mjs | 41 +++ .../features/messages/lib/messageLinkLabel.ts | 24 ++ .../messages/lib/plainTextProjection.test.mjs | 16 + .../messages/lib/plainTextProjection.ts | 12 +- .../lib/sendToChannelSemantics.test.mjs | 111 +++++++ .../messages/lib/sendToChannelSemantics.ts | 83 ++++++ .../messages/lib/sentFromThread.test.mjs | 82 +++++ .../features/messages/lib/sentFromThread.ts | 70 +++++ .../messages/lib/timelineItems.test.mjs | 30 ++ .../features/messages/lib/timelineItems.ts | 2 + .../features/messages/lib/useChannelLinks.ts | 1 + .../messages/lib/useComposerMessageLinks.ts | 59 ++++ .../messages/lib/useRichTextEditor.ts | 52 ++-- .../features/messages/ui/MessageActionBar.tsx | 33 +++ .../features/messages/ui/MessageComposer.tsx | 10 +- .../messages/ui/MessageComposer.types.ts | 31 +- .../src/features/messages/ui/MessageRow.tsx | 30 ++ .../messages/ui/MessageThreadPanel.tsx | 27 +- .../messages/ui/SentFromThreadLine.tsx | 53 ++++ .../messages/ui/submitMessageEdit.test.mjs | 83 ++++++ .../features/messages/ui/submitMessageEdit.ts | 33 ++- .../ui/useStableSendToChannel.test.mjs | 105 +++++++ .../messages/ui/useStableSendToChannel.ts | 32 ++ desktop/src/shared/api/editMessage.ts | 2 + desktop/src/shared/api/tauri.ts | 12 +- desktop/src/shared/api/tauriMessageTypes.ts | 7 + desktop/src/shared/ui/icons.ts | 9 + desktop/src/shared/ui/markdown.tsx | 2 - .../shared/ui/markdown/MessageLinkPill.tsx | 138 ++++++++- desktop/src/shared/ui/markdown/types.ts | 3 +- desktop/src/testing/e2eBridge.ts | 16 +- desktop/tests/e2e/channels.spec.ts | 103 +++++++ desktop/tests/e2e/messaging.spec.ts | 280 ++++++++++++++++++ desktop/tests/e2e/navigation.spec.ts | 43 ++- 57 files changed, 2687 insertions(+), 224 deletions(-) create mode 100644 desktop/src-tauri/src/events/message_tags.rs create mode 100644 desktop/src/features/messages/lib/canSendToChannel.test.mjs create mode 100644 desktop/src/features/messages/lib/canSendToChannel.ts create mode 100644 desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs create mode 100644 desktop/src/features/messages/lib/composerMessageLinkNode.ts create mode 100644 desktop/src/features/messages/lib/draftMentionRefs.test.mjs create mode 100644 desktop/src/features/messages/lib/messageLinkLabel.test.mjs create mode 100644 desktop/src/features/messages/lib/messageLinkLabel.ts create mode 100644 desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs create mode 100644 desktop/src/features/messages/lib/sendToChannelSemantics.ts create mode 100644 desktop/src/features/messages/lib/sentFromThread.test.mjs create mode 100644 desktop/src/features/messages/lib/sentFromThread.ts create mode 100644 desktop/src/features/messages/lib/useComposerMessageLinks.ts create mode 100644 desktop/src/features/messages/ui/SentFromThreadLine.tsx create mode 100644 desktop/src/features/messages/ui/submitMessageEdit.test.mjs create mode 100644 desktop/src/features/messages/ui/useStableSendToChannel.test.mjs create mode 100644 desktop/src/features/messages/ui/useStableSendToChannel.ts create mode 100644 desktop/src/shared/api/tauriMessageTypes.ts diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 7b4b9b785f1..4f839638b93 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -486,6 +486,7 @@ pub async fn send_channel_message( emoji_tags: Option>>, mention_tags: Option>>, link_preview_tags: Option>>, + sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -500,6 +501,9 @@ pub async fn send_channel_message( let link_previews = link_preview_tags.unwrap_or_default(); let relay_base = crate::relay::relay_api_base_url_with_override(&state); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); + if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { + return Err("sent-from-thread provenance requires a stream message".into()); + } let mut resolved_root: Option = None; @@ -544,6 +548,7 @@ pub async fn send_channel_message( &emoji, &mention_refs_only, &link_previews, + sent_from_thread_tag.as_deref(), &relay_base, )? } @@ -712,6 +717,7 @@ fn build_managed_agent_channel_message( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), client_tags, ) @@ -890,6 +896,10 @@ pub struct EditMessageInput { // tag, so a typo-fix edit never re-wakes existing mentions. #[serde(default)] mention_pubkeys: Vec, + // Full stable mention identity set selected in the edited composer. `None` + // means a partial edit that must preserve the existing snapshot; `Some`, + // including an empty set, authoritatively replaces it. + mention_tags: Option>>, #[serde(default)] suppress_link_previews: bool, } @@ -914,9 +924,12 @@ pub async fn edit_message( channel_uuid, target_eid, trimmed, - &input.media_tags, - &input.emoji_tags, - &mention_refs, + events::MessageEditTags { + media: &input.media_tags, + custom_emoji: &input.emoji_tags, + mentions: &mention_refs, + mention_refs: input.mention_tags.as_deref(), + }, input.suppress_link_previews, )?; submit_event(builder, &state).await?; diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 23cb2ba220c..1513742beaf 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -165,6 +165,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); @@ -181,6 +182,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf1..df814afb36f 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -11,6 +11,12 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; + +mod message_tags; + +use message_tags::{ + append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags, +}; // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -74,56 +80,6 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { Ok(tags) } -fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mention in mentions { - if mention.first().map(String::as_str) != Some("mention") { - return Err(format!( - "mention reference tags must use 'mention' prefix (got {:?})", - mention.first() - )); - } - let Some(pubkey) = mention.get(1) else { - return Err("mention reference tag missing pubkey".into()); - }; - check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); - } - Ok(()) -} - -/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" -/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). -fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mt in media_tags { - if mt.first().map(String::as_str) != Some("imeta") { - return Err(format!( - "media tags must use 'imeta' prefix (got {:?})", - mt.first() - )); - } - let parts: Vec<&str> = mt.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); - } - Ok(()) -} - -/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects -/// any tag whose first element is not "emoji" so this path can't be used to -/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. -fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for et in emoji_tags { - if et.first().map(String::as_str) != Some("emoji") { - return Err(format!( - "emoji tags must use 'emoji' prefix (got {:?})", - et.first() - )); - } - let parts: Vec<&str> = et.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); - } - Ok(()) -} - /// Validate a hex pubkey is exactly 64 hex characters. fn check_pubkey(pubkey: &str) -> Result<(), String> { if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { @@ -302,6 +258,7 @@ pub fn build_message( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, ) -> Result { build_message_with_client_tags( @@ -313,6 +270,7 @@ pub fn build_message( custom_emoji_tags, mention_ref_tags, link_preview_tags, + sent_from_thread_tag, relay_base, &[], ) @@ -333,9 +291,13 @@ pub fn build_message_with_client_tags( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, client_tags: &[Vec], ) -> Result { + if sent_from_thread_tag.is_some() && thread_ref.is_some() { + return Err("sent-from-thread provenance requires a top-level message".into()); + } check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; if let Some(tr) = thread_ref { @@ -346,27 +308,11 @@ pub fn build_message_with_client_tags( emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; + append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } -fn append_client_tags(client_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for client_tag in client_tags { - if client_tag.first().map(String::as_str) != Some("client") { - return Err(format!( - "client tags must use 'client' prefix (got {:?})", - client_tag.first() - )); - } - if client_tag.len() < 2 { - return Err("client tag missing marker".into()); - } - let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); - } - Ok(()) -} - /// Kind 45001 — forum post. pub fn build_forum_post( channel_id: Uuid, @@ -401,15 +347,20 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } +pub struct MessageEditTags<'a> { + pub media: &'a [Vec], + pub custom_emoji: &'a [Vec], + pub mentions: &'a [&'a str], + pub mention_refs: Option<&'a [Vec]>, +} + /// Kind 40003 — edit a message with full content, media, emoji, mentions, /// and optional monotonic link-preview suppression. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, content: &str, - media_tags: &[Vec], - custom_emoji_tags: &[Vec], - mentions: &[&str], + edit_tags: MessageEditTags<'_>, suppress_link_previews: bool, ) -> Result { check_content(content)?; @@ -417,9 +368,13 @@ pub fn build_message_edit( tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; - tags.extend(mention_tags(mentions)?); - imeta_tags(media_tags, &mut tags)?; - emoji_tags(custom_emoji_tags, &mut tags)?; + tags.extend(mention_tags(edit_tags.mentions)?); + imeta_tags(edit_tags.media, &mut tags)?; + emoji_tags(edit_tags.custom_emoji, &mut tags)?; + if let Some(mention_refs) = edit_tags.mention_refs { + mention_reference_tags(mention_refs, &mut tags)?; + tags.push(tag(vec!["buzz:mention-snapshot"])?); + } if suppress_link_previews { tags.push(tag(vec!["link-preview", "none"])?); } @@ -930,25 +885,35 @@ mod tests { assert_eq!(event.pubkey.to_hex(), TARGET_HEX); } - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── - // - // The composer diffs the edited body's mentions against the original and - // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added - // mention (deduped, lowercased), and none when the added set is empty - // (typo-fix edit) — so an unchanged mention set re-wakes nobody. - const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; fn edit_tags(mentions: &[&str]) -> Vec> { + edit_tags_with_refs(mentions, Some(&[])) + } + + fn edit_tags_with_refs( + mentions: &[&str], + mention_refs: Option<&[Vec]>, + ) -> Vec> { let channel = Uuid::parse_str(CH_ID).unwrap(); let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = - build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); + let builder = build_message_edit( + channel, + target, + "hi @alice", + MessageEditTags { + media: &[], + custom_emoji: &[], + mentions, + mention_refs, + }, + false, + ) + .unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) @@ -962,7 +927,6 @@ mod tests { let tags = edit_tags(&[ALICE_HEX]); assert_eq!(tags[0][0], "h"); assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); } @@ -979,6 +943,42 @@ mod tests { ); } + #[test] + fn edit_emits_full_mention_reference_snapshot() { + let tags = edit_tags_with_refs(&[], Some(&[vec!["mention".into(), ALICE_HEX.into()]])); + assert!( + tags.iter().any(|tag| tag == &["mention", ALICE_HEX]), + "stable mention reference must be present: {tags:?}" + ); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "snapshot marker must be present: {tags:?}" + ); + } + + #[test] + fn empty_edit_mention_snapshot_is_explicit() { + let tags = edit_tags_with_refs(&[], Some(&[])); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "empty snapshot must still clear stale references: {tags:?}" + ); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + } + + #[test] + fn partial_edit_omits_mention_snapshot() { + let tags = edit_tags_with_refs(&[], None); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("buzz:mention-snapshot"))); + } + #[test] fn edit_mentions_are_deduped_and_lowercased() { let alice_upper = ALICE_HEX.to_ascii_uppercase(); diff --git a/desktop/src-tauri/src/events/message_tags.rs b/desktop/src-tauri/src/events/message_tags.rs new file mode 100644 index 00000000000..c43a8874def --- /dev/null +++ b/desktop/src-tauri/src/events/message_tags.rs @@ -0,0 +1,140 @@ +use nostr::{EventId, Tag}; + +use super::check_pubkey; + +const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64; +const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread"; + +pub(super) fn mention_reference_tags( + mentions: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for mention in mentions { + if mention.first().map(String::as_str) != Some("mention") { + return Err(format!( + "mention reference tags must use 'mention' prefix (got {:?})", + mention.first() + )); + } + let Some(pubkey) = mention.get(1) else { + return Err("mention reference tag missing pubkey".into()); + }; + check_pubkey(pubkey)?; + tags.push( + Tag::parse(vec!["mention", &pubkey.to_ascii_lowercase()]) + .map_err(|error| format!("invalid mention reference tag: {error}"))?, + ); + } + Ok(()) +} + +pub(super) fn append_sent_from_thread_tag( + source_tag: Option<&[String]>, + tags: &mut Vec, +) -> Result<(), String> { + let Some(source_tag) = source_tag else { + return Ok(()); + }; + if !matches!(source_tag.len(), 2 | 3) + || source_tag.first().map(String::as_str) != Some(SENT_FROM_THREAD_TAG) + { + return Err("invalid sent-from-thread tag shape".into()); + } + + EventId::from_hex(source_tag[1].trim()) + .map_err(|_| "sent-from-thread tag has invalid root event ID")?; + + if let Some(excerpt) = source_tag.get(2) { + if excerpt.trim().is_empty() + || excerpt.chars().count() > MAX_THREAD_ROOT_EXCERPT_CHARS + || excerpt.chars().any(char::is_control) + { + return Err("sent-from-thread tag has invalid root excerpt".into()); + } + } + + let parts: Vec<&str> = source_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid sent-from-thread tag: {e}"))?); + Ok(()) +} + +/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" +/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). +pub(super) fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for media_tag in media_tags { + if media_tag.first().map(String::as_str) != Some("imeta") { + return Err(format!( + "media tags must use 'imeta' prefix (got {:?})", + media_tag.first() + )); + } + let parts: Vec<&str> = media_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); + } + Ok(()) +} + +/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects +/// any tag whose first element is not "emoji" so this path can't be used to +/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. +pub(super) fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for emoji_tag in emoji_tags { + if emoji_tag.first().map(String::as_str) != Some("emoji") { + return Err(format!( + "emoji tags must use 'emoji' prefix (got {:?})", + emoji_tag.first() + )); + } + let parts: Vec<&str> = emoji_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); + } + Ok(()) +} + +pub(super) fn append_client_tags( + client_tags: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for client_tag in client_tags { + if client_tag.first().map(String::as_str) != Some("client") { + return Err(format!( + "client tags must use 'client' prefix (got {:?})", + client_tag.first() + )); + } + if client_tag.len() < 2 { + return Err("client tag missing marker".into()); + } + let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1"; + + #[test] + fn message_accepts_only_valid_sent_from_thread_provenance() { + let source_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + ROOT_HEX.to_string(), + "Root message excerpt".to_string(), + ]; + let mut tags = Vec::new(); + append_sent_from_thread_tag(Some(&source_tag), &mut tags).unwrap(); + assert_eq!(tags[0].as_slice(), source_tag); + + let forged_channel_tag = vec!["h".to_string(), "channel-id".to_string()]; + assert!(append_sent_from_thread_tag(Some(&forged_channel_tag), &mut Vec::new()).is_err()); + + let invalid_root_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + "not-an-event-id".to_string(), + ]; + assert!(append_sent_from_thread_tag(Some(&invalid_root_tag), &mut Vec::new()).is_err()); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 4d4e840104e..b05b6b7fe47 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -668,6 +668,7 @@ pub(crate) fn spawn_transcription_task( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) { Ok(b) => b, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 410b05a2ccd..6c8fbd8c84c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -127,6 +127,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, + onSendToChannel, onSendVideoReviewComment, onSendThreadReply, onThreadScrollTargetResolved, @@ -265,9 +266,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit(target); return true; }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); - const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → // ordinary DM, composer enabled. @@ -820,6 +819,9 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} + onSendToChannel={ + isComposerDisabled ? undefined : onSendToChannel + } onScrollTargetResolved={() => resolveScrollTarget()} onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 763b8bf3797..1cf0dff981b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -7,6 +7,7 @@ import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channel import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { useChannelFind } from "@/features/search/useChannelFind"; import type { ProfilePanelTab, @@ -42,6 +43,7 @@ export type ChannelPaneProps = { body: string; id: string; imetaMedia?: ImetaMedia[]; + mentionRefs?: DraftMentionRef[]; } | null; fetchOlder?: () => Promise; header?: React.ReactNode; @@ -107,6 +109,11 @@ export type ChannelPaneProps = { mediaTags?: string[][], channelId?: string | null, ) => Promise; + onSendToChannel: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onSendVideoReviewComment?: ( message: TimelineMessage, content: string, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 86663949384..c1d62e57622 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -42,9 +42,9 @@ import { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; +import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { resolveTimelineLoadingLatch, @@ -84,8 +84,7 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, - EMPTY_RELAY_EVENTS: RelayEvent[] = []; +const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, @@ -204,9 +203,8 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) { + if (getThreadReference(messages[index].tags).parentId === null) return messages[index]; - } } return null; }, [messagesQuery.data]); @@ -495,6 +493,7 @@ export function ChannelScreen({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, @@ -506,6 +505,7 @@ export function ChannelScreen({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + profiles: messageProfiles, recordThreadInteraction, openThreadHeadId: effectiveOpenThreadHeadId, onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, @@ -713,7 +713,7 @@ export function ChannelScreen({ const shouldCompactHeaderActions = hasAuxiliaryPanel && channelContentWidthPx > 0 && - channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX; + channelContentWidthPx < 760; const channelHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, ...channelContentTopPaddingMeasurement, @@ -879,14 +879,13 @@ export function ChannelScreen({ welcomeKickoffSettingUp={welcomeKickoffSettingUp} editTarget={ editTargetMessage - ? { - author: editTargetMessage.author, - body: editTargetMessage.body, - id: editTargetMessage.id, - imetaMedia: imetaMediaFromTags( - editTargetMessage.tags, - ), - } + ? buildMessageComposerEditTarget( + editTargetMessage, + messageProfiles, + (pubkey) => + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) : null } followThreadById={followThread} @@ -940,6 +939,7 @@ export function ChannelScreen({ onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} onThreadScrollTargetResolved={ diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 7e78d9601f1..d7b2e5a6fd7 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -7,7 +7,10 @@ import type { useToggleReactionMutation, } from "@/features/messages/hooks"; import { resolveThreadReplyTarget } from "@/features/messages/hooks"; +import { getSendToChannelSemantics } from "@/features/messages/lib/sendToChannelSemantics"; +import { summarizeThreadRoot } from "@/features/messages/lib/sentFromThread"; import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -25,6 +28,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + profiles, recordThreadInteraction, onOptimisticOpenThreadHeadIdChange, onRequestEmptyEditDelete, @@ -45,6 +49,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; markRevealedRepliesRead: (messageId: string) => void; + profiles: UserProfileLookup | undefined; recordThreadInteraction: (rootId: string) => void; onOptimisticOpenThreadHeadIdChange: React.Dispatch< React.SetStateAction @@ -73,6 +78,9 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; + const profilesRef = React.useRef(profiles); + profilesRef.current = profiles; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -287,6 +295,28 @@ export function useChannelPaneHandlers({ [], ); + const handleSendToChannel = React.useCallback( + async ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => { + const { mentionPubkeys, semanticTags } = getSendToChannelSemantics( + message, + profilesRef.current, + ); + await sendMutateRef.current({ + channelId, + content: message.body, + mediaTags: semanticTags, + mentionPubkeys, + sentFromThreadRootExcerpt: summarizeThreadRoot(threadRoot.body), + sentFromThreadRootId: threadRoot.id, + }); + }, + [], + ); + const handleSendThreadReply = React.useCallback( async ( content: string, @@ -376,6 +406,7 @@ export function useChannelPaneHandlers({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 625dc173600..6204186abe9 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -114,6 +114,7 @@ export function ForumComposer({ editable: !disabled, mentionNames: mentions.knownNames, channelNames: channelLinks.knownChannelNames, + messageLinkChannels: channelLinks.channels, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, onEditLink: (info) => onEditLinkRef.current?.(info), diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 54b47820eb0..c1ad2607528 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -29,9 +29,11 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; import { hasSameMessageAuthor, isWithinGroupingWindow, + startsNewMessageGroup, } from "@/features/messages/lib/messageGrouping"; import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import { buildEditMentionState } from "@/features/messages/lib/draftMentionRefs"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -401,12 +403,21 @@ function InboxMessageDetailPane({ displayMessages.find((message) => message.id === replyTargetId) ?? null; const editTarget = displayMessages.find((message) => message.id === editTargetId) ?? null; + const editMentionState = editTarget + ? buildEditMentionState( + editTarget.content, + editTarget.tags, + profiles, + (pubkey) => agentPubkeys?.has(pubkey) === true, + ) + : null; const composerEditTarget = editTarget ? { author: editTarget.authorLabel, body: editTarget.content, id: editTarget.id, imetaMedia: imetaMediaFromTags(editTarget.tags), + ...editMentionState, } : null; // Explicit sub-message reply wins. Otherwise use the captured default parent @@ -614,6 +625,7 @@ function InboxMessageDetailPane({ const previousMessage = displayMessages[index - 1]; const isContinuation = !isAfterSeparator && + !startsNewMessageGroup(message) && hasSameMessageAuthor( { pubkey: previousMessage?.authorPubkey }, { pubkey: message.authorPubkey }, diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index fa214dc730e..17b06bf284d 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -82,13 +82,14 @@ function InboxLabel({
{label.text} {label.channelLabel ? ( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index c3d09f3ad7f..9091121d0bf 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -28,6 +28,7 @@ import { export { mergeMessages, mergeTimelineCacheMessages }; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { messageMentionPubkeys } from "@/features/messages/lib/messageMentionPubkeys"; +import { buildSentFromThreadTag } from "@/features/messages/lib/sentFromThread"; import { clearTimeoutState, recordTimeoutFromRejection, @@ -88,6 +89,8 @@ export function createOptimisticMessage( mentionPubkeys: string[] = [], parentEventId: string | null = null, mediaTags: string[][] = [], + sentFromThreadRootId: string | null = null, + sentFromThreadRootExcerpt: string | null = null, ): RelayEvent { const localKey = `optimistic-${crypto.randomUUID()}`; const tags: string[][] = []; @@ -116,6 +119,11 @@ export function createOptimisticMessage( for (const tag of mediaTags) { tags.push(tag); } + if (sentFromThreadRootId) { + tags.push( + buildSentFromThreadTag(sentFromThreadRootId, sentFromThreadRootExcerpt), + ); + } return { id: localKey, @@ -417,6 +425,8 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + sentFromThreadRootId?: string | null; + sentFromThreadRootExcerpt?: string | null; }, MessageQueryContext | undefined >({ @@ -427,6 +437,8 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + sentFromThreadRootId, + sentFromThreadRootExcerpt, }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -469,6 +481,18 @@ export function useSendMessageMutation( identity.pubkey, mentionPubkeys, ); + if (sentFromThreadRootId && parentEventId) { + throw new Error( + "A thread message can only be sent as a top-level message.", + ); + } + + const sentFromThreadTag = sentFromThreadRootId + ? buildSentFromThreadTag( + sentFromThreadRootId, + sentFromThreadRootExcerpt, + ) + : undefined; // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra @@ -493,6 +517,7 @@ export function useSendMessageMutation( emojiTags, mentionTags, linkPreviewTags, + sentFromThreadTag, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -531,6 +556,7 @@ export function useSendMessageMutation( ...emojiTags, ...mentionTags, ...linkPreviewTags, + ...(sentFromThreadTag ? [sentFromThreadTag] : []), ], content: content.trim(), sig: "", @@ -541,7 +567,7 @@ export function useSendMessageMutation( effectiveChannel.id, content, recipientPubkeys, - mentionTags, + [...mentionTags, ...(sentFromThreadTag ? [sentFromThreadTag] : [])], ); }, onMutate: async ({ @@ -551,6 +577,8 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + sentFromThreadRootId, + sentFromThreadRootExcerpt, }) => { // Mirror mutationFn's target resolution so the optimistic message lands // in the cache for the same channel as the real send. A caller-supplied @@ -586,6 +614,8 @@ export function useSendMessageMutation( mentionPubkeys ?? [], parentEventId ?? null, mediaTags ?? [], + sentFromThreadRootId ?? null, + sentFromThreadRootExcerpt ?? null, ); const nextWindow = mergeLiveChannelWindowEvent( @@ -722,7 +752,11 @@ export function useEditMessageMutation(channel: Channel | null) { // Split so each rides its own validated Tauri arg — emoji tags must NOT // go through the imeta-only `mediaTags` channel (the Rust `imeta_tags` // guard rejects any non-imeta prefix), mirroring the send path. - const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags); + const { + mediaTags: imetaTags, + emojiTags, + mentionTags, + } = splitOutgoingTags(mediaTags); await editMessage( channel.id, @@ -731,9 +765,11 @@ export function useEditMessageMutation(channel: Channel | null) { imetaTags, emojiTags, mentionPubkeys, + false, + mentionTags, ); }, - onSuccess: (_data, { eventId, content, mediaTags }) => { + onSuccess: (_data, { eventId, content, mediaTags, mentionPubkeys }) => { if (!channel) { return; } @@ -746,9 +782,15 @@ export function useEditMessageMutation(channel: Channel | null) { // only because the edit event round-trip can lag perceptibly.) const applyEdit = (message: RelayEvent): RelayEvent => { if (message.id !== eventId) return message; - const nextTags = mediaTags - ? applyEditTagOverlay(message.tags, mediaTags) - : message.tags; + const editTags = [ + ...(mediaTags ?? []), + ...(mentionPubkeys ?? []).map((pubkey) => ["p", pubkey]), + ["buzz:mention-snapshot"], + ]; + const nextTags = + mediaTags !== undefined || editTags.length > 0 + ? applyEditTagOverlay(message.tags, editTags) + : message.tags; return { ...message, content, tags: nextTags }; }; diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs index becd3203be0..809dcac3bd4 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs @@ -12,6 +12,12 @@ /** * Merge the original event's tags with an edit's tags so that: * - `imeta` tags come exclusively from the edit (full new attachment set); + * - `p` tags from the edit join the original set because only newly added + * mentions notify. Reference-only `mention` tags, by contrast, are a full + * snapshot from the edited composer (marked by `buzz:mention-snapshot`) + * and therefore replace the original set; this preserves the edited body's + * stable recipient identities even before profiles load or after an alias + * changes; * - `emoji` (NIP-30 custom-emoji) tags come from the edit *when the edit * supplies any* — the edited body may add or remove custom emoji, so a * supplied set rebuilds the shortcode→url map. But when the edit supplies @@ -22,22 +28,37 @@ * `:shortcode:` that the original rendered fine. Preserving on empty is * strictly safe: an orphaned emoji tag whose shortcode is no longer in the * body resolves nothing, so it can't cause a stale render. - * - all other tag kinds (`h`, `e`, `p` mentions, etc.) come exclusively - * from the original — the edit can't rewrite channel membership, - * thread refs, or mention targets. + * - all other tag kinds (`h`, `e`, etc.) come exclusively from the original + * so the edit can't rewrite channel membership or thread references. * * When `editTags` is undefined, returns `originalTags` unchanged. */ export function applyEditTagOverlay(originalTags, editTags) { if (!editTags) return originalTags; const editEmoji = editTags.filter((t) => t[0] === "emoji"); + const hasMentionSnapshot = editTags.some( + (t) => t[0] === "buzz:mention-snapshot", + ); + const editMentions = editTags.filter((t) => t[0] === "mention"); // imeta is always fully replaced by the edit. emoji is replaced only when // the edit actually supplies emoji tags; otherwise the original's are kept. - const droppedFromOriginal = - editEmoji.length > 0 - ? (t) => t[0] !== "imeta" && t[0] !== "emoji" - : (t) => t[0] !== "imeta"; + // An edit carrying the private snapshot marker is authoritative, including + // an empty mention set. Legacy edits without the marker preserve original + // references so older clients remain compatible. + const droppedFromOriginal = (tag) => { + if (tag[0] === "imeta") return false; + if (editEmoji.length > 0 && tag[0] === "emoji") return false; + if (hasMentionSnapshot && tag[0] === "mention") return false; + return true; + }; const baseFromOriginal = originalTags.filter(droppedFromOriginal); - const overlaidFromEdit = editTags.filter((t) => t[0] === "imeta"); - return [...baseFromOriginal, ...overlaidFromEdit, ...editEmoji]; + const overlaidFromEdit = editTags.filter( + (t) => t[0] === "imeta" || t[0] === "p" || t[0] === "buzz:mention-snapshot", + ); + return [ + ...baseFromOriginal, + ...overlaidFromEdit, + ...editEmoji, + ...editMentions, + ]; } diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs index 783586be68c..d77bf9d940a 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs @@ -104,6 +104,70 @@ test("edit's non-imeta tags are dropped (only imeta wins)", () => { assert.equal(out.filter((t) => t[0] === "imeta").length, 1); }); +test("edit overlays newly added mention tags without replacing original routing", () => { + const original = [ + ["h", "uuid"], + ["p", "original-mention"], + ]; + const edit = [ + ["h", "uuid"], + ["e", "x"], + ["p", "added-mention"], + ]; + + assert.deepEqual( + applyEditTagOverlay(original, edit).filter((tag) => tag[0] === "p"), + [ + ["p", "original-mention"], + ["p", "added-mention"], + ], + ); +}); + +test("edit mention snapshot replaces original references, including removals", () => { + const original = [ + ["h", "uuid"], + ["mention", "original-mention"], + ]; + const replacement = applyEditTagOverlay(original, [ + ["buzz:mention-snapshot"], + ["mention", "replacement-mention"], + ]); + assert.deepEqual( + replacement.filter((tag) => tag[0] === "mention"), + [["mention", "replacement-mention"]], + ); + assert.deepEqual( + replacement.filter((tag) => tag[0] === "buzz:mention-snapshot"), + [["buzz:mention-snapshot"]], + ); + + const removed = applyEditTagOverlay(original, [["buzz:mention-snapshot"]]); + assert.deepEqual( + removed.filter((tag) => tag[0] === "mention"), + [], + ); + assert.deepEqual( + removed.filter((tag) => tag[0] === "buzz:mention-snapshot"), + [["buzz:mention-snapshot"]], + ); +}); + +test("legacy edits preserve original mention references", () => { + const original = [ + ["h", "uuid"], + ["mention", "original-mention"], + ]; + const out = applyEditTagOverlay(original, [ + ["h", "uuid"], + ["e", "x"], + ]); + assert.deepEqual( + out.filter((tag) => tag[0] === "mention"), + [["mention", "original-mention"]], + ); +}); + const EMOJI = (shortcode, url) => ["emoji", shortcode, url]; test("edit replaces the original's emoji tags with the edit's set", () => { diff --git a/desktop/src/features/messages/lib/canSendToChannel.test.mjs b/desktop/src/features/messages/lib/canSendToChannel.test.mjs new file mode 100644 index 00000000000..dd7eb488907 --- /dev/null +++ b/desktop/src/features/messages/lib/canSendToChannel.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertCanSendMessageToChannel, + canSendMessageToChannel, +} from "./canSendToChannel.ts"; + +const CURRENT = "a".repeat(64); +const OWNED_AGENT = "b".repeat(64); +const OTHER_PERSON = "c".repeat(64); +const OTHER_AGENT = "d".repeat(64); + +const message = (pubkey) => ({ kind: 9, pubkey }); +const profiles = { + [OWNED_AGENT]: { isAgent: true, ownerPubkey: CURRENT }, + [OTHER_AGENT]: { isAgent: true, ownerPubkey: OTHER_PERSON }, +}; + +test("send-to-channel permits self-authored messages", () => { + assert.equal( + canSendMessageToChannel(message(CURRENT), CURRENT, profiles), + true, + ); +}); + +test("send-to-channel permits messages from an agent owned by the viewer", () => { + assert.equal( + canSendMessageToChannel(message(OWNED_AGENT), CURRENT, profiles), + true, + ); +}); + +test("send-to-channel rejects specialized message kinds", () => { + const diffMessage = { ...message(CURRENT), kind: 40008 }; + + assert.equal(canSendMessageToChannel(diffMessage, CURRENT, profiles), false); + assert.throws( + () => assertCanSendMessageToChannel(diffMessage, CURRENT, profiles), + /Only ordinary channel messages/, + ); +}); + +test("send-to-channel rejects pending messages", () => { + const pendingMessage = { ...message(CURRENT), pending: true }; + + assert.equal( + canSendMessageToChannel(pendingMessage, CURRENT, profiles), + false, + ); + assert.throws( + () => assertCanSendMessageToChannel(pendingMessage, CURRENT, profiles), + /finish sending first/, + ); +}); + +test("send-to-channel rejects third-party people and agents", () => { + assert.equal( + canSendMessageToChannel(message(OTHER_PERSON), CURRENT, profiles), + false, + ); + assert.equal( + canSendMessageToChannel(message(OTHER_AGENT), CURRENT, profiles), + false, + ); + assert.throws( + () => + assertCanSendMessageToChannel(message(OTHER_AGENT), CURRENT, profiles), + /only send your own or your agents' messages/, + ); +}); diff --git a/desktop/src/features/messages/lib/canSendToChannel.ts b/desktop/src/features/messages/lib/canSendToChannel.ts new file mode 100644 index 00000000000..d3f040689e3 --- /dev/null +++ b/desktop/src/features/messages/lib/canSendToChannel.ts @@ -0,0 +1,34 @@ +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import type { TimelineMessage } from "@/features/messages/types"; +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +export function canSendMessageToChannel( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): boolean { + return ( + message.kind === KIND_STREAM_MESSAGE && + !message.pending && + canManageMessageForCurrentUser(message, currentPubkey, profiles) + ); +} + +export function assertCanSendMessageToChannel( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): void { + if (message.kind !== KIND_STREAM_MESSAGE) { + throw new Error( + "Only ordinary channel messages can be sent to the channel.", + ); + } + if (message.pending) { + throw new Error("Wait for the message to finish sending first."); + } + if (!canManageMessageForCurrentUser(message, currentPubkey, profiles)) { + throw new Error("You can only send your own or your agents' messages."); + } +} diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs new file mode 100644 index 00000000000..867f37677da --- /dev/null +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +import { + registerComposerMessageLinkMarkdownIt, + resolveComposerMessageLinkAttributes, +} from "./composerMessageLinkNode.ts"; + +const requireFromTiptap = createRequire(import.meta.resolve("tiptap-markdown")); +const MarkdownIt = requireFromTiptap("markdown-it"); + +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const MESSAGE_ID = "root-event"; +const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`; + +test("resolves a composer preview and canonicalizes the underlying href", () => { + assert.deepEqual( + resolveComposerMessageLinkAttributes( + HREF.replace("buzz://", "BUZZ://"), + (channelId) => (channelId === CHANNEL_ID ? "general" : undefined), + ), + { channelName: "general", href: HREF }, + ); +}); + +test("rejects malformed message links", () => { + assert.equal( + resolveComposerMessageLinkAttributes( + `buzz://message?channel=${CHANNEL_ID}`, + () => "general", + ), + null, + ); +}); + +function captureMarkdownRule() { + let capturedAnchor = null; + let capturedRule = null; + const md = { + renderer: { rules: {} }, + inline: { + ruler: { + before(anchor, _name, rule) { + capturedAnchor = anchor; + capturedRule = rule; + }, + }, + }, + utils: { + escapeHtml: (value) => value.replaceAll("&", "&"), + }, + }; + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + }); + return { anchor: capturedAnchor, md, rule: capturedRule }; +} + +test("markdown parsing materializes a bare message link in composer content", () => { + const { anchor, rule } = captureMarkdownRule(); + assert.equal(anchor, "text"); + let token = null; + const state = { + src: `See ${HREF}.`, + pos: 4, + push: () => { + token = { meta: null }; + return token; + }, + }; + + assert.equal(rule(state, false), true); + assert.equal(state.pos, 4 + HREF.length); + assert.deepEqual(token.meta, { channelName: "general", href: HREF }); +}); + +test("real markdown-it parsing materializes a restored message link", () => { + const md = new MarkdownIt(); + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + }); + + const html = md.renderInline(`See ${HREF}.`); + assert.match(html, /See { + const { rule } = captureMarkdownRule(); + let token = null; + const state = { + pending: "See buzz", + src: `See ${HREF}`, + pos: "See buzz".length, + push: () => { + token = { meta: null }; + return token; + }, + }; + + assert.equal(rule(state, false), true); + assert.equal(state.pending, "See "); + assert.equal(state.pos, state.src.length); + assert.deepEqual(token.meta, { channelName: "general", href: HREF }); +}); + +test("markdown parsing stops message links before emphasis delimiters", () => { + const { rule } = captureMarkdownRule(); + let token = null; + const state = { + src: `${HREF}*`, + pos: 0, + push: () => { + token = { meta: null }; + return token; + }, + }; + + assert.equal(rule(state, false), true); + assert.equal(state.pos, HREF.length); + assert.deepEqual(token.meta, { channelName: "general", href: HREF }); +}); + +test("markdown rendering stores identity in attributes, not visible id text", () => { + const { md } = captureMarkdownRule(); + const render = md.renderer.rules.buzz_composer_message_link; + const html = render([{ meta: { channelName: "general", href: HREF } }], 0); + + assert.match(html, /data-composer-message-link=""/); + assert.match(html, /data-channel-name="general"/); + assert.match(html, /data-href="buzz:\/\/message\?channel=.*&id=/); + assert.doesNotMatch(html, />[^<]*root-event/); +}); diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts new file mode 100644 index 00000000000..5431e2c9605 --- /dev/null +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -0,0 +1,242 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { MENTION_CHIP_BASE_CLASSES } from "@/shared/ui/mentionChip"; +import { + getMessageLinkChannelLabel, + getMessageLinkLabel, + MESSAGE_LINK_PREFIX, +} from "./messageLinkLabel"; +import { buildMessageLink, parseMessageLink } from "./messageLink"; + +export const COMPOSER_MESSAGE_LINK_NODE_NAME = "composerMessageLink"; + +export type ComposerMessageLinkNodeOptions = { + resolveChannelName: (channelId: string) => string | undefined; +}; + +export type ComposerMessageLinkAttributes = { + channelName: string; + href: string; +}; + +const BARE_MESSAGE_LINK_AT_START = /^(?:buzz):\/\/message\?[^\s<>"')\]}*_]+/i; +const TRAILING_PUNCTUATION = /[.,;:!?]+$/; + +function trimBareMessageLink(value: string): string { + let trimmed = value.replace(TRAILING_PUNCTUATION, ""); + while (/[)\]]$/.test(trimmed)) { + const closing = trimmed.at(-1) ?? ""; + const opening = closing === ")" ? "(" : "["; + if (trimmed.split(closing).length <= trimmed.split(opening).length) break; + trimmed = trimmed.slice(0, -1).replace(TRAILING_PUNCTUATION, ""); + } + return trimmed; +} + +export function resolveComposerMessageLinkAttributes( + href: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): ComposerMessageLinkAttributes | null { + const parsed = parseMessageLink(href); + if (!parsed.ok) return null; + return { + channelName: resolveChannelName(parsed.value.channelId) ?? "", + href: buildMessageLink({ + channelId: parsed.value.channelId, + messageId: parsed.value.messageId, + threadRootId: parsed.value.threadRootId, + }), + }; +} + +function unwrapExactMessageLink(text: string): string | null { + const href = + text.startsWith("<") && text.endsWith(">") ? text.slice(1, -1) : text; + if (!href || /\s/.test(href)) return null; + return parseMessageLink(href).ok ? href : null; +} + +function unwrapExactHttpLink(text: string): string | null { + const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); + return match?.[1] ?? match?.[2] ?? null; +} + +function replaceSelectionWithNode(view: EditorView, node: ProseMirrorNode) { + const { from, to } = view.state.selection; + let transaction = view.state.tr.replaceRangeWith(from, to, node); + const end = transaction.mapping.map(to); + transaction = transaction.insertText(" ", end); + const linkMark = view.state.schema.marks.link; + if (linkMark) transaction = transaction.removeMark(end, end + 1, linkMark); + transaction = transaction.setSelection( + TextSelection.create(transaction.doc, end + 1), + ); + view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); + view.focus(); +} + +export function createComposerLinkPasteHandler( + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +) { + return (view: EditorView, event: ClipboardEvent): boolean => { + const text = event.clipboardData?.getData("text/plain") ?? ""; + const messageHref = unwrapExactMessageLink(text); + const messageLinkType = + view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME]; + if (messageHref && messageLinkType) { + const attrs = resolveComposerMessageLinkAttributes( + messageHref, + resolveChannelName, + ); + if (attrs) { + replaceSelectionWithNode(view, messageLinkType.create(attrs)); + event.preventDefault(); + return true; + } + } + + const httpHref = unwrapExactHttpLink(text); + const linkMark = view.state.schema.marks.link; + if (!httpHref || !linkMark) return false; + replaceSelectionWithNode( + view, + view.state.schema.text(httpHref, [linkMark.create({ href: httpHref })]), + ); + event.preventDefault(); + return true; + }; +} + +export function registerComposerMessageLinkMarkdownIt( + // biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here + md: any, + options: ComposerMessageLinkNodeOptions, +): void { + const ruleName = "buzz_composer_message_link"; + const tokenType = "buzz_composer_message_link"; + if (md.renderer.rules[tokenType]) return; + + // biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent + const rule = (state: any, silent: boolean): boolean => { + const remaining = state.src.slice(state.pos); + const fullMatch = BARE_MESSAGE_LINK_AT_START.exec(remaining); + const suffixMatch = /^:\/\/message\?[^\s<>"')\]}*_]+/i.exec(remaining); + const resumesTextToken = + !fullMatch && suffixMatch && /buzz$/i.test(state.pending ?? ""); + const rawHref = + fullMatch?.[0] ?? (resumesTextToken ? `buzz${suffixMatch[0]}` : null); + if (!rawHref) return false; + const href = trimBareMessageLink(rawHref); + const attrs = resolveComposerMessageLinkAttributes( + href, + options.resolveChannelName, + ); + if (!attrs) return false; + if (!silent) { + if (resumesTextToken) state.pending = state.pending.slice(0, -4); + const token = state.push(tokenType, "span", 0); + token.meta = attrs; + } + state.pos += href.length - (resumesTextToken ? 4 : 0); + return true; + }; + + md.inline.ruler.before("text", ruleName, rule); + // biome-ignore lint/suspicious/noExplicitAny: markdown-it token + md.renderer.rules[tokenType] = (tokens: any[], index: number): string => { + const attrs = tokens[index].meta as ComposerMessageLinkAttributes; + const escapeHtml = md.utils.escapeHtml; + return ``; + }; +} + +export const ComposerMessageLinkNode = + Node.create({ + name: COMPOSER_MESSAGE_LINK_NODE_NAME, + group: "inline", + inline: true, + atom: true, + selectable: true, + + addOptions() { + return { resolveChannelName: () => undefined }; + }, + + addAttributes() { + return { + channelName: { + default: "", + parseHTML: (element) => + (element as HTMLElement).getAttribute("data-channel-name") ?? "", + renderHTML: () => ({}), + }, + href: { + default: "", + parseHTML: (element) => + (element as HTMLElement).getAttribute("data-href") ?? "", + renderHTML: () => ({}), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-composer-message-link]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const href = String(node.attrs.href ?? ""); + const parsed = parseMessageLink(href); + const channelName = parsed.ok + ? (this.options.resolveChannelName(parsed.value.channelId) ?? + (String(node.attrs.channelName ?? "") || "channel")) + : "channel"; + const label = getMessageLinkLabel({ channelName }); + const channelLinkLabel = getMessageLinkChannelLabel(channelName); + return [ + "span", + mergeAttributes(HTMLAttributes, { + "aria-label": label, + class: + "inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline", + "data-channel-name": channelName, + "data-composer-message-link": "", + "data-href": href, + "data-message-link": "", + title: label, + }), + ["span", { class: "shrink-0" }, MESSAGE_LINK_PREFIX], + [ + "span", + { + class: `${MENTION_CHIP_BASE_CLASSES} min-w-0 max-w-full truncate`, + "data-channel-link": "", + }, + channelLinkLabel, + ], + ]; + }, + + renderText({ node }) { + return String(node.attrs.href ?? ""); + }, + + addStorage() { + return { + markdown: { + // biome-ignore lint/suspicious/noExplicitAny: prosemirror-markdown is untyped here + serialize(state: any, node: any) { + state.write(String(node.attrs.href ?? "")); + }, + parse: { + // biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here + setup(this: { options: ComposerMessageLinkNodeOptions }, md: any) { + registerComposerMessageLinkMarkdownIt(md, this.options); + }, + }, + }, + }; + }, + }); diff --git a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs new file mode 100644 index 00000000000..87ec604464b --- /dev/null +++ b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildEditMentionState, + buildMessageComposerEditTarget, + resolveEditMentionRefs, +} from "./draftMentionRefs.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const message = (body, tags) => ({ + author: "Alice", + body, + id: "message-id", + tags, +}); + +const profiles = { + [ALICE]: { displayName: "Alice" }, + [BOB]: { displayName: "Bob" }, +}; + +test("edit mention refs resolve from visible text and loaded profiles", () => { + assert.deepEqual( + resolveEditMentionRefs( + "Please review this, @Alice.", + [["p", ALICE]], + profiles, + () => false, + ), + [{ displayName: "Alice", isAgent: false, pubkey: ALICE }], + ); + + const target = buildMessageComposerEditTarget( + message("Please review this, @Alice.", [["p", ALICE]]), + profiles, + () => false, + ); + assert.deepEqual(target.unresolvedMentionPubkeys, []); +}); + +test("shared edit mention state preserves tagged identities while profiles are unavailable", () => { + assert.deepEqual( + buildEditMentionState( + "Please review this, @Alice and @Bob.", + [ + ["p", ALICE], + ["mention", BOB], + ], + undefined, + () => false, + ), + { mentionRefs: [], unresolvedMentionPubkeys: [ALICE, BOB] }, + ); +}); + +test("edit target preserves tagged identities while profiles are unavailable", () => { + const target = buildMessageComposerEditTarget( + message("Please review this, @Alice and @Bob.", [ + ["p", ALICE], + ["mention", BOB], + ]), + undefined, + () => false, + ); + + assert.deepEqual(target.mentionRefs, []); + assert.deepEqual(target.unresolvedMentionPubkeys, [ALICE, BOB]); +}); + +test("edit target separates resolved refs from identities missing profiles", () => { + const target = buildMessageComposerEditTarget( + message("Please review this, @Alice and @Bob.", [ + ["p", ALICE], + ["mention", BOB], + ]), + { [ALICE]: profiles[ALICE] }, + () => false, + ); + + assert.deepEqual(target.mentionRefs, [ + { displayName: "Alice", isAgent: false, pubkey: ALICE }, + ]); + assert.deepEqual(target.unresolvedMentionPubkeys, [BOB]); +}); diff --git a/desktop/src/features/messages/lib/draftMentionRefs.ts b/desktop/src/features/messages/lib/draftMentionRefs.ts index 861d3b8e956..65c7a68fec9 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.ts +++ b/desktop/src/features/messages/lib/draftMentionRefs.ts @@ -1,7 +1,104 @@ -import type { DraftMentionRef } from "./useDrafts"; - +import { hasMention } from "@/features/messages/lib/hasMention"; +import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { hasMention } from "./hasMention"; +import { + getMentionTagPubkey, + resolveMentionProps, +} from "@/shared/lib/resolveMentionNames"; + +export function resolveEditMentionRefs( + content: string, + tags: string[][] | undefined, + profiles: UserProfileLookup | undefined, + isAgentPubkey: (pubkey: string) => boolean, +): DraftMentionRef[] { + const { mentionNames, mentionPubkeysByName } = resolveMentionProps( + tags, + profiles, + ); + const refs = (mentionNames ?? []) + .filter((displayName) => hasMention(content, displayName)) + .flatMap((displayName) => { + const pubkey = mentionPubkeysByName?.[displayName.toLowerCase()]; + return pubkey + ? [ + { + displayName, + pubkey, + isAgent: isAgentPubkey(normalizePubkey(pubkey)), + }, + ] + : []; + }); + return refs; +} + +function unresolvedEditMentionPubkeys( + content: string, + tags: string[][] | undefined, + refs: readonly DraftMentionRef[], +): string[] { + if (!content.includes("@")) { + return []; + } + + const resolved = new Set(refs.map((ref) => normalizePubkey(ref.pubkey))); + return [ + ...new Set( + (tags ?? []) + .map(getMentionTagPubkey) + .filter((pubkey): pubkey is string => Boolean(pubkey)) + .map(normalizePubkey) + .filter((pubkey) => pubkey && !resolved.has(pubkey)), + ), + ]; +} + +export function buildEditMentionState( + content: string, + tags: string[][] | undefined, + profiles: UserProfileLookup | undefined, + isAgentPubkey: (pubkey: string) => boolean, +): Pick { + const mentionRefs = resolveEditMentionRefs( + content, + tags, + profiles, + isAgentPubkey, + ); + return { + mentionRefs, + unresolvedMentionPubkeys: unresolvedEditMentionPubkeys( + content, + tags, + mentionRefs, + ), + }; +} + +export function buildMessageComposerEditTarget( + message: TimelineMessage, + profiles: UserProfileLookup | undefined, + isAgentPubkey: (pubkey: string) => boolean, +): MessageComposerEditTarget { + const mentionState = buildEditMentionState( + message.body, + message.tags, + profiles, + isAgentPubkey, + ); + return { + author: message.author, + body: message.body, + id: message.id, + imetaMedia: imetaMediaFromTags(message.tags), + ...mentionState, + }; +} export function snapshotDraftMentionRefs( content: string, diff --git a/desktop/src/features/messages/lib/messageGrouping.test.mjs b/desktop/src/features/messages/lib/messageGrouping.test.mjs index 2b25df5b6d8..106792767f8 100644 --- a/desktop/src/features/messages/lib/messageGrouping.test.mjs +++ b/desktop/src/features/messages/lib/messageGrouping.test.mjs @@ -5,8 +5,20 @@ import { MESSAGE_GROUPING_WINDOW_SECONDS, hasSameMessageAuthor, isWithinGroupingWindow, + startsNewMessageGroup, } from "./messageGrouping.ts"; +test("startsNewMessageGroup: sent-from-thread messages start a fresh group", () => { + assert.equal( + startsNewMessageGroup({ + tags: [["buzz:sent-from-thread", "root-event", "Root summary"]], + }), + true, + ); + assert.equal(startsNewMessageGroup({ tags: [["h", "channel-id"]] }), false); + assert.equal(startsNewMessageGroup(undefined), false); +}); + test("hasSameMessageAuthor: matches case-insensitively and trims", () => { assert.equal( hasSameMessageAuthor({ pubkey: " ABC " }, { pubkey: "abc" }), diff --git a/desktop/src/features/messages/lib/messageGrouping.ts b/desktop/src/features/messages/lib/messageGrouping.ts index 8864f60098a..df8f125bb0e 100644 --- a/desktop/src/features/messages/lib/messageGrouping.ts +++ b/desktop/src/features/messages/lib/messageGrouping.ts @@ -1,7 +1,13 @@ +import { getSentFromThreadRootId } from "@/features/messages/lib/sentFromThread"; + type MessageAuthorCandidate = { pubkey?: string | null; }; +type MessageGroupingCandidate = { + tags?: readonly (readonly string[])[] | null; +}; + /** * Max gap (seconds) between two same-author messages for the later one to still * render as a continuation (time-only, no avatar). Beyond this the message @@ -11,6 +17,16 @@ type MessageAuthorCandidate = { */ export const MESSAGE_GROUPING_WINDOW_SECONDS = 10 * 60; +/** + * Shared thread messages introduce context from another conversation, so they + * always start a fresh visual message group even beside the same author. + */ +export function startsNewMessageGroup( + message: MessageGroupingCandidate | null | undefined, +) { + return getSentFromThreadRootId(message?.tags) !== null; +} + export function hasSameMessageAuthor( previous: MessageAuthorCandidate | null | undefined, current: MessageAuthorCandidate | null | undefined, diff --git a/desktop/src/features/messages/lib/messageLinkLabel.test.mjs b/desktop/src/features/messages/lib/messageLinkLabel.test.mjs new file mode 100644 index 00000000000..f9c075e6804 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkLabel.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getMessageLinkChannelLabel, + getMessageLinkLabel, + MESSAGE_LINK_PREFIX, +} from "./messageLinkLabel.ts"; + +test("ordinary message links expose an Inbox-style prefix and channel label", () => { + assert.equal(MESSAGE_LINK_PREFIX, "Thread in"); + assert.equal(getMessageLinkChannelLabel("general"), "#general"); +}); + +test("ordinary message links name their target thread", () => { + assert.equal( + getMessageLinkLabel({ channelName: "general" }), + "Thread in #general", + ); +}); + +test("ordinary message links include a provided root excerpt", () => { + assert.equal( + getMessageLinkLabel({ + channelName: "general", + threadExcerpt: "Release notes", + }), + "Thread in #general — Release notes", + ); +}); + +test("sent-from-thread links use the excerpt as their visible link", () => { + assert.equal( + getMessageLinkLabel({ + channelName: "general", + threadExcerpt: "Release notes", + variant: "sent-from-thread", + }), + "Release notes", + ); +}); diff --git a/desktop/src/features/messages/lib/messageLinkLabel.ts b/desktop/src/features/messages/lib/messageLinkLabel.ts new file mode 100644 index 00000000000..249988e3d28 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLinkLabel.ts @@ -0,0 +1,24 @@ +export type MessageLinkLabelVariant = "default" | "sent-from-thread"; + +export const MESSAGE_LINK_PREFIX = "Thread in"; + +export function getMessageLinkChannelLabel(channelName: string): string { + return `#${channelName}`; +} + +export function getMessageLinkLabel({ + channelName, + threadExcerpt, + variant = "default", +}: { + channelName: string; + threadExcerpt?: string | null; + variant?: MessageLinkLabelVariant; +}): string { + const normalizedExcerpt = threadExcerpt?.trim(); + const baseLabel = `${MESSAGE_LINK_PREFIX} ${getMessageLinkChannelLabel(channelName)}`; + if (variant === "sent-from-thread") { + return normalizedExcerpt ?? baseLabel; + } + return normalizedExcerpt ? `${baseLabel} — ${normalizedExcerpt}` : baseLabel; +} diff --git a/desktop/src/features/messages/lib/plainTextProjection.test.mjs b/desktop/src/features/messages/lib/plainTextProjection.test.mjs index f3ecb18ce3b..f914cd85430 100644 --- a/desktop/src/features/messages/lib/plainTextProjection.test.mjs +++ b/desktop/src/features/messages/lib/plainTextProjection.test.mjs @@ -277,6 +277,7 @@ test("round-trip: text offset → PM → text offset is identity", () => { // mismatch so cursor math and autocomplete offsets stay correct. import { CustomEmojiNode } from "./customEmojiNode.ts"; +import { ComposerMessageLinkNode } from "./composerMessageLinkNode.ts"; const schemaWithEmoji = getSchema([ StarterKit.configure({ @@ -286,6 +287,9 @@ const schemaWithEmoji = getSchema([ link: false, }), CustomEmojiNode, + ComposerMessageLinkNode.configure({ + resolveChannelName: () => "general", + }), ]); const eDoc = (...content) => schemaWithEmoji.nodes.doc.create(null, content); @@ -293,6 +297,11 @@ const ePara = (...c) => schemaWithEmoji.nodes.paragraph.create(null, c); const eText = (s) => schemaWithEmoji.text(s); const emoji = (shortcode) => schemaWithEmoji.nodes.customEmoji.create({ shortcode, src: "" }); +const messageLink = (href) => + schemaWithEmoji.nodes.composerMessageLink.create({ + channelName: "general", + href, + }); test("atom: projects to its full :shortcode: text", () => { const d = eDoc(ePara(eText("hi "), emoji("wave"), eText(" there"))); @@ -359,3 +368,10 @@ test("atom: caret offsets around an atom round-trip", () => { assert.equal(back, offset, `caret offset ${offset} → pm ${pm} → ${back}`); } }); + +test("message-link atom projects to its full underlying deep link", () => { + const href = "buzz://message?channel=general-id&id=root-id"; + const d = eDoc(ePara(eText("See "), messageLink(href), eText(" now"))); + const p = buildPlainTextProjection(d); + assert.equal(p.text, `See ${href} now`); +}); diff --git a/desktop/src/features/messages/lib/plainTextProjection.ts b/desktop/src/features/messages/lib/plainTextProjection.ts index bbafacffe0c..2a670cdcc92 100644 --- a/desktop/src/features/messages/lib/plainTextProjection.ts +++ b/desktop/src/features/messages/lib/plainTextProjection.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; +import { COMPOSER_MESSAGE_LINK_NODE_NAME } from "./composerMessageLinkNode"; /** * Plain-text projection of a ProseMirror document. @@ -174,9 +175,14 @@ export function buildPlainTextProjection( // 1 PM position wide, projects to its full `:shortcode:` text. Keeps // the two mappings consistent with what `renderText` emits, so cursor // math and autocomplete offsets see the shortcode at its natural width. - if (node.type.name === CUSTOM_EMOJI_NODE_NAME) { - const shortcode = String(node.attrs.shortcode ?? ""); - const projected = `:${shortcode}:`; + if ( + node.type.name === CUSTOM_EMOJI_NODE_NAME || + node.type.name === COMPOSER_MESSAGE_LINK_NODE_NAME + ) { + const projected = + node.type.name === CUSTOM_EMOJI_NODE_NAME + ? `:${String(node.attrs.shortcode ?? "")}:` + : String(node.attrs.href ?? ""); segments.push({ kind: "atom", pmFrom: pos, diff --git a/desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs b/desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs new file mode 100644 index 00000000000..73dd101082a --- /dev/null +++ b/desktop/src/features/messages/lib/sendToChannelSemantics.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getSendToChannelSemantics } from "./sendToChannelSemantics.ts"; + +const SOURCE = "a".repeat(64); +const SIGNER = "b".repeat(64); +const MENTION = "c".repeat(64); + +test("send-to-channel preserves supported message semantics", () => { + const imeta = ["imeta", "url https://relay.example/media/file.png"]; + const emoji = ["emoji", "party", "https://relay.example/party.png"]; + const mention = ["mention", MENTION]; + const preview = ["link-preview", "none"]; + + assert.deepEqual( + getSendToChannelSemantics({ + pubkey: SOURCE, + signerPubkey: SIGNER, + tags: [ + ["h", "channel-id"], + ["e", "thread-root", "", "reply"], + ["p", SOURCE], + ["p", SIGNER.toUpperCase()], + ["p", MENTION.toUpperCase()], + ["p", MENTION], + ["p", "not-a-pubkey"], + imeta, + emoji, + mention, + preview, + ["client", "source-only-marker"], + ], + }), + { + mentionPubkeys: [MENTION], + semanticTags: [imeta, emoji, mention, preview], + }, + ); +}); + +test("edited messages recompute effective mention recipients from the body", () => { + const ADDED = "d".repeat(64); + const profiles = { + [MENTION]: { displayName: "Alice" }, + [ADDED]: { displayName: "Bob" }, + }; + + assert.deepEqual( + getSendToChannelSemantics( + { + body: "Now pinging @Bob", + edited: true, + pubkey: SOURCE, + tags: [ + ["p", MENTION], + ["p", ADDED], + ], + }, + profiles, + ), + { mentionPubkeys: [ADDED], semanticTags: [] }, + ); +}); + +test("edited messages preserve snapshotted mention recipients without profiles", () => { + assert.deepEqual( + getSendToChannelSemantics({ + body: "Now pinging @Renamed", + edited: true, + pubkey: SOURCE, + tags: [["p", MENTION], ["mention", MENTION], ["buzz:mention-snapshot"]], + }), + { + mentionPubkeys: [MENTION], + semanticTags: [["mention", MENTION]], + }, + ); +}); + +test("an empty edited mention snapshot drops stale original recipients", () => { + assert.deepEqual( + getSendToChannelSemantics({ + body: "No longer pinging anyone", + edited: true, + pubkey: SOURCE, + tags: [["p", MENTION], ["buzz:mention-snapshot"]], + }), + { mentionPubkeys: [], semanticTags: [] }, + ); +}); + +test("send-to-channel canonicalizes suppressed link previews", () => { + const snapshot = ["link-preview", "https://example.com", "snapshot"]; + const suppression = ["link-preview", "none"]; + + assert.deepEqual( + getSendToChannelSemantics({ + pubkey: SOURCE, + tags: [snapshot, suppression], + }), + { mentionPubkeys: [], semanticTags: [suppression] }, + ); +}); + +test("send-to-channel handles messages without semantic tags", () => { + assert.deepEqual( + getSendToChannelSemantics({ pubkey: SOURCE, tags: undefined }), + { mentionPubkeys: [], semanticTags: [] }, + ); +}); diff --git a/desktop/src/features/messages/lib/sendToChannelSemantics.ts b/desktop/src/features/messages/lib/sendToChannelSemantics.ts new file mode 100644 index 00000000000..f8cc79cac77 --- /dev/null +++ b/desktop/src/features/messages/lib/sendToChannelSemantics.ts @@ -0,0 +1,83 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; + +const PUBKEY_PATTERN = /^[0-9a-f]{64}$/; +const SHAREABLE_TAG_KINDS = new Set([ + "emoji", + "imeta", + "link-preview", + "mention", +]); + +export type SendToChannelSemantics = { + mentionPubkeys: string[]; + semanticTags: string[][]; +}; + +/** + * Preserve the source message metadata that gives its body meaning without + * copying structural channel/thread tags or the source event's self `p` tag. + */ +export function getSendToChannelSemantics( + message: TimelineMessage, + profiles?: UserProfileLookup, +): SendToChannelSemantics { + const sourceAuthors = new Set( + [message.pubkey, message.signerPubkey] + .filter((pubkey): pubkey is string => Boolean(pubkey)) + .map(normalizePubkey), + ); + const seenMentions = new Set(); + const mentionPubkeys: string[] = []; + const effectiveMentionPubkeys = message.edited + ? new Set( + (message.tags ?? []).some((tag) => tag[0] === "buzz:mention-snapshot") + ? (message.tags ?? []) + .filter((tag) => tag[0] === "mention") + .map((tag) => normalizePubkey(tag[1] ?? "")) + .filter((pubkey) => PUBKEY_PATTERN.test(pubkey)) + : orderMentionPubkeysByText( + message.body, + resolveMentionProps(message.tags, profiles).mentionPubkeysByName, + () => true, + ), + ) + : null; + const semanticTags: string[][] = []; + const hasPreviewSuppression = message.tags?.some( + (tag) => tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none", + ); + + for (const tag of message.tags ?? []) { + if (tag[0] === "p") { + const pubkey = normalizePubkey(tag[1] ?? ""); + if ( + PUBKEY_PATTERN.test(pubkey) && + !sourceAuthors.has(pubkey) && + (effectiveMentionPubkeys === null || + effectiveMentionPubkeys.has(pubkey)) && + !seenMentions.has(pubkey) + ) { + seenMentions.add(pubkey); + mentionPubkeys.push(pubkey); + } + continue; + } + + if (SHAREABLE_TAG_KINDS.has(tag[0] ?? "")) { + if ( + tag[0] === "link-preview" && + hasPreviewSuppression && + !(tag.length === 2 && tag[1] === "none") + ) { + continue; + } + semanticTags.push([...tag]); + } + } + + return { mentionPubkeys, semanticTags }; +} diff --git a/desktop/src/features/messages/lib/sentFromThread.test.mjs b/desktop/src/features/messages/lib/sentFromThread.test.mjs new file mode 100644 index 00000000000..36d8786c3b0 --- /dev/null +++ b/desktop/src/features/messages/lib/sentFromThread.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildSentFromThreadTag, + getSentFromThreadReference, + getSentFromThreadRootId, + SENT_FROM_THREAD_TAG, + summarizeThreadRoot, +} from "./sentFromThread.ts"; + +test("buildSentFromThreadTag records the normalized root event ID", () => { + assert.deepEqual(buildSentFromThreadTag(" root-event "), [ + SENT_FROM_THREAD_TAG, + "root-event", + ]); +}); + +test("buildSentFromThreadTag includes a normalized human excerpt", () => { + assert.deepEqual(buildSentFromThreadTag("root-event", " Launch plan "), [ + SENT_FROM_THREAD_TAG, + "root-event", + "Launch plan", + ]); +}); + +test("buildSentFromThreadTag rejects an empty root event ID", () => { + assert.throws( + () => buildSentFromThreadTag(" "), + /thread root event ID is required/, + ); +}); + +test("sent-from-thread references accept an optional excerpt", () => { + assert.equal( + getSentFromThreadRootId([ + ["h", "channel-id"], + [SENT_FROM_THREAD_TAG, "root-event"], + ]), + "root-event", + ); + assert.deepEqual( + getSentFromThreadReference([ + [SENT_FROM_THREAD_TAG, "root-event", "Root summary"], + ]), + { rootEventId: "root-event", rootExcerpt: "Root summary" }, + ); + assert.equal( + getSentFromThreadRootId([ + [SENT_FROM_THREAD_TAG, "root-event", "summary", "extra"], + ]), + null, + ); + assert.equal(getSentFromThreadRootId([[SENT_FROM_THREAD_TAG, " "]]), null); + assert.equal(getSentFromThreadRootId(undefined), null); +}); + +test("summarizeThreadRoot keeps concise text and ignores media-only roots", () => { + assert.equal(summarizeThreadRoot(" **Launch** plan "), "Launch plan"); + assert.equal( + summarizeThreadRoot("![diagram](https://example.com/diagram.png)"), + null, + ); + assert.equal( + summarizeThreadRoot("See [the plan](https://example.com/plan) for details"), + "See the plan for details", + ); + assert.match(summarizeThreadRoot("word ".repeat(30)) ?? "", /…$/); +}); + +test("summarizeThreadRoot preserves Unicode boundaries, strips controls, and redacts spoilers", () => { + const summary = summarizeThreadRoot(`${"a".repeat(62)}😀 more`); + assert.equal(summary, `${"a".repeat(62)}😀…`); + assert.equal( + summarizeThreadRoot("Public\u0000\u001f\u007f\u0085 update"), + "Public update", + ); + assert.equal( + summarizeThreadRoot("Public ||confidential details|| update"), + "Public update", + ); +}); diff --git a/desktop/src/features/messages/lib/sentFromThread.ts b/desktop/src/features/messages/lib/sentFromThread.ts new file mode 100644 index 00000000000..90076c04c7e --- /dev/null +++ b/desktop/src/features/messages/lib/sentFromThread.ts @@ -0,0 +1,70 @@ +export const SENT_FROM_THREAD_TAG = "buzz:sent-from-thread"; +const THREAD_ROOT_EXCERPT_MAX_LENGTH = 64; + +export type SentFromThreadReference = { + rootEventId: string; + rootExcerpt: string | null; +}; + +export function summarizeThreadRoot(content: string): string | null { + const withoutControls = Array.from(content, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) + ? " " + : character; + }).join(""); + const normalized = withoutControls + .replace(/\|\|[^|]*(?:\|(?!\|)[^|]*)*\|\|/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/?/g, " ") + .replace(/[`*_~>#|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const characters = Array.from(normalized); + if (!normalized) return null; + if (characters.length <= THREAD_ROOT_EXCERPT_MAX_LENGTH) return normalized; + const clipped = characters + .slice(0, THREAD_ROOT_EXCERPT_MAX_LENGTH - 1) + .join(""); + const lastSpace = clipped.lastIndexOf(" "); + const excerpt = lastSpace > 32 ? clipped.slice(0, lastSpace) : clipped; + return `${excerpt.trimEnd()}…`; +} + +export function buildSentFromThreadTag( + rootEventId: string, + rootExcerpt?: string | null, +): string[] { + const normalizedRootEventId = rootEventId.trim(); + if (!normalizedRootEventId) { + throw new Error("A thread root event ID is required."); + } + + const normalizedExcerpt = rootExcerpt?.trim(); + return normalizedExcerpt + ? [SENT_FROM_THREAD_TAG, normalizedRootEventId, normalizedExcerpt] + : [SENT_FROM_THREAD_TAG, normalizedRootEventId]; +} + +export function getSentFromThreadReference( + tags: readonly (readonly string[])[] | null | undefined, +): SentFromThreadReference | null { + const tag = tags?.find( + (candidate) => + (candidate.length === 2 || candidate.length === 3) && + candidate[0] === SENT_FROM_THREAD_TAG, + ); + const rootEventId = tag?.[1]?.trim(); + if (!rootEventId) return null; + return { + rootEventId, + rootExcerpt: tag?.[2]?.trim() || null, + }; +} + +export function getSentFromThreadRootId( + tags: readonly (readonly string[])[] | null | undefined, +): string | null { + return getSentFromThreadReference(tags)?.rootEventId ?? null; +} diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 2b87b5ebc39..f7b91b6db0c 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -305,6 +305,36 @@ test("buildTimelineItems: pending messages remain standalone until acknowledged" ); }); +test("buildTimelineItems: sent-from-thread messages start a fresh author group", () => { + const entries = [ + entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), + entry({ + id: "b", + pubkey: "author-a", + createdAt: dayAt(2026, 6, 14, 12, 2), + tags: [["buzz:sent-from-thread", "root-event", "Root summary"]], + }), + entry({ + id: "c", + pubkey: "author-a", + createdAt: dayAt(2026, 6, 14, 12, 3), + }), + ]; + + const messageItems = buildTimelineItems(entries, null).items.filter( + (item) => item.kind === "message", + ); + + assert.deepEqual( + messageItems.map((item) => item.isContinuation), + [false, false, true], + ); + assert.deepEqual( + messageItems.map((item) => item.isFollowedByContinuation), + [false, true, false], + ); +}); + test("buildTimelineItems: same-author messages past the window start a new group", () => { const author = "author-a"; const entries = [ diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index c2838710258..db5753bcf20 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -15,6 +15,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { hasSameMessageAuthor, isWithinGroupingWindow, + startsNewMessageGroup, } from "@/features/messages/lib/messageGrouping"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -250,6 +251,7 @@ export function buildTimelineItems( // that same standalone state until the send acknowledgement arrives. const isContinuation = !message.pending && + !startsNewMessageGroup(message) && previousGroupEntry !== null && !previousGroupEntry.message.pending && hasSameMessageAuthor(previousGroupEntry.message, message) && diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 47d07017648..5ffc1b8c2ef 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -184,6 +184,7 @@ export function useChannelLinks() { ); return { + channels, channelQuery, channelSelectedIndex, channelSuggestions, diff --git a/desktop/src/features/messages/lib/useComposerMessageLinks.ts b/desktop/src/features/messages/lib/useComposerMessageLinks.ts new file mode 100644 index 00000000000..60dd3ec677c --- /dev/null +++ b/desktop/src/features/messages/lib/useComposerMessageLinks.ts @@ -0,0 +1,59 @@ +import type { Editor } from "@tiptap/react"; +import * as React from "react"; + +import { + COMPOSER_MESSAGE_LINK_NODE_NAME, + ComposerMessageLinkNode, +} from "./composerMessageLinkNode"; +import { parseMessageLink } from "./messageLink"; + +export type ComposerMessageLinkChannel = { id: string; name: string }; + +export function useComposerMessageLinks( + channels: readonly ComposerMessageLinkChannel[] | undefined, +) { + const channelsRef = React.useRef(channels ?? []); + channelsRef.current = channels ?? []; + + const resolveChannelName = React.useCallback( + (channelId: string) => + channelsRef.current.find((channel) => channel.id === channelId)?.name, + [], + ); + + const extension = React.useMemo( + () => ComposerMessageLinkNode.configure({ resolveChannelName }), + [resolveChannelName], + ); + + const syncChannelNames = React.useCallback( + (editor: Editor) => { + const channelNamesById = new Map( + (channels ?? []).map((channel) => [channel.id, channel.name]), + ); + let transaction = editor.state.tr; + let changed = false; + editor.state.doc.descendants((node, position) => { + if (node.type.name !== COMPOSER_MESSAGE_LINK_NODE_NAME) return; + const parsed = parseMessageLink(String(node.attrs.href ?? "")); + const nextName = parsed.ok + ? (channelNamesById.get(parsed.value.channelId) ?? "") + : ""; + if (nextName !== node.attrs.channelName) { + transaction = transaction.setNodeAttribute( + position, + "channelName", + nextName, + ); + changed = true; + } + }); + if (changed) { + editor.view.dispatch(transaction.setMeta("addToHistory", false)); + } + }, + [channels], + ); + + return { extension, resolveChannelName, syncChannelNames }; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 054b8778bba..e800787ca7a 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -38,6 +38,9 @@ import { insertNewlineInCodeBlock, } from "./codeBlockExtensions"; import { SpoilerMark } from "./spoilerMark"; +import { createComposerLinkPasteHandler } from "./composerMessageLinkNode"; +import type { ComposerMessageLinkChannel } from "./useComposerMessageLinks"; +import { useComposerMessageLinks } from "./useComposerMessageLinks"; function hardBreakLineBounds($from: ResolvedPos) { const parentStart = $from.start(); @@ -83,6 +86,7 @@ export type RichTextEditorOptions = { mentionNames?: string[]; agentMentionNames?: string[]; channelNames?: string[]; + messageLinkChannels?: readonly ComposerMessageLinkChannel[]; /** Known custom-emoji set; used to render `:shortcode:` inline as images. */ customEmoji?: CustomEmoji[]; /** Called on plain Enter (submit). Handled inside Tiptap's extension system @@ -137,11 +141,6 @@ function shouldAppendSpaceAfterPaste(text: string): boolean { return PASTED_LINK_AT_END_RE.test(trimmedEnd); } -function unwrapExactHttpLink(text: string): string | null { - const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); - return match?.[1] ?? match?.[2] ?? null; -} - const LinkPasteTrailingSpace = Extension.create({ name: "linkPasteTrailingSpace", @@ -206,6 +205,7 @@ export function useRichTextEditor({ mentionNames, agentMentionNames, channelNames, + messageLinkChannels, customEmoji, onSubmit, onEditLastOwnMessage, @@ -238,6 +238,7 @@ export function useRichTextEditor({ // Custom-emoji atom node wiring (config + src re-resolve). Kept in a sibling // hook so this file stays focused on generic editor setup. const customEmojiWiring = useComposerCustomEmoji(customEmoji); + const messageLinkWiring = useComposerMessageLinks(messageLinkChannels); const editor = useEditor( { @@ -462,6 +463,7 @@ export function useRichTextEditor({ SpoilerMark, MentionHighlightExtension, customEmojiWiring.extension, + messageLinkWiring.extension, Placeholder.configure({ placeholder: () => placeholderRef.current ?? "Write a message…", }), @@ -495,34 +497,15 @@ export function useRichTextEditor({ ], editorProps: { handleDOMEvents: { - paste: (view, event) => { - const clipboard = (event as ClipboardEvent).clipboardData; - if ( - parseSnapshotClipboardHtml(clipboard?.getData("text/html") ?? "") + paste: (view, event) => + parseSnapshotClipboardHtml( + (event as ClipboardEvent).clipboardData?.getData("text/html") ?? + "", ) - return false; - const url = unwrapExactHttpLink( - clipboard?.getData("text/plain") ?? "", - ); - if (!url) return false; - const link = view.state.schema.marks.link; - if (!link) return false; - const { from, to } = view.state.selection; - let transaction = view.state.tr.replaceRangeWith( - from, - to, - view.state.schema.text(url, [link.create({ href: url })]), - ); - const end = transaction.mapping.map(to); - transaction = transaction.insertText(" ", end); - transaction = transaction.removeMark(end, end + 1, link); - transaction = transaction.setSelection( - TextSelection.create(transaction.doc, end + 1), - ); - view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); - event.preventDefault(); - return true; - }, + ? false + : createComposerLinkPasteHandler( + messageLinkWiring.resolveChannelName, + )(view, event as ClipboardEvent), }, attributes: { autocapitalize: "none", @@ -732,6 +715,11 @@ export function useRichTextEditor({ customEmojiWiring.syncEmojiSrc(editor); }, [editor, customEmojiWiring.syncEmojiSrc]); + React.useEffect(() => { + if (!editor) return; + messageLinkWiring.syncChannelNames(editor); + }, [editor, messageLinkWiring.syncChannelNames]); + const getMarkdown = React.useCallback((): string => { if (!editor) return ""; return getMarkdownFromEditor(editor); diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 967e50f5d2f..11163aa8c7a 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -14,6 +14,7 @@ import { Trash2, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; @@ -36,6 +37,7 @@ import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { Button } from "@/shared/ui/button"; +import { HashArrowIn } from "@/shared/ui/icons"; import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { DropdownMenu, @@ -61,6 +63,7 @@ function MoreActionsMenu({ onMarkRead, onOpenChange, onRemindLater, + onSendToChannel, onUnfollowThread, open, isFollowingThread, @@ -77,6 +80,7 @@ function MoreActionsMenu({ onMarkRead?: (message: TimelineMessage) => void; onOpenChange: (open: boolean) => void; onRemindLater?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; open: boolean; isFollowingThread?: boolean; @@ -213,6 +217,31 @@ function MoreActionsMenu({ ) : null} + {onSendToChannel ? ( + { + void onSendToChannel(message) + .then(() => toast.success("Sent to channel")) + .catch((error) => { + console.error( + "Failed to send thread message to channel", + error, + ); + toast.error("Couldn't send to channel"); + }); + }} + > + + ) : null} + {hasCopyActions && channelId ? ( Promise; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; reactions: TimelineReaction[]; @@ -398,6 +429,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ Boolean(onFollowThread) || Boolean(onUnfollowThread) || Boolean(onRemindLater) || + Boolean(onSendToChannel) || !message.pending; const wouldAddReaction = React.useCallback( @@ -545,6 +577,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ onMarkRead={onMarkRead} onOpenChange={setIsDropdownOpen} onRemindLater={onRemindLater} + onSendToChannel={onSendToChannel} onUnfollowThread={onUnfollowThread} open={isDropdownOpen} isFollowingThread={isFollowingThread} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index aec94c6f37a..9d61e9c3657 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -164,7 +164,6 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - // Restore/persist drafts at a key boundary; the hook handles StrictMode. useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -254,6 +253,7 @@ function MessageComposerImpl({ mentionNames: mentions.knownNames, agentMentionNames: mentions.agentKnownNames, channelNames: channelLinks.knownChannelNames, + messageLinkChannels: channelLinks.channels, customEmoji, onSubmit: () => submitMessageRef.current(), onEditLastOwnMessage: () => { @@ -360,9 +360,9 @@ function MessageComposerImpl({ const editableBody = stripImetaMediaLines(editTarget.body, editableImeta); setComposerContent(editableBody); richText.setContent(editableBody); - // Seed the composer's pending-imeta state with the original event's - // attachments so they show up in `ComposerAttachments` and the user - // can remove existing ones / add new ones before saving. + // Seed pending imeta with removable originals before saving the edit. + // New attachments can then be added through the same row. + mentions.restoreDraftMentionRefs(editTarget.mentionRefs ?? []); media.setPendingImeta(editableImeta); media.clearQueuedAttachments(); setSpoileredAttachmentUrls( @@ -485,7 +485,6 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - // ── @ mention picker (toolbar button) ─────────────────────────────── const openMentionPicker = React.useCallback(() => { if (!richText.editor) return; const { text, cursor } = richText.getPlainTextAndCursor(); @@ -526,6 +525,7 @@ function MessageComposerImpl({ customEmoji, originalContent: editTargetRef.current.body, ownerPubkey: ownerPubkeyRef.current, + editTarget: editTargetRef.current, getMentionRefs: mentions.getDraftMentionRefs, pendingImeta: media.pendingImetaRef.current, queuedAttachments: media.queuedAttachmentsRef.current, diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index 22e9b75b0a3..ddf987d0f33 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -1,10 +1,27 @@ import type { ReactNode } from "react"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +export type MessageComposerEditTarget = { + author: string; + body: string; + id: string; + /** + * NIP-92 imeta attachments on the original event, in tag order. Loaded + * into the composer's pending-imeta state on edit-open so the user sees + * them as removable thumbnails (just like the send path) and can add + * more. The submit path emits a fresh full imeta tag set on the edit + * event; the receiver overlays it. + */ + imetaMedia?: ImetaMedia[]; + mentionRefs?: DraftMentionRef[]; + unresolvedMentionPubkeys?: string[]; +}; + export type MessageComposerProps = { audienceContext?: { type: "thread"; @@ -36,19 +53,7 @@ export type MessageComposerProps = { autoSubmitDraftKey?: string | null; /** Called when the auto-submit fires so the parent can clear the trigger. */ onAutoSubmitComplete?: () => void; - editTarget?: { - author: string; - body: string; - id: string; - /** - * NIP-92 imeta attachments on the original event, in tag order. Loaded - * into the composer's pending-imeta state on edit-open so the user sees - * them as removable thumbnails (just like the send path) and can add - * more. The submit path emits a fresh full imeta tag set on the edit - * event; the receiver overlays it. - */ - imetaMedia?: ImetaMedia[]; - } | null; + editTarget?: MessageComposerEditTarget | null; isSending?: boolean; mediaController?: MediaUploadController; onDeferredEditPendingChange?: (isPending: boolean) => void; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 51d9832c128..f6be01d71be 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -7,6 +7,10 @@ import { reactionsEqual, tagsEqual, } from "@/features/messages/lib/messageRowEquality"; +import { + assertCanSendMessageToChannel, + canSendMessageToChannel, +} from "@/features/messages/lib/canSendToChannel"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; @@ -50,6 +54,7 @@ import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -66,6 +71,7 @@ export type ThreadDepthGuideAction = { export const MessageRow = React.memo( function MessageRow({ channelId = null, + currentPubkey, collapseDepthGuideActions, connectDescendants = false, depthGuideDepths, @@ -95,6 +101,7 @@ export const MessageRow = React.memo( onMarkRead, onToggleReaction, onReply, + onSendToChannel, onEntranceComplete, playEntrance = false, onUnfollowThread, @@ -105,6 +112,7 @@ export const MessageRow = React.memo( videoReviewContext, }: { channelId?: string | null; + currentPubkey?: string; collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; depthGuideDepths?: ReadonlyArray; @@ -144,6 +152,7 @@ export const MessageRow = React.memo( remove: boolean, ) => Promise; onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; onEntranceComplete?: (messageId: string) => void; playEntrance?: boolean; @@ -173,6 +182,7 @@ export const MessageRow = React.memo( tags.filter((tag) => tag[0] === "emoji"), undefined, true, + tags.filter((tag) => tag[0] === "mention"), ); } catch (error) { toast.error( @@ -216,6 +226,18 @@ export const MessageRow = React.memo( }, [channelId, openReminder], ); + const sendToChannelAllowed = canSendMessageToChannel( + message, + currentPubkey, + profiles, + ); + const handleSendToChannel = React.useCallback( + async (target: TimelineMessage) => { + assertCanSendMessageToChannel(target, currentPubkey, profiles); + await onSendToChannel?.(target); + }, + [currentPubkey, onSendToChannel, profiles], + ); const { mentionNames, mentionPubkeysByName } = React.useMemo( () => resolveMentionProps(message.tags, profiles), [profiles, message.tags], @@ -569,6 +591,11 @@ export const MessageRow = React.memo( } onRemindLater={handleRemindLater} onReply={onReply} + onSendToChannel={ + onSendToChannel && sendToChannelAllowed + ? handleSendToChannel + : undefined + } onUnfollowThread={onUnfollowThread} reactionErrorMessage={reactionErrorMessage} reactions={reactions} @@ -646,6 +673,7 @@ export const MessageRow = React.memo( const messageBodyNode = ( <> + {renderBody()} {continuationMetadataNode} void; onCancelReply: () => void; @@ -98,6 +94,11 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadHeadId: string | null; } | null, ) => Promise; + onSendToChannel?: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -219,6 +220,7 @@ export function MessageThreadPanel({ onScrollTargetSettled, onSelectReplyTarget, onSend, + onSendToChannel, onToggleReaction, onUnfollowThread, profiles, @@ -522,7 +524,6 @@ export function MessageThreadPanel({ "padding", settleAtBottomAfterLayout, ); - const knownAgentPubkeys = useKnownAgentPubkeys(); const initialAgentPubkeys = React.useMemo(() => { if ( @@ -546,11 +547,14 @@ export function MessageThreadPanel({ knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true, ); }, [currentPubkey, knownAgentPubkeys, profiles, threadHead]); - + const stableSendToChannel = useStableSendToChannel( + channelId, + threadHead, + onSendToChannel, + ); if (!threadHead) { return null; } - const threadScrollRegion = ( : null} { + void goChannel(target.channelId, { + messageId: target.messageId, + threadRootId: target.threadRootId, + }); + }, + [goChannel], + ); + + if (!channelId || !reference) return null; + const link: ParsedMessageLink = { + channelId, + messageId: reference.rootEventId, + threadRootId: reference.rootEventId, + }; + + return ( +
+ Sent from thread: + +
+ ); +} diff --git a/desktop/src/features/messages/ui/submitMessageEdit.test.mjs b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs new file mode 100644 index 00000000000..126dfd13fcd --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { submitMessageEdit } from "./submitMessageEdit.ts"; + +const UNRESOLVED_USER = "b".repeat(64); + +function baseOptions( + save, + { + content = "hello @Missing User", + editTarget = { + mentionRefs: [], + unresolvedMentionPubkeys: [UNRESOLVED_USER], + }, + } = {}, +) { + return { + clearComposer: () => {}, + content, + customEmoji: [], + editTarget, + editTargetId: "event-id", + extractMentionPubkeys: () => [], + getMentionRefs: () => [], + originalContent: content, + ownerPubkey: "a".repeat(64), + pendingImeta: [], + queuedAttachments: [], + restoreComposer: () => {}, + restoreMentionRefs: () => {}, + setDeferredUploadPending: () => {}, + setUploadError: () => {}, + shouldRestoreComposer: () => true, + spoileredAttachmentUrls: new Set(), + save, + }; +} + +test("edit save emits unresolved identities as non-notifying mention references", async () => { + let saved; + await submitMessageEdit( + baseOptions(async (content, tags, mentionPubkeys, eventId) => { + saved = { content, tags, mentionPubkeys, eventId }; + }), + ); + + assert.deepEqual(saved, { + content: "hello @Missing User", + tags: [["mention", UNRESOLVED_USER]], + mentionPubkeys: [], + eventId: "event-id", + }); +}); + +test("edit save uses edit-target refs that resolve after edit-open", async () => { + let saved; + const resolvedRef = { + displayName: "Missing User", + isAgent: false, + pubkey: UNRESOLVED_USER, + }; + await submitMessageEdit( + baseOptions( + async (content, tags, mentionPubkeys, eventId) => { + saved = { content, tags, mentionPubkeys, eventId }; + }, + { + editTarget: { + mentionRefs: [resolvedRef], + unresolvedMentionPubkeys: [], + }, + }, + ), + ); + + assert.deepEqual(saved, { + content: "hello @Missing User", + tags: [["mention", UNRESOLVED_USER]], + mentionPubkeys: [], + eventId: "event-id", + }); +}); diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts index 8edeea615e5..06c0de19287 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -1,12 +1,15 @@ import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { hasMention } from "@/features/messages/lib/hasMention"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; import { buildOutgoingMessage, type ImetaMedia, mergeOutgoingTags, } from "@/features/messages/lib/imetaMediaMarkdown"; import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { mergeOutgoingTagsWithReferenceMentions } from "@/features/messages/ui/useMentionSendFlow.helpers"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -16,14 +19,22 @@ type EditDraft = { pendingImeta: ImetaMedia[]; queuedAttachments: QueuedMediaAttachment[]; spoileredAttachmentUrls: Set; + unresolvedMentionPubkeys: string[]; }; -type SubmitMessageEditOptions = Omit & { +type SubmitMessageEditOptions = Omit< + EditDraft, + "mentionRefs" | "unresolvedMentionPubkeys" +> & { clearComposer: () => void; customEmoji: ReadonlyArray; extractMentionPubkeys: (content: string) => string[]; getMentionRefs: (content: string) => DraftMentionRef[]; editTargetId: string; + editTarget: Pick< + MessageComposerEditTarget, + "mentionRefs" | "unresolvedMentionPubkeys" + >; originalContent: string; ownerPubkey: string | null; restoreComposer: (draft: EditDraft) => void; @@ -39,12 +50,12 @@ type SubmitMessageEditOptions = Omit & { setUploadError: (message: string) => void; }; -/** Clear an edited message immediately, then upload and save captured state. */ export async function submitMessageEdit({ clearComposer, content, customEmoji, editTargetId, + editTarget, extractMentionPubkeys, getMentionRefs, originalContent, @@ -59,12 +70,19 @@ export async function submitMessageEdit({ setUploadError, spoileredAttachmentUrls, }: SubmitMessageEditOptions): Promise { + const currentMentionRefs = editTarget.mentionRefs ?? []; const draft: EditDraft = { content, - mentionRefs: getMentionRefs(content), + mentionRefs: [ + ...getMentionRefs(content), + ...currentMentionRefs.filter((ref) => + hasMention(content, ref.displayName), + ), + ], pendingImeta: [...pendingImeta], queuedAttachments: [...queuedAttachments], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + unresolvedMentionPubkeys: [...(editTarget.unresolvedMentionPubkeys ?? [])], }; const restoreDraft = () => { if (shouldRestoreComposer()) { @@ -93,11 +111,16 @@ export async function submitMessageEdit({ ), ]), ); - const outgoingTags = + const outgoingTags = mergeOutgoingTagsWithReferenceMentions( mergeOutgoingTags( mediaTags, buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; + ), + [ + ...draft.mentionRefs.map(({ pubkey }) => pubkey), + ...draft.unresolvedMentionPubkeys, + ], + ); if (signal?.aborted) return; await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId); }; diff --git a/desktop/src/features/messages/ui/useStableSendToChannel.test.mjs b/desktop/src/features/messages/ui/useStableSendToChannel.test.mjs new file mode 100644 index 00000000000..6de2a03397e --- /dev/null +++ b/desktop/src/features/messages/ui/useStableSendToChannel.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +class EventTargetShim { + addEventListener() {} + removeEventListener() {} +} +class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.nodeType = 1; + this.nodeName = tagName.toUpperCase(); + this.tagName = tagName; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.ownerDocument = globalThis.document; + this.parentNode = null; + this.children = []; + this.childNodes = []; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + child.parentNode = null; + return child; + } +} +class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + createElement(tagName) { + return new NodeShim(tagName); + } +} +globalThis.document = new DocumentShim(); +globalThis.HTMLIFrameElement = NodeShim; +globalThis.HTMLDivElement = NodeShim; +globalThis.HTMLElement = NodeShim; +globalThis.Node = NodeShim; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, +}); + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { useStableSendToChannel } from "./useStableSendToChannel.ts"; + +function Harness({ channelId, onSendToChannel, threadHead, output }) { + output.current = useStableSendToChannel( + channelId, + threadHead, + onSendToChannel, + ); + return null; +} + +test("send-to-channel callback stays stable while using the latest thread context", async () => { + const output = { current: undefined }; + const calls = []; + const firstRoot = { id: "first" }; + const secondRoot = { id: "second" }; + const firstSend = async (...args) => calls.push(["first", ...args]); + const secondSend = async (...args) => calls.push(["second", ...args]); + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render( + React.createElement(Harness, { + channelId: "channel-a", + onSendToChannel: firstSend, + threadHead: firstRoot, + output, + }), + ); + }); + const initialCallback = output.current; + + await act(async () => { + root.render( + React.createElement(Harness, { + channelId: "channel-b", + onSendToChannel: secondSend, + threadHead: secondRoot, + output, + }), + ); + }); + + assert.equal(output.current, initialCallback); + const message = { id: "reply" }; + await output.current(message); + assert.deepEqual(calls, [["second", message, secondRoot, "channel-b"]]); + + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/messages/ui/useStableSendToChannel.ts b/desktop/src/features/messages/ui/useStableSendToChannel.ts new file mode 100644 index 00000000000..ff0dc2bc61e --- /dev/null +++ b/desktop/src/features/messages/ui/useStableSendToChannel.ts @@ -0,0 +1,32 @@ +import * as React from "react"; + +import type { TimelineMessage } from "@/features/messages/types"; + +type SendToChannel = ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, +) => Promise; + +export function useStableSendToChannel( + channelId: string | null, + threadHead: TimelineMessage | null, + onSendToChannel?: SendToChannel, +): ((message: TimelineMessage) => Promise) | undefined { + const contextRef = React.useRef({ channelId, onSendToChannel, threadHead }); + React.useLayoutEffect(() => { + contextRef.current = { channelId, onSendToChannel, threadHead }; + }, [channelId, onSendToChannel, threadHead]); + const sendToChannel = React.useCallback((message: TimelineMessage) => { + const context = contextRef.current; + if (!context.onSendToChannel || !context.threadHead || !context.channelId) { + return Promise.resolve(); + } + return context.onSendToChannel( + message, + context.threadHead, + context.channelId, + ); + }, []); + return onSendToChannel && channelId ? sendToChannel : undefined; +} diff --git a/desktop/src/shared/api/editMessage.ts b/desktop/src/shared/api/editMessage.ts index fc63502e49b..00ed82095ff 100644 --- a/desktop/src/shared/api/editMessage.ts +++ b/desktop/src/shared/api/editMessage.ts @@ -8,6 +8,7 @@ export async function editMessage( emojiTags?: string[][], mentionPubkeys?: string[], suppressLinkPreviews?: boolean, + mentionTags?: string[][], ): Promise { await invokeTauri("edit_message", { input: { @@ -18,6 +19,7 @@ export async function editMessage( emojiTags: emojiTags ?? [], mentionPubkeys: mentionPubkeys ?? [], suppressLinkPreviews: suppressLinkPreviews ?? false, + mentionTags: mentionTags ?? null, }, }); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 52aa8f19ebb..8eb626a81dd 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -7,6 +7,7 @@ import { fromRawInstallRuntimeResult, type RawInstallRuntimeResult, } from "@/shared/api/installTypes"; +import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes"; import type { AddChannelMembersInput, AddChannelMembersResult, @@ -97,14 +98,6 @@ type RawSearchResponse = { found: number; }; -type RawSendChannelMessageResult = { - event_id: string; - parent_event_id: string | null; - root_event_id: string | null; - depth: number; - created_at: number; -}; - type RawRelayAgent = { pubkey: string; name: string; @@ -550,6 +543,7 @@ export async function sendChannelMessage( emojiTags?: string[][], mentionTags?: string[][], linkPreviewTags?: string[][], + sentFromThreadTag?: string[], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -561,11 +555,11 @@ export async function sendChannelMessage( emojiTags: emojiTags ?? null, mentionTags: mentionTags ?? null, linkPreviewTags, + sentFromThreadTag: sentFromThreadTag ?? null, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, }, ); - return { eventId: response.event_id, parentEventId: response.parent_event_id, diff --git a/desktop/src/shared/api/tauriMessageTypes.ts b/desktop/src/shared/api/tauriMessageTypes.ts new file mode 100644 index 00000000000..12a94a59517 --- /dev/null +++ b/desktop/src/shared/api/tauriMessageTypes.ts @@ -0,0 +1,7 @@ +export type RawSendChannelMessageResult = { + event_id: string; + parent_event_id: string | null; + root_event_id: string | null; + depth: number; + created_at: number; +}; diff --git a/desktop/src/shared/ui/icons.ts b/desktop/src/shared/ui/icons.ts index 4c2da1dace8..804fa63f3d0 100644 --- a/desktop/src/shared/ui/icons.ts +++ b/desktop/src/shared/ui/icons.ts @@ -9,6 +9,15 @@ export const HashSearch = createLucideIcon("hash-search", [ ["circle", { cx: "17", cy: "17", r: "3", key: "18b49y" }], ]); +export const HashArrowIn = createLucideIcon("hash-arrow-in", [ + ["line", { x1: "4", x2: "20", y1: "9", y2: "9", key: "pulu6f" }], + ["line", { x1: "4", x2: "11", y1: "15", y2: "15", key: "th0qa4" }], + ["line", { x1: "10", x2: "8", y1: "3", y2: "21", key: "1ggp8o" }], + ["line", { x1: "16", x2: "15", y1: "3", y2: "12", key: "noe5so" }], + ["path", { d: "M21 18h-7", key: "1c9c8q" }], + ["path", { d: "m17 15-3 3 3 3", key: "18z0pk" }], +]); + export const ListSortDescending = createLucideIcon("list-sort-descending", [ ["path", { d: "M15 12H3", key: "1d0spu" }], ["path", { d: "M3 5h18", key: "d7x3do" }], diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b71cc0947e6..433a8ce6e1c 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1361,7 +1361,6 @@ function createMarkdownComponents( return ( { + const segments: Array<{ isEmoji: boolean; start: number; text: string }> = []; + const graphemes = graphemeSegmenter + ? Array.from(graphemeSegmenter.segment(label), ({ index, segment }) => ({ + start: index, + text: segment, + })) + : Array.from(label, (text, start) => ({ start, text })); + for (const { start, text } of graphemes) { + const isEmoji = emojiGraphemePattern.test(text); + const previous = segments.at(-1); + if (previous?.isEmoji === isEmoji) { + previous.text += text; + } else { + segments.push({ isEmoji, start, text }); + } + } + return segments; +} export function MessageLinkPill({ channels, - href, interactive, link, onOpenMessageLink, + threadExcerpt, + variant = "default", }: MessageLinkPillProps) { + const [isHovered, setIsHovered] = React.useState(false); const channel = channels.find((c) => c.id === link.channelId); const channelLabel = channel?.name ?? "channel"; - const shortId = link.messageId.slice(0, 6); - const label = ( - <> - #{channelLabel} · {shortId} - - ); + const isSentFromThread = variant === "sent-from-thread"; + const label = getMessageLinkLabel({ + channelName: channelLabel, + threadExcerpt, + variant, + }); + const channelLinkLabel = getMessageLinkChannelLabel(channelLabel); if (!interactive) { - return {label}; + if (!isSentFromThread) { + return ( + + {MESSAGE_LINK_PREFIX} + + {channelLinkLabel} + + + ); + } + return ( + + {label} + + ); + } + + if (!isSentFromThread) { + return ( + + {MESSAGE_LINK_PREFIX} + + + ); } return ( ); } diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 20ecfc2e084..56f02ec1f68 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -20,10 +20,11 @@ export type ImetaLookup = Map; export type MessageLinkPillProps = { channels: Channel[]; - href: string; interactive: boolean; link: ParsedMessageLink; onOpenMessageLink: (link: ParsedMessageLink) => void; + threadExcerpt?: string | null; + variant?: "default" | "sent-from-thread"; }; export type MarkdownRuntime = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 751b06f484f..5a84de16331 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9001,6 +9001,7 @@ async function handleSendChannelMessage( emojiTags?: string[][] | null; mentionTags?: string[][] | null; linkPreviewTags?: string[][] | null; + sentFromThreadTag?: string[] | null; suppressLinkPreviews?: boolean; }, config: E2eConfig | undefined, @@ -9060,6 +9061,7 @@ async function handleSendChannelMessage( ...emojiTags, ...mentionTags, ...linkPreviewTags, + ...(args.sentFromThreadTag ? [args.sentFromThreadTag] : []), ...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []), ]; const identity = getIdentity(config); @@ -9296,12 +9298,24 @@ async function handleEditMessage( content: string; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + mentionPubkeys?: string[] | null; + mentionTags?: string[][] | null; + suppressLinkPreviews?: boolean; }, config: E2eConfig | undefined, ): Promise { const mediaTags = args.mediaTags ?? []; const emojiTags = args.emojiTags ?? []; - const extraTags = [...mediaTags, ...emojiTags]; + const mentionPubkeys = args.mentionPubkeys ?? []; + const mentionTags = args.mentionTags; + const extraTags = [ + ...mediaTags, + ...emojiTags, + ...mentionPubkeys.map((pubkey) => ["p", pubkey]), + ...(mentionTags ?? []), + ...(mentionTags ? [["buzz:mention-snapshot"]] : []), + ...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []), + ]; const tags = [["h", args.channelId], ["e", args.eventId], ...extraTags]; const content = args.content.trim(); const identity = getIdentity(config); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index ff410205cd3..ab7eac41b06 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2775,6 +2775,109 @@ test("Inbox All excludes generic channel traffic", async ({ page }) => { ).toHaveCount(0); }); +test("Inbox type labels keep the same height with and without a channel chip", async ({ + page, +}) => { + const dmId = "inbox-type-label-dm"; + const mentionId = "inbox-type-label-mention"; + const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; + + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + await page.waitForFunction(() => { + const win = window as MockFeedWindow; + return ( + typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function" + ); + }); + + await page.evaluate( + ({ + channelId, + currentPubkey, + dmChannelId: directChannelId, + dmId: directId, + mentionId: channelMentionId, + senderPubkey, + }) => { + const win = window as MockFeedWindow; + const emitMessage = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + const pushFeedItem = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!emitMessage || !pushFeedItem) { + throw new Error("Mock bridge helpers are not installed."); + } + + const createdAt = Math.floor(Date.now() / 1_000); + const directMessage = emitMessage({ + channelName: "alice-tyler", + content: "A direct message without a channel chip", + createdAt, + id: directId, + pubkey: senderPubkey, + }); + pushFeedItem({ + category: "activity", + channel_id: directChannelId, + channel_name: "alice-tyler", + channel_type: null, + content: directMessage.content, + created_at: directMessage.created_at, + id: directMessage.id, + kind: directMessage.kind, + pubkey: directMessage.pubkey, + tags: directMessage.tags, + }); + pushFeedItem({ + category: "mention", + channel_id: channelId, + channel_name: "general", + channel_type: "stream", + content: "A channel mention with a channel chip", + created_at: createdAt + 1, + id: channelMentionId, + kind: 9, + pubkey: senderPubkey, + tags: [ + ["h", channelId], + ["p", currentPubkey], + ], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + currentPubkey: MOCK_IDENTITY_PUBKEY, + dmChannelId, + dmId, + mentionId, + senderPubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); + + const dmLabel = page + .getByTestId(`home-inbox-item-${dmId}`) + .locator('[data-inbox-type-label=""]'); + const mentionLabel = page + .getByTestId(`home-inbox-item-${mentionId}`) + .locator('[data-inbox-type-label=""]'); + await expect(dmLabel).toContainText("DM from alice"); + await expect(dmLabel.locator('[data-channel-link=""]')).toHaveCount(0); + await expect(mentionLabel).toContainText("Mentioned in"); + await expect(mentionLabel.locator('[data-channel-link=""]')).toHaveText( + "#general", + ); + + const [dmBox, mentionBox] = await Promise.all([ + dmLabel.boundingBox(), + mentionLabel.boundingBox(), + ]); + expect(dmBox).not.toBeNull(); + expect(mentionBox).not.toBeNull(); + expect( + Math.abs((dmBox?.height ?? 0) - (mentionBox?.height ?? 0)), + ).toBeLessThan(0.5); +}); + test("Inbox All never lists drafts and unread-only hides reminders", async ({ page, }) => { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2d7f3eacda9..adacdb62952 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1733,6 +1733,286 @@ test("send message to DM channel p-tags the recipient", async ({ page }) => { .toContainEqual(["p", TEST_IDENTITIES.alice.pubkey]); }); +test("sends a thread message to its parent channel with a root-thread link", async ({ + page, +}) => { + const timestamp = Date.now(); + const rootContent = `🧵 Share source thread ${timestamp}`; + const priorChannelMessage = `Prior channel message ${timestamp}`; + const replySummary = `Share this reply ${timestamp}`; + const attachmentSha = "d".repeat(64); + const attachmentUrl = `http://localhost:3000/media/${attachmentSha}.txt`; + const customEmojiUrl = "https://example.com/send-to-channel-party.svg"; + const previewUrl = "https://github.com/block/buzz/pull/5305"; + const ownReplyContent = [ + `${replySummary} with @alice :party:`, + `[launch-notes.txt](${attachmentUrl})`, + previewUrl, + ].join("\n\n"); + const imetaTag = [ + "imeta", + `url ${attachmentUrl}`, + "m text/plain", + `x ${attachmentSha}`, + "size 42", + "filename launch-notes.txt", + ]; + const emojiTag = ["emoji", "party", customEmojiUrl]; + const mentionTag = ["mention", TEST_IDENTITIES.alice.pubkey]; + const linkPreviewTag = [ + "link-preview", + "snapshot", + "1", + previewUrl, + "Add Send to channel for thread messages", + "GitHub", + "A shared link preview preserved from the source thread message.", + "", + "", + "", + "", + ]; + + await page.route(customEmojiUrl, (route) => + route.fulfill({ + body: '', + contentType: "image/svg+xml", + }), + ); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && + (window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? + false), + ); + + const { ownReplyId, rootId } = await page.evaluate( + ({ alicePubkey, ownReply, root, semanticTags }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const rootEvent = emit({ + channelName: "general", + content: root, + pubkey: alicePubkey, + }); + const ownReplyEvent = emit({ + channelName: "general", + content: ownReply, + extraTags: semanticTags, + mentionPubkeys: [alicePubkey], + parentEventId: rootEvent.id, + }); + return { + ownReplyId: ownReplyEvent.id, + rootId: rootEvent.id, + }; + }, + { + alicePubkey: TEST_IDENTITIES.alice.pubkey, + ownReply: ownReplyContent, + root: rootContent, + semanticTags: [imetaTag, emojiTag, mentionTag, linkPreviewTag], + }, + ); + + const timeline = page.getByTestId("message-timeline"); + const rootRow = timeline.locator(`[data-message-id="${rootId}"]`); + await expect(rootRow).toContainText(rootContent); + + await page.getByTestId("message-input").fill(priorChannelMessage); + await page.getByTestId("send-message").click(); + const priorChannelRow = timeline + .getByTestId("message-row") + .filter({ hasText: priorChannelMessage }); + await expect(priorChannelRow).toBeVisible(); + await expect(priorChannelRow.getByTestId("message-send-status")).toHaveCount( + 0, + ); + + await timeline + .locator( + `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, + ) + .click(); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadRootRow = threadPanel.locator(`[data-message-id="${rootId}"]`); + const rootMoreActions = threadRootRow.getByTestId(`more-actions-${rootId}`); + await rootMoreActions.click({ force: true }); + await expect(page.getByRole("menu")).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Send to channel" }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + + const ownReplyRow = threadPanel.locator(`[data-message-id="${ownReplyId}"]`); + await expect(ownReplyRow).toContainText(replySummary); + await ownReplyRow + .getByTestId(`more-actions-${ownReplyId}`) + .click({ force: true }); + const sendToChannelItem = page.getByRole("menuitem", { + name: "Send to channel", + }); + const sendToChannelIcon = sendToChannelItem.getByTestId( + "send-to-channel-icon", + ); + await expect(sendToChannelIcon).toBeVisible(); + await expect(sendToChannelIcon).toHaveAttribute("aria-hidden", "true"); + await expect(sendToChannelIcon).toHaveClass(/lucide-hash-arrow-in/); + await expect + .poll(async () => { + const box = await sendToChannelIcon.boundingBox(); + return box ? [box.width, box.height] : null; + }) + .toEqual([16, 16]); + await sendToChannelItem.click(); + + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "Sent to channel" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate((content) => { + return Boolean( + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( + (entry) => + entry.command === "send_channel_message" && + (entry.payload as { content?: string } | undefined)?.content === + content, + ), + ); + }, ownReplyContent), + ) + .toBe(true); + const sentPayload = await page.evaluate( + (content) => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( + (entry) => + entry.command === "send_channel_message" && + (entry.payload as { content?: string } | undefined)?.content === + content, + )?.payload as Record | undefined, + ownReplyContent, + ); + expect(sentPayload).toMatchObject({ + channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + content: ownReplyContent, + emojiTags: [emojiTag], + linkPreviewTags: [linkPreviewTag], + mediaTags: [imetaTag], + mentionPubkeys: [TEST_IDENTITIES.alice.pubkey], + mentionTags: [mentionTag], + parentEventId: null, + sentFromThreadTag: ["buzz:sent-from-thread", rootId, rootContent], + }); + + await page.getByTestId("auxiliary-panel-close").click(); + + const sharedRow = timeline + .getByTestId("message-row") + .filter({ hasText: replySummary }) + .last(); + await expect + .poll(async () => { + if (await sharedRow.isVisible()) return true; + const scrollToLatest = page.getByTestId("message-scroll-to-latest"); + if (await scrollToLatest.isVisible()) await scrollToLatest.click(); + return false; + }) + .toBe(true); + await expect(sharedRow.getByTestId("message-author")).toHaveText( + "npub1mock...", + ); + await expect(sharedRow.getByTestId("message-avatar-fallback")).toBeVisible(); + await expect(sharedRow.locator('[data-mention=""]')).toContainText("alice"); + await expect(sharedRow.locator("img[data-custom-emoji]")).toHaveAttribute( + "src", + customEmojiUrl, + ); + await expect(sharedRow.getByTestId("file-card")).toContainText( + "launch-notes.txt", + ); + await expect( + sharedRow.locator('[data-link-preview="github-pull-request"]'), + ).toContainText("Add Send to channel for thread messages"); + const sourceLine = sharedRow.getByTestId("sent-from-thread"); + await expect(sourceLine).toContainText("Sent from thread:"); + await expect(sourceLine).toHaveClass(/message-markdown/); + await expect(sourceLine).toHaveClass(/pt-0\.5/); + await expect(sourceLine).toHaveClass(/text-sm/); + await expect(sourceLine).toHaveClass(/font-normal/); + await expect(sourceLine).toHaveClass(/leading-4/); + await expect(sourceLine).toHaveClass(/text-muted-foreground\/70/); + const rootLink = sourceLine.locator("[data-message-link]"); + const sourcePrefix = sourceLine.locator("span").first(); + const rootLinkLabel = rootContent; + await expect(rootLink).toHaveText(rootLinkLabel); + await expect(rootLink).toHaveAttribute( + "aria-label", + "Open thread in general", + ); + await expect(rootLink).toHaveAttribute("title", rootLinkLabel); + await expect(rootLink).toHaveClass(/max-w-80/); + await expect(rootLink).toHaveClass(/truncate/); + await expect(rootLink).toHaveClass(/inline-block/); + await expect(rootLink).toHaveClass(/font-medium/); + await expect(rootLink).not.toHaveClass(/mention-chip/); + await expect(rootLink).not.toHaveClass(/border-b/); + const rootLinkText = rootLink.locator("[data-message-link-text]"); + const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]"); + await expect(rootLinkText).toHaveText(` Share source thread ${timestamp}`); + await expect(rootLinkText).not.toHaveClass(/border-b/); + await expect(rootLinkEmoji).toHaveText("🧵"); + await expect(rootLinkEmoji).not.toHaveClass(/border-b/); + await expect(rootLink).not.toHaveAttribute("data-hovered"); + const [prefixColor, linkColorBeforeHover] = await Promise.all([ + sourcePrefix.evaluate((element) => getComputedStyle(element).color), + rootLink.evaluate((element) => getComputedStyle(element).color), + ]); + expect(linkColorBeforeHover).not.toBe(prefixColor); + await expect + .poll(() => + rootLink.evaluate((element) => getComputedStyle(element).backgroundColor), + ) + .toBe("rgba(0, 0, 0, 0)"); + await expect + .poll(() => + rootLinkText.evaluate((element) => getComputedStyle(element).boxShadow), + ) + .toBe("none"); + + await rootLink.hover(); + await expect(rootLink).toHaveAttribute("data-hovered", ""); + await expect + .poll(() => rootLink.evaluate((element) => getComputedStyle(element).color)) + .toBe(linkColorBeforeHover); + await expect + .poll(() => + rootLinkText.evaluate( + (element) => getComputedStyle(element).boxShadow !== "none", + ), + ) + .toBe(true); + + await expect + .poll(() => + rootLinkEmoji.evaluate((element) => getComputedStyle(element).boxShadow), + ) + .toBe("none"); + + await rootLink.click(); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + rootContent, + ); +}); + test("shows your avatar on your own message when profile avatar is set", async ({ page, }) => { diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 18db55a50ab..eb76ef3a4f9 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -349,7 +349,28 @@ test("message links to visible root messages open the thread panel", async ({ const link = "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; - await page.getByTestId("message-input").fill(`Root link repro ${link}`); + const composerInput = page.getByTestId("message-input"); + await composerInput.fill("Root link repro "); + await composerInput.focus(); + await composerInput.evaluate((element, href) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", href); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, link); + const composerLink = composerInput.locator('[data-composer-message-link=""]'); + await expect(composerLink).toContainText("Thread in"); + const composerChannelLink = composerLink.locator('[data-channel-link=""]'); + await expect(composerChannelLink).toHaveText("#general"); + await expect(composerChannelLink).toHaveClass(/mention-chip/); + await expect(composerLink).not.toHaveClass(/mention-chip/); + await expect(composerLink).toHaveAttribute("title", "Thread in #general"); + await expect(composerInput).not.toContainText("buzz://message"); await page.getByTestId("send-message").click(); const linkMessage = page @@ -357,9 +378,15 @@ test("message links to visible root messages open the thread panel", async ({ .filter({ hasText: "Root link repro" }) .last(); await expect(linkMessage).toBeVisible(); - await linkMessage - .getByRole("button", { name: "Open message in general" }) - .click(); + const rootThreadLink = linkMessage.getByRole("button", { + name: "Open thread in general", + }); + await expect(linkMessage.locator('[data-message-link=""]')).toContainText( + "Thread in", + ); + await expect(rootThreadLink).toHaveText("#general"); + await expect(rootThreadLink).toHaveClass(/mention-chip/); + await rootThreadLink.click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -398,9 +425,11 @@ test("message links reopen a closed thread when the same messageId is already in .filter({ hasText: "Reopen same root link repro" }) .last(); await expect(linkMessage).toBeVisible(); - await linkMessage - .getByRole("button", { name: "Open message in general" }) - .click(); + const rootThreadLink = linkMessage.getByRole("button", { + name: "Open thread in general", + }); + await expect(rootThreadLink).toHaveText("#general"); + await rootThreadLink.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( From cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 11 Aug 2026 18:25:23 +0100 Subject: [PATCH 10/20] Add glass appearance and cohesive settings (#5478) ## Summary - add an opt-in native glass sidebar with opacity controls and live theme previews - refine sidebar spacing and Buzz-only active rows while preserving production defaults - unify settings section cards, subtitles, and agent runtime rows ## Validation - repository format, lint, type, and file-size checks - 4,538 desktop tests and 2,270 native desktop tests - desktop and web production builds - 1,261 mobile tests in the completed full gate - focused Playwright appearance, sidebar, settings, pairing, and runtime coverage --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Wes Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Wes Co-authored-by: Carl Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/lib.rs | 7 + desktop/src/app/useTauriWindowDrag.ts | 4 + .../ui/CommunityMembersSettingsCard.tsx | 28 +- .../ui/CustomEmojiSettingsCard.tsx | 47 +- .../ui/LocalArchiveSettingsCard.tsx | 71 +- .../ui/MeshComputeSettingsCard.tsx | 302 ++++---- .../profile/ui/AnimatedAvatarControls.tsx | 27 +- .../src/features/settings/UpdateChecker.tsx | 51 +- .../settings/ui/AgentDefaultsSettingsCard.tsx | 17 +- .../settings/ui/AgentsSettingsPanel.tsx | 21 + .../ui/AppearanceSettingsControls.tsx | 395 +++++++++++ .../ui/ChannelTemplatesSettingsCard.tsx | 47 +- .../settings/ui/ExperimentalFeaturesCard.tsx | 13 +- .../src/features/settings/ui/HarnessRow.tsx | 26 +- .../settings/ui/HarnessesSettingsPanel.tsx | 115 +-- .../ui/HostedCommunitiesSettingsCard.tsx | 25 +- .../settings/ui/KeyboardShortcutsCard.tsx | 44 +- .../settings/ui/MobilePairingCard.tsx | 16 +- .../settings/ui/ModerationQueueCard.tsx | 16 +- .../settings/ui/NotificationSettingsCard.tsx | 33 +- .../settings/ui/PreventSleepSettingsCard.tsx | 25 +- .../settings/ui/ProfileSettingsCard.tsx | 5 +- .../settings/ui/SettingsOptionGroup.tsx | 39 +- .../features/settings/ui/SettingsPanels.tsx | 573 ++++++--------- .../settings/ui/SettingsSectionHeader.tsx | 6 +- .../src/features/settings/ui/SettingsView.tsx | 12 +- .../features/settings/ui/SignOutSection.tsx | 47 +- .../settings/ui/VoiceSettingsCard.tsx | 14 +- .../sidebar/ui/CustomChannelSection.tsx | 3 +- .../src/features/sidebar/ui/SidebarDnd.tsx | 11 +- .../features/sidebar/ui/SidebarSection.tsx | 2 +- .../shared/styles/globals/avatar-framing.css | 11 + desktop/src/shared/styles/globals/theme.css | 178 +++-- desktop/src/shared/theme/ThemeProvider.tsx | 286 ++++---- desktop/src/shared/ui/sidebar.tsx | 6 +- desktop/src/testing/e2eBridge.ts | 2 + desktop/tests/e2e/badge.spec.ts | 8 +- .../tests/e2e/buzz-theme-screenshots.spec.ts | 660 +++++++++++++++++- desktop/tests/e2e/channel-mute.spec.ts | 21 +- desktop/tests/e2e/doctor-states.spec.ts | 45 +- .../global-agent-config-screenshots.spec.ts | 31 +- desktop/tests/e2e/mobile-pairing-qr.spec.ts | 5 + desktop/tests/e2e/profile.spec.ts | 91 ++- desktop/tests/e2e/sidebar.spec.ts | 121 ++++ 44 files changed, 2503 insertions(+), 1004 deletions(-) create mode 100644 desktop/src/features/settings/ui/AgentsSettingsPanel.tsx create mode 100644 desktop/src/features/settings/ui/AppearanceSettingsControls.tsx diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..8e2ddfc389b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -147,6 +147,13 @@ pub fn run() { // on macOS/Windows. linux_media::enable_media_capture(&webview); + #[cfg(target_os = "macos")] + if let Err(error) = webview + .set_background_color(Some(tauri::window::Color(0, 0, 0, 0))) + { + eprintln!("buzz-desktop: failed to make the macOS webview transparent: {error}"); + } + // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to // commit the startup surface before revealing it. diff --git a/desktop/src/app/useTauriWindowDrag.ts b/desktop/src/app/useTauriWindowDrag.ts index 1ac7acc93c0..8d9342ab57d 100644 --- a/desktop/src/app/useTauriWindowDrag.ts +++ b/desktop/src/app/useTauriWindowDrag.ts @@ -15,6 +15,10 @@ export function useTauriWindowDrag() { return; } + // A native window drag replaces the browser's normal pointer gesture. + // Cancel that gesture before handing control to Tauri so moving from the + // titlebar across page copy cannot start a text selection. + event.preventDefault(); void getCurrentWindow().startDragging(); } diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index d861fae802e..a2056b6d37c 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -154,7 +154,10 @@ function RelayMemberRow({ ) : null}
-
+
{member.role}