Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fbb367f
Add media review workspace
delkc Sep 11, 2026
1fdf625
Respect unavailable media resolution
delkc Sep 11, 2026
cb7406a
Preserve non-seekable media timecodes
delkc Sep 11, 2026
19cd769
Contain media viewer focus
delkc Sep 11, 2026
a2e217c
Gate review workspace on resolved thread
delkc Sep 11, 2026
60319c5
Stabilize modal lifecycle callbacks
delkc Sep 11, 2026
df00448
Cover media review lifecycle regressions
delkc Sep 11, 2026
8488066
Route reply media through thread review
delkc Sep 11, 2026
3f1a674
Replace active media within review workspace
delkc Sep 11, 2026
ada6839
Avoid ambiguous multi-video seeks
delkc Sep 11, 2026
55ce0d1
Keep media viewers draggable
delkc Sep 11, 2026
3467ed6
Surface interrupted media review reads
delkc Sep 11, 2026
b655fa8
Dispose media review on channel navigation
delkc Sep 11, 2026
4d1a056
Merge main into clay/media-review-prototype
Sep 12, 2026
b126235
Merge latest main into media review prototype
delkc Sep 14, 2026
5e424cc
Keep media callbacks stable across timeline updates
delkc Sep 14, 2026
964c186
Merge latest main into media review prototype
delkc Sep 14, 2026
b5354bf
Merge latest main into media review prototype
delkc Sep 15, 2026
7cebf4b
Merge latest main into media review prototype
delkc Sep 15, 2026
bdd60b7
Merge latest main into media review prototype
delkc Sep 16, 2026
bb0c5d1
Align media review with shared design foundations
delkc Sep 16, 2026
abd4663
fix(messages): keep fullscreen completions inside modal
Sep 18, 2026
0c5edab
Merge main into media review and preserve shared avatar hints
Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -198,6 +200,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
Expand Down
60 changes: 49 additions & 11 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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";
Expand Down Expand Up @@ -724,11 +725,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),
Expand All @@ -737,21 +745,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" });
server.config.logger.info(
`[relay-broker] media ${target.pathname} ${bytes.length}B ${type} (${Date.now() - startedAt}ms)`,
);
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 (
Expand Down
12 changes: 11 additions & 1 deletion src/bundled/channels/ChannelsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, it, vi } from "vitest";
import type { ReactElement } from "react";
import type { RelayData, RelaySnapshot } from "../../features/relay/service";
import type { Panels } from "../../features/panels/service";
import { ChannelsPage } from "./ChannelsPage";
import { ChannelsPage, mediaReviewForDestination } from "./ChannelsPage";

// This is a shallow element-boundary test, not a React render. Navigation effects
// are exercised by the browser navigation journeys; do not execute them here.
Expand All @@ -23,6 +23,16 @@ function workspace(scope: string, generation: number) {
return page.props.children as ReactElement<{ scope: string }>;
}

it("never carries a media review across channel navigation or resurrects it on return", () => {
const review = { channelId: "alpha", messageId: "root", entryId: "visit-a" };
expect(mediaReviewForDestination(review, "alpha", "visit-a")).toBe(review);
expect(mediaReviewForDestination(review, "beta", "visit-a")).toBeUndefined();
expect(mediaReviewForDestination(review, "alpha", "visit-b")).toBeUndefined();
expect(
mediaReviewForDestination(undefined, "alpha", "visit-a"),
).toBeUndefined();
});

it("distinguishes ready communities with the same connection generation", () => {
const a = workspace("community-a:viewer", 1);
const b = workspace("community-b:viewer", 1);
Expand Down
88 changes: 87 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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";
Expand Down Expand Up @@ -355,6 +357,53 @@ function ChannelWorkspace({
},
[currentId, navigator, viewer, scope, open],
);
const mediaReviewTrigger = useRef<HTMLElement | null>(null);
const [mediaReview, setMediaReview] = useState<{
channelId: string;
channelName: string;
messageId: string;
attachment: Attachment;
initialTime: number;
entryId?: string | undefined;
}>();
const showingMediaReview = mediaReviewForDestination(
mediaReview,
current?.id,
navigation?.entryId,
);
useEffect(() => {
if (mediaReview && !showingMediaReview) setMediaReview(undefined);
}, [mediaReview, showingMediaReview]);
const openMediaReview = useCallback(
(messageId: string, attachment: Attachment, initialTime: number) => {
if (!current) return;
mediaReviewTrigger.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setThread(undefined);
setMediaReview({
channelId: current.id,
channelName: current.name,
messageId,
attachment,
initialTime,
...(navigation ? { entryId: navigation.entryId } : {}),
});
},
[current, navigation],
);
useEffect(() => {
if (
mediaReview &&
list.status === "ready" &&
list.coverage !== "partial" &&
!list.channels.some(
(channel) => channel.id === mediaReview.channelId && !channel.archived,
)
)
setMediaReview(undefined);
}, [mediaReview, list]);
const openActivityThread = useCallback(
(channelId: string, rootId: string) => {
threadTrigger.current =
Expand Down Expand Up @@ -704,6 +753,7 @@ function ChannelWorkspace({
onOpenLink={openLink}
canOpenLink={canOpenLink}
onOpenThread={openThread}
onOpenMediaReview={openMediaReview}
revealMessageId={
sent?.channelId === current.id ? sent.id : undefined
}
Expand All @@ -724,7 +774,21 @@ function ChannelWorkspace({
)}
{drawer.content}
</article>
{(panel || showingThread || companion) && (
{showingMediaReview && (
<MediaReviewViewer
extensions={extensions}
attachment={showingMediaReview.attachment}
session={queries}
scope={scope}
channelId={showingMediaReview.channelId}
channelName={showingMediaReview.channelName}
messageId={showingMediaReview.messageId}
initialTime={showingMediaReview.initialTime}
restoreFocus={mediaReviewTrigger}
close={() => setMediaReview(undefined)}
/>
)}
{!showingMediaReview && (panel || showingThread || companion) && (
<div className={styles.panelStack}>
{showingThread && (
<ThreadPanel
Expand All @@ -737,6 +801,7 @@ function ChannelWorkspace({
navigation={showingThread.navigation}
close={closeThread}
onOpenLink={openLink}
onOpenMediaReview={openMediaReview}
canOpenLink={canOpenLink}
/>
)}
Expand All @@ -762,6 +827,20 @@ function ChannelWorkspace({
);
}

export function mediaReviewForDestination<
T extends { channelId: string; entryId?: string | undefined },
>(
review: T | undefined,
channelId: string | undefined,
entryId: string | undefined,
): T | undefined {
return review &&
review.channelId === channelId &&
(review.entryId === undefined || review.entryId === entryId)
? review
: undefined;
}

const ChannelBody = memo(function ChannelBody({
viewer,
extensions,
Expand All @@ -772,6 +851,7 @@ const ChannelBody = memo(function ChannelBody({
canOpenLink,
revealMessageId,
onOpenThread,
onOpenMediaReview,
navigation,
}: {
extensions?: ConversationExtensions | undefined;
Expand All @@ -784,6 +864,11 @@ const ChannelBody = memo(function ChannelBody({
canOpenLink?: ((target: string) => boolean) | undefined;
revealMessageId?: string | undefined;
onOpenThread(messageId: string, threadRootId: string): void;
onOpenMediaReview(
messageId: string,
attachment: Attachment,
seconds: number,
): void;
}) {
const window = useChannelWindow(queries.channels, channelId);
useEffect(() => {
Expand Down Expand Up @@ -827,6 +912,7 @@ const ChannelBody = memo(function ChannelBody({
onOpenLink={onOpenLink}
canOpenLink={canOpenLink}
onOpenThread={onOpenThread}
onOpenMediaReview={onOpenMediaReview}
revealMessageId={revealMessageId}
navigation={navigation}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/features/conversation/ComposerCompletions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,10 @@ function OwnedCompletion({
</div>
{status && <p role="status">{status}</p>}
</section>,
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,
)}
</>
);
Expand Down
Loading
Loading