Conflict resolution in mediaUrl.ts unifies both sides under one canonical
origin rule: main's canonicalOrigin() (lowercased scheme/host, default port
dropped) is now the only representation the cached relay origin ever holds,
so both rewriteRelayUrl and isRelayDownloadable (which compares
URL.origin === relayOrigin) inherit case-insensitive matching. Dawn's
retried, deadline-bounded poll loop and useSyncExternalStore origin store
are kept; the two generation counters (main's cacheGeneration, the lane's
relayOriginGeneration) are collapsed into one cacheGeneration guarding both
the port cache and origin publication. Both sides' tests retained. The
app_state.rs size override is bumped to the union of the two independently
approved additions (media-fetch client builder + profile-reconcile flag).
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Adds a shared MediaContextMenu with relay-eligibility gating (fail-closed
off-relay), fixes RELAY_MEDIA_RE to recognize .webm/.mov, makes the
relay-origin fetch retried + generation-guarded with a hard 5s deadline,
and hardens the download HTTP client with redirect::Policy::none().
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/bugbash/dawn-media:
Add link and video right-click Download/Copy menus (bug-bash media lane)
Buzz renders inside a native webview whose default context menu has no
useful link or media actions, so right-clicking a link was a no-op
(#02ef9f1b) and videos had no Download at all. This adds custom in-app
context menus for links and inline videos, and dedupes the existing
image menu into a single shared primitive.
- New `MediaContextMenu` primitive (portal menu + deferred-dismiss hook),
reused three ways: image (Copy/Download), link (Open/Copy), video
(Download/Copy). Each variant emits a distinct data attribute
(`data-image-context-menu` / `data-link-context-menu` /
`data-video-context-menu`) plus the shared `data-media-context-menu`
marker, so e2e locators target one surface without aliasing another;
the image variant additionally carries `data-image-lightbox-controls`
for the in-lightbox dismiss guard.
- Links: `ExternalLinkAnchor` adds Open link (OS opener) / Copy link.
- Videos: the `useVideoContextMenu` hook owns the inline right-click menu's
state and actions — Download video (reuses the existing `download_file`
command) and Copy link — keeping that logic out of the large `VideoPlayer`
and out of the pure `videoDownload.ts` helpers.
Render classification and download eligibility are independent (pure,
DOM-free helpers in `markdown/mediaEntry.ts`):
- `isVideoMedia` classifies from the imeta MIME (`video/*`), authoritative
when present, with a legacy path-extension fallback for pre-MIME events.
Previously only `.mp4` was recognized, so relay `.webm`/`.mov` fell into
the image renderer and Tyler's video Download was absent. The relay-media
proxy regex is widened to `mp4|webm|mov` to match.
- `isRelayDownloadable` gates Download on relay `/media/` origin, not mere
URL presence, so an external video renders and offers Copy link but omits
Download (which the Rust SSRF gate would reject anyway). The original
relay `src` is the download URL, never the rewritten proxy `resolvedSrc`.
Download eligibility is reactive and fails closed. The relay origin resolves
asynchronously and is commonly still null on first render; a synchronous read
would freeze eligibility, so `mediaUrl.ts` exposes the origin as an observable
(`subscribeRelayOrigin` + `getCachedRelayOrigin`) consumed via
`useRelayOrigin()` (`useSyncExternalStore`). `MarkdownVideoPlayer` reads it, so
eligibility recomputes when the origin resolves and on workspace switch.
`isRelayDownloadable` now returns false for an unresolved origin (fail closed)
rather than optimistically true — before, an off-relay `/media/` URL wrongly
showed Download until resolution. A generation token (`beginRelayOriginFetch`)
ensures an in-flight origin fetch from a previous community can never publish
after a workspace switch. The origin fetch is retried inside the port poll loop
(a single fire-and-forget attempt could fail before the Tauri bridge was ready
and leave the origin null forever), and each poll invoke is bounded by the
remaining deadline (`withDeadline`) so a never-settling IPC call cannot hang
startup past the 5s budget.
Redirect-hop SSRF: authenticated media *download* fetches use a dedicated
no-redirect client built by `build_media_fetch_client() -> Result`
(`redirect::Policy::none()`). `build_app_state()` calls `.expect(...)` so a
build failure panics loudly at startup rather than silently degrading to a
redirect-following client (fail closed, not fail open). A relay 3xx is returned
verbatim and mapped by `redirect_refusal_error` to an actionable "redirect
refused" error — not a generic relay failure and never a followed cross-origin
fetch of the minted media Authorization header. The app-wide `http_client` is
unchanged.
Follow-up (NOT fixed here — this PR hardens the download path only, and
must not be read as making rendering redirect-safe): the media *rendering*
path shares the same exposure. The axum proxy in
`src-tauri/src/media_proxy.rs` and the `buzz-media://` protocol handler
(registered in `src-tauri/src/lib.rs`) both mint a media Authorization
header and follow redirects, so a relay 3xx to a private/off-origin target
would forward that header cross-origin. It is left untouched here because
it is the passive streaming path (a legitimate relay CDN-offload redirect
would break `<video>`/`<img>`), it is broader than this Download lane, and
scope was deliberately kept narrow.
Tests: `mediaEntry.test.mjs` (MIME/extension classification incl. uppercase,
query strings, MIME-with-extensionless URL; relay/off-relay/malformed download
eligibility; fail-closed unresolved origin + both resolution transitions);
`mediaUrl.test.mjs` (observable store: publish/notify, notify-only-on-change,
per-listener unsubscribe, the A->reset->B late-A race, transient-failure retry
publishes exactly once, reset-mid-retry discards the stale generation, and
`withDeadline` incl. a never-settling invoke, an early rejection, and a
late rejection after timeout staying observed); `videoDownload.test.mjs`
(save-dialog filename fallback); Rust `validate_download_url` mp4/webm parity
and private-host rejection, a loopback no-redirect test proving a 302 is not
followed, exactly one request is issued, and the command-facing error
identifies the refused redirect, plus `build_media_fetch_client` constructs
with the no-redirect policy and `redirect_refusal_error` only fires for 3xx;
e2e distinct-selector probes for link (Open/Copy via a genuine bubbling
contextmenu event), relay video (Download appears once the origin resolves),
and off-relay video (Copy only, never Download), each asserting no cross-variant
attributes. 3031 desktop unit tests, whole-package
`cargo test -p buzz-desktop` (1445 lib + 3 integration), typecheck, lint, and
`pnpm build` all clean. Note: a real authenticated saved-byte download check
remains for the integrated Desktop tip (can't drive a live relay from this lane).
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Editing a message to add an @mention never notified the newly-mentioned
party. build_message_edit (kind 40003) emitted h/e/imeta/emoji tags but
no `p` tags at all — the send path's mention_tags(mentions) was simply
omitted from the edit path, so no mention-filtered subscription (e.g. the
ACP agent harness) ever saw the added mention.
Emit `p` tags for newly-added mentions ONLY. The composer diffs the
edited body's resolved mentions against the original body's (both via the
same channel-roster resolver the send path uses) and passes just the
added set down through the edit command into build_message_edit. A
typo-fix edit that leaves the mention set unchanged emits zero `p` tags
and re-wakes nobody.
Plumbing threads one new arg per layer: build_message_edit(mentions),
edit_message(mention_pubkeys), editMessage/useEditMessageMutation
(mentionPubkeys), handleEditSave + the onEditSave signature. The diff
lives in a pure threading.ts helper (diffAddedMentionPubkeys) next to
normalizeMentionPubkeys — lowercase/dedup/self-scrub, case-insensitive
comparison so a case-only difference is never treated as newly added.
Tests: events.rs pins the builder contract (added -> `p` tag,
unchanged -> none, dedup+lowercase); diffAddedMentionPubkeys.test.mjs
covers the diff both ways plus self-scrub, case-only no-op, remove/re-add.
check-file-sizes.mjs: MessageComposer.tsx limit ratcheted 1091 -> 1114
(+23, load-bearing composer-side wiring; diff logic lives in threading.ts),
matching the file's existing per-lane ratchet convention.
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
detectPrefixQuery required the trigger char to sit at start-of-string or
after whitespace, so typing `(#channel` (or `[@name`, `{#chan`) glued to
an opening bracket never opened the autocomplete — the user had to add a
space after the bracket. Allow opening brackets ([{ as a word boundary in
both the fast-path regex and the multi-word backward scan, kept in sync via
a single isBoundaryChar definition. Fixes 7796a4f4.
Adds detectPrefixQuery.test.mjs (first test on this helper): bracket
boundaries for # and @, single- and multi-word paths, and negatives
(prefix glued to a word char still does not trigger).
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>