Files
buzz/desktop/src/shared/ui/useVideoContextMenu.tsx
tlongwell-block c12012a83a 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>
2026-07-17 07:14:37 -04:00

84 lines
2.7 KiB
TypeScript

import * as React from "react";
import { invokeTauri } from "@/shared/api/tauri";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
MediaContextMenu,
type MediaContextMenuItem,
type MediaContextMenuPosition,
useDismissMediaContextMenu,
} from "@/shared/ui/markdown/MediaContextMenu";
import { resolveVideoDownloadFilename } from "@/shared/ui/videoDownload";
import { toast } from "sonner";
type UseVideoContextMenu = {
/** `onContextMenuCapture` handler for the inline video surface. */
onContextMenu: (event: React.MouseEvent) => void;
/** The positioned menu element while open, or `null`. */
menu: React.ReactNode;
};
/**
* Owns the inline video right-click menu: open/close state, the pointer-anchor
* handler, and the Download/Copy actions. Kept out of `VideoPlayer` so that
* large component stays focused on playback, and out of the pure
* `videoDownload.ts` helpers so they keep their DOM-free, Node-testable shape.
*
* `downloadUrl` is the original relay `/media/` URL (distinct from a rewritten
* proxy `src`); when absent the menu omits Download and offers only Copy link,
* so a non-relay video — which the download command's SSRF gate would reject —
* never surfaces an action that could only error.
*/
export function useVideoContextMenu(
src: string,
downloadUrl?: string,
filename?: string,
): UseVideoContextMenu {
const [position, setPosition] =
React.useState<MediaContextMenuPosition | null>(null);
const close = React.useCallback(() => setPosition(null), []);
useDismissMediaContextMenu(Boolean(position), close);
const onContextMenu = React.useCallback((event: React.MouseEvent) => {
event.preventDefault();
setPosition({ x: event.clientX, y: event.clientY });
}, []);
const items = React.useMemo<MediaContextMenuItem[]>(() => {
const entries: MediaContextMenuItem[] = [];
if (downloadUrl) {
entries.push({
label: "Download video",
onSelect: () => {
close();
invokeTauri("download_file", {
url: downloadUrl,
filename: resolveVideoDownloadFilename(filename),
}).catch((err: unknown) => {
toast.error(err instanceof Error ? err.message : "Download failed");
});
},
});
}
entries.push({
label: "Copy link",
onSelect: () => {
close();
copyTextToClipboard(downloadUrl ?? src, "Link copied to clipboard");
},
});
return entries;
}, [close, downloadUrl, filename, src]);
return {
onContextMenu,
menu: position ? (
<MediaContextMenu
dataAttributes={["data-video-context-menu"]}
items={items}
position={position}
/>
) : null,
};
}