mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Fix video review comments in threads (#1056)
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
152
desktop/src/features/messages/lib/videoReviewContext.test.mjs
Normal file
152
desktop/src/features/messages/lib/videoReviewContext.test.mjs
Normal 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" }),
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
118
desktop/src/features/messages/lib/videoReviewContext.ts
Normal file
118
desktop/src/features/messages/lib/videoReviewContext.ts
Normal 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(") 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,
|
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>
|
||||||
|
|||||||
@@ -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(") 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;
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
57
desktop/src/shared/ui/videoPlayerState.ts
Normal file
57
desktop/src/shared/ui/videoPlayerState.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user