Fix video review comments in threads (#1056)

This commit is contained in:
klopez4212
2026-06-15 21:55:36 +01:00
committed by GitHub
parent 81296d9766
commit 424ea70254
13 changed files with 689 additions and 150 deletions
@@ -10,6 +10,10 @@ import {
} from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
@@ -597,6 +601,38 @@ export const ChannelPane = React.memo(function ChannelPane({
return messages.filter((message) => !isWelcomeSetupSystemMessage(message));
}, [activeChannel, messages]);
const videoReviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
);
const activeVideoReviewCommentSender = activeChannel?.archivedAt
? undefined
: onSendVideoReviewComment;
const threadHeadVideoReviewContext = React.useMemo(() => {
if (!threadHeadMessage) {
return undefined;
}
return buildVideoReviewContextForMessage({
channelId: activeChannel?.id ?? null,
channelName: activeChannel?.name,
channelType: activeChannel?.channelType ?? null,
comments: videoReviewCommentsByRootId.get(threadHeadMessage.id) ?? [],
isSendingVideoReviewComment: isSending,
message: threadHeadMessage,
onSendVideoReviewComment: activeVideoReviewCommentSender,
onToggleReaction,
profiles,
});
}, [
activeChannel,
activeVideoReviewCommentSender,
isSending,
onToggleReaction,
profiles,
threadHeadMessage,
videoReviewCommentsByRootId,
]);
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
@@ -825,6 +861,7 @@ export const ChannelPane = React.memo(function ChannelPane({
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
@@ -33,7 +33,10 @@ import {
collectMessageMentionPubkeys,
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
import {
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
} from "@/features/messages/lib/threadPanel";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
@@ -317,10 +320,14 @@ export function ChannelScreen({
},
[directReplyIdsByParentId],
);
const threadPanelIndex = React.useMemo(
() => buildThreadPanelIndex(timelineMessages),
[timelineMessages],
);
const threadPanelData = React.useMemo(
() =>
buildThreadPanelData(
timelineMessages,
buildThreadPanelDataFromIndex(
threadPanelIndex,
openThreadHeadId,
threadReplyTargetId,
expandedThreadReplyIds,
@@ -329,7 +336,7 @@ export function ChannelScreen({
expandedThreadReplyIds,
openThreadHeadId,
threadReplyTargetId,
timelineMessages,
threadPanelIndex,
],
);
const openThreadHeadMessage = threadPanelData.threadHead;
@@ -4,6 +4,8 @@ import test from "node:test";
import {
buildMainTimelineEntries,
buildThreadPanelData,
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
} from "./threadPanel.ts";
function message(overrides) {
@@ -100,3 +102,42 @@ test("buildThreadPanelData keeps direct comments unindented", () => {
],
);
});
test("buildThreadPanelDataFromIndex matches direct panel data", () => {
const root = message({ id: "root", createdAt: 1 });
const directComment = message({
id: "direct-comment",
createdAt: 2,
parentId: "root",
rootId: "root",
depth: 1,
tags: [["e", "root", "", "reply"]],
});
const nestedReply = message({
id: "nested-reply",
createdAt: 3,
parentId: "direct-comment",
rootId: "root",
depth: 2,
tags: [
["e", "root", "", "root"],
["e", "direct-comment", "", "reply"],
],
});
const messages = [root, directComment, nestedReply];
const direct = buildThreadPanelData(
messages,
"root",
"direct-comment",
new Set(["direct-comment"]),
);
const indexed = buildThreadPanelDataFromIndex(
buildThreadPanelIndex(messages),
"root",
"direct-comment",
new Set(["direct-comment"]),
);
assert.deepEqual(indexed, direct);
});
@@ -32,6 +32,12 @@ type ThreadDescendantStats = {
recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[];
};
export type ThreadPanelIndex = {
directChildrenByParentId: Map<string, TimelineMessage[]>;
descendantStatsByMessageId: Map<string, ThreadDescendantStats>;
messageById: Map<string, TimelineMessage>;
};
const MAX_SUMMARY_PARTICIPANTS = 3;
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
@@ -69,8 +75,8 @@ function buildDirectChildrenByParentId(messages: TimelineMessage[]) {
function buildDescendantStatsByMessageId(
messages: TimelineMessage[],
messageById: Map<string, TimelineMessage>,
): Map<string, ThreadDescendantStats> {
const messageById = new Map(messages.map((message) => [message.id, message]));
const descendantStatsByMessageId = new Map<string, ThreadDescendantStats>(
messages.map((message) => [
message.id,
@@ -135,6 +141,21 @@ function buildDescendantStatsByMessageId(
return descendantStatsByMessageId;
}
export function buildThreadPanelIndex(
messages: TimelineMessage[],
): ThreadPanelIndex {
const messageById = new Map(messages.map((message) => [message.id, message]));
return {
directChildrenByParentId: buildDirectChildrenByParentId(messages),
descendantStatsByMessageId: buildDescendantStatsByMessageId(
messages,
messageById,
),
messageById,
};
}
function buildSummaryForDirectReplies(
messageId: string,
descendantStatsByMessageId: Map<string, ThreadDescendantStats>,
@@ -221,7 +242,7 @@ function buildVisibleThreadReplies(params: {
export function buildMainTimelineEntries(
messages: TimelineMessage[],
): MainTimelineEntry[] {
const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages);
const { descendantStatsByMessageId } = buildThreadPanelIndex(messages);
return messages
.filter(
@@ -239,8 +260,8 @@ export function buildMainTimelineEntries(
});
}
export function buildThreadPanelData(
messages: TimelineMessage[],
export function buildThreadPanelDataFromIndex(
index: ThreadPanelIndex,
openThreadHeadId: string | null,
threadReplyTargetId: string | null,
expandedReplyIds: ReadonlySet<string>,
@@ -254,7 +275,8 @@ export function buildThreadPanelData(
};
}
const messageById = new Map(messages.map((message) => [message.id, message]));
const { directChildrenByParentId, descendantStatsByMessageId, messageById } =
index;
const threadHead = messageById.get(openThreadHeadId) ?? null;
if (!threadHead) {
@@ -266,8 +288,6 @@ export function buildThreadPanelData(
};
}
const directChildrenByParentId = buildDirectChildrenByParentId(messages);
const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages);
const normalizedThreadHead = normalizeHeadMessage(threadHead);
const visibleReplies = buildVisibleThreadReplies({
openThreadHeadId,
@@ -289,3 +309,17 @@ export function buildThreadPanelData(
replyTargetMessage: replyTargetInBranch ?? normalizedThreadHead,
};
}
export function buildThreadPanelData(
messages: TimelineMessage[],
openThreadHeadId: string | null,
threadReplyTargetId: string | null,
expandedReplyIds: ReadonlySet<string>,
): ThreadPanelData {
return buildThreadPanelDataFromIndex(
buildThreadPanelIndex(messages),
openThreadHeadId,
threadReplyTargetId,
expandedReplyIds,
);
}
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
hasVideoAttachment,
} from "./videoReviewContext.ts";
function message(overrides) {
return {
id: "message",
createdAt: 1,
pubkey: "author",
author: "Author",
avatarUrl: null,
role: undefined,
personaDisplayName: undefined,
time: "12:00 PM",
body: "body",
parentId: null,
rootId: null,
depth: 0,
accent: false,
pending: undefined,
edited: false,
kind: 9,
tags: [],
reactions: undefined,
...overrides,
};
}
test("hasVideoAttachment detects markdown and imeta videos", () => {
assert.equal(
hasVideoAttachment(
message({ body: "Launch cut\n![video](https://relay/media/a.mp4)" }),
),
true,
);
assert.equal(
hasVideoAttachment(
message({
tags: [
[
"imeta",
"url https://relay/media/a.mp4",
"m video/mp4",
"dim 1920x1080",
],
],
}),
),
true,
);
assert.equal(hasVideoAttachment(message({ body: "plain text" })), false);
});
test("buildVideoReviewCommentsByRootId includes nested descendants", () => {
const video = message({
id: "video",
body: "![video](https://relay/media/a.mp4)",
createdAt: 1,
});
const firstComment = message({
id: "first-comment",
body: "[00:01] tighten this",
createdAt: 3,
parentId: "video",
rootId: "video",
});
const nestedReply = message({
id: "nested-reply",
body: "agreed",
createdAt: 4,
parentId: "first-comment",
rootId: "video",
});
const earlierComment = message({
id: "earlier-comment",
body: "[00:00] opener",
createdAt: 2,
parentId: "video",
rootId: "video",
});
const commentsByRootId = buildVideoReviewCommentsByRootId([
video,
firstComment,
nestedReply,
earlierComment,
]);
assert.deepEqual(
commentsByRootId.get("video")?.map((comment) => comment.id),
["earlier-comment", "first-comment", "nested-reply"],
);
});
test("buildVideoReviewContextForMessage posts against the source video", async () => {
const video = message({
id: "video",
body: "![video](https://relay/media/a.mp4)",
createdAt: 1,
});
const comment = message({
id: "comment",
body: "[00:01] tighten this",
createdAt: 2,
parentId: "video",
rootId: "video",
});
const calls = [];
const context = buildVideoReviewContextForMessage({
channelId: "channel",
comments: [comment],
message: video,
onSendVideoReviewComment: async (
source,
content,
mentionPubkeys,
mediaTags,
parentEventId,
) => {
calls.push({
content,
mediaTags,
mentionPubkeys,
parentEventId,
sourceId: source.id,
});
},
});
assert.equal(context?.rootEventId, "video");
assert.equal(context?.comments[0].id, "comment");
await context?.onSendComment?.("looks good", ["alice"], undefined, "comment");
assert.deepEqual(calls, [
{
content: "looks good",
mediaTags: undefined,
mentionPubkeys: ["alice"],
parentEventId: "comment",
sourceId: "video",
},
]);
});
@@ -0,0 +1,118 @@
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
type SendVideoReviewComment = (
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) => Promise<void>;
type ToggleMessageReaction = (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
export function hasVideoAttachment(message: TimelineMessage): boolean {
if (message.body.includes("![video](")) return true;
return (
message.tags?.some(
(tag) =>
tag[0] === "imeta" &&
tag.some((part) => part.toLowerCase().startsWith("m video/")),
) ?? false
);
}
export function buildVideoReviewCommentsByRootId(
messages: TimelineMessage[],
): Map<string, TimelineMessage[]> {
const messageById = new Map(messages.map((message) => [message.id, message]));
const commentsByRootId = new Map<string, TimelineMessage[]>();
for (const message of messages) {
let ancestorId = message.parentId ?? null;
let hops = 0;
const maxHops = messages.length + 1;
while (ancestorId && hops < maxHops) {
const comments = commentsByRootId.get(ancestorId) ?? [];
comments.push(message);
commentsByRootId.set(ancestorId, comments);
ancestorId = messageById.get(ancestorId)?.parentId ?? null;
hops += 1;
}
}
for (const comments of commentsByRootId.values()) {
comments.sort((left, right) => {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
});
}
return commentsByRootId;
}
export function buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments,
isSendingVideoReviewComment = false,
message,
onSendVideoReviewComment,
onToggleReaction,
profiles,
}: {
channelId?: string | null;
channelName?: string;
channelType?: ChannelType | null;
comments: TimelineMessage[];
isSendingVideoReviewComment?: boolean;
message: TimelineMessage;
onSendVideoReviewComment?: SendVideoReviewComment;
onToggleReaction?: ToggleMessageReaction;
profiles?: UserProfileLookup;
}): VideoReviewContext | undefined {
if (!hasVideoAttachment(message)) {
return undefined;
}
return {
channelId,
channelName,
channelType,
comments,
disabled: !onSendVideoReviewComment || message.pending,
isSending: isSendingVideoReviewComment,
onSendComment: onSendVideoReviewComment
? (content, mentionPubkeys, mediaTags, parentEventId) =>
onSendVideoReviewComment(
message,
content,
mentionPubkeys,
mediaTags,
parentEventId,
)
: undefined,
onToggleCommentReaction: onToggleReaction
? (comment, emoji, remove) => {
const sourceComment = comments.find(
(candidate) => candidate.id === comment.id,
);
if (!sourceComment) return Promise.resolve();
return onToggleReaction(sourceComment, emoji, remove);
}
: undefined,
profiles,
rootEventId: message.id,
};
}
@@ -24,6 +24,7 @@ import {
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { Skeleton } from "@/shared/ui/skeleton";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -74,6 +75,7 @@ type MessageThreadPanelProps = {
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
threadTypingPubkeys: string[];
threadHeadVideoReviewContext?: VideoReviewContext;
toolbarExtraActions?: React.ReactNode;
widthPx: number;
isFollowingThread?: boolean;
@@ -283,6 +285,7 @@ export function MessageThreadPanel({
replyTargetMessage,
scrollTargetId,
threadHead,
threadHeadVideoReviewContext,
threadReplies,
threadTypingPubkeys,
toolbarExtraActions,
@@ -376,6 +379,7 @@ export function MessageThreadPanel({
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
}
profiles={profiles}
videoReviewContext={threadHeadVideoReviewContext}
/>
</div>
</div>
@@ -5,12 +5,15 @@ import {
isSameDay,
} from "@/features/messages/lib/dateFormatters";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} from "@/features/messages/lib/videoReviewContext";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -56,50 +59,6 @@ type TimelineMessageListProps = {
searchQuery?: string;
};
function hasVideoAttachment(message: TimelineMessage): boolean {
if (message.body.includes("![video](")) return true;
return (
message.tags?.some(
(tag) =>
tag[0] === "imeta" &&
tag.some((part) => part.toLowerCase().startsWith("m video/")),
) ?? false
);
}
function buildReviewCommentsByRootId(
messages: TimelineMessage[],
): Map<string, TimelineMessage[]> {
const messageById = new Map(messages.map((message) => [message.id, message]));
const commentsByRootId = new Map<string, TimelineMessage[]>();
for (const message of messages) {
let ancestorId = message.parentId ?? null;
let hops = 0;
const maxHops = messages.length + 1;
while (ancestorId && hops < maxHops) {
const comments = commentsByRootId.get(ancestorId) ?? [];
comments.push(message);
commentsByRootId.set(ancestorId, comments);
ancestorId = messageById.get(ancestorId)?.parentId ?? null;
hops += 1;
}
}
for (const comments of commentsByRootId.values()) {
comments.sort((left, right) => {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
});
}
return commentsByRootId;
}
export const TimelineMessageList = React.memo(function TimelineMessageList({
agentPubkeys,
channelId,
@@ -130,7 +89,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
[messages],
);
const reviewCommentsByRootId = React.useMemo(
() => buildReviewCommentsByRootId(messages),
() => buildVideoReviewCommentsByRootId(messages),
[messages],
);
// Contexts are memoized per message id so MessageRow/Markdown memo
@@ -138,39 +97,26 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
// indicators, presence updates) — a fresh context object per render would
// defeat the memo and re-render every video message on every pass.
const videoReviewContextById = React.useMemo(() => {
const contexts = new Map<string, VideoReviewContext>();
const contexts = new Map<
string,
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
>();
for (const message of messages) {
if (!hasVideoAttachment(message)) continue;
const comments = reviewCommentsByRootId.get(message.id) ?? [];
contexts.set(message.id, {
const context = buildVideoReviewContextForMessage({
channelId,
channelName,
channelType,
comments,
disabled: !onSendVideoReviewComment || message.pending,
isSending: isSendingVideoReviewComment,
onSendComment: onSendVideoReviewComment
? (content, mentionPubkeys, mediaTags, parentEventId) =>
onSendVideoReviewComment(
message,
content,
mentionPubkeys,
mediaTags,
parentEventId,
)
: undefined,
onToggleCommentReaction: onToggleReaction
? (comment, emoji, remove) => {
const sourceComment = comments.find(
(candidate) => candidate.id === comment.id,
);
if (!sourceComment) return Promise.resolve();
return onToggleReaction(sourceComment, emoji, remove);
}
: undefined,
isSendingVideoReviewComment,
message,
onSendVideoReviewComment,
onToggleReaction,
profiles,
rootEventId: message.id,
});
if (context) {
contexts.set(message.id, context);
}
}
return contexts;
}, [
@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import { initFirstWorkspace } from "./workspaceStorage";
import type { Workspace } from "./types";
@@ -25,6 +26,7 @@ function resetWorkspaceState(): void {
relayClient.disconnect();
resetAgentObserverStore();
resetMediaCaches();
resetVideoPlayerState();
clearSearchHitEventCache();
clearAllDrafts();
}
+71 -20
View File
@@ -25,6 +25,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Spinner } from "./spinner";
import {
getInlinePlaybackPosition,
getReviewPlaybackPosition,
isVideoReviewOpen,
saveInlinePlaybackPosition,
saveReviewPlaybackPosition,
setVideoReviewOpen,
} from "./videoPlayerState";
type VideoReviewReaction = {
emoji: string;
@@ -93,11 +101,6 @@ type TimecodedComment = {
const TIMECODE_RE =
/^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
// Review open state and playback positions survive player remounts (e.g. the
// optimistic→acked message row swap) so an open review dialog doesn't snap
// shut or lose its place mid-session.
const openReviewKeys = new Set<string>();
const reviewPlaybackPositions = new Map<string, number>();
/**
* Frosted-glass backing layer for floating media controls. The parent must
@@ -614,7 +617,10 @@ export function VideoPlayer({
const [isPlaying, setIsPlaying] = React.useState(false);
const [isBuffering, setIsBuffering] = React.useState(false);
const [hasError, setHasError] = React.useState(false);
const [currentTime, setCurrentTime] = React.useState(0);
const [currentTime, setCurrentTimeState] = React.useState(
() => getInlinePlaybackPosition(persistedReviewKey) ?? 0,
);
const currentTimeRef = React.useRef(currentTime);
const [duration, setDuration] = React.useState(durationSeconds ?? 0);
const [volume, setVolume] = React.useState(1);
const [muted, setMuted] = React.useState(false);
@@ -622,10 +628,10 @@ export function VideoPlayer({
number | null
>(null);
const [reviewOpen, setReviewOpenState] = React.useState(() =>
openReviewKeys.has(persistedReviewKey),
isVideoReviewOpen(persistedReviewKey),
);
const [reviewCurrentTime, setReviewCurrentTimeState] = React.useState(
() => reviewPlaybackPositions.get(persistedReviewKey) ?? 0,
() => getReviewPlaybackPosition(persistedReviewKey) ?? 0,
);
const [pendingSeekSeconds, setPendingSeekSeconds] = React.useState<
number | null
@@ -642,16 +648,49 @@ export function VideoPlayer({
}, [durationSeconds]);
React.useEffect(() => {
setReviewOpenState(openReviewKeys.has(persistedReviewKey));
const savedCurrentTime = getInlinePlaybackPosition(persistedReviewKey) ?? 0;
currentTimeRef.current = savedCurrentTime;
setStarted(false);
setCurrentTimeState(savedCurrentTime);
setIsPlaying(false);
setIsBuffering(false);
setHasError(false);
setReviewOpenState(isVideoReviewOpen(persistedReviewKey));
setReviewCurrentTimeState(
reviewPlaybackPositions.get(persistedReviewKey) ?? 0,
getReviewPlaybackPosition(persistedReviewKey) ?? 0,
);
}, [persistedReviewKey]);
const setReviewCurrentTime = React.useCallback(
React.useEffect(() => {
return () => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.currentTime)) {
return;
}
saveInlinePlaybackPosition(
persistedReviewKey,
Math.max(video.currentTime, currentTimeRef.current),
{ ignoreResetToZero: true },
);
};
}, [persistedReviewKey]);
const setCurrentTime = React.useCallback(
(seconds: number) => {
const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
reviewPlaybackPositions.set(persistedReviewKey, nextSeconds);
currentTimeRef.current = nextSeconds;
saveInlinePlaybackPosition(persistedReviewKey, nextSeconds);
setCurrentTimeState(nextSeconds);
},
[persistedReviewKey],
);
const setReviewCurrentTime = React.useCallback(
(seconds: number) => {
const nextSeconds = saveReviewPlaybackPosition(
persistedReviewKey,
seconds,
);
setReviewCurrentTimeState(nextSeconds);
},
[persistedReviewKey],
@@ -659,11 +698,7 @@ export function VideoPlayer({
const setReviewOpen = React.useCallback(
(open: boolean) => {
if (open) {
openReviewKeys.add(persistedReviewKey);
} else {
openReviewKeys.delete(persistedReviewKey);
}
setVideoReviewOpen(persistedReviewKey, open);
setReviewOpenState(open);
},
[persistedReviewKey],
@@ -714,7 +749,7 @@ export function VideoPlayer({
inlineSeek.requestSeek(bounded);
setCurrentTime(bounded);
},
[duration, inlineSeek],
[duration, inlineSeek, setCurrentTime],
);
const handleToggleMute = React.useCallback(() => {
@@ -752,7 +787,7 @@ export function VideoPlayer({
// Hand the review position back to the inline player so playback
// resumes where the review left off.
const video = videoRef.current;
const reviewSeconds = reviewPlaybackPositions.get(persistedReviewKey);
const reviewSeconds = getReviewPlaybackPosition(persistedReviewKey);
if (
video &&
reviewSeconds !== undefined &&
@@ -767,7 +802,7 @@ export function VideoPlayer({
}
setReviewOpen(open);
},
[persistedReviewKey, setReviewOpen],
[persistedReviewKey, setCurrentTime, setReviewOpen],
);
const handlePendingSeekConsumed = React.useCallback(() => {
@@ -830,6 +865,22 @@ export function VideoPlayer({
videoWidth,
} = event.currentTarget;
handleMediaDuration(mediaDuration);
const savedSeconds =
getInlinePlaybackPosition(persistedReviewKey);
if (
savedSeconds !== undefined &&
savedSeconds > 0 &&
Number.isFinite(savedSeconds)
) {
const restoredSeconds = Math.min(
savedSeconds,
Number.isFinite(mediaDuration) && mediaDuration > 0
? mediaDuration
: savedSeconds,
);
event.currentTarget.currentTime = restoredSeconds;
setCurrentTime(restoredSeconds);
}
if (videoWidth > 0 && videoHeight > 0) {
setNaturalAspectRatio(videoWidth / videoHeight);
}
+54 -40
View File
@@ -145,6 +145,21 @@ const VideoReviewMarkdownContext = React.createContext<
VideoReviewContext | undefined
>(undefined);
type MarkdownRuntime = {
agentMentionPubkeysByName?: Record<string, string>;
channels: Channel[];
imetaByUrl?: ImetaLookup;
mentionPubkeysByName?: Record<string, string>;
onOpenChannel: (channelId: string) => void;
onOpenMessageLink: (link: ParsedMessageLink) => void;
};
function useLatestRef<T>(value: T) {
const ref = React.useRef(value);
ref.current = value;
return ref;
}
function MarkdownVideoPlayer({
alt,
entry,
@@ -731,12 +746,7 @@ function SyntaxHighlightedCode({
}
function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
onOpenChannel: (channelId: string) => void,
onOpenMessageLink: (link: ParsedMessageLink) => void,
imetaByUrl?: ImetaLookup,
mentionPubkeysByName?: Record<string, string>,
agentMentionPubkeysByName?: Record<string, string>,
runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true,
): Components {
const paragraphClassName =
@@ -754,6 +764,7 @@ function createMarkdownComponents(
return {
a: ({ children, href, ...props }) => {
const { imetaByUrl, onOpenMessageLink } = runtimeRef.current;
if (!interactive) {
return <span className="font-medium text-current">{children}</span>;
}
@@ -875,6 +886,7 @@ function createMarkdownComponents(
),
hr: () => <hr className="border-border/80" />,
img: ({ alt, src }) => {
const { imetaByUrl } = runtimeRef.current;
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
if (!interactive) {
const fallbackLabel = resolvedSrc?.endsWith(".mp4")
@@ -971,6 +983,8 @@ function createMarkdownComponents(
<ul className={cn("list-disc", listClassName)}>{children}</ul>
),
mention: ({ children }: { children?: React.ReactNode }) => {
const { agentMentionPubkeysByName, mentionPubkeysByName } =
runtimeRef.current;
const mentionText = String(children ?? "");
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
const pubkey = mentionPubkeysByName?.[mentionName];
@@ -1017,6 +1031,7 @@ function createMarkdownComponents(
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
},
"channel-link": ({ children }: { children?: React.ReactNode }) => {
const { channels, onOpenChannel } = runtimeRef.current;
const text = String(children ?? "");
const channelName = text.startsWith("#") ? text.slice(1) : text;
const channel = channels.find(
@@ -1051,6 +1066,7 @@ function createMarkdownComponents(
);
},
"message-link": ({ children }: { children?: React.ReactNode }) => {
const { channels, onOpenMessageLink } = runtimeRef.current;
const href = String(children ?? "");
const parsed = parseMessageLink(href);
if (!parsed.ok) {
@@ -1113,42 +1129,40 @@ function MarkdownInner({
const { channels: rawChannels } = useChannelNavigation();
const channels = useStableArray(rawChannels);
const { goChannel } = useAppNavigation();
const onOpenChannel = React.useCallback(
(channelId: string) => {
void goChannel(channelId);
},
[goChannel],
);
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
// route already handles scroll-into-view + highlight via
// `useTimelineScrollManager` + `getEventById` backfill, and works for
// both stream-message replies and forum threads. Detecting "the thread
// root is a forum post" up front would require an event lookup we don't
// currently have synchronously; the brief explicitly allows skipping
// that detection and falling through.
void goChannel(link.channelId, {
messageId: link.messageId,
threadRootId: link.threadRootId,
});
},
[goChannel],
);
const runtimeRef = useLatestRef<MarkdownRuntime>({
agentMentionPubkeysByName,
channels,
imetaByUrl,
mentionPubkeysByName,
onOpenChannel,
onOpenMessageLink,
});
const components = React.useMemo(
() =>
createMarkdownComponents(
variant,
channels,
(channelId) => {
void goChannel(channelId);
},
(link) => {
// Always route through `goChannel` with `messageId` set: the
// channel route already handles scroll-into-view + highlight via
// `useTimelineScrollManager` + `getEventById` backfill, and works
// for both stream-message replies and forum threads. Detecting
// "the thread root is a forum post" up front would require an
// event lookup we don't currently have synchronously; the brief
// explicitly allows skipping that detection and falling through.
void goChannel(link.channelId, {
messageId: link.messageId,
threadRootId: link.threadRootId,
});
},
imetaByUrl,
mentionPubkeysByName,
agentMentionPubkeysByName,
interactive,
),
[
goChannel,
variant,
channels,
imetaByUrl,
mentionPubkeysByName,
agentMentionPubkeysByName,
interactive,
],
() => createMarkdownComponents(variant, runtimeRef, interactive),
[variant, runtimeRef, interactive],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
+57
View File
@@ -0,0 +1,57 @@
// Inline/review playback state survives player remounts (for example route
// swaps, optimistic-to-acked row replacement, or markdown context refreshes)
// without leaking across workspace switches.
const inlinePlaybackPositions = new Map<string, number>();
const openReviewKeys = new Set<string>();
const reviewPlaybackPositions = new Map<string, number>();
export function resetVideoPlayerState(): void {
inlinePlaybackPositions.clear();
openReviewKeys.clear();
reviewPlaybackPositions.clear();
}
export function getInlinePlaybackPosition(key: string): number | undefined {
return inlinePlaybackPositions.get(key);
}
export function saveInlinePlaybackPosition(
key: string,
seconds: number,
options?: { ignoreResetToZero?: boolean },
): void {
if (!Number.isFinite(seconds)) {
return;
}
const nextSeconds = Math.max(0, seconds);
const savedSeconds = inlinePlaybackPositions.get(key) ?? 0;
if (options?.ignoreResetToZero && nextSeconds === 0 && savedSeconds > 0) {
return;
}
inlinePlaybackPositions.set(key, nextSeconds);
}
export function isVideoReviewOpen(key: string): boolean {
return openReviewKeys.has(key);
}
export function setVideoReviewOpen(key: string, open: boolean): void {
if (open) {
openReviewKeys.add(key);
} else {
openReviewKeys.delete(key);
}
}
export function getReviewPlaybackPosition(key: string): number | undefined {
return reviewPlaybackPositions.get(key);
}
export function saveReviewPlaybackPosition(
key: string,
seconds: number,
): number {
const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
reviewPlaybackPositions.set(key, nextSeconds);
return nextSeconds;
}
+84 -8
View File
@@ -47,16 +47,30 @@ function emitMockMessage(page: Page, channelName: string, content: string) {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
let mediaCurrentTime = 0;
let mediaPaused = true;
type MediaState = {
currentTime: number;
paused: boolean;
};
const mediaState = new WeakMap<HTMLMediaElement, MediaState>();
const getMediaState = (element: HTMLMediaElement) => {
let state = mediaState.get(element);
if (!state) {
state = { currentTime: 0, paused: true };
mediaState.set(element, state);
}
return state;
};
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value() {},
value() {
getMediaState(this as HTMLMediaElement).currentTime = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value() {
mediaPaused = false;
getMediaState(this as HTMLMediaElement).paused = false;
this.dispatchEvent(new Event("play"));
return Promise.resolve();
},
@@ -64,23 +78,24 @@ test.beforeEach(async ({ page }) => {
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
value() {
mediaPaused = true;
getMediaState(this as HTMLMediaElement).paused = true;
this.dispatchEvent(new Event("pause"));
},
});
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get() {
return mediaPaused;
return getMediaState(this as HTMLMediaElement).paused;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get() {
return mediaCurrentTime;
return getMediaState(this as HTMLMediaElement).currentTime;
},
set(value) {
mediaCurrentTime = Number(value) || 0;
getMediaState(this as HTMLMediaElement).currentTime =
Number(value) || 0;
this.dispatchEvent(new Event("seeked"));
},
});
@@ -143,6 +158,12 @@ test("video upload previews use poster frames and inline videos open review mode
const messageId = row?.getAttribute("data-message-id") ?? "";
return Boolean(messageId) && !messageId.startsWith("optimistic");
});
const videoMessageId = await reviewButton.evaluate((button) =>
button.closest("[data-message-id]")?.getAttribute("data-message-id"),
);
if (!videoMessageId) {
throw new Error("Expected uploaded video row to have a message id.");
}
const inlinePlayer = page.getByTestId("video-player").last();
const inlineVideo = inlinePlayer.locator("video");
@@ -180,6 +201,37 @@ test("video upload previews use poster frames and inline videos open review mode
.toBeLessThan(6.5);
await expect(page.getByTestId("video-inline-time")).toHaveText("00:06");
await inlinePlayer.getByRole("button", { name: "Pause video" }).click();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const restoredInlinePlayer = page.getByTestId("video-player").last();
const restoredInlineVideo = restoredInlinePlayer.locator("video");
await restoredInlineVideo.evaluate((video) => {
video.dispatchEvent(new Event("loadedmetadata"));
});
await expect
.poll(() =>
restoredInlineVideo.evaluate(
(video) => (video as HTMLVideoElement).currentTime,
),
)
.toBeGreaterThan(6);
await expect
.poll(() =>
restoredInlineVideo.evaluate(
(video) => (video as HTMLVideoElement).currentTime,
),
)
.toBeLessThan(6.5);
await restoredInlinePlayer
.getByRole("button", { name: "Play video" })
.click();
await expect(
restoredInlinePlayer.getByRole("button", { name: "Pause video" }),
).toBeVisible();
// Inline volume controls.
await inlinePlayer.getByRole("button", { name: "Mute" }).click();
await expect
@@ -526,4 +578,28 @@ test("video upload previews use poster frames and inline videos open review mode
.getByTestId("video-review-backdrop")
.click({ position: { x: 4, y: 4 } });
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
const videoSummaryRow = page.locator(
`[data-thread-head-id="${videoMessageId}"]`,
);
await expect(videoSummaryRow).toBeVisible();
await videoSummaryRow.click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadHead = threadPanel.getByTestId("message-thread-head");
await expect(threadHead.getByTestId("video-player")).toBeVisible();
await threadHead.getByRole("button", { name: "Open video review" }).click();
const threadReviewDialog = page.getByTestId("video-review-dialog");
await expect(threadReviewDialog).toBeVisible();
await expect(
threadReviewDialog.getByTestId("video-review-comments-panel"),
).toBeVisible();
await expect(
threadReviewDialog.getByTestId("message-composer"),
).toBeVisible();
await expect(
threadReviewDialog.getByTestId("video-review-comments"),
).toContainText("Color pass looks right");
});