klopez4212
2026-08-07 07:45:18 -07:00
committed by GitHub
parent c71f658539
commit cd2125c34b
12 changed files with 755 additions and 142 deletions
@@ -22,7 +22,7 @@ import {
getDmHuddleMemberPubkeys,
hasOtherDmParticipant,
} from "@/features/channels/lib/dmHuddleMembers";
import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext";
import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
@@ -464,7 +464,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const activeVideoReviewCommentSender = activeChannel?.archivedAt
? undefined
: onSendVideoReviewComment;
const threadVideoReviewContextsByMessageId = React.useMemo(() => {
const threadVideoReviewPresentation = React.useMemo(() => {
const messagesById = new Map(
messages.map((message) => [message.id, message]),
);
@@ -475,7 +475,7 @@ export const ChannelPane = React.memo(function ChannelPane({
messagesById.set(message.id, message);
}
return buildVideoReviewContextsByMessageId({
return buildVideoReviewPresentationByMessageId({
channelId: activeChannel?.id ?? null,
channelName: activeChannel?.name,
channelType: activeChannel?.channelType ?? null,
@@ -883,9 +883,7 @@ export const ChannelPane = React.memo(function ChannelPane({
scrollTargetHighlights={!layoutScrollTargetId}
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
threadHead={threadHeadMessage}
videoReviewContextsByMessageId={
threadVideoReviewContextsByMessageId
}
videoReviewPresentation={threadVideoReviewPresentation}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadRepliesPending={threadMessagesPending}
@@ -4,6 +4,7 @@ import test from "node:test";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewCommentsForRoot,
buildVideoReviewCommentRootIdsByMessageId,
buildVideoReviewContextForMessage,
buildVideoReviewContextsByMessageId,
hasVideoAttachment,
@@ -159,6 +160,57 @@ test("buildVideoReviewCommentsForRoot returns descendants for one root", () => {
);
});
test("buildVideoReviewCommentRootIdsByMessageId targets the nearest video ancestor", () => {
const root = message({ id: "root", body: "Review request" });
const firstVideo = message({
id: "first-video",
body: "![video](https://relay/media/a.mp4)",
parentId: root.id,
rootId: root.id,
});
const firstComment = message({
id: "first-comment",
body: "[00:01] tighten this",
parentId: firstVideo.id,
rootId: root.id,
});
const nestedVideo = message({
id: "nested-video",
body: "![video](https://relay/media/b.mp4)",
parentId: firstComment.id,
rootId: root.id,
});
const nestedComment = message({
id: "nested-comment",
body: "[00:02] check this frame",
parentId: nestedVideo.id,
rootId: root.id,
});
const plainReply = message({
id: "plain-reply",
body: "No video ancestor",
parentId: root.id,
rootId: root.id,
});
const rootIds = buildVideoReviewCommentRootIdsByMessageId([
root,
firstVideo,
firstComment,
nestedVideo,
nestedComment,
plainReply,
]);
assert.deepEqual(
[...rootIds.entries()],
[
[firstComment.id, firstVideo.id],
[nestedComment.id, nestedVideo.id],
],
);
});
test("buildVideoReviewContextForMessage posts against the source video", async () => {
const video = message({
id: "video",
@@ -93,6 +93,33 @@ export function buildVideoReviewCommentsForRoot(
return comments;
}
export function buildVideoReviewCommentRootIdsByMessageId(
messages: TimelineMessage[],
): ReadonlyMap<string, string> {
const messageById = new Map(messages.map((message) => [message.id, message]));
const videoMessageIds = new Set(
messages.filter(hasVideoAttachment).map((message) => message.id),
);
const rootIdsByMessageId = new Map<string, string>();
for (const message of messages) {
if (videoMessageIds.has(message.id)) continue;
let ancestorId = message.parentId ?? null;
const visited = new Set<string>();
while (ancestorId && !visited.has(ancestorId)) {
if (videoMessageIds.has(ancestorId)) {
rootIdsByMessageId.set(message.id, ancestorId);
break;
}
visited.add(ancestorId);
ancestorId = messageById.get(ancestorId)?.parentId ?? null;
}
}
return rootIdsByMessageId;
}
export function buildVideoReviewContextForMessage({
channelId,
channelName,
@@ -193,3 +220,18 @@ export function buildVideoReviewContextsByMessageId({
return contexts;
}
export function buildVideoReviewPresentationByMessageId(
args: Parameters<typeof buildVideoReviewContextsByMessageId>[0],
) {
return {
commentRootIdsByMessageId: buildVideoReviewCommentRootIdsByMessageId(
args.messages,
),
contextsByMessageId: buildVideoReviewContextsByMessageId(args),
};
}
export type VideoReviewPresentation = ReturnType<
typeof buildVideoReviewPresentationByMessageId
>;
+42 -15
View File
@@ -40,6 +40,9 @@ import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedB
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation";
import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode";
import { VideoReviewTimecodeButton } from "@/shared/ui/VideoReviewTimecodeButton";
import { MessageActionBar } from "./MessageActionBar";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
@@ -95,6 +98,7 @@ export const MessageRow = React.memo(
profiles,
searchQuery,
showDepthGuides = true,
videoReviewCommentRootId,
videoReviewContext,
}: {
channelId?: string | null;
@@ -143,6 +147,7 @@ export const MessageRow = React.memo(
profiles?: UserProfileLookup;
searchQuery?: string;
showDepthGuides?: boolean;
videoReviewCommentRootId?: string;
videoReviewContext?: VideoReviewContext;
}) {
// Keep the transient send state with its timestamp rather than collapsing
@@ -244,6 +249,7 @@ export const MessageRow = React.memo(
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
const openVideoReviewAt = useOpenVideoReviewAt();
const indentRem = getThreadReplyIndentRem(message.depth);
const descendantGuideOffsetRem = connectDescendants
@@ -340,22 +346,24 @@ export const MessageRow = React.memo(
message={message}
/>
);
default:
{
const waveMessage = parseWaveMessageContent(message.body);
if (waveMessage) {
return (
<WaveMessageAttachment
channelId={channelId}
fallbackText={waveMessage.fallbackText}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
/>
);
}
default: {
const waveMessage = parseWaveMessageContent(message.body);
if (waveMessage) {
return (
<WaveMessageAttachment
channelId={channelId}
fallbackText={waveMessage.fallbackText}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
/>
);
}
return (
const reviewRootEventId = videoReviewCommentRootId;
const reviewTimecode = reviewRootEventId
? parseVideoReviewTimecode(message.body)
: null;
const markdown = (
<Markdown
channelNames={channelNames}
className={cn(
@@ -371,7 +379,7 @@ export const MessageRow = React.memo(
message,
isKnownAgentPubkey,
)}
content={message.body}
content={reviewTimecode?.text ?? message.body}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
agentMentionPubkeysByName={agentMentionPubkeysByName}
@@ -382,6 +390,24 @@ export const MessageRow = React.memo(
videoReviewContext={videoReviewContext}
/>
);
if (!reviewRootEventId || !reviewTimecode || !openVideoReviewAt) {
return markdown;
}
return (
<div className="flex min-w-0 items-start gap-1.5">
<VideoReviewTimecodeButton
surface="message"
timecode={reviewTimecode.timecode}
onClick={(event) => {
event.stopPropagation();
openVideoReviewAt(reviewRootEventId, reviewTimecode.seconds);
}}
/>
<div className="min-w-0 flex-1">{markdown}</div>
</div>
);
}
}
};
@@ -893,6 +919,7 @@ export const MessageRow = React.memo(
prev.playEntrance === next.playEntrance &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
prev.videoReviewCommentRootId === next.videoReviewCommentRootId &&
prev.videoReviewContext === next.videoReviewContext,
);
@@ -18,11 +18,13 @@ import {
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
import type { TimelineMessage } from "@/features/messages/types";
import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation";
import { cn } from "@/shared/lib/cn";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
@@ -38,7 +40,6 @@ import {
} from "@/features/messages/lib/messageThreadPanelLayout";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { ComposerActivityAccessory } from "./ComposerActivityAccessory";
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
import { MessageComposer } from "./MessageComposer";
@@ -111,7 +112,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
threadUnreadCount?: number;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadTypingPubkeys: string[];
videoReviewContextsByMessageId?: ReadonlyMap<string, VideoReviewContext>;
videoReviewPresentation?: VideoReviewPresentation;
activityAccessoryContent?: React.ReactNode;
activityAccessoryVisible: boolean;
widthPx: number;
@@ -225,7 +226,7 @@ export function MessageThreadPanel({
scrollTargetId,
scrollTargetHighlights = true,
threadHead,
videoReviewContextsByMessageId,
videoReviewPresentation,
threadReplies,
threadRepliesPending = false,
threadUnreadCount,
@@ -617,7 +618,10 @@ export function MessageThreadPanel({
}
profiles={profiles}
showDepthGuides={shouldShowThreadBranchGuides}
videoReviewContext={videoReviewContextsByMessageId?.get(
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
threadHead.id,
)}
videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
threadHead.id,
)}
/>
@@ -776,7 +780,10 @@ export function MessageThreadPanel({
onToggleReaction={onToggleReaction}
profiles={profiles}
showDepthGuides={shouldShowThreadBranchGuides}
videoReviewContext={videoReviewContextsByMessageId?.get(
videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
entry.message.id,
)}
videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
entry.message.id,
)}
/>
@@ -955,24 +962,26 @@ export function MessageThreadPanel({
);
return (
<AuxiliaryPanel
className="relative"
// The focus drawer animates itself; a second slide here would compound.
enterMotion={!isFocusMode}
footer={threadFooter}
header={
isHuddleTranscript ? undefined : (
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
)
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadScrollRegion}
</AuxiliaryPanel>
<VideoReviewNavigationProvider>
<AuxiliaryPanel
className="relative"
// The focus drawer animates itself; a second slide here would compound.
enterMotion={!isFocusMode}
footer={threadFooter}
header={
isHuddleTranscript ? undefined : (
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
)
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadScrollRegion}
</AuxiliaryPanel>
</VideoReviewNavigationProvider>
);
}
+88 -91
View File
@@ -28,6 +28,13 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Spinner } from "./spinner";
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
import { useVideoContextMenu } from "./useVideoContextMenu";
import { useRegisterVideoReview } from "./VideoReviewNavigation";
import { VideoReviewPosterPreview } from "./VideoReviewPosterPreview";
import { parseVideoReviewTimecode } from "./videoReviewTimecode";
import {
VideoReviewTimecodeButton,
VIDEO_REVIEW_TIMECODE_ACCENT_CLASS,
} from "./VideoReviewTimecodeButton";
import {
getInlinePlaybackPosition,
getReviewPlaybackPosition,
@@ -109,16 +116,10 @@ type TimecodedComment = {
text: string;
};
const TIMECODE_RE =
/^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
const DEFAULT_PLAYBACK_SPEED = 1;
const INLINE_SPEED_CONTROL_MIN_WIDTH = 220;
const PLAYBACK_SPEEDS = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25];
const TIMECODE_ACCENT_CLASS =
"bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
const TIMECODE_ACCENT_HOVER_CLASS =
"hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
/**
* Frosted-glass backing layer for floating media controls. The parent must
@@ -188,40 +189,11 @@ function isPlaybackSpeedOption(speed: number): boolean {
return PLAYBACK_SPEEDS.some((option) => option === speed);
}
function parseTimecode(value: string): number | null {
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
return null;
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return null;
}
function parseTimecodedComment(comment: VideoReviewComment): TimecodedComment {
const match = comment.body.match(TIMECODE_RE);
if (!match) {
return {
comment,
seconds: null,
timecode: null,
text: comment.body.trim(),
};
}
return {
comment,
seconds: parseTimecode(match[1]),
timecode: match[1],
text: comment.body.slice(match[0].length).trim(),
};
const parsed = parseVideoReviewTimecode(comment.body);
return parsed
? { comment, ...parsed }
: { comment, seconds: null, text: comment.body.trim(), timecode: null };
}
function sortTimecodedComments(
@@ -928,20 +900,32 @@ export function VideoPlayer({
video.muted = value <= 0;
}, []);
const openReviewAt = React.useCallback(
(seconds: number) => {
const video = videoRef.current;
video?.pause();
const safeSeconds = Number.isFinite(seconds) ? Math.max(seconds, 0) : 0;
const nextSeconds =
duration > 0 ? Math.min(safeSeconds, duration) : safeSeconds;
setPendingSeekSeconds(nextSeconds);
setReviewCurrentTime(nextSeconds);
setReviewOpen(true);
},
[duration, setReviewCurrentTime, setReviewOpen],
);
useRegisterVideoReview(reviewContext, persistedReviewKey, openReviewAt);
const handleOpenReview = React.useCallback(
(event?: React.SyntheticEvent) => {
event?.stopPropagation();
const video = videoRef.current;
video?.pause();
const startTime =
video && Number.isFinite(video.currentTime)
? video.currentTime
: currentTime;
setPendingSeekSeconds(startTime);
setReviewCurrentTime(startTime);
setReviewOpen(true);
openReviewAt(startTime);
},
[currentTime, setReviewCurrentTime, setReviewOpen],
[currentTime, openReviewAt],
);
const handleReviewOpenChange = React.useCallback(
@@ -989,7 +973,12 @@ export function VideoPlayer({
maxHeight: 256,
width: inlineSurfaceWidth,
};
const showControls = started && !hasError;
const hideInlineControls = !started || isPlaying;
const inlineControlsRevealClass = cn(
"transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
hideInlineControls &&
"opacity-0 group-focus-within/inline-controls:opacity-100 group-hover/video:opacity-100",
);
return (
<>
@@ -1015,7 +1004,7 @@ export function VideoPlayer({
poster={poster}
preload="metadata"
src={src}
onClick={showControls ? handleTogglePlay : undefined}
onClick={started ? handleTogglePlay : undefined}
onDurationChange={(event) =>
handleMediaDuration(event.currentTarget.duration)
}
@@ -1072,16 +1061,29 @@ export function VideoPlayer({
}}
onWaiting={() => setIsBuffering(true)}
/>
{!started && !hasError ? (
{!hasError && !isBuffering ? (
<button
aria-label={isPlaying ? "Pause video" : "Play video"}
className={cn(
"absolute inset-0 flex cursor-pointer items-center justify-center opacity-100 outline-hidden transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-white/60 motion-reduce:transition-none",
started &&
isPlaying &&
"opacity-0 group-hover/video:opacity-100 focus-visible:opacity-100",
)}
data-testid="video-inline-center-playback"
type="button"
aria-label="Play video"
className="group absolute inset-0 flex cursor-pointer items-center justify-center"
onClick={handleTogglePlay}
>
<span className="relative isolate flex h-14 w-14 items-center justify-center rounded-full transition-transform duration-200 ease-out group-hover:scale-105">
<span
className="relative isolate flex h-14 w-14 items-center justify-center rounded-full"
data-testid="video-inline-center-icon"
>
<GlassSurface className="rounded-full" />
<Play className="h-6 w-6 fill-white text-white" />
{isPlaying ? (
<Pause className="h-6 w-6 fill-white text-white" />
) : (
<Play className="h-6 w-6 fill-white text-white" />
)}
</span>
</button>
) : null}
@@ -1109,33 +1111,22 @@ export function VideoPlayer({
</span>
</button>
) : null}
{/* Slide (not fade) the pill out: animating opacity on an ancestor
of a backdrop-filter flattens the glass into a plain fill
mid-transition, which reads as a flicker. The video container's
overflow-hidden clips the slid-out pill. */}
{showControls ? (
{!hasError ? (
<div
className={cn(
"absolute inset-x-1.5 bottom-1.5 z-10 transition-transform duration-300 ease-out",
isPlaying &&
"pointer-events-none translate-y-[150%] focus-within:pointer-events-auto focus-within:translate-y-0 group-hover/video:pointer-events-auto group-hover/video:translate-y-0",
"group/inline-controls absolute inset-x-1.5 bottom-1.5 z-10 isolate rounded-[10px]",
hideInlineControls &&
"pointer-events-none focus-within:pointer-events-auto group-hover/video:pointer-events-auto",
)}
data-testid="video-inline-controls"
>
<div className="relative isolate flex items-center gap-1 rounded-[10px] px-1.5 py-1">
<GlassSurface />
<button
aria-label={isPlaying ? "Pause video" : "Play video"}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white transition-colors hover:bg-white/15 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary"
type="button"
onClick={handleTogglePlay}
>
{isPlaying ? (
<Pause className="pointer-events-none h-4 w-4 fill-white" />
) : (
<Play className="pointer-events-none h-4 w-4 fill-white" />
)}
</button>
<GlassSurface className={inlineControlsRevealClass} />
<div
className={cn(
"flex items-center gap-1 px-1.5 py-1",
inlineControlsRevealClass,
)}
data-testid="video-inline-controls"
>
<span
className="shrink-0 text-2xs font-medium tabular-nums leading-none text-white"
data-testid="video-inline-time"
@@ -1260,6 +1251,7 @@ function VideoReviewDialog({
const [volume, setVolume] = React.useState(1);
const [muted, setMuted] = React.useState(false);
const [mediaRatio, setMediaRatio] = React.useState<number | null>(null);
const [hasVisibleFrame, setHasVisibleFrame] = React.useState(false);
const [videoAreaSize, setVideoAreaSize] = React.useState<{
height: number;
width: number;
@@ -1364,6 +1356,7 @@ function VideoReviewDialog({
React.useEffect(() => {
if (!open) {
setIsComposerMounted(false);
setHasVisibleFrame(false);
return;
}
// Two frames: one for the dialog to paint, one for the browser to
@@ -1715,7 +1708,7 @@ function VideoReviewDialog({
className="h-full w-full min-h-0 object-contain"
playsInline
poster={poster}
preload="metadata"
preload="auto"
src={src}
onClick={togglePlay}
onDurationChange={(event) =>
@@ -1740,6 +1733,7 @@ function VideoReviewDialog({
syncCurrentTime(pendingSeekSeconds);
}
}}
onLoadedData={() => setHasVisibleFrame(true)}
onPause={(event) => {
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(false);
@@ -1748,7 +1742,15 @@ function VideoReviewDialog({
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(true);
}}
onSeeked={reviewSeek.handleSeeked}
onSeeked={(event) => {
reviewSeek.handleSeeked();
if (
event.currentTarget.readyState >=
HTMLMediaElement.HAVE_CURRENT_DATA
) {
setHasVisibleFrame(true);
}
}}
onTimeUpdate={(event) => {
syncCurrentTime(event.currentTarget.currentTime);
}}
@@ -1757,6 +1759,10 @@ function VideoReviewDialog({
setMuted(event.currentTarget.muted);
}}
/>
<VideoReviewPosterPreview
poster={poster}
visible={!hasVisibleFrame}
/>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 sm:inset-x-4 sm:bottom-3">
<div className="relative isolate flex items-center gap-2 rounded-xl px-2 py-1.5">
@@ -1903,12 +1909,12 @@ function VideoReviewDialog({
{showCommentsPanel ? (
<aside
className="min-h-0 shrink-0 overflow-hidden transition-[width] duration-200 ease-out"
className="relative z-10 min-h-0 shrink-0 overflow-hidden bg-neutral-950 transition-[width] duration-200 ease-out"
data-testid="video-review-comments-panel"
inert={!isPanelOpen || undefined}
style={{ width: isPanelOpen ? 380 : 0 }}
>
<div className="flex h-full w-[380px] min-h-0 flex-col border-l border-border bg-background">
<div className="flex h-full w-[380px] min-h-0 flex-col border-l border-border bg-neutral-950">
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
<div className="flex min-w-0 items-center gap-2">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
@@ -1960,7 +1966,7 @@ function VideoReviewDialog({
className={cn(
"rounded-md px-2 py-1 font-mono text-xs font-semibold transition-colors",
!replyTarget && postAtCurrentFrame
? TIMECODE_ACCENT_CLASS
? VIDEO_REVIEW_TIMECODE_ACCENT_CLASS
: "bg-muted text-muted-foreground/70",
)}
data-testid="video-review-composer-timecode"
@@ -2124,19 +2130,10 @@ function VideoReviewCommentBody({
const text = item.text || item.comment.body;
const timecodeButton =
item.seconds !== null && item.timecode ? (
<button
aria-label={`Jump to ${item.timecode}`}
className={cn(
"inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60",
TIMECODE_ACCENT_CLASS,
TIMECODE_ACCENT_HOVER_CLASS,
)}
data-testid="video-review-comment-timecode"
type="button"
<VideoReviewTimecodeButton
timecode={item.timecode}
onClick={() => onSeek(item.seconds ?? 0)}
>
{item.timecode}
</button>
/>
) : null;
return (
@@ -0,0 +1,101 @@
import * as React from "react";
type OpenVideoReview = (seconds: number) => void;
type VideoReviewNavigationValue = {
open: (rootEventId: string, seconds: number) => void;
register: (
rootEventId: string,
attachmentKey: string,
handler: OpenVideoReview,
) => () => void;
};
const VideoReviewNavigationContext =
React.createContext<VideoReviewNavigationValue | null>(null);
export function VideoReviewNavigationProvider({
children,
}: {
children: React.ReactNode;
}) {
// Review comments are scoped to their message, not an individual attachment.
// Keep attachment registration order so a multi-video message always opens
// its first video instead of whichever player happened to update last.
const handlersRef = React.useRef(
new Map<string, Map<string, Set<OpenVideoReview>>>(),
);
const value = React.useMemo<VideoReviewNavigationValue>(
() => ({
open(rootEventId, seconds) {
handlersRef.current
.get(rootEventId)
?.values()
.next()
.value?.values()
.next()
.value?.(seconds);
},
register(rootEventId, attachmentKey, handler) {
let rootHandlers = handlersRef.current.get(rootEventId);
if (!rootHandlers) {
rootHandlers = new Map();
handlersRef.current.set(rootEventId, rootHandlers);
}
let attachmentHandlers = rootHandlers.get(attachmentKey);
if (!attachmentHandlers) {
attachmentHandlers = new Set();
rootHandlers.set(attachmentKey, attachmentHandlers);
}
attachmentHandlers.add(handler);
return () => {
const registeredHandlers = handlersRef.current.get(rootEventId);
const registeredAttachmentHandlers =
registeredHandlers?.get(attachmentKey);
registeredAttachmentHandlers?.delete(handler);
if (registeredAttachmentHandlers?.size === 0) {
registeredHandlers?.delete(attachmentKey);
}
if (registeredHandlers?.size === 0) {
handlersRef.current.delete(rootEventId);
}
};
},
}),
[],
);
return (
<VideoReviewNavigationContext.Provider value={value}>
{children}
</VideoReviewNavigationContext.Provider>
);
}
export function useOpenVideoReviewAt():
| VideoReviewNavigationValue["open"]
| null {
return React.useContext(VideoReviewNavigationContext)?.open ?? null;
}
export function useRegisterVideoReview(
reviewContext: { rootEventId?: string } | undefined,
attachmentKey: string,
handler: OpenVideoReview,
): void {
const navigation = React.useContext(VideoReviewNavigationContext);
const rootEventId = reviewContext?.rootEventId;
const handlerRef = React.useRef(handler);
React.useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
const registeredHandler = React.useCallback(
(seconds: number) => handlerRef.current(seconds),
[],
);
React.useEffect(() => {
if (!navigation || !rootEventId) return;
return navigation.register(rootEventId, attachmentKey, registeredHandler);
}, [attachmentKey, navigation, registeredHandler, rootEventId]);
}
@@ -0,0 +1,22 @@
export function VideoReviewPosterPreview({
poster,
visible,
}: {
poster?: string;
visible: boolean;
}) {
if (!poster || !visible) return null;
return (
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
data-testid="video-review-poster-preview"
decoding="sync"
draggable={false}
fetchPriority="high"
src={poster}
/>
);
}
@@ -0,0 +1,42 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
const TIMECODE_ACCENT_CLASS =
"bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
const TIMECODE_ACCENT_HOVER_CLASS =
"hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
const MESSAGE_TIMECODE_ACCENT_CLASS =
"bg-primary/15 text-primary hover:bg-primary/30";
export function VideoReviewTimecodeButton({
className,
onClick,
surface = "review",
timecode,
}: {
className?: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
surface?: "message" | "review";
timecode: string;
}) {
return (
<button
aria-label={`Jump to ${timecode}`}
className={cn(
"inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60",
surface === "review"
? [TIMECODE_ACCENT_CLASS, TIMECODE_ACCENT_HOVER_CLASS]
: MESSAGE_TIMECODE_ACCENT_CLASS,
className,
)}
data-testid="video-review-comment-timecode"
type="button"
onClick={onClick}
>
{timecode}
</button>
);
}
export const VIDEO_REVIEW_TIMECODE_ACCENT_CLASS = TIMECODE_ACCENT_CLASS;
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseVideoReviewTimecode } from "./videoReviewTimecode.ts";
test("parseVideoReviewTimecode extracts supported leading timecodes", () => {
assert.deepEqual(parseVideoReviewTimecode("[00:10.7] Tighten **this** cut"), {
seconds: 10.7,
text: "Tighten **this** cut",
timecode: "00:10.7",
});
assert.deepEqual(parseVideoReviewTimecode("[1:02:03] Long-form note"), {
seconds: 3723,
text: "Long-form note",
timecode: "1:02:03",
});
});
test("parseVideoReviewTimecode ignores ordinary bracketed markdown", () => {
assert.equal(parseVideoReviewTimecode("[docs](https://example.com)"), null);
assert.equal(parseVideoReviewTimecode("Comment at [00:10]"), null);
});
@@ -0,0 +1,41 @@
export type VideoReviewTimecode = {
seconds: number;
text: string;
timecode: string;
};
const TIMECODE_RE =
/^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
function parseTimecode(value: string): number | null {
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
return null;
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return null;
}
export function parseVideoReviewTimecode(
content: string,
): VideoReviewTimecode | null {
const match = content.match(TIMECODE_RE);
if (!match) return null;
const seconds = parseTimecode(match[1]);
if (seconds === null) return null;
return {
seconds,
text: content.slice(match[0].length).trim(),
timecode: match[1],
};
}
+266 -6
View File
@@ -624,12 +624,20 @@ test("video upload previews use poster frames and inline videos open review mode
// must not remount the review dialog or wipe an in-progress comment draft.
await commentBox.click();
await commentBox.fill("Second pass note");
const commentEditor = await commentBox.elementHandle();
if (!commentEditor) {
throw new Error("Expected the review comment editor to be mounted.");
}
await emitMockMessage(page, "general", "Unrelated chatter mid-review");
await expect(
page
.getByTestId("message-row")
.filter({ hasText: "Unrelated chatter mid-review" }),
).toHaveCount(1);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
expect(await commentEditor.evaluate((element) => element.isConnected)).toBe(
true,
);
await expect(commentBox).toHaveText("Second pass note");
await expect(commentBox).toBeFocused();
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
@@ -746,6 +754,14 @@ test("video upload previews use poster frames and inline videos open review mode
.click({ position: { x: 4, y: 4 } });
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
// Re-open the channel so the thread summary is sourced from the persisted
// mock history instead of depending on whether the background timeline row
// stayed mounted while the modal handled live comment updates.
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 videoSummaryRow = page.locator(
`[data-thread-head-id="${videoMessageId}"]`,
);
@@ -771,6 +787,103 @@ test("video upload previews use poster frames and inline videos open review mode
).toContainText("Color pass looks right");
});
test("inline video hover reveals a timeline without a second play control", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", `![video](${VIDEO_URL})`, {
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename launch-demo.mp4",
],
],
});
const player = page.getByTestId("video-player").last();
const video = player.locator("video");
const surface = video.locator("..");
const centerPlayback = player.getByTestId("video-inline-center-playback");
const centerIcon = player.getByTestId("video-inline-center-icon");
const controls = player.getByTestId("video-inline-controls");
await expect(centerPlayback).toHaveAttribute("aria-label", "Play video");
await expect(
controls.getByRole("button", { name: /^(?:Play|Pause) video$/ }),
).toHaveCount(0);
await expect(
player.getByRole("button", { name: "Open video review" }),
).toBeVisible();
await expect(player.getByTestId("video-inline-duration")).toHaveText("00:12");
await video.evaluate((element) => {
element.currentTime = 6.25;
element.dispatchEvent(new Event("timeupdate"));
});
await expect
.poll(() =>
player
.getByTestId("video-inline-progress-fill")
.evaluate((element) => element.style.width),
)
.toBe("50%");
const restingControlsBox = await controls.boundingBox();
const restingIconTransform = await centerIcon.evaluate(
(element) => window.getComputedStyle(element).transform,
);
expect(restingControlsBox).not.toBeNull();
await expect(controls).toHaveCSS("opacity", "0");
await surface.hover();
await expect(controls).toHaveCSS("opacity", "1");
const hoveredControlsBox = await controls.boundingBox();
expect(hoveredControlsBox).not.toBeNull();
expect(
Math.abs((hoveredControlsBox?.y ?? 0) - (restingControlsBox?.y ?? 0)),
).toBeLessThan(0.5);
await expect
.poll(() =>
centerIcon.evaluate(
(element) => window.getComputedStyle(element).transform,
),
)
.toBe(restingIconTransform);
await centerPlayback.click();
await expect
.poll(() => video.evaluate((element) => element.paused))
.toBe(false);
await expect(centerPlayback).toHaveAttribute("aria-label", "Pause video");
await page.mouse.move(0, 0);
await expect(centerPlayback).toHaveCSS("opacity", "0");
await surface.hover();
await expect(centerPlayback).toHaveCSS("opacity", "1");
await expect(centerPlayback).toHaveCSS("transition-property", "opacity");
await expect(centerPlayback).toHaveCSS("transition-duration", "0.15s");
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(centerPlayback).toHaveCSS("transition-property", "none");
await expect(controls).toHaveCSS("transition-property", "none");
await centerPlayback.click();
await expect
.poll(() => video.evaluate((element) => element.paused))
.toBe(true);
});
test("video replies in threads open the review comments view", async ({
page,
}) => {
@@ -791,6 +904,19 @@ test("video replies in threads open the review comments view", async ({
"general",
`![video](${VIDEO_URL})`,
{
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"size 987654",
"dim 160x80",
"duration 12.5",
`image ${POSTER_DATA_URL}`,
"filename launch-demo.mp4",
],
],
parentEventId: root.id,
},
)) as { id: string };
@@ -808,9 +934,73 @@ test("video replies in threads open the review comments view", async ({
name: "Open video review",
});
await expect(reviewButton).toBeVisible();
await reviewButton.click();
const nestedVideoSummary = threadReplies.locator(
`[data-thread-head-id="${videoReply.id}"]`,
);
await expect(nestedVideoSummary).toBeVisible();
await expect(threadReplies.locator("video")).toHaveAttribute(
"preload",
"metadata",
);
await nestedVideoSummary.click();
const outsideTimecode = threadReplies.getByRole("button", {
name: "Jump to 00:01",
});
await expect(outsideTimecode).toBeVisible();
await expect(outsideTimecode).toHaveAttribute(
"data-testid",
"video-review-comment-timecode",
);
const outsideTimecodeStyles = await outsideTimecode.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderRadius: styles.borderRadius,
fontFamily: styles.fontFamily,
height: styles.height,
paddingLeft: styles.paddingLeft,
paddingRight: styles.paddingRight,
};
});
expect(outsideTimecodeStyles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
await outsideTimecode.click();
const reviewDialog = page.getByTestId("video-review-dialog");
const reviewVideo = reviewDialog.locator("video");
const posterPreview = reviewDialog.getByTestId("video-review-poster-preview");
await expect(posterPreview).toBeVisible();
await expect(posterPreview).toHaveAttribute("src", POSTER_DATA_URL);
await expect(
reviewDialog.getByTestId("video-review-comments-panel"),
).toHaveCSS("background-color", "oklch(0.145 0 0)");
const modalTimecode = reviewDialog
.getByRole("button", { name: "Jump to 00:01" })
.first();
await expect(modalTimecode).toBeVisible();
const modalTimecodeStyles = await modalTimecode.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
borderRadius: styles.borderRadius,
fontFamily: styles.fontFamily,
height: styles.height,
paddingLeft: styles.paddingLeft,
paddingRight: styles.paddingRight,
};
});
expect(outsideTimecodeStyles).toMatchObject(modalTimecodeStyles);
await expect(reviewVideo).toHaveAttribute("preload", "auto");
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).paused),
)
.toBe(true);
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(1);
await reviewVideo.dispatchEvent("loadeddata");
await expect(posterPreview).toHaveCount(0);
await expect(
reviewDialog.getByTestId("video-review-comments-panel"),
).toBeVisible();
@@ -820,6 +1010,76 @@ test("video replies in threads open the review comments view", async ({
);
});
test("message timecodes deterministically open the first attached video", async ({
page,
}) => {
await installVideoReviewHarness(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const videoMessage = (await emitMockMessage(
page,
"general",
`![first video](${VIDEO_URL})\n\n![second video](${PORTRAIT_VIDEO_URL})`,
{
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"dim 160x80",
"duration 12.5",
"filename first-cut.mp4",
],
[
"imeta",
`url ${PORTRAIT_VIDEO_URL}`,
"m video/mp4",
`x ${PORTRAIT_VIDEO_SHA}`,
"dim 80x160",
"duration 12.5",
"filename second-cut.mp4",
],
],
},
)) as { id: string };
await emitMockMessage(page, "general", "[00:01] Check this frame.", {
parentEventId: videoMessage.id,
});
const threadSummary = page.locator(
`[data-thread-head-id="${videoMessage.id}"]`,
);
await expect(threadSummary).toBeVisible();
await threadSummary.click();
const threadPanel = page.getByTestId("message-thread-panel");
const threadHead = threadPanel.getByTestId("message-thread-head");
const inlineVideos = threadHead.locator("video");
await expect(inlineVideos).toHaveCount(2);
const firstVideoSrc = await inlineVideos.nth(0).getAttribute("src");
const secondVideoSrc = await inlineVideos.nth(1).getAttribute("src");
expect(firstVideoSrc).toBeTruthy();
expect(firstVideoSrc).not.toBe(secondVideoSrc);
await threadPanel
.getByTestId("message-thread-replies")
.getByRole("button", { name: "Jump to 00:01" })
.click();
const reviewVideo = page.getByTestId("video-review-dialog").locator("video");
await expect(reviewVideo).toHaveAttribute("src", firstVideoSrc ?? "");
await expect
.poll(() =>
reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
)
.toBe(1);
});
test("narrow inline videos hide playback speed control", async ({ page }) => {
await installVideoReviewHarness(page);