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>