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

View File

@@ -10,6 +10,10 @@ import {
} from "@/features/messages/ui/MessageThreadPanel"; } from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
@@ -597,6 +601,38 @@ export const ChannelPane = React.memo(function ChannelPane({
return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); return messages.filter((message) => !isWelcomeSetupSystemMessage(message));
}, [activeChannel, messages]); }, [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 isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
@@ -825,6 +861,7 @@ export const ChannelPane = React.memo(function ChannelPane({
replyTargetMessage={threadReplyTargetMessage} replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId} scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage} threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx} widthPx={threadPanelWidthPx}
threadReplies={threadMessages} threadReplies={threadMessages}
threadTypingPubkeys={threadTypingPubkeys} threadTypingPubkeys={threadTypingPubkeys}

View File

@@ -33,7 +33,10 @@ import {
collectMessageMentionPubkeys, collectMessageMentionPubkeys,
formatTimelineMessages, formatTimelineMessages,
} from "@/features/messages/lib/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 { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
@@ -317,10 +320,14 @@ export function ChannelScreen({
}, },
[directReplyIdsByParentId], [directReplyIdsByParentId],
); );
const threadPanelIndex = React.useMemo(
() => buildThreadPanelIndex(timelineMessages),
[timelineMessages],
);
const threadPanelData = React.useMemo( const threadPanelData = React.useMemo(
() => () =>
buildThreadPanelData( buildThreadPanelDataFromIndex(
timelineMessages, threadPanelIndex,
openThreadHeadId, openThreadHeadId,
threadReplyTargetId, threadReplyTargetId,
expandedThreadReplyIds, expandedThreadReplyIds,
@@ -329,7 +336,7 @@ export function ChannelScreen({
expandedThreadReplyIds, expandedThreadReplyIds,
openThreadHeadId, openThreadHeadId,
threadReplyTargetId, threadReplyTargetId,
timelineMessages, threadPanelIndex,
], ],
); );
const openThreadHeadMessage = threadPanelData.threadHead; const openThreadHeadMessage = threadPanelData.threadHead;

View File

@@ -4,6 +4,8 @@ import test from "node:test";
import { import {
buildMainTimelineEntries, buildMainTimelineEntries,
buildThreadPanelData, buildThreadPanelData,
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
} from "./threadPanel.ts"; } from "./threadPanel.ts";
function message(overrides) { 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);
});

View File

@@ -32,6 +32,12 @@ type ThreadDescendantStats = {
recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[]; recentParticipantsNewestFirst: TimelineThreadSummaryParticipant[];
}; };
export type ThreadPanelIndex = {
directChildrenByParentId: Map<string, TimelineMessage[]>;
descendantStatsByMessageId: Map<string, ThreadDescendantStats>;
messageById: Map<string, TimelineMessage>;
};
const MAX_SUMMARY_PARTICIPANTS = 3; const MAX_SUMMARY_PARTICIPANTS = 3;
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage { function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
@@ -69,8 +75,8 @@ function buildDirectChildrenByParentId(messages: TimelineMessage[]) {
function buildDescendantStatsByMessageId( function buildDescendantStatsByMessageId(
messages: TimelineMessage[], messages: TimelineMessage[],
messageById: Map<string, TimelineMessage>,
): Map<string, ThreadDescendantStats> { ): Map<string, ThreadDescendantStats> {
const messageById = new Map(messages.map((message) => [message.id, message]));
const descendantStatsByMessageId = new Map<string, ThreadDescendantStats>( const descendantStatsByMessageId = new Map<string, ThreadDescendantStats>(
messages.map((message) => [ messages.map((message) => [
message.id, message.id,
@@ -135,6 +141,21 @@ function buildDescendantStatsByMessageId(
return descendantStatsByMessageId; 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( function buildSummaryForDirectReplies(
messageId: string, messageId: string,
descendantStatsByMessageId: Map<string, ThreadDescendantStats>, descendantStatsByMessageId: Map<string, ThreadDescendantStats>,
@@ -221,7 +242,7 @@ function buildVisibleThreadReplies(params: {
export function buildMainTimelineEntries( export function buildMainTimelineEntries(
messages: TimelineMessage[], messages: TimelineMessage[],
): MainTimelineEntry[] { ): MainTimelineEntry[] {
const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages); const { descendantStatsByMessageId } = buildThreadPanelIndex(messages);
return messages return messages
.filter( .filter(
@@ -239,8 +260,8 @@ export function buildMainTimelineEntries(
}); });
} }
export function buildThreadPanelData( export function buildThreadPanelDataFromIndex(
messages: TimelineMessage[], index: ThreadPanelIndex,
openThreadHeadId: string | null, openThreadHeadId: string | null,
threadReplyTargetId: string | null, threadReplyTargetId: string | null,
expandedReplyIds: ReadonlySet<string>, 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; const threadHead = messageById.get(openThreadHeadId) ?? null;
if (!threadHead) { if (!threadHead) {
@@ -266,8 +288,6 @@ export function buildThreadPanelData(
}; };
} }
const directChildrenByParentId = buildDirectChildrenByParentId(messages);
const descendantStatsByMessageId = buildDescendantStatsByMessageId(messages);
const normalizedThreadHead = normalizeHeadMessage(threadHead); const normalizedThreadHead = normalizeHeadMessage(threadHead);
const visibleReplies = buildVisibleThreadReplies({ const visibleReplies = buildVisibleThreadReplies({
openThreadHeadId, openThreadHeadId,
@@ -289,3 +309,17 @@ export function buildThreadPanelData(
replyTargetMessage: replyTargetInBranch ?? normalizedThreadHead, 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,
);
}

View File

@@ -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",
},
]);
});

View File

@@ -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,
};
}

View File

@@ -24,6 +24,7 @@ import {
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop"; } from "@/shared/ui/OverlayPanelBackdrop";
import { Skeleton } from "@/shared/ui/skeleton"; import { Skeleton } from "@/shared/ui/skeleton";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageComposer } from "./MessageComposer"; import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow"; import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -74,6 +75,7 @@ type MessageThreadPanelProps = {
threadHead: TimelineMessage | null; threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[]; threadReplies: MainTimelineEntry[];
threadTypingPubkeys: string[]; threadTypingPubkeys: string[];
threadHeadVideoReviewContext?: VideoReviewContext;
toolbarExtraActions?: React.ReactNode; toolbarExtraActions?: React.ReactNode;
widthPx: number; widthPx: number;
isFollowingThread?: boolean; isFollowingThread?: boolean;
@@ -283,6 +285,7 @@ export function MessageThreadPanel({
replyTargetMessage, replyTargetMessage,
scrollTargetId, scrollTargetId,
threadHead, threadHead,
threadHeadVideoReviewContext,
threadReplies, threadReplies,
threadTypingPubkeys, threadTypingPubkeys,
toolbarExtraActions, toolbarExtraActions,
@@ -376,6 +379,7 @@ export function MessageThreadPanel({
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
} }
profiles={profiles} profiles={profiles}
videoReviewContext={threadHeadVideoReviewContext}
/> />
</div> </div>
</div> </div>

View File

@@ -5,12 +5,15 @@ import {
isSameDay, isSameDay,
} from "@/features/messages/lib/dateFormatters"; } from "@/features/messages/lib/dateFormatters";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
} from "@/features/messages/lib/videoReviewContext";
import type { TimelineMessage } from "@/features/messages/types"; import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types"; import type { ChannelType } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { DayDivider } from "./DayDivider"; import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow"; import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -56,50 +59,6 @@ type TimelineMessageListProps = {
searchQuery?: string; 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({ export const TimelineMessageList = React.memo(function TimelineMessageList({
agentPubkeys, agentPubkeys,
channelId, channelId,
@@ -130,7 +89,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
[messages], [messages],
); );
const reviewCommentsByRootId = React.useMemo( const reviewCommentsByRootId = React.useMemo(
() => buildReviewCommentsByRootId(messages), () => buildVideoReviewCommentsByRootId(messages),
[messages], [messages],
); );
// Contexts are memoized per message id so MessageRow/Markdown memo // 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 // indicators, presence updates) — a fresh context object per render would
// defeat the memo and re-render every video message on every pass. // defeat the memo and re-render every video message on every pass.
const videoReviewContextById = React.useMemo(() => { const videoReviewContextById = React.useMemo(() => {
const contexts = new Map<string, VideoReviewContext>(); const contexts = new Map<
string,
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
>();
for (const message of messages) { for (const message of messages) {
if (!hasVideoAttachment(message)) continue;
const comments = reviewCommentsByRootId.get(message.id) ?? []; const comments = reviewCommentsByRootId.get(message.id) ?? [];
contexts.set(message.id, { const context = buildVideoReviewContextForMessage({
channelId, channelId,
channelName, channelName,
channelType, channelType,
comments, comments,
disabled: !onSendVideoReviewComment || message.pending, isSendingVideoReviewComment,
isSending: isSendingVideoReviewComment, message,
onSendComment: onSendVideoReviewComment onSendVideoReviewComment,
? (content, mentionPubkeys, mediaTags, parentEventId) => onToggleReaction,
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, profiles,
rootEventId: message.id,
}); });
if (context) {
contexts.set(message.id, context);
}
} }
return contexts; return contexts;
}, [ }, [

View File

@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import { initFirstWorkspace } from "./workspaceStorage"; import { initFirstWorkspace } from "./workspaceStorage";
import type { Workspace } from "./types"; import type { Workspace } from "./types";
@@ -25,6 +26,7 @@ function resetWorkspaceState(): void {
relayClient.disconnect(); relayClient.disconnect();
resetAgentObserverStore(); resetAgentObserverStore();
resetMediaCaches(); resetMediaCaches();
resetVideoPlayerState();
clearSearchHitEventCache(); clearSearchHitEventCache();
clearAllDrafts(); clearAllDrafts();
} }

View File

@@ -25,6 +25,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Spinner } from "./spinner"; import { Spinner } from "./spinner";
import {
getInlinePlaybackPosition,
getReviewPlaybackPosition,
isVideoReviewOpen,
saveInlinePlaybackPosition,
saveReviewPlaybackPosition,
setVideoReviewOpen,
} from "./videoPlayerState";
type VideoReviewReaction = { type VideoReviewReaction = {
emoji: string; emoji: string;
@@ -93,11 +101,6 @@ type TimecodedComment = {
const TIMECODE_RE = const TIMECODE_RE =
/^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/; /^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"]; 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 * 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 [isPlaying, setIsPlaying] = React.useState(false);
const [isBuffering, setIsBuffering] = React.useState(false); const [isBuffering, setIsBuffering] = React.useState(false);
const [hasError, setHasError] = 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 [duration, setDuration] = React.useState(durationSeconds ?? 0);
const [volume, setVolume] = React.useState(1); const [volume, setVolume] = React.useState(1);
const [muted, setMuted] = React.useState(false); const [muted, setMuted] = React.useState(false);
@@ -622,10 +628,10 @@ export function VideoPlayer({
number | null number | null
>(null); >(null);
const [reviewOpen, setReviewOpenState] = React.useState(() => const [reviewOpen, setReviewOpenState] = React.useState(() =>
openReviewKeys.has(persistedReviewKey), isVideoReviewOpen(persistedReviewKey),
); );
const [reviewCurrentTime, setReviewCurrentTimeState] = React.useState( const [reviewCurrentTime, setReviewCurrentTimeState] = React.useState(
() => reviewPlaybackPositions.get(persistedReviewKey) ?? 0, () => getReviewPlaybackPosition(persistedReviewKey) ?? 0,
); );
const [pendingSeekSeconds, setPendingSeekSeconds] = React.useState< const [pendingSeekSeconds, setPendingSeekSeconds] = React.useState<
number | null number | null
@@ -642,16 +648,49 @@ export function VideoPlayer({
}, [durationSeconds]); }, [durationSeconds]);
React.useEffect(() => { 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( setReviewCurrentTimeState(
reviewPlaybackPositions.get(persistedReviewKey) ?? 0, getReviewPlaybackPosition(persistedReviewKey) ?? 0,
); );
}, [persistedReviewKey]); }, [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) => { (seconds: number) => {
const nextSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; 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); setReviewCurrentTimeState(nextSeconds);
}, },
[persistedReviewKey], [persistedReviewKey],
@@ -659,11 +698,7 @@ export function VideoPlayer({
const setReviewOpen = React.useCallback( const setReviewOpen = React.useCallback(
(open: boolean) => { (open: boolean) => {
if (open) { setVideoReviewOpen(persistedReviewKey, open);
openReviewKeys.add(persistedReviewKey);
} else {
openReviewKeys.delete(persistedReviewKey);
}
setReviewOpenState(open); setReviewOpenState(open);
}, },
[persistedReviewKey], [persistedReviewKey],
@@ -714,7 +749,7 @@ export function VideoPlayer({
inlineSeek.requestSeek(bounded); inlineSeek.requestSeek(bounded);
setCurrentTime(bounded); setCurrentTime(bounded);
}, },
[duration, inlineSeek], [duration, inlineSeek, setCurrentTime],
); );
const handleToggleMute = React.useCallback(() => { const handleToggleMute = React.useCallback(() => {
@@ -752,7 +787,7 @@ export function VideoPlayer({
// Hand the review position back to the inline player so playback // Hand the review position back to the inline player so playback
// resumes where the review left off. // resumes where the review left off.
const video = videoRef.current; const video = videoRef.current;
const reviewSeconds = reviewPlaybackPositions.get(persistedReviewKey); const reviewSeconds = getReviewPlaybackPosition(persistedReviewKey);
if ( if (
video && video &&
reviewSeconds !== undefined && reviewSeconds !== undefined &&
@@ -767,7 +802,7 @@ export function VideoPlayer({
} }
setReviewOpen(open); setReviewOpen(open);
}, },
[persistedReviewKey, setReviewOpen], [persistedReviewKey, setCurrentTime, setReviewOpen],
); );
const handlePendingSeekConsumed = React.useCallback(() => { const handlePendingSeekConsumed = React.useCallback(() => {
@@ -830,6 +865,22 @@ export function VideoPlayer({
videoWidth, videoWidth,
} = event.currentTarget; } = event.currentTarget;
handleMediaDuration(mediaDuration); 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) { if (videoWidth > 0 && videoHeight > 0) {
setNaturalAspectRatio(videoWidth / videoHeight); setNaturalAspectRatio(videoWidth / videoHeight);
} }

View File

@@ -145,6 +145,21 @@ const VideoReviewMarkdownContext = React.createContext<
VideoReviewContext | undefined VideoReviewContext | undefined
>(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({ function MarkdownVideoPlayer({
alt, alt,
entry, entry,
@@ -731,12 +746,7 @@ function SyntaxHighlightedCode({
} }
function createMarkdownComponents( function createMarkdownComponents(
variant: MarkdownVariant, variant: MarkdownVariant,
channels: Channel[], runtimeRef: React.RefObject<MarkdownRuntime>,
onOpenChannel: (channelId: string) => void,
onOpenMessageLink: (link: ParsedMessageLink) => void,
imetaByUrl?: ImetaLookup,
mentionPubkeysByName?: Record<string, string>,
agentMentionPubkeysByName?: Record<string, string>,
interactive = true, interactive = true,
): Components { ): Components {
const paragraphClassName = const paragraphClassName =
@@ -754,6 +764,7 @@ function createMarkdownComponents(
return { return {
a: ({ children, href, ...props }) => { a: ({ children, href, ...props }) => {
const { imetaByUrl, onOpenMessageLink } = runtimeRef.current;
if (!interactive) { if (!interactive) {
return <span className="font-medium text-current">{children}</span>; return <span className="font-medium text-current">{children}</span>;
} }
@@ -875,6 +886,7 @@ function createMarkdownComponents(
), ),
hr: () => <hr className="border-border/80" />, hr: () => <hr className="border-border/80" />,
img: ({ alt, src }) => { img: ({ alt, src }) => {
const { imetaByUrl } = runtimeRef.current;
const resolvedSrc = src ? rewriteRelayUrl(src) : src; const resolvedSrc = src ? rewriteRelayUrl(src) : src;
if (!interactive) { if (!interactive) {
const fallbackLabel = resolvedSrc?.endsWith(".mp4") const fallbackLabel = resolvedSrc?.endsWith(".mp4")
@@ -971,6 +983,8 @@ function createMarkdownComponents(
<ul className={cn("list-disc", listClassName)}>{children}</ul> <ul className={cn("list-disc", listClassName)}>{children}</ul>
), ),
mention: ({ children }: { children?: React.ReactNode }) => { mention: ({ children }: { children?: React.ReactNode }) => {
const { agentMentionPubkeysByName, mentionPubkeysByName } =
runtimeRef.current;
const mentionText = String(children ?? ""); const mentionText = String(children ?? "");
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase(); const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
const pubkey = mentionPubkeysByName?.[mentionName]; const pubkey = mentionPubkeysByName?.[mentionName];
@@ -1017,6 +1031,7 @@ function createMarkdownComponents(
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />; return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
}, },
"channel-link": ({ children }: { children?: React.ReactNode }) => { "channel-link": ({ children }: { children?: React.ReactNode }) => {
const { channels, onOpenChannel } = runtimeRef.current;
const text = String(children ?? ""); const text = String(children ?? "");
const channelName = text.startsWith("#") ? text.slice(1) : text; const channelName = text.startsWith("#") ? text.slice(1) : text;
const channel = channels.find( const channel = channels.find(
@@ -1051,6 +1066,7 @@ function createMarkdownComponents(
); );
}, },
"message-link": ({ children }: { children?: React.ReactNode }) => { "message-link": ({ children }: { children?: React.ReactNode }) => {
const { channels, onOpenMessageLink } = runtimeRef.current;
const href = String(children ?? ""); const href = String(children ?? "");
const parsed = parseMessageLink(href); const parsed = parseMessageLink(href);
if (!parsed.ok) { if (!parsed.ok) {
@@ -1113,42 +1129,40 @@ function MarkdownInner({
const { channels: rawChannels } = useChannelNavigation(); const { channels: rawChannels } = useChannelNavigation();
const channels = useStableArray(rawChannels); const channels = useStableArray(rawChannels);
const { goChannel } = useAppNavigation(); 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( const components = React.useMemo(
() => () => createMarkdownComponents(variant, runtimeRef, interactive),
createMarkdownComponents( [variant, runtimeRef, interactive],
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,
],
); );
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable

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;
}

View File

@@ -47,16 +47,30 @@ function emitMockMessage(page: Page, channelName: string, content: string) {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
let mediaCurrentTime = 0; type MediaState = {
let mediaPaused = true; 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", { Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true, configurable: true,
value() {}, value() {
getMediaState(this as HTMLMediaElement).currentTime = 0;
},
}); });
Object.defineProperty(HTMLMediaElement.prototype, "play", { Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true, configurable: true,
value() { value() {
mediaPaused = false; getMediaState(this as HTMLMediaElement).paused = false;
this.dispatchEvent(new Event("play")); this.dispatchEvent(new Event("play"));
return Promise.resolve(); return Promise.resolve();
}, },
@@ -64,23 +78,24 @@ test.beforeEach(async ({ page }) => {
Object.defineProperty(HTMLMediaElement.prototype, "pause", { Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true, configurable: true,
value() { value() {
mediaPaused = true; getMediaState(this as HTMLMediaElement).paused = true;
this.dispatchEvent(new Event("pause")); this.dispatchEvent(new Event("pause"));
}, },
}); });
Object.defineProperty(HTMLMediaElement.prototype, "paused", { Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true, configurable: true,
get() { get() {
return mediaPaused; return getMediaState(this as HTMLMediaElement).paused;
}, },
}); });
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", { Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true, configurable: true,
get() { get() {
return mediaCurrentTime; return getMediaState(this as HTMLMediaElement).currentTime;
}, },
set(value) { set(value) {
mediaCurrentTime = Number(value) || 0; getMediaState(this as HTMLMediaElement).currentTime =
Number(value) || 0;
this.dispatchEvent(new Event("seeked")); 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") ?? ""; const messageId = row?.getAttribute("data-message-id") ?? "";
return Boolean(messageId) && !messageId.startsWith("optimistic"); 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 inlinePlayer = page.getByTestId("video-player").last();
const inlineVideo = inlinePlayer.locator("video"); 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); .toBeLessThan(6.5);
await expect(page.getByTestId("video-inline-time")).toHaveText("00:06"); 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. // Inline volume controls.
await inlinePlayer.getByRole("button", { name: "Mute" }).click(); await inlinePlayer.getByRole("button", { name: "Mute" }).click();
await expect await expect
@@ -526,4 +578,28 @@ test("video upload previews use poster frames and inline videos open review mode
.getByTestId("video-review-backdrop") .getByTestId("video-review-backdrop")
.click({ position: { x: 4, y: 4 } }); .click({ position: { x: 4, y: 4 } });
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0); 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");
}); });