mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix video review comments in threads (#1056)
This commit is contained in:
@@ -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" }),
|
||||
),
|
||||
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: "",
|
||||
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: "",
|
||||
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(") 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(") 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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user