From fbb367f5ca122248c3a7ab8bae6aa936df45448f Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 11 Sep 2026 10:32:17 -0400 Subject: [PATCH 01/16] Add media review workspace Signed-off-by: Clay Delk --- dev/relay-broker-api.test.mjs | 80 ++++ dev/relay-broker.mjs | 57 ++- src/bundled/channels/ChannelsPage.tsx | 44 ++ src/features/messages/ChannelTimeline.tsx | 16 +- src/features/messages/ImageReviewStage.tsx | 203 ++++++++ src/features/messages/MediaAttachment.tsx | 311 +++++++++++++ src/features/messages/MediaReviewViewer.tsx | 280 +++++++++++ src/features/messages/MessageComposer.tsx | 28 +- src/features/messages/MessageRow.tsx | 91 ++-- src/features/messages/Messages.module.css | 466 +++++++++++++++++++ src/features/messages/ThreadPanel.tsx | 78 +++- src/features/messages/media-timecode.test.ts | 27 ++ src/features/messages/media-timecode.ts | 42 ++ src/features/relay/contracts.ts | 9 +- src/features/relay/fold.test.ts | 15 +- src/features/relay/fold.ts | 12 +- 16 files changed, 1700 insertions(+), 59 deletions(-) create mode 100644 src/features/messages/ImageReviewStage.tsx create mode 100644 src/features/messages/MediaAttachment.tsx create mode 100644 src/features/messages/MediaReviewViewer.tsx create mode 100644 src/features/messages/media-timecode.test.ts create mode 100644 src/features/messages/media-timecode.ts diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index f594723b..f89c33e1 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -2,6 +2,7 @@ import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; import { createRelayReader } from "../src/features/relay/reader.ts"; import { createServer } from "node:http"; import { createHash } from "node:crypto"; +import { ReadableStream } from "node:stream/web"; import { setTimeout as delay } from "node:timers/promises"; import { test, expect, vi, beforeEach, afterEach } from "vitest"; import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools"; @@ -52,6 +53,7 @@ async function harness(respond) { url: upstreamUrl, body: init?.body ? JSON.parse(init.body) : undefined, signal: init?.signal, + headers: init?.headers, auth, at: performance.now(), }; @@ -177,6 +179,84 @@ test("GIF search follows the relay-advertised KLIPY path with signed, bounded in } }); +test("media proxy streams authenticated video ranges and preserves seek headers", async () => { + const bytes = Buffer.from("video-range"); + const h = await harness((call) => { + expect(call.url).toBe(`${fixtureRelayUrl}/media/clip.mp4`); + expect(call.headers.Range).toBe("bytes=100-"); + return new Response(bytes, { + status: 206, + headers: { + "Content-Type": "video/mp4", + "Content-Length": String(bytes.length), + "Content-Range": "bytes 100-110/1000", + "Accept-Ranges": "bytes", + }, + }); + }); + try { + const response = await fetch( + `${h.base}/api/relay/media?url=${encodeURIComponent(`${fixtureRelayUrl}/media/clip.mp4`)}`, + { headers: { Range: "bytes=100-" } }, + ); + expect(response.status).toBe(206); + expect(response.headers.get("content-type")).toBe("video/mp4"); + expect(response.headers.get("content-range")).toBe("bytes 100-110/1000"); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(Buffer.from(await response.arrayBuffer())).toEqual(bytes); + expect(h.calls).toHaveLength(1); + } finally { + await h.close(); + } +}); + +test("an upstream video stream error closes only that response, not the broker", async () => { + const h = await harness(() => { + let controller; + const body = new ReadableStream({ + start(value) { + controller = value; + value.enqueue(new Uint8Array([1, 2, 3])); + }, + }); + queueMicrotask(() => + controller.error(new DOMException("timed out", "TimeoutError")), + ); + return new Response(body, { + status: 206, + headers: { "Content-Type": "video/mp4", "Content-Range": "bytes 0-2/10" }, + }); + }); + try { + await fetch( + `${h.base}/api/relay/media?url=${encodeURIComponent(`${fixtureRelayUrl}/media/clip.mp4`)}`, + { headers: { Range: "bytes=0-" } }, + ) + .then((response) => response.arrayBuffer()) + .catch(() => {}); + const session = await fetch(`${h.base}/api/relay/session`); + expect(session.status).toBe(200); + } finally { + await h.close(); + } +}); + +test("media proxy rejects malformed ranges before upstream I/O", async () => { + const h = await harness(() => { + throw new Error("unexpected upstream call"); + }); + try { + const response = await fetch( + `${h.base}/api/relay/media?url=${encodeURIComponent(`${fixtureRelayUrl}/media/clip.mp4`)}`, + { headers: { Range: "items=0-1" } }, + ); + expect(response.status).toBe(416); + expect(h.calls).toHaveLength(0); + } finally { + await h.close(); + } +}); + test("upstream quota survives browser recreation, gates reads/profile/publish and leaves other communities independent", async () => { const h = await harness((call, count, event) => count === 1 diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 9ec90c45..571403ea 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -38,6 +38,7 @@ import { } from "../src/features/relay/http-admission.ts"; import { execFileSync } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; +import { Readable } from "node:stream"; import dc from "node:diagnostics_channel"; import { finalizeEvent, getPublicKey, nip19, verifyEvent } from "nostr-tools"; import { Agent, fetch as upstreamHttp, interceptors } from "undici"; @@ -659,11 +660,18 @@ export function relayBrokerPlugin({ }, key, ); + const range = req.headers.range; + if ( + range !== undefined && + (typeof range !== "string" || !/^bytes=\d+-\d*$/.test(range)) + ) + return json(res, 416, { error: "Media range rejected" }); const upstream = await fetchUpstream(target, { headers: { Authorization: "Nostr " + Buffer.from(JSON.stringify(auth)).toString("base64url"), + ...(range ? { Range: range } : {}), }, redirect: "error", signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), @@ -672,18 +680,51 @@ export function relayBrokerPlugin({ if (!upstream.ok) return json(res, upstream.status, { error: "Media read failed" }); const type = upstream.headers.get("content-type") ?? ""; - if (!type.startsWith("image/")) - return json(res, 415, { - error: "Only image previews are proxied", - }); - const bytes = Buffer.from(await upstream.arrayBuffer()); - if (bytes.length > MAX_MEDIA_BYTES) + const image = type.startsWith("image/"); + const video = type.startsWith("video/"); + if (!image && !video) + return json(res, 415, { error: "Media type rejected" }); + const length = Number(upstream.headers.get("content-length")); + if ( + Number.isFinite(length) && + length > MAX_MEDIA_BYTES && + !(video && upstream.status === 206) + ) return json(res, 413, { error: "Media budget exceeded" }); - res.writeHead(200, { + const headers = { "Content-Type": type, "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - }); + ...(upstream.headers.get("content-length") + ? { "Content-Length": upstream.headers.get("content-length") } + : {}), + ...(upstream.headers.get("content-range") + ? { "Content-Range": upstream.headers.get("content-range") } + : {}), + ...(video + ? { + "Accept-Ranges": + upstream.headers.get("accept-ranges") ?? "bytes", + } + : {}), + }; + if (video) { + res.writeHead(upstream.status, headers); + if (!upstream.body) return res.end(); + const stream = Readable.fromWeb(upstream.body); + // A range request may time out or be cancelled after headers. A + // piped Readable has no automatic error consumer; without this, + // Node treats the upstream abort as an uncaught process error and + // kills the live broker along with unrelated message traffic. + stream.once("error", () => res.destroy()); + res.once("close", () => stream.destroy()); + stream.pipe(res); + return; + } + const bytes = Buffer.from(await upstream.arrayBuffer()); + if (bytes.length > MAX_MEDIA_BYTES) + return json(res, 413, { error: "Media budget exceeded" }); + res.writeHead(200, headers); return res.end(bytes); } if ( diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 5f67e97a..cb52f932 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -36,6 +36,8 @@ import { LiveStatus } from "./LiveStatus"; import { MessageComposer } from "../../features/messages/MessageComposer"; import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; +import { MediaReviewViewer } from "../../features/messages/MediaReviewViewer"; +import type { Attachment } from "../../features/relay/contracts"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; import { useSidebarPreferences } from "./useSidebarPreferences"; @@ -226,6 +228,26 @@ function ChannelWorkspace({ }, [current], ); + const [mediaReview, setMediaReview] = useState<{ + channelId: string; + channelName: string; + messageId: string; + attachment: Attachment; + initialTime: number; + }>(); + const openMediaReview = useCallback( + (messageId: string, attachment: Attachment, initialTime: number) => { + if (!current) return; + setMediaReview({ + channelId: current.id, + channelName: current.name, + messageId, + attachment, + initialTime, + }); + }, + [current], + ); const closeThread = useCallback(() => { setThread(undefined); if (threadTrigger.current?.isConnected) threadTrigger.current.focus(); @@ -444,6 +466,7 @@ function ChannelWorkspace({ navigation={navigation} onOpenLink={openLink} onOpenThread={openThread} + onOpenMediaReview={openMediaReview} revealMessageId={ sent?.channelId === current.id ? sent.id : undefined } @@ -463,6 +486,19 @@ function ChannelWorkspace({ /> )} + {mediaReview && ( + setMediaReview(undefined)} + /> + )} {(panel || showingThread || companion) && (
{showingThread && ( @@ -476,6 +512,7 @@ function ChannelWorkspace({ messageId={showingThread.messageId} close={closeThread} onOpenLink={openLink} + onOpenMediaReview={openMediaReview} /> )} @@ -507,6 +544,7 @@ function ChannelBody({ onOpenLink, revealMessageId, onOpenThread, + onOpenMediaReview, navigation, }: { extensions?: ConversationExtensions | undefined; @@ -517,6 +555,11 @@ function ChannelBody({ onOpenLink(url: string): boolean; revealMessageId?: string | undefined; onOpenThread(messageId: string): void; + onOpenMediaReview( + messageId: string, + attachment: Attachment, + seconds: number, + ): void; }) { const window = useChannelWindow(queries.channels, channelId); useEffect(() => { @@ -558,6 +601,7 @@ function ChannelBody({ window={window} onOpenLink={onOpenLink} onOpenThread={onOpenThread} + onOpenMediaReview={onOpenMediaReview} revealMessageId={revealMessageId} /> ); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 55523ab0..ba6e1f0b 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -4,7 +4,7 @@ import type { RelaySession } from "../relay/session"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Virtualizer, type VirtualizerHandle } from "virtua"; import { MessageRow } from "./MessageRow"; -import type { ChannelWindow } from "../relay/contracts"; +import type { Attachment, ChannelWindow } from "../relay/contracts"; import { useRowProfiles } from "../relay/react"; import { geometryFor, geometrySignature } from "./geometry"; import { readView, writeView } from "../../shared/view-state"; @@ -54,6 +54,11 @@ export type ChannelTimelineProps = { onOpenLink(url: string): boolean; revealMessageId?: string | undefined; onOpenThread?(messageId: string): void; + onOpenMediaReview?( + messageId: string, + attachment: Attachment, + seconds: number, + ): void; }; /** Safe to retarget through ordinary props; callers do not own internal remount keys. */ @@ -74,6 +79,7 @@ function Timeline({ onOpenLink, revealMessageId, onOpenThread, + onOpenMediaReview, }: ChannelTimelineProps) { const [initialPosition] = useState(() => readView(scope, `scroll:${channelId}`, null), @@ -343,6 +349,14 @@ function Timeline({ media={queries.media} onOpenLink={onOpenLink} onOpenThread={onOpenThread} + {...(onOpenMediaReview + ? { + onOpenMediaReview: ( + attachment: Attachment, + seconds: number, + ) => onOpenMediaReview(row.id, attachment, seconds), + } + : {})} retry={queries.outbox?.retry} day={ index === 0 || diff --git a/src/features/messages/ImageReviewStage.tsx b/src/features/messages/ImageReviewStage.tsx new file mode 100644 index 00000000..216b4593 --- /dev/null +++ b/src/features/messages/ImageReviewStage.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronLeft, ChevronRight, Download, Minus, Plus } from "lucide-react"; +import type { Attachment } from "../relay/contracts"; +import styles from "./Messages.module.css"; + +const MIN_ZOOM = 1; +const MAX_ZOOM = 4; +const ZOOM_STEP = 0.25; + +type Point = Readonly<{ x: number; y: number }>; + +type ImageReviewStageProps = { + attachments: readonly Attachment[]; + selectedUrl: string; + media(url: string): string | undefined; + select(url: string): void; +}; + +function clamp(value: number, limit: number) { + return Math.max(-limit, Math.min(limit, value)); +} + +export function ImageReviewStage({ + attachments, + selectedUrl, + media, + select, +}: ImageReviewStageProps) { + const stage = useRef(null); + const image = useRef(null); + const drag = useRef< + { pointer: number; origin: Point; offset: Point } | undefined + >(undefined); + const [zoom, setZoom] = useState(MIN_ZOOM); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const selectedIndex = Math.max( + 0, + attachments.findIndex((item) => item.url === selectedUrl), + ); + const selected = attachments[selectedIndex] ?? attachments[0]; + const source = selected ? (media(selected.url) ?? selected.url) : ""; + const pannable = zoom > MIN_ZOOM; + + const panLimits = (nextZoom = zoom) => { + const frame = stage.current?.getBoundingClientRect(); + const element = image.current; + if (!frame || !element?.naturalWidth || !element.naturalHeight) + return { x: 0, y: 0 }; + const fit = Math.min( + frame.width / element.naturalWidth, + frame.height / element.naturalHeight, + ); + const width = element.naturalWidth * fit * nextZoom; + const height = element.naturalHeight * fit * nextZoom; + return { + x: Math.max(0, (width - frame.width) / 2), + y: Math.max(0, (height - frame.height) / 2), + }; + }; + const setBoundedZoom = (value: number) => { + const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)); + const limits = panLimits(next); + setZoom(next); + setOffset((current) => ({ + x: clamp(current.x, limits.x), + y: clamp(current.y, limits.y), + })); + }; + const choose = (index: number) => { + const item = attachments[index]; + if (!item) return; + setZoom(MIN_ZOOM); + setOffset({ x: 0, y: 0 }); + select(item.url); + }; + + useEffect(() => { + const reset = () => setBoundedZoom(zoom); + window.addEventListener("resize", reset); + return () => window.removeEventListener("resize", reset); + }); + + if (!selected) return null; + return ( +
{ + if (!pannable) return; + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { + pointer: event.pointerId, + origin: { x: event.clientX, y: event.clientY }, + offset, + }; + setDragging(true); + }} + onPointerMove={(event) => { + const active = drag.current; + if (!active || active.pointer !== event.pointerId) return; + const limits = panLimits(); + setOffset({ + x: clamp(active.offset.x + event.clientX - active.origin.x, limits.x), + y: clamp(active.offset.y + event.clientY - active.origin.y, limits.y), + }); + }} + onPointerUp={(event) => { + if (drag.current?.pointer !== event.pointerId) return; + drag.current = undefined; + setDragging(false); + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={() => { + drag.current = undefined; + setDragging(false); + }} + > + Attachment preview +
event.stopPropagation()} + > + {attachments.length > 1 && ( +
+ + + {selectedIndex + 1} / {attachments.length} + + +
+ )} +
+ + + setBoundedZoom(Number(event.currentTarget.value)) + } + /> + + +
+ + +
+
+ ); +} diff --git a/src/features/messages/MediaAttachment.tsx b/src/features/messages/MediaAttachment.tsx new file mode 100644 index 00000000..04c8e02f --- /dev/null +++ b/src/features/messages/MediaAttachment.tsx @@ -0,0 +1,311 @@ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, + type RefObject, +} from "react"; +import { Expand, Pause, Play, X } from "lucide-react"; +import { createPortal } from "react-dom"; +import type { Attachment } from "../relay/contracts"; +import { formatMediaTime } from "./media-timecode"; +import styles from "./Messages.module.css"; + +export type MediaPlayback = Readonly<{ + attachmentUrl: string; + seconds: number; +}>; + +type MediaAttachmentProps = { + attachment: Attachment; + media(url: string): string | undefined; + mode?: "inline" | "thread"; + seekTo?: number; + seekRequest?: number; + onPlayback?(playback: MediaPlayback): void; + onOpenReview?(attachment: Attachment, seconds: number): void; +}; + +function useVideoPosition( + video: RefObject, + seekTo: number | undefined, + seekRequest: number | undefined, +) { + useEffect(() => { + // A monotonically increasing request lets the same timecode seek again. + void seekRequest; + if (seekTo === undefined || !video.current) return; + const seek = () => { + if (!video.current) return; + video.current.currentTime = Math.max(0, seekTo); + void video.current.play().catch(() => {}); + }; + if (video.current.readyState >= HTMLMediaElement.HAVE_METADATA) seek(); + else video.current.addEventListener("loadedmetadata", seek, { once: true }); + }, [seekTo, seekRequest, video]); +} + +export function MediaAttachment({ + attachment, + media, + mode = "inline", + seekTo, + seekRequest, + onPlayback, + onOpenReview, +}: MediaAttachmentProps) { + const source = media(attachment.url) ?? attachment.url; + const preview = attachment.previewUrl + ? (media(attachment.previewUrl) ?? attachment.previewUrl) + : undefined; + const video = useRef(null); + const [viewerOpen, setViewerOpen] = useState(false); + const [currentTime, setCurrentTime] = useState(seekTo ?? 0); + const [playing, setPlaying] = useState(false); + const [started, setStarted] = useState(false); + const [failed, setFailed] = useState(false); + const [capturedPreview, setCapturedPreview] = useState(); + const [measuredDimensions, setMeasuredDimensions] = useState<{ + width: number; + height: number; + }>(); + const dimensions = attachment.dimensions ?? measuredDimensions; + const previewStyle = dimensions + ? ({ + "--media-ratio": `${dimensions.width} / ${dimensions.height}`, + aspectRatio: "var(--media-ratio)", + } as CSSProperties) + : undefined; + const visiblePreview = preview ?? capturedPreview; + useVideoPosition(video, seekTo, seekRequest); + + if (failed) + return ( + + {attachment.video ? "Open video" : "Open image"} ↗ + + ); + + if (!attachment.video) + return ( + <> + + {viewerOpen && + createPortal( + setViewerOpen(false)} + > + Attachment preview + , + document.body, + )} + + ); + + const videoElement = ( + // biome-ignore lint/a11y/useMediaCaption: signed attachment metadata has no caption track URL. +
+ {threadRootId && + mediaTimeSeconds !== undefined && + !hideMediaTimeIndicator && ( +
+ Commenting at {formatMediaTime(mediaTimeSeconds)} + +
+ )} {!!value.recipients.length && (
void) | undefined; onOpenThread?: ((messageId: string) => void) | undefined; + mediaMode?: "inline" | "thread"; + mediaSeekTo?: number; + mediaSeekRequest?: number; + onMediaPlayback?: (playback: MediaPlayback) => void; + onMediaTime?: (seconds: number) => void; + onOpenMediaReview?: ( + attachment: ChannelMessage["attachments"][number], + seconds: number, + ) => void; }; export const MessageRow = memo(function MessageRow({ @@ -32,6 +43,12 @@ export const MessageRow = memo(function MessageRow({ retry, onOpenThread, participantProfiles, + mediaMode = "inline", + mediaSeekTo, + mediaSeekRequest, + onMediaPlayback, + onMediaTime, + onOpenMediaReview, }: MessageRowProps) { const threadUnread = useThreadUnread( row.replyCount > 0 && onOpenThread ? unread : undefined, @@ -48,7 +65,10 @@ export const MessageRow = memo(function MessageRow({ : undefined; const name = profile?.name ?? row.authorId.slice(0, 10); const picture = profile?.picture ? media(profile.picture) : undefined; - const emojiOnly = usesLargeEmojiPresentation(row.content, row.emoji); + const timeReply = parseMediaTimeReply(row.content); + const replaceTime = !!timeReply && !!onMediaTime; + const displayRow = replaceTime ? { ...row, content: timeReply.content } : row; + const emojiOnly = usesLargeEmojiPresentation(displayRow.content, row.emoji); return (
{day && ( @@ -81,48 +101,47 @@ export const MessageRow = memo(function MessageRow({

+ {timeReply && onMediaTime && ( + + )}

- {row.attachments.map((attachment) => { - const source = media(attachment.url); - return attachment.video || !source ? ( - - {attachment.video ? "Video attachment" : "Image attachment"} ↗ - - ) : ( - { - if ( - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - onOpenLink(attachment.url) - ) - event.preventDefault(); - }} - > - - - ); - })} + {row.attachments.length > 0 && ( +
+ {row.attachments.map((attachment) => ( + + ))} +
+ )} {row.reactions.length > 0 && (
{row.reactions.map((reaction) => ( diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index feccdce6..0eea1dba 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -497,6 +497,472 @@ .mentionRecipients code { font-size: calc(10px * var(--buzz-text-scale, 1)); } +.mediaAttachments { + display: grid; + justify-items: start; + gap: 0.5rem; + margin-top: 0.5rem; +} +.mediaPreview { + position: relative; + display: block; + width: min(32rem, 100%, calc(min(28rem, 56vh) * var(--media-ratio, 16 / 9))); + max-width: 100%; + aspect-ratio: var(--media-ratio, 16 / 9); + overflow: hidden; + padding: 0; + border: 0; + border-radius: var(--radius-card); + background: var(--surface-control); + color: white; + isolation: isolate; +} +button.mediaPreview { + cursor: zoom-in; +} +.mediaPreviewThread { + width: min(32rem, 100%, calc(min(22rem, 45vh) * var(--media-ratio, 16 / 9))); +} +.mediaPreview > img:not(.mediaPoster), +.mediaVideo { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + background: #101114; +} +.mediaPoster { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; +} +.mediaPlay, +.mediaExpand { + position: absolute; + z-index: 2; + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 0; + border-radius: 50%; + color: white; + background: rgb(16 17 20 / 72%); + backdrop-filter: blur(0.75rem); +} +.mediaPlay { + inset: 50% auto auto 50%; + translate: -50% -50%; +} +.mediaExpand { + top: 0.75rem; + right: 0.75rem; +} +.mediaPlay:hover, +.mediaExpand:hover { + background: rgb(16 17 20 / 88%); +} +.mediaTime { + position: absolute; + left: 0.75rem; + bottom: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 999px; + color: white; + background: rgb(16 17 20 / 72%); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + backdrop-filter: blur(0.75rem); +} +.mediaTimeLink { + margin-right: 0.4rem; + padding: 0.15rem 0.45rem; + border: 0; + border-radius: 999px; + background: var(--surface-accent); + color: var(--link); + font: inherit; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + cursor: pointer; +} +.mediaTimeLink:hover { + background: var(--surface-hover); +} +.mediaComposerAnchor { + display: flex; + align-items: center; + align-self: flex-start; + gap: 0.35rem; + padding: 0.25rem 0.35rem 0.25rem 0.55rem; + border-radius: 999px; + color: var(--text); + background: var(--surface-accent); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} +.mediaComposerAnchor button { + display: grid; + place-items: center; + width: 1.4rem; + height: 1.4rem; + padding: 0; + border: 0; + border-radius: 50%; + color: inherit; + background: transparent; +} +.mediaComposerAnchor button:hover { + background: var(--surface-hover); +} +.mediaViewerBackdrop { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 2rem; + background: rgb(10 11 14 / 88%); +} +.mediaViewer { + position: relative; + display: grid; + place-items: center; + width: min(90rem, 100%); + height: min(54rem, calc(100vh - 4rem)); + overflow: hidden; + border-radius: var(--radius-card); + background: #101114; + box-shadow: var(--elevation-popover); +} +.mediaViewerDragRegion { + position: absolute; + z-index: 1; + top: 0; + right: 4rem; + left: 0; + height: 3.5rem; +} +.mediaViewerClose { + position: absolute; + z-index: 2; + top: 1rem; + right: 1rem; + display: grid; + place-items: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 0; + border-radius: 50%; + color: white; + background: rgb(16 17 20 / 72%); +} +.mediaViewerImage, +.mediaViewerVideo { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +@media (max-width: 650px) { + .mediaViewerBackdrop { + padding: 0; + } + .mediaViewer { + width: 100%; + height: 100%; + border-radius: 0; + } + .mediaPreview { + max-height: 18rem; + } +} + +.mediaReviewBackdrop { + position: fixed; + inset: 0; + z-index: 100; + padding: 1.5rem; + background: rgb(10 11 14 / 88%); +} +.mediaReviewViewer { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(19rem, 24rem); + grid-template-rows: auto minmax(0, 1fr); + width: min(96rem, 100%); + height: 100%; + margin: 0 auto; + overflow: hidden; + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--elevation-popover); +} +.mediaReviewHeading { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 3.5rem; + padding: 0.75rem 1rem; + color: var(--text); + background: var(--surface); +} +.mediaReviewHeading button { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--text); + background: var(--surface-control); +} +.mediaReviewStage { + display: grid; + place-items: center; + min-width: 0; + min-height: 0; + padding: 1rem; + background: #101114; +} +.mediaReviewStage video, +.mediaReviewStage img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.mediaReviewConversation { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; + background: var(--surface); +} +.mediaReviewThread { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 1rem; + scrollbar-width: thin; +} +.mediaReviewThreadHeading { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0 0.75rem; + background: var(--surface); +} +.mediaReviewThreadHeading span { + color: var(--text-muted); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} +.mediaReviewThread .message { + padding-bottom: 1rem; +} +.mediaReviewThread .avatar { + width: 2rem; + height: 2rem; +} +.mediaReviewTimeOption { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.85rem 1rem 0; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.75rem; +} +.mediaReviewTimeOption > span { + padding: 0.25rem 0.5rem; + border-radius: 0.4rem; + background: var(--surface-accent); + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.mediaReviewTimeOption label { + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + user-select: none; +} +.mediaReviewTimeOption input { + width: 0.9rem; + height: 0.9rem; + margin: 0; + accent-color: var(--action); +} +@media (max-width: 800px) { + .mediaReviewBackdrop { + padding: 0; + } + .mediaReviewViewer { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr) auto; + border-radius: 0; + } + .mediaReviewConversation { + max-height: 18rem; + } +} + +.mediaCommentAction { + align-self: flex-start; + margin: -0.1rem 0 0.75rem 3.25rem; + padding: 0.4rem 0.65rem; + border: 0; + border-radius: 0.5rem; + background: var(--surface-accent); + color: var(--text); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} +.mediaCommentAction:hover { + background: var(--surface-hover); +} + +.imageReviewStage { + position: relative; + display: grid; + place-items: center; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + touch-action: none; + user-select: none; +} +.imageReviewStage > img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + transform-origin: center; + will-change: transform; +} +.imageReviewPannable { + cursor: grab; +} +.imageReviewDragging { + cursor: grabbing; +} +.imageReviewToolbar { + position: absolute; + z-index: 2; + left: 50%; + bottom: 1rem; + translate: -50% 0; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.45rem; + border: 1px solid rgb(255 255 255 / 16%); + border-radius: 999px; + color: white; + background: rgb(16 17 20 / 78%); + box-shadow: var(--elevation-popover); + backdrop-filter: blur(0.9rem); +} +.imageReviewToolbar button, +.imageReviewToolbar a { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 0; + border-radius: 50%; + color: inherit; + background: transparent; + text-decoration: none; +} +.imageReviewToolbar button:hover:not(:disabled), +.imageReviewToolbar a:hover { + background: rgb(255 255 255 / 12%); +} +.imageReviewToolbar button:disabled { + opacity: 0.35; +} +.imageReviewSwitcher, +.imageReviewZoom { + display: flex; + align-items: center; + gap: 0.3rem; +} +.imageReviewSwitcher { + padding-right: 0.5rem; + border-right: 1px solid rgb(255 255 255 / 16%); +} +.imageReviewSwitcher span, +.imageReviewPercent { + min-width: 2.8rem; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + text-align: center; +} +.imageReviewZoom input { + width: min(9rem, 18vw); + accent-color: white; +} +.imageReviewZoom .imageReviewPercent { + width: auto; + padding: 0 0.35rem; + border-radius: 0.35rem; +} +@media (max-width: 650px) { + .imageReviewToolbar { + right: 0.5rem; + bottom: 0.6rem; + left: 0.5rem; + translate: 0 0; + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.25rem; + border-radius: 1rem; + } + .imageReviewZoom { + min-width: 0; + } + .imageReviewZoom input { + width: 100%; + min-width: 3rem; + } + .imageReviewSwitcher { + padding-right: 0.25rem; + } +} + +.attachmentUnavailable, +.mediaReviewUnavailable { + color: var(--text-muted); + font-size: 0.8rem; +} +.attachmentUnavailable { + display: inline-block; + margin-top: 0.5rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--surface-control); +} .text[data-single-emoji] > span { display: flex; height: calc(42px * var(--buzz-text-scale, 1)); diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 128bbec8..ccddf9f3 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { X } from "lucide-react"; import type { ConversationExtensions } from "../conversation/contracts"; +import type { ChannelMessage } from "../relay/contracts"; import type { RelaySession } from "../relay/session"; import type { ThreadView } from "../relay/threads"; import { useRowProfiles } from "../relay/react"; @@ -17,6 +18,8 @@ import { MessageComposer } from "./MessageComposer"; import styles from "./Messages.module.css"; import { useReading } from "./use-reading"; import { messageViewKey } from "./view-key"; +import type { MediaPlayback } from "./MediaAttachment"; +import { formatMediaTime } from "./media-timecode"; export type ThreadPanelProps = { extensions?: ConversationExtensions | undefined; @@ -27,6 +30,11 @@ export type ThreadPanelProps = { messageId: string; close(): void; onOpenLink(url: string): boolean; + onOpenMediaReview?( + messageId: string, + attachment: ChannelMessage["attachments"][number], + seconds: number, + ): void; }; /** Safe to retarget through ordinary props; callers do not own internal remount keys. */ @@ -52,6 +60,7 @@ function OwnedThreadPanel({ messageId, close, onOpenLink, + onOpenMediaReview, }: ThreadPanelProps) { const [view, setView] = useState(); const [error, setError] = useState(); @@ -112,8 +121,10 @@ function OwnedThreadPanel({ scope={scope} channelId={channelId} channelName={channelName} + messageId={messageId} view={view} onOpenLink={onOpenLink} + onOpenMediaReview={onOpenMediaReview} /> ) : (

@@ -129,16 +140,20 @@ function ThreadMessages({ scope, channelId, channelName, + messageId, view, onOpenLink, + onOpenMediaReview, }: { extensions?: ConversationExtensions | undefined; session: RelaySession; scope: string; channelId: string; channelName: string; + messageId: string; view: ThreadView; onOpenLink(url: string): boolean; + onOpenMediaReview?: ThreadPanelProps["onOpenMediaReview"]; }) { const snapshot = useSyncExternalStore( view.subscribe, @@ -165,6 +180,13 @@ function ThreadMessages({ const follow = useRef(true); useReading({ session, channelId, scroller, settled: positioned }); const [sent, setSent] = useState(); + const [mediaPlayback, setMediaPlayback] = useState(); + const [mediaCommentTime, setMediaCommentTime] = useState(); + const [mediaSeek, setMediaSeek] = useState<{ + seconds: number; + request: number; + }>(); + const videoAttachment = snapshot.root?.attachments.find((item) => item.video); // The bridge walks oldest-first. Finish its bounded range automatically, rather // than exposing transport pagination as a conversation control. useEffect(() => { @@ -223,15 +245,40 @@ function ThreadMessages({ tabIndex={0} > {snapshot.root ? ( - + <> + + onOpenMediaReview(messageId, attachment, seconds), + } + : {})} + /> + {videoAttachment && mediaPlayback && ( + + )} + ) : ( snapshot.status !== "loading" && (

Original message unavailable.

@@ -252,6 +299,15 @@ function ThreadMessages({ onOpenLink={onOpenLink} day={false} retry={session.messages.retry} + {...(videoAttachment + ? { + onMediaTime: (seconds: number) => + setMediaSeek((current) => ({ + seconds, + request: (current?.request ?? 0) + 1, + })), + } + : {})} /> ))} @@ -281,6 +337,10 @@ function ThreadMessages({ channelId={channelId} channelName={channelName} threadRootId={snapshot.root.id} + {...(videoAttachment && mediaCommentTime !== undefined + ? { mediaTimeSeconds: mediaCommentTime } + : {})} + clearMediaTime={() => setMediaCommentTime(undefined)} onSend={(id) => { positioned.current = true; follow.current = true; diff --git a/src/features/messages/media-timecode.test.ts b/src/features/messages/media-timecode.test.ts new file mode 100644 index 00000000..a4098798 --- /dev/null +++ b/src/features/messages/media-timecode.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + formatMediaTime, + mediaTimeReply, + parseMediaTimeReply, +} from "./media-timecode"; + +describe("media time replies", () => { + it("formats short and hour-long positions", () => { + expect(formatMediaTime(42.9)).toBe("0:42"); + expect(formatMediaTime(3_725)).toBe("1:02:05"); + }); + + it("round-trips a reply into a typed time anchor", () => { + const content = mediaTimeReply(42.9, "The transition feels abrupt"); + expect(content).toBe("⏱ 0:42 — The transition feels abrupt"); + expect(parseMediaTimeReply(content)).toEqual({ + anchor: { type: "time", seconds: 42 }, + label: "0:42", + content: "The transition feels abrupt", + }); + }); + + it("leaves ordinary messages alone", () => { + expect(parseMediaTimeReply("Meet me at 0:42")).toBeUndefined(); + }); +}); diff --git a/src/features/messages/media-timecode.ts b/src/features/messages/media-timecode.ts new file mode 100644 index 00000000..8a901de9 --- /dev/null +++ b/src/features/messages/media-timecode.ts @@ -0,0 +1,42 @@ +export type MediaTimeAnchor = Readonly<{ + type: "time"; + seconds: number; +}>; + +const TIMECODE = /^⏱\s*((?:(\d+):)?(\d{1,2}):(\d{2}))\s+—\s+([\s\S]+)$/; + +export function formatMediaTime(seconds: number): string { + const total = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const remainder = total % 60; + return hours + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}` + : `${minutes}:${String(remainder).padStart(2, "0")}`; +} + +export function mediaTimeReply(seconds: number, content: string): string { + return `⏱ ${formatMediaTime(seconds)} — ${content.trim()}`; +} + +export function parseMediaTimeReply( + content: string, +): + | Readonly<{ anchor: MediaTimeAnchor; label: string; content: string }> + | undefined { + const match = TIMECODE.exec(content); + if (!match) return undefined; + const hours = Number(match[2] ?? 0); + const minutes = Number(match[3]); + const seconds = Number(match[4]); + if (minutes > 59 && hours > 0) return undefined; + const value = hours * 3600 + minutes * 60 + seconds; + const label = match[1]; + const body = match[5]; + if (!label || !body) return undefined; + return { + anchor: { type: "time", seconds: value }, + label, + content: body, + }; +} diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 28955af8..9523f1e2 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -16,7 +16,14 @@ export type ChannelSummary = Readonly<{ participants?: readonly string[]; }>; export type Profile = Readonly<{ name: string; picture?: string }>; -export type Attachment = Readonly<{ url: string; video: boolean }>; +export type Attachment = Readonly<{ + url: string; + video: boolean; + /** Signed NIP-92 dimensions, when supplied by the sender. */ + dimensions?: Readonly<{ width: number; height: number }>; + /** Signed video poster or media thumbnail URL. */ + previewUrl?: string; +}>; export type ChannelMessage = Readonly<{ id: string; channelId: string; diff --git a/src/features/relay/fold.test.ts b/src/features/relay/fold.test.ts index f129369a..f95a8f99 100644 --- a/src/features/relay/fold.test.ts +++ b/src/features/relay/fold.test.ts @@ -170,7 +170,13 @@ describe("message fold", () => { created_at: 10, tags: [ ["h", channel], - ["imeta", "url https://x.test/c.jpg", "m image/jpeg"], + [ + "imeta", + "url https://x.test/c.jpg", + "m image/jpeg", + "dim 1280x720", + "image https://x.test/c-poster.jpg", + ], ["imeta", "url http://insecure.test/d.jpg"], ], }); @@ -178,7 +184,12 @@ describe("message fold", () => { assert.exists(row); expect(row.content).toBe("See and"); expect(row.attachments).toEqual([ - { url: "https://x.test/c.jpg", video: false }, + { + url: "https://x.test/c.jpg", + video: false, + dimensions: { width: 1280, height: 720 }, + previewUrl: "https://x.test/c-poster.jpg", + }, { url: "https://x.test/a.png", video: false }, { url: "https://x.test/b.mp4", video: true }, ]); diff --git a/src/features/relay/fold.ts b/src/features/relay/fold.ts index 73eb40f8..cf9a4f8b 100644 --- a/src/features/relay/fold.ts +++ b/src/features/relay/fold.ts @@ -26,7 +26,17 @@ export function parseAttachments( const url = fields.url; if (!url || seen.has(url) || !/^https:\/\//.test(url)) continue; seen.add(url); - result.push({ url, video: fields.m?.startsWith("video/") ?? false }); + const dimensions = fields.dim?.match(/^(\d+)x(\d+)$/); + const width = Number(dimensions?.[1]); + const height = Number(dimensions?.[2]); + result.push({ + url, + video: fields.m?.startsWith("video/") ?? false, + ...(width > 0 && height > 0 ? { dimensions: { width, height } } : {}), + ...(fields.image || fields.thumb + ? { previewUrl: fields.image ?? fields.thumb } + : {}), + }); } for (const match of content.matchAll(IMAGE_MARKDOWN)) { const url = match[1]; From 1fdf625dff5bc8fee02c8b64d7d29d2d0a8466ff Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 11 Sep 2026 11:13:29 -0400 Subject: [PATCH 02/16] Respect unavailable media resolution Signed-off-by: Clay Delk --- src/features/messages/ImageReviewStage.tsx | 9 +++++++-- src/features/messages/MediaAttachment.tsx | 22 +++++++++++---------- src/features/messages/MediaReviewViewer.tsx | 8 ++++++-- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/features/messages/ImageReviewStage.tsx b/src/features/messages/ImageReviewStage.tsx index 216b4593..f2b6dca7 100644 --- a/src/features/messages/ImageReviewStage.tsx +++ b/src/features/messages/ImageReviewStage.tsx @@ -39,7 +39,7 @@ export function ImageReviewStage({ attachments.findIndex((item) => item.url === selectedUrl), ); const selected = attachments[selectedIndex] ?? attachments[0]; - const source = selected ? (media(selected.url) ?? selected.url) : ""; + const source = selected ? media(selected.url) : undefined; const pannable = zoom > MIN_ZOOM; const panLimits = (nextZoom = zoom) => { @@ -81,7 +81,12 @@ export function ImageReviewStage({ return () => window.removeEventListener("resize", reset); }); - if (!selected) return null; + if (!selected || !source) + return ( +

+ Image unavailable +

+ ); return (
(null); const [viewerOpen, setViewerOpen] = useState(false); @@ -80,16 +80,18 @@ export function MediaAttachment({ const visiblePreview = preview ?? capturedPreview; useVideoPosition(video, seekTo, seekRequest); + if (!source) + return ( + + {attachment.video ? "Video unavailable" : "Image unavailable"} + + ); + if (failed) return ( - - {attachment.video ? "Open video" : "Open image"} ↗ - + + {attachment.video ? "Video unavailable" : "Image unavailable"} + ); if (!attachment.video) diff --git a/src/features/messages/MediaReviewViewer.tsx b/src/features/messages/MediaReviewViewer.tsx index ebc8dff3..56efac2f 100644 --- a/src/features/messages/MediaReviewViewer.tsx +++ b/src/features/messages/MediaReviewViewer.tsx @@ -41,7 +41,7 @@ export function MediaReviewViewer({ initialTime, close, }: MediaReviewViewerProps) { - const source = session.media(attachment.url) ?? attachment.url; + const source = session.media(attachment.url); const closeButton = useRef(null); const video = useRef(null); const [currentTime, setCurrentTime] = useState(initialTime); @@ -97,7 +97,11 @@ export function MediaReviewViewer({
- {attachment.video ? ( + {!source ? ( +

+ {attachment.video ? "Video unavailable" : "Image unavailable"} +

+ ) : attachment.video ? ( // biome-ignore lint/a11y/useMediaCaption: signed attachment metadata has no caption track URL.
, - input.current.ownerDocument.body, + // A body sibling would sit behind a fullscreen modal and outside its + // accessibility boundary. Ordinary composers keep the body portal. + input.current.closest('[role="dialog"][aria-modal="true"]') ?? + input.current.ownerDocument.body, )} ); diff --git a/tests/browser/messages.spec.mjs b/tests/browser/messages.spec.mjs index 4eaf0b28..02008f14 100644 --- a/tests/browser/messages.spec.mjs +++ b/tests/browser/messages.spec.mjs @@ -352,6 +352,95 @@ test("shared thread UI auto-loads, follows live replies, retries and isolates re } }); +// Browser-only: real hit testing must reject suggestions painted behind the modal. +// DOM visibility alone cannot prove the listbox is visible or pointer-accessible. +test("media review completions stay visible and preserve modal keyboard ownership", async ({ + page, +}) => { + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + plugins: [react()], + logLevel: "error", + server: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + await server.listen(); + try { + const address = server.httpServer.address(); + await page.goto( + `http://127.0.0.1:${address.port}/tests/fixtures/messages.html`, + ); + await page.evaluate(() => window.messagesFixture.activate()); + const trigger = page.getByRole("button", { + name: "Review image", + exact: true, + }); + await trigger.click(); + const dialog = page.getByRole("dialog", { name: "Image viewer" }); + const input = dialog.getByRole("textbox", { name: "Reply to thread" }); + const topmost = (option) => + option.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + ), + ); + }); + for (const width of [1440, 320]) { + await page.setViewportSize({ width, height: 950 }); + await input.fill("@Fixture"); + const mention = page + .getByRole("listbox", { + name: "Mention suggestions", + }) + .getByRole("option") + .first(); + await expect(mention).toContainText("Fixture Reader"); + await expect.poll(() => topmost(mention)).toBe(true); + await mention.click(); + await expect(input).toHaveJSProperty("value", "@Fixture Reader "); + await expect(input).toBeFocused(); + await expect( + dialog.getByRole("region", { + name: "Notification recipients", + }), + ).toContainText("Fixture Reader"); + for (const key of ["Enter", "Tab"]) { + await input.fill(":smile"); + const emoji = page + .getByRole("listbox", { + name: "Emoji suggestions", + }) + .getByRole("option") + .first(); + await expect(emoji).toContainText(":smile:"); + await expect.poll(() => topmost(emoji)).toBe(true); + await input.press(key); + await expect(input).toHaveJSProperty("value", "😄"); + await expect(input).toBeFocused(); + await expect(page.getByRole("listbox")).toHaveCount(0); + } + } + await input.fill(":smile"); + await expect(page.getByRole("option").first()).toBeVisible(); + await input.press("Escape"); + await expect(page.getByRole("listbox")).toHaveCount(0); + await expect(dialog).toBeVisible(); + await expect(input).toBeFocused(); + await input.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + expect( + await page.evaluate(() => window.messagesFixture.report.publications), + ).toEqual([]); + } finally { + await server.close(); + } +}); + test("exact reply media keeps its selected attachment and canonical thread", async ({ page, }) => {