Fix video comment effect wrapping (#5748)

## What changed

- render video-review timecode chips inside the first Markdown paragraph
so comment text wraps naturally around them
- reuse the canonical video-review chip treatment across the timeline,
Inbox previews, and Inbox detail
- preserve video-review context in Inbox so timestamp chips remain
interactive

## Why

Video comments now support Markdown-like effects, but non-player
surfaces rendered the timestamp beside a separate text layout. That kept
the chip and comment from sharing the same inline flow and made Inbox
behavior inconsistent with the player.

## Validation

- `pnpm --dir desktop check`
- 100 focused Markdown, timecode, video-review, and Inbox unit tests
- `pnpm --dir desktop build:e2e`
- focused `video-attachment.spec.ts` Playwright scenario
- pre-push desktop typecheck and 4,761-test desktop suite
- native Builderlab staging with the configured profile

Focused timeline and Inbox snapshots will be attached in a PR comment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-14 17:16:28 +01:00
committed by GitHub
co-authored by Fast Fizz
parent 1d51081b8a
commit 17d2147eca
20 changed files with 951 additions and 127 deletions
+1
View File
@@ -67,6 +67,7 @@
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"lucide-react": "^1.0.0",
"mdast-util-from-markdown": "^2.0.3",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
@@ -228,6 +228,7 @@ export function toInboxContextMessage(
export function toTimelineMessage(
message: InboxContextMessage,
): TimelineMessage {
const threadReference = getThreadReference(message.tags ?? []);
return {
id: message.id,
author: message.authorLabel,
@@ -239,8 +240,10 @@ export function toTimelineMessage(
createdAt: message.createdAt,
depth: message.depth,
kind: message.kind,
parentId: message.parentId ?? threadReference.parentId,
pubkey: message.authorPubkey,
reactions: message.reactions ?? [],
rootId: message.rootId ?? threadReference.rootId,
signerPubkey: message.signerPubkey,
tags: message.tags,
time: message.timeLabel ?? message.fullTimestampLabel,
+119 -33
View File
@@ -19,7 +19,10 @@ import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { useCommunities } from "@/features/communities/useCommunities";
import { formatInboxTypeLabel } from "@/features/home/lib/inbox";
import { hasInboxThreadContext } from "@/features/home/lib/inboxViewHelpers";
import {
hasInboxThreadContext,
toTimelineMessage,
} from "@/features/home/lib/inboxViewHelpers";
import {
type InboxDisplayMessage,
InboxMessageRow,
@@ -35,6 +38,10 @@ import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionP
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
import { buildEditMentionState } from "@/features/messages/lib/draftMentionRefs";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import {
buildVideoReviewPresentationByMessageId,
hasRenderedVideoAttachment,
} from "@/features/messages/lib/videoReviewContext";
import { getThreadReference } from "@/features/messages/lib/threading";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
@@ -46,6 +53,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation";
import {
DropdownMenu,
DropdownMenuContent,
@@ -64,6 +72,9 @@ const MembersSidebar = React.lazy(async () => {
return { default: module.MembersSidebar };
});
const EMPTY_CONTEXT_MESSAGES: InboxContextMessage[] = [];
const EMPTY_REPLIES: InboxReply[] = [];
type InboxDetailPaneProps = {
agentPubkeys?: ReadonlySet<string>;
canDelete: boolean;
@@ -143,7 +154,11 @@ export function InboxDetailPane(props: InboxDetailPaneProps) {
);
}
return <InboxMessageDetailPane {...props} />;
return (
<VideoReviewNavigationProvider>
<InboxMessageDetailPane {...props} />
</VideoReviewNavigationProvider>
);
}
function InboxMessageDetailPane({
@@ -160,9 +175,9 @@ function InboxMessageDetailPane({
hasThreadContextLoadError = false,
isThreadContextLoading = false,
item,
messages = [],
messages = EMPTY_CONTEXT_MESSAGES,
profiles,
replies = [],
replies = EMPTY_REPLIES,
channel,
contextChannelName = null,
currentPubkey,
@@ -199,7 +214,6 @@ function InboxMessageDetailPane({
// Build the plain, non-virtualized timeline the shared hook anchors against.
// Live arrivals rerun its layout compensation without changing the target.
const selectedMessage = messages.find((message) => message.isSelected);
// A latest reply can represent an Inbox conversation. Resolve the actual
// root from loaded context or the complete feed group; never treat an
// unresolved root/profile lookup as an authoritative empty audience.
@@ -234,34 +248,100 @@ function InboxMessageDetailPane({
)
: []
: undefined;
const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({
...reply,
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
isSelected: false,
mentionNames: [],
}));
const displayMessages: InboxDisplayMessage[] =
messages.length > 0
? [...messages, ...pendingReplyMessages]
: item
? [
{
authorLabel: item.senderLabel,
authorPubkey: item.item.pubkey,
avatarUrl: item.avatarUrl,
content: item.preview,
createdAt: item.item.createdAt,
depth: 0,
fullTimestampLabel: item.fullTimestampLabel,
id: item.id,
isSelected: true,
mentionNames: item.mentionNames,
mentionPubkeysByName: item.mentionPubkeysByName,
timeLabel: formatTime(item.item.createdAt),
},
...pendingReplyMessages,
]
: pendingReplyMessages;
const displayMessages = React.useMemo<InboxDisplayMessage[]>(() => {
const selectedMessage = messages.find((message) => message.isSelected);
const pendingReplyMessages: InboxDisplayMessage[] = replies.map(
(reply) => ({
...reply,
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
isSelected: false,
mentionNames: [],
}),
);
if (messages.length > 0) {
return [...messages, ...pendingReplyMessages];
}
if (!item) return pendingReplyMessages;
const threadReference = getThreadReference(item.item.tags);
return [
{
authorLabel: item.senderLabel,
authorPubkey: item.item.pubkey,
avatarUrl: item.avatarUrl,
content: item.preview,
createdAt: item.item.createdAt,
depth: 0,
fullTimestampLabel: item.fullTimestampLabel,
id: item.id,
isSelected: true,
mentionNames: item.mentionNames,
mentionPubkeysByName: item.mentionPubkeysByName,
kind: item.item.kind,
parentId: threadReference.parentId,
rootId: threadReference.rootId,
tags: item.item.tags,
timeLabel: formatTime(item.item.createdAt),
},
...pendingReplyMessages,
];
}, [item, messages, replies]);
const videoReviewMessages = React.useMemo(
() => displayMessages.map(toTimelineMessage),
[displayMessages],
);
const videoReviewChannelType =
item?.item.channelType === "dm" ||
item?.item.channelType === "stream" ||
item?.item.channelType === "forum"
? item.item.channelType
: null;
const handleSendVideoReviewComment = React.useCallback(
(
message: TimelineMessage,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
parentEventId?: string,
) =>
onSendReply({
content,
mediaTags,
mentionPubkeys,
parentEventId: parentEventId ?? message.id,
}),
[onSendReply],
);
const videoReviewPresentation = React.useMemo(
() =>
buildVideoReviewPresentationByMessageId(
{
channelId: item?.item.channelId,
channelName: contextChannelName ?? item?.channelLabel ?? undefined,
channelType: videoReviewChannelType,
isSendingVideoReviewComment: isSendingReply,
messages: videoReviewMessages,
onSendVideoReviewComment: canReply
? handleSendVideoReviewComment
: undefined,
onToggleReaction,
profiles,
},
hasRenderedVideoAttachment,
),
[
canReply,
contextChannelName,
handleSendVideoReviewComment,
isSendingReply,
item,
onToggleReaction,
profiles,
videoReviewChannelType,
videoReviewMessages,
],
);
const { onScroll } = useAnchoredScroll({
channelId: conversationId,
contentRef,
@@ -674,6 +754,12 @@ function InboxMessageDetailPane({
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
showUnreadBoundary={hasUnreadBoundary}
videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get(
message.id,
)}
videoReviewContext={videoReviewPresentation.contextsByMessageId.get(
message.id,
)}
/>
);
})}
+34 -2
View File
@@ -8,6 +8,8 @@ import {
type InboxTypeLabel,
} from "@/features/home/lib/inbox";
import { buildInboxListRows } from "@/features/home/lib/inboxListRows";
import { hasRenderedVideoAttachment } from "@/features/messages/lib/videoReviewContext";
import { getThreadReference } from "@/features/messages/lib/threading";
import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu";
import {
DraftsPanel,
@@ -30,7 +32,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { Markdown } from "@/shared/ui/markdown";
import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown";
import {
MENTION_CHIP_BASE_CLASSES,
MESSAGE_MARKDOWN_CLASS,
@@ -121,6 +123,34 @@ function formatReminderStatus(notBefore: number | undefined) {
return `Reminder in ${Math.floor(secondsUntil / 86_400)}d`;
}
function getInboxVideoReviewCommentRootId(item: InboxItem) {
const feedItems = [item.item, ...item.groupItems];
const feedItemById = new Map(
feedItems.map((feedItem) => [feedItem.id, feedItem]),
);
const videoMessageIds = new Set(
feedItems
.filter((feedItem) =>
hasRenderedVideoAttachment({
body: feedItem.content,
tags: feedItem.tags,
}),
)
.map((feedItem) => feedItem.id),
);
const visited = new Set<string>();
let ancestorId = getThreadReference(item.item.tags).parentId;
while (ancestorId && !visited.has(ancestorId)) {
if (videoMessageIds.has(ancestorId)) return ancestorId;
visited.add(ancestorId);
const ancestor = feedItemById.get(ancestorId);
ancestorId = ancestor ? getThreadReference(ancestor.tags).parentId : null;
}
return undefined;
}
function PersonalItemRow({
id,
location,
@@ -274,6 +304,7 @@ export function InboxListPane({
);
const hasChannelTarget = Boolean(item.item.channelId);
const typeLabel = getInboxTypeLabel(item);
const videoReviewCommentRootId = getInboxVideoReviewCommentRootId(item);
const isSenderAgent =
agentPubkeys?.has(normalizePubkey(item.item.pubkey)) === true;
const profileRole = isSenderAgent ? "bot" : undefined;
@@ -408,11 +439,12 @@ export function InboxListPane({
: "font-semibold text-foreground",
)}
>
<Markdown
<VideoReviewCommentMarkdown
className="inbox-preview-markdown text-inherit leading-5"
content={item.preview}
interactive={false}
mentionNames={item.mentionNames}
videoReviewCommentRootId={videoReviewCommentRootId}
/>
</div>
</div>
@@ -17,9 +17,11 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
export type InboxDisplayMessage = InboxContextMessage & {
depth: number;
@@ -43,6 +45,8 @@ type InboxMessageRowProps = {
remove: boolean,
) => Promise<void>;
showUnreadBoundary?: boolean;
videoReviewCommentRootId?: string;
videoReviewContext?: VideoReviewContext;
};
export function InboxMessageRow({
@@ -58,11 +62,17 @@ export function InboxMessageRow({
onSelectReplyTarget,
onToggleReaction,
showUnreadBoundary = false,
videoReviewCommentRootId,
videoReviewContext,
}: InboxMessageRowProps) {
const timelineMessage = React.useMemo(
() => toTimelineMessage(message),
[message],
);
const imetaByUrl = React.useMemo(
() => (message.tags ? parseImetaTags(message.tags) : undefined),
[message.tags],
);
const { customEmoji, emojiOnly } = useMessageEmoji(
message.content,
message.tags,
@@ -231,7 +241,7 @@ export function InboxMessageRow({
)}
<div className={isContinuation ? "mt-0" : "mt-0.5"}>
<Markdown
<VideoReviewCommentMarkdown
className={cn(
"max-w-full text-left text-sm text-foreground",
emojiOnly &&
@@ -251,8 +261,11 @@ export function InboxMessageRow({
timelineMessage.tags,
)}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
mentionNames={message.mentionNames}
mentionPubkeysByName={message.mentionPubkeysByName}
videoReviewCommentRootId={videoReviewCommentRootId}
videoReviewContext={videoReviewContext}
/>
<MessageReactions
canToggle={canToggleReactions}
@@ -7,6 +7,7 @@ import {
buildVideoReviewCommentRootIdsByMessageId,
buildVideoReviewContextForMessage,
buildVideoReviewContextsByMessageId,
hasRenderedVideoAttachment,
hasVideoAttachment,
} from "./videoReviewContext.ts";
@@ -59,6 +60,63 @@ test("hasVideoAttachment detects markdown and imeta videos", () => {
);
assert.equal(hasVideoAttachment(message({ body: "plain text" })), false);
assert.equal(
hasVideoAttachment(
message({
body: "orphan metadata only",
tags: [["imeta", "url https://cdn.example.com/cut.mp4", "m video/mp4"]],
}),
),
true,
);
assert.equal(
hasRenderedVideoAttachment(
message({
body: "orphan metadata only",
tags: [["imeta", "url https://cdn.example.com/cut.mp4", "m video/mp4"]],
}),
),
false,
);
});
test("hasVideoAttachment uses the Markdown renderer's video classification", () => {
assert.equal(
hasVideoAttachment(
message({ body: "![Demo](https://cdn.example.com/cut.mp4)" }),
),
true,
);
assert.equal(
hasVideoAttachment(
message({ body: "![Poster](https://cdn.example.com/cut.jpg)" }),
),
false,
);
assert.equal(
hasVideoAttachment(
message({
body: "![Demo](https://relay/media/cut.mp4)",
tags: [["imeta", "url https://relay/media/cut.mp4", "m image/png"]],
}),
),
false,
);
assert.equal(
hasVideoAttachment(
message({
body: "![Demo][clip]\n\n[clip]: https://cdn.example.com/cut.mp4",
}),
),
true,
);
assert.equal(
hasVideoAttachment(
message({
body: "```md\n![Demo](https://cdn.example.com/cut.mp4)\n```",
}),
),
false,
);
});
test("buildVideoReviewCommentsByRootId includes nested descendants", () => {
@@ -211,6 +269,39 @@ test("buildVideoReviewCommentRootIdsByMessageId targets the nearest video ancest
);
});
test("buildVideoReviewCommentRootIdsByMessageId can require rendered video roots", () => {
const orphanVideo = message({
id: "orphan-video",
body: "metadata only",
tags: [["imeta", "url https://relay/media/a.mp4", "m video/mp4"]],
});
const comment = message({
id: "comment",
body: "[00:01] review this",
parentId: orphanVideo.id,
rootId: orphanVideo.id,
});
assert.deepEqual(
[
...buildVideoReviewCommentRootIdsByMessageId([
orphanVideo,
comment,
]).entries(),
],
[[comment.id, orphanVideo.id]],
);
assert.deepEqual(
[
...buildVideoReviewCommentRootIdsByMessageId(
[orphanVideo, comment],
hasRenderedVideoAttachment,
).entries(),
],
[],
);
});
test("buildVideoReviewContextForMessage posts against the source video", async () => {
const video = message({
id: "video",
@@ -1,6 +1,10 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { isVideoMedia } from "@/shared/ui/markdown/mediaEntry";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
type SendVideoReviewComment = (
@@ -17,18 +21,79 @@ type ToggleMessageReaction = (
remove: boolean,
) => Promise<void>;
export function hasVideoAttachment(message: TimelineMessage): boolean {
if (message.body.includes("![video](")) return true;
type VideoRootPredicate = (
message: Pick<TimelineMessage, "body" | "tags">,
) => boolean;
return (
message.tags?.some(
(tag) =>
tag[0] === "imeta" &&
tag.some((part) => part.toLowerCase().startsWith("m video/")),
) ?? false
type MarkdownAstNode = {
children?: MarkdownAstNode[];
identifier?: string;
type: string;
url?: string;
};
function markdownImageUrls(body: string): string[] {
if (!body.includes("![")) return [];
const definitions = new Map<string, string>();
const directUrls: string[] = [];
const referenceIds: string[] = [];
const visit = (node: MarkdownAstNode) => {
if (node.type === "definition" && node.identifier && node.url) {
if (!definitions.has(node.identifier)) {
definitions.set(node.identifier, node.url);
}
} else if (node.type === "image" && node.url) {
directUrls.push(node.url);
} else if (node.type === "imageReference" && node.identifier) {
referenceIds.push(node.identifier);
}
node.children?.forEach(visit);
};
visit(fromMarkdown(body) as MarkdownAstNode);
return [
...directUrls,
...referenceIds.flatMap((identifier) => {
const url = definitions.get(identifier);
return url ? [url] : [];
}),
];
}
/**
* Returns whether a message contains a video URL in a Markdown image that
* the renderer will actually mount. Orphan imeta entries are intentionally
* excluded because they do not produce a video player.
*/
export function hasRenderedVideoAttachment(
message: Pick<TimelineMessage, "body" | "tags">,
): boolean {
const imetaByUrl = parseImetaTags(message.tags ?? []);
return markdownImageUrls(message.body).some((src) =>
isVideoMedia(src, imetaByUrl.get(src)?.m),
);
}
export function hasVideoAttachment(
message: Pick<TimelineMessage, "body" | "tags">,
): boolean {
const imetaByUrl = parseImetaTags(message.tags ?? []);
if (
[...imetaByUrl.values()].some((entry) => isVideoMedia(entry.url, entry.m))
) {
return true;
}
for (const src of markdownImageUrls(message.body)) {
if (isVideoMedia(src, imetaByUrl.get(src)?.m)) return true;
}
return false;
}
export function buildVideoReviewCommentsByRootId(
messages: TimelineMessage[],
): Map<string, TimelineMessage[]> {
@@ -95,10 +160,11 @@ export function buildVideoReviewCommentsForRoot(
export function buildVideoReviewCommentRootIdsByMessageId(
messages: TimelineMessage[],
videoRootPredicate: VideoRootPredicate = hasVideoAttachment,
): ReadonlyMap<string, string> {
const messageById = new Map(messages.map((message) => [message.id, message]));
const videoMessageIds = new Set(
messages.filter(hasVideoAttachment).map((message) => message.id),
messages.filter(videoRootPredicate).map((message) => message.id),
);
const rootIdsByMessageId = new Map<string, string>();
@@ -130,6 +196,7 @@ export function buildVideoReviewContextForMessage({
onSendVideoReviewComment,
onToggleReaction,
profiles,
videoRootPredicate = hasVideoAttachment,
}: {
channelId?: string | null;
channelName?: string;
@@ -140,8 +207,9 @@ export function buildVideoReviewContextForMessage({
onSendVideoReviewComment?: SendVideoReviewComment;
onToggleReaction?: ToggleMessageReaction;
profiles?: UserProfileLookup;
videoRootPredicate?: VideoRootPredicate;
}): VideoReviewContext | undefined {
if (!hasVideoAttachment(message)) {
if (!videoRootPredicate(message)) {
return undefined;
}
@@ -185,6 +253,7 @@ export function buildVideoReviewContextsByMessageId({
onSendVideoReviewComment,
onToggleReaction,
profiles,
videoRootPredicate = hasVideoAttachment,
}: {
channelId?: string | null;
channelName?: string;
@@ -194,9 +263,10 @@ export function buildVideoReviewContextsByMessageId({
onSendVideoReviewComment?: SendVideoReviewComment;
onToggleReaction?: ToggleMessageReaction;
profiles?: UserProfileLookup;
videoRootPredicate?: VideoRootPredicate;
}): ReadonlyMap<string, VideoReviewContext> {
const contexts = new Map<string, VideoReviewContext>();
if (!messages.some(hasVideoAttachment)) {
if (!messages.some(videoRootPredicate)) {
return contexts;
}
@@ -212,6 +282,7 @@ export function buildVideoReviewContextsByMessageId({
onSendVideoReviewComment,
onToggleReaction,
profiles,
videoRootPredicate,
});
if (context) {
contexts.set(message.id, context);
@@ -221,17 +292,28 @@ export function buildVideoReviewContextsByMessageId({
return contexts;
}
/**
* Builds the paired video-review maps used by timeline presentation: contexts
* are keyed by video message, while comment roots map each descendant back to
* its nearest video ancestor.
*/
export function buildVideoReviewPresentationByMessageId(
args: Parameters<typeof buildVideoReviewContextsByMessageId>[0],
videoRootPredicate: VideoRootPredicate = hasVideoAttachment,
) {
return {
commentRootIdsByMessageId: buildVideoReviewCommentRootIdsByMessageId(
args.messages,
videoRootPredicate,
),
contextsByMessageId: buildVideoReviewContextsByMessageId(args),
contextsByMessageId: buildVideoReviewContextsByMessageId({
...args,
videoRootPredicate,
}),
};
}
/** The synchronized context and comment-root maps for a rendered timeline. */
export type VideoReviewPresentation = ReturnType<
typeof buildVideoReviewPresentationByMessageId
>;
@@ -42,11 +42,8 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage";
import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy";
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 { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown";
import { MessageActionBar } from "./MessageActionBar";
import { editMessage } from "@/shared/api/tauri";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
@@ -301,7 +298,6 @@ 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
@@ -411,12 +407,8 @@ export const MessageRow = React.memo(
);
}
const reviewRootEventId = videoReviewCommentRootId;
const reviewTimecode = reviewRootEventId
? parseVideoReviewTimecode(message.body)
: null;
const markdown = (
<Markdown
return (
<VideoReviewCommentMarkdown
channelNames={channelNames}
className={cn(
"max-w-full text-sm",
@@ -431,7 +423,7 @@ export const MessageRow = React.memo(
message,
isKnownAgentPubkey,
)}
content={reviewTimecode?.text ?? message.body}
content={message.body}
messageId={message.id}
linkPreviewsSuppressed={linkPreviewsSuppressed}
linkPreviewTags={message.tags}
@@ -443,26 +435,10 @@ export const MessageRow = React.memo(
mentionPubkeysByName={mentionPubkeysByName}
searchQuery={searchQuery}
snapshotSharedBy={snapshotSharedBy}
videoReviewCommentRootId={videoReviewCommentRootId}
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>
);
}
}
};
@@ -45,3 +45,15 @@ export function selectProseOrNudge(
): ReactNode {
return configNudge === null ? markdownNode : null;
}
/**
* Keeps inline content visible beside the nudge card when the prose node is
* suppressed. This preserves controls, such as a video-review timecode, that
* were extracted from the original message before the sentinel was removed.
*/
export function selectNudgeLeadingContent(
configNudge: ConfigNudgePayload | null,
leadingInlineContent: ReactNode | undefined,
): ReactNode {
return configNudge !== null ? leadingInlineContent : null;
}
@@ -0,0 +1,115 @@
// Minimal HAST types — matches the pattern in rehypeImageGallery.ts.
interface HastText {
type: "text";
value: string;
}
interface HastElement {
type: "element";
tagName: string;
properties: Record<string, unknown>;
children: HastNode[];
}
type HastNode = HastElement | HastText | { type: string };
interface HastRoot {
type: "root";
children: HastNode[];
}
const INLINE_TARGETS = new Set([
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"li",
"p",
"td",
"th",
]);
function isElement(node: HastNode): node is HastElement {
return node.type === "element";
}
function isText(node: HastNode): node is HastText {
return node.type === "text";
}
function isMediaOnlyParagraph(node: HastElement): boolean {
if (node.tagName !== "p") return false;
const meaningful = node.children.filter(
(child) =>
!(isText(child) && child.value.trim() === "") &&
!(isElement(child) && child.tagName === "br"),
);
return (
meaningful.length > 0 &&
meaningful.every((child) => isElement(child) && child.tagName === "img")
);
}
function leadingMarker(): HastElement {
return {
type: "element",
tagName: "span",
properties: { "data-leading-inline-content": "" },
children: [],
};
}
function isMeaningfulNode(node: HastNode): boolean {
return !(isText(node) && node.value.trim() === "");
}
function prependToFirstInlineTarget(node: HastNode): boolean {
if (!isElement(node)) return false;
if (INLINE_TARGETS.has(node.tagName) && !isMediaOnlyParagraph(node)) {
// Nested paragraphs provide the natural prose flow for quotes and loose
// list items. Tight list items contain text directly, so the <li> itself
// is the correct fallback target.
if (node.tagName === "li") {
const directParagraph = node.children.find(
(child) => isElement(child) && child.tagName === "p",
);
if (directParagraph && prependToFirstInlineTarget(directParagraph)) {
return true;
}
}
node.children.unshift(leadingMarker());
return true;
}
// Only inspect the first rendered block. If it cannot accept inline content
// (for example, code or media), the caller inserts the fallback before its
// containing block instead of moving the marker into later prose.
const firstChild = node.children.find(isMeaningfulNode);
return firstChild ? prependToFirstInlineTarget(firstChild) : false;
}
/**
* Inserts a render-time marker into the first prose-capable Markdown block.
* Blocks without inline flow, such as code and media, receive a preceding
* marker paragraph so callers never lose their leading control.
*/
export default function rehypeLeadingInlineContent() {
return (tree: HastRoot) => {
for (const child of tree.children) {
if (isText(child) && child.value.trim() === "") continue;
if (prependToFirstInlineTarget(child)) return;
break;
}
tree.children.unshift({
type: "element",
tagName: "p",
properties: {},
children: [leadingMarker()],
});
};
}
@@ -0,0 +1,77 @@
import * as React from "react";
import { Markdown } from "@/shared/ui/markdown";
import type { MarkdownProps } from "@/shared/ui/markdown/types";
import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation";
import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode";
import {
VideoReviewTimecodeButton,
VideoReviewTimecodeChip,
} from "@/shared/ui/VideoReviewTimecodeButton";
type VideoReviewCommentMarkdownProps = Omit<
MarkdownProps,
"leadingInlineContent"
> & {
videoReviewCommentRootId?: string;
};
/** Renders a video-review timecode inside the comment's first Markdown line. */
export function VideoReviewCommentMarkdown({
content,
interactive = true,
videoReviewCommentRootId,
...markdownProps
}: VideoReviewCommentMarkdownProps) {
const openVideoReviewAt = useOpenVideoReviewAt();
const reviewTimecode = React.useMemo(
() => (videoReviewCommentRootId ? parseVideoReviewTimecode(content) : null),
[content, videoReviewCommentRootId],
);
const handleTimecodeClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (reviewTimecode && videoReviewCommentRootId) {
openVideoReviewAt?.(videoReviewCommentRootId, reviewTimecode.seconds);
}
},
[openVideoReviewAt, reviewTimecode, videoReviewCommentRootId],
);
const leadingInlineContent = React.useMemo(() => {
if (!reviewTimecode) return undefined;
const timecode =
interactive && openVideoReviewAt ? (
<VideoReviewTimecodeButton
surface="message"
timecode={reviewTimecode.timecode}
onClick={handleTimecodeClick}
/>
) : (
<VideoReviewTimecodeChip
surface="message"
timecode={reviewTimecode.timecode}
/>
);
return <>{timecode} </>;
}, [handleTimecodeClick, interactive, openVideoReviewAt, reviewTimecode]);
if (!reviewTimecode) {
return (
<Markdown
{...markdownProps}
content={content}
interactive={interactive}
/>
);
}
return (
<Markdown
{...markdownProps}
content={reviewTimecode.text || "\u200B"}
interactive={interactive}
leadingInlineContent={leadingInlineContent}
/>
);
}
@@ -9,6 +9,28 @@ const TIMECODE_ACCENT_HOVER_CLASS =
const MESSAGE_TIMECODE_ACCENT_CLASS =
"bg-primary/15 text-primary hover:bg-primary/30";
function timecodeClasses({
className,
interactive,
surface,
}: {
className?: string;
interactive: boolean;
surface: "message" | "review";
}) {
return cn(
"inline-flex h-5 shrink-0 items-center rounded px-1.5 align-middle font-mono text-2xs font-semibold",
interactive &&
"outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-white/60",
surface === "review"
? [TIMECODE_ACCENT_CLASS, interactive && TIMECODE_ACCENT_HOVER_CLASS]
: interactive
? MESSAGE_TIMECODE_ACCENT_CLASS
: "bg-primary/15 text-primary",
className,
);
}
export function VideoReviewTimecodeButton({
className,
onClick,
@@ -23,13 +45,7 @@ export function VideoReviewTimecodeButton({
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,
)}
className={timecodeClasses({ className, interactive: true, surface })}
data-testid="video-review-comment-timecode"
type="button"
onClick={onClick}
@@ -39,4 +55,24 @@ export function VideoReviewTimecodeButton({
);
}
/** Renders a non-interactive timecode with the same visual treatment. */
export function VideoReviewTimecodeChip({
className,
surface = "review",
timecode,
}: {
className?: string;
surface?: "message" | "review";
timecode: string;
}) {
return (
<span
className={timecodeClasses({ className, interactive: false, surface })}
data-testid="video-review-comment-timecode"
>
{timecode}
</span>
);
}
export const VIDEO_REVIEW_TIMECODE_ACCENT_CLASS = TIMECODE_ACCENT_CLASS;
+29 -2
View File
@@ -902,6 +902,7 @@ test("remarkMessageLinks: text inside inlineCode is left alone", () => {
import {
computeConfigNudge,
selectNudgeLeadingContent,
selectProseOrNudge,
} from "../lib/computeConfigNudge.ts";
import { stripConfigNudgeSentinel } from "../lib/configNudge.ts";
@@ -932,7 +933,7 @@ function nudgeBody(agentPubkey) {
// `computeConfigNudge.ts` — `computeConfigNudge` to detect the payload and
// `selectProseOrNudge` for the prose-suppression branch — without importing
// any Tauri or context dependencies.
function GuardStub({ content, configNudgeAuthorPubkey }) {
function GuardStub({ content, configNudgeAuthorPubkey, leadingInlineContent }) {
const configNudge = computeConfigNudge(
content,
true,
@@ -950,7 +951,11 @@ function GuardStub({ content, configNudgeAuthorPubkey }) {
null,
selectProseOrNudge(configNudge, markdownNode),
configNudge !== null
? React.createElement("div", { "data-config-nudge": "" })
? React.createElement(
"div",
{ "data-config-nudge": "" },
selectNudgeLeadingContent(configNudge, leadingInlineContent),
)
: null,
);
}
@@ -979,6 +984,28 @@ test("nudgeGuard_sentinelPresentMatchingAuthor_cardRenderedProseAbsent", () => {
);
});
test("nudgeGuard_sentinelPresentMatchingAuthor_preservesLeadingContent", () => {
const body = nudgeBody(AGENT_PUBKEY);
const html = renderToStaticMarkup(
React.createElement(GuardStub, {
content: body,
configNudgeAuthorPubkey: AGENT_PUBKEY,
leadingInlineContent: React.createElement(
"span",
{ "data-video-review-timecode": "" },
"[00:10]",
),
}),
);
assert.ok(
html.includes("data-video-review-timecode"),
"leading video-review content must remain visible beside the nudge card",
);
assert.ok(
!html.includes("data-markdown-prose"),
"nudge prose must remain suppressed while leading content is preserved",
);
});
test("nudgeGuard_sentinelPresentWrongAuthor_proseRenderedCardAbsent", () => {
// Sentinel present, but author pubkey is human — auth guard rejects, prose shown.
const body = nudgeBody(AGENT_PUBKEY);
+27 -28
View File
@@ -31,6 +31,7 @@ import { LinkPreviewList } from "@/shared/ui/link-preview-list";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
computeConfigNudge,
selectNudgeLeadingContent,
selectProseOrNudge,
} from "@/shared/lib/computeConfigNudge";
import {
@@ -100,6 +101,7 @@ import {
imageLightboxCornerRadiiFromElement,
imageLightboxCornerRadiiStyle,
imageLightboxExpandedCornerRadii,
getImageLightboxFocusableElements,
imageLightboxReturnTargetForItem,
imageLightboxSourceScopeForTrigger,
imageLightboxStyle,
@@ -147,28 +149,6 @@ type WebKitGestureLikeEvent = Event & {
scale?: number;
};
function getImageLightboxFocusableElements(
container: HTMLElement,
): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"select:not(:disabled)",
"textarea:not(:disabled)",
"[tabindex]:not([tabindex='-1'])",
].join(","),
),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.getClientRects().length > 0,
);
}
function ImageZoomOverlay({
alt,
galleryIndex = 0,
@@ -1429,6 +1409,13 @@ function createMarkdownComponents(
{children}
</SpoilerInline>
),
span: function MarkdownSpan({ children, node: _node, ...props }) {
const { leadingInlineContent } = useMarkdownRuntime();
if ("data-leading-inline-content" in props) {
return <>{leadingInlineContent}</>;
}
return <span {...props}>{children}</span>;
},
a: MarkdownAnchor,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground [&>*:first-child]:mt-0 [&>*+*]:mt-2">
@@ -1546,7 +1533,7 @@ function createMarkdownComponents(
ol: ({ children }) => (
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
),
p: ({ children }) => {
p: function MarkdownParagraph({ children }) {
// Detect media-only paragraphs (images + <br> from remarkBreaks).
// Multi-image: render as a compact, count-aware mosaic. Two images split
// a row, three form a hero-and-stack triptych, and larger odd counts let
@@ -1728,11 +1715,11 @@ function createMarkdownComponents(
}
/**
* The component map only varies by the two boolean render flags, so at most
* four instances ever exist. Module-stable maps mean cached markdown element
* The component map only varies by the three boolean render flags, so at most
* eight instances ever exist. Module-stable maps mean cached markdown element
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
*/
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "5";
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "6";
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
type MarkdownComponentSet = { components: Components; variant: string };
@@ -1746,9 +1733,10 @@ type MarkdownComponentSet = { components: Components; variant: string };
*/
function getMarkdownComponents(
interactive: boolean,
leadingInlineContent: boolean,
mediaInset: boolean,
): MarkdownComponentSet {
const variant = `${MARKDOWN_COMPONENT_SCHEMA_VERSION}:${interactive ? "i" : ""}${mediaInset ? "m" : ""}`;
const variant = `${MARKDOWN_COMPONENT_SCHEMA_VERSION}:${interactive ? "i" : ""}${leadingInlineContent ? "l" : ""}${mediaInset ? "m" : ""}`;
let entry = markdownComponentsByVariant.get(variant);
if (!entry) {
entry = {
@@ -1769,6 +1757,7 @@ function MarkdownInner({
imetaByUrl,
interactive = true,
agentMentionPubkeysByName,
leadingInlineContent,
mediaInset = false,
messageId,
linkPreviewsSuppressed = false,
@@ -1823,6 +1812,7 @@ function MarkdownInner({
agentMentionPubkeysByName,
channels,
imetaByUrl,
leadingInlineContent,
mentionPubkeysByName,
onOpenChannel,
onOpenEntityLink,
@@ -1842,6 +1832,7 @@ function MarkdownInner({
agentMentionPubkeysByName,
channels,
imetaByUrl,
leadingInlineContent,
mentionPubkeysByName,
onOpenChannel,
onOpenEntityLink,
@@ -1874,7 +1865,12 @@ function MarkdownInner({
// When a config-nudge suppresses the prose (selectProseOrNudge returns
// null), skip the parse entirely — it would be thrown away unrendered.
const componentSet = getMarkdownComponents(interactive, mediaInset);
const hasLeadingInlineContent = leadingInlineContent != null;
const componentSet = getMarkdownComponents(
interactive,
hasLeadingInlineContent,
mediaInset,
);
const markdownNode =
configNudge === null
? renderCachedMarkdown({
@@ -1882,6 +1878,7 @@ function MarkdownInner({
components: componentSet.components,
content: processedContent,
customEmoji,
leadingInlineContent: hasLeadingInlineContent,
mentionNames,
searchQuery,
variant: componentSet.variant,
@@ -1917,6 +1914,7 @@ function MarkdownInner({
className="max-w-full flex-wrap overflow-visible pb-0"
data-config-nudge=""
>
{selectNudgeLeadingContent(configNudge, leadingInlineContent)}
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
@@ -1949,6 +1947,7 @@ export const Markdown = React.memo(
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
shallowArrayEqual(prev.channelNames, next.channelNames) &&
prev.imetaByUrl === next.imetaByUrl &&
prev.leadingInlineContent === next.leadingInlineContent &&
prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey &&
prev.searchQuery === next.searchQuery &&
prev.snapshotSharedBy === next.snapshotSharedBy &&
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { clearMarkdownNodeCache, renderCachedMarkdown } from "./nodeCache.ts";
@@ -81,6 +82,103 @@ test("render variants do not collide", () => {
assert.notEqual(interactive, nonInteractive);
});
test("leading inline content is inserted into the first prose-capable block", () => {
const components = {
span: ({ children, node: _node, ...props }) =>
"data-leading-inline-content" in props
? React.createElement(
"button",
{ "data-chip": "", type: "button" },
"00:01",
)
: React.createElement("span", props, children),
};
for (const [content, pattern] of [
["plain note", /<p><button[^>]*>00:01<\/button>plain note<\/p>/],
["> quoted note", /<blockquote>\s*<p><button[^>]*>00:01<\/button>quoted/],
["- list note", /<li><button[^>]*>00:01<\/button>list note<\/li>/],
["# heading", /<h1><button[^>]*>00:01<\/button>heading<\/h1>/],
]) {
const node = renderCachedMarkdown({
...BASE,
components,
content,
leadingInlineContent: true,
variant: "leading",
});
assert.match(renderToStaticMarkup(node), pattern);
}
});
test("leading inline content falls back before code and media blocks", () => {
const components = {
span: ({ children, node: _node, ...props }) =>
"data-leading-inline-content" in props
? React.createElement(
"button",
{ "data-chip": "", type: "button" },
"00:01",
)
: React.createElement("span", props, children),
};
for (const [content, blockPattern] of [
["```js\nconst answer = 42;\n```", "<pre"],
["![](https://example.com/review.png)", "<img"],
["```js\nconst answer = 42;\n```\n\nlater note", "<pre"],
["![](https://example.com/review.png)\n\nlater note", "<img"],
["> ```js\nconst answer = 42;\n```\n\n> later note", "<pre"],
["> ![](https://example.com/review.png)\n\n> later note", "<img"],
]) {
const node = renderCachedMarkdown({
...BASE,
components,
content,
leadingInlineContent: true,
variant: "leading-fallback",
});
const html = renderToStaticMarkup(node);
assert.match(html, /<p><button[^>]*>00:01<\/button><\/p>/);
assert.ok(html.indexOf("<button") < html.indexOf(blockPattern));
}
});
test("leading inline content stays on the outer tight-list item", () => {
const components = {
span: ({ children, node: _node, ...props }) =>
"data-leading-inline-content" in props
? React.createElement(
"button",
{ "data-chip": "", type: "button" },
"00:01",
)
: React.createElement("span", props, children),
};
const node = renderCachedMarkdown({
...BASE,
components,
content: "- parent\n - child",
leadingInlineContent: true,
variant: "leading-tight-nested-list",
});
assert.match(
renderToStaticMarkup(node),
/<li><button[^>]*>00:01<\/button>parent\s*<ul>\s*<li>child<\/li>/,
);
});
test("leading inline content participates in the cache key", () => {
clearMarkdownNodeCache();
const withoutLeading = renderCachedMarkdown({ ...BASE });
const withLeading = renderCachedMarkdown({
...BASE,
leadingInlineContent: true,
});
assert.notEqual(withoutLeading, withLeading);
});
test("crafted values cannot forge key-segment boundaries", () => {
clearMarkdownNodeCache();
// Length-prefixed segments: a single name containing arbitrary bytes must
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
import rehypeLeadingInlineContent from "@/shared/lib/rehypeLeadingInlineContent";
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkCustomEmoji, {
@@ -65,6 +66,8 @@ export type MarkdownParseInputs = {
components: Components;
content: string;
customEmoji?: CustomEmoji[];
/** Inserts the runtime-provided leading content marker during parsing. */
leadingInlineContent?: boolean;
mentionNames?: string[];
searchQuery?: string;
variant: string;
@@ -84,6 +87,9 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
markdownParseCount += 1;
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const rehypePlugins: any[] = [rehypeImageGallery];
if (input.leadingInlineContent) {
rehypePlugins.push(rehypeLeadingInlineContent);
}
if (input.searchQuery && input.searchQuery.trim().length >= 2) {
rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]);
}
@@ -131,6 +137,7 @@ export function renderCachedMarkdown(
// before it is self-delimiting.
const key =
segment(input.variant) +
segment(input.leadingInlineContent ? "leading" : "") +
listSegment(input.mentionNames) +
listSegment(input.channelNames) +
listSegment(
+6
View File
@@ -1,3 +1,5 @@
import type * as React from "react";
import type { ParsedMessageLink } from "@/features/messages/lib/messageLink";
import type { ParsedEntityLink } from "@/shared/lib/entityLink";
import type { Channel } from "@/shared/api/types";
@@ -31,6 +33,8 @@ export type MarkdownRuntime = {
agentMentionPubkeysByName?: Record<string, string>;
channels: Channel[];
imetaByUrl?: ImetaLookup;
/** Inline content supplied to the first prose-capable Markdown block. */
leadingInlineContent?: React.ReactNode;
mentionPubkeysByName?: Record<string, string>;
onOpenChannel: (channelId: string) => void;
/** Navigate to a Buzz git entity (`buzz://pr|issue|repo` deep link). */
@@ -73,6 +77,8 @@ export type MarkdownProps = {
messageId?: string;
linkPreviewsSuppressed?: boolean;
linkPreviewTags?: readonly (readonly string[])[];
/** Inline content prepended inside the first rendered prose paragraph. */
leadingInlineContent?: React.ReactNode;
onRemoveLinkPreviewsForEveryone?: () => Promise<void>;
searchQuery?: string;
/** Display name shown in shared-agent card metadata. */
+6 -6
View File
@@ -265,9 +265,9 @@ test("editing an immediate attachment reply preserves its media tags", async ({
.locator('[data-testid="home-inbox-context-message"]')
.filter({ hasText: "Attachment reply before editing." });
await expect(reply).toBeVisible();
await expect(
reply.getByRole("link", { name: ATTACHMENT_FILENAME }),
).toHaveAttribute("href", ATTACHMENT_URL);
await expect(reply.getByTestId("file-card")).toContainText(
ATTACHMENT_FILENAME,
);
const replyId = await reply.getAttribute("data-message-id");
expect(replyId).not.toBeNull();
const replyRow = detail.locator(`[data-message-id="${replyId}"]`);
@@ -317,9 +317,9 @@ test("editing an immediate attachment reply preserves its media tags", async ({
expect(releasedEchoes).toBe(1);
await expect(replyRow).toContainText("Attachment reply after editing.");
await expect(
replyRow.getByRole("link", { name: ATTACHMENT_FILENAME }),
).toHaveAttribute("href", ATTACHMENT_URL);
await expect(replyRow.getByTestId("file-card")).toContainText(
ATTACHMENT_FILENAME,
);
});
test("Inbox offers Edit and Delete actions only for manageable messages", async ({
+166 -6
View File
@@ -5,6 +5,8 @@ import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
const VIDEO_SHA = "b".repeat(64);
const VIDEO_URL = `http://localhost:3000/media/${VIDEO_SHA}.mp4`;
const EXTENSIONLESS_VIDEO_URL = `http://localhost:3000/media/${VIDEO_SHA}`;
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const PORTRAIT_VIDEO_SHA = "c".repeat(64);
const PORTRAIT_VIDEO_URL = `http://localhost:3000/media/${PORTRAIT_VIDEO_SHA}.mp4`;
const CONSTRAINED_LANDSCAPE_VIDEO_SHA = "d".repeat(64);
@@ -31,6 +33,15 @@ const VIDEO_REVIEW_NEUTRAL_DARK_RGB = "rgb(250, 250, 250)";
const POSTER_DATA_URL =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNjAgODAiPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iODAiIGZpbGw9IiMyNjQ2NTMiLz48Y2lyY2xlIGN4PSI1NCIgY3k9IjQwIiByPSIyMiIgZmlsbD0iI2YyYzE0ZSIvPjxwYXRoIGQ9Ik05MiAyNGg0NHYzMkg5MnoiIGZpbGw9IiNmNzgxNTQiLz48L3N2Zz4=";
type MockFeedMessage = {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(async () => {
@@ -81,6 +92,43 @@ function emitMockMessage(
);
}
function pushMockFeedItems(page: Page, messages: MockFeedMessage[]) {
return page.evaluate(
({ channelId, messages }) => {
const pushFeedItem = (
window as Window & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention";
channel_id: string;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
}
).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) throw new Error("Mock feed helper is unavailable.");
for (const message of messages) {
pushFeedItem({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: message.content,
created_at: message.created_at,
id: message.id,
kind: message.kind,
pubkey: message.pubkey,
tags: message.tags,
});
}
},
{ channelId: GENERAL_CHANNEL_ID, messages },
);
}
async function installVideoReviewHarness(
page: Page,
{
@@ -898,16 +946,16 @@ test("video replies in threads open the review comments view", async ({
page,
"general",
"Can you review this cut?",
)) as { id: string };
)) as MockFeedMessage;
const videoReply = (await emitMockMessage(
page,
"general",
`![video](${VIDEO_URL})`,
`![video](${EXTENSIONLESS_VIDEO_URL})`,
{
extraTags: [
[
"imeta",
`url ${VIDEO_URL}`,
`url ${EXTENSIONLESS_VIDEO_URL}`,
"m video/mp4",
`x ${VIDEO_SHA}`,
"size 987654",
@@ -920,9 +968,13 @@ test("video replies in threads open the review comments view", async ({
parentEventId: root.id,
},
)) as { id: string };
await emitMockMessage(page, "general", "[00:01] Tighten this transition.", {
parentEventId: videoReply.id,
});
const reviewComment = (await emitMockMessage(
page,
"general",
"[00:01] > Tighten this transition.",
{ parentEventId: videoReply.id },
)) as MockFeedMessage;
await pushMockFeedItems(page, [videoReply, reviewComment]);
const threadSummary = page.locator(`[data-thread-head-id="${root.id}"]`);
await expect(threadSummary).toBeVisible();
@@ -951,6 +1003,12 @@ test("video replies in threads open the review comments view", async ({
"data-testid",
"video-review-comment-timecode",
);
await expect(outsideTimecode.locator("xpath=ancestor::p")).toContainText(
"Tighten this transition.",
);
await expect(
outsideTimecode.locator("xpath=ancestor::blockquote"),
).toBeVisible();
const outsideTimecodeStyles = await outsideTimecode.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
@@ -1008,6 +1066,108 @@ test("video replies in threads open the review comments view", async ({
await expect(reviewDialog.getByTestId("video-review-comments")).toContainText(
"Tighten this transition.",
);
await page
.getByTestId("video-review-backdrop")
.click({ position: { x: 4, y: 4 } });
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const inboxRow = page.getByTestId(`home-inbox-item-${reviewComment.id}`);
await expect(inboxRow).toBeVisible();
const inboxPreviewTimecode = inboxRow.getByTestId(
"video-review-comment-timecode",
);
await expect(inboxPreviewTimecode.locator("xpath=ancestor::p")).toContainText(
"Tighten this transition.",
);
await expect(
inboxPreviewTimecode.locator("xpath=ancestor::blockquote"),
).toBeVisible();
await inboxRow.click();
const inboxDetail = page.getByTestId("home-inbox-detail");
const inboxDetailTimecode = inboxDetail.getByRole("button", {
name: "Jump to 00:01",
});
await expect(inboxDetailTimecode).toBeVisible();
await expect(inboxDetailTimecode.locator("xpath=ancestor::p")).toContainText(
"Tighten this transition.",
);
await expect(
inboxDetailTimecode.locator("xpath=ancestor::blockquote"),
).toBeVisible();
await inboxDetailTimecode.click();
await expect(page.getByTestId("video-review-dialog")).toBeVisible();
});
test("Inbox preserves bracketed timestamps without video evidence", 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 root = (await emitMockMessage(page, "general", "Planning note")) as {
id: string;
};
const reply = (await emitMockMessage(
page,
"general",
"[12:30] Meeting starts",
{ parentEventId: root.id },
)) as MockFeedMessage;
await pushMockFeedItems(page, [reply]);
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const inboxRow = page.getByTestId(`home-inbox-item-${reply.id}`);
await expect(inboxRow).toContainText("[12:30] Meeting starts");
await expect(
inboxRow.getByTestId("video-review-comment-timecode"),
).toHaveCount(0);
});
test("Inbox recognizes reference-style video ancestors with custom alt text", 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 root = (await emitMockMessage(
page,
"general",
"Can you review this cut?",
)) as { id: string };
const video = (await emitMockMessage(
page,
"general",
`![Launch demo][cut]\n\n[cut]: ${VIDEO_URL}`,
{ parentEventId: root.id },
)) as MockFeedMessage;
const comment = (await emitMockMessage(
page,
"general",
"[00:01] Tighten this transition.",
{ parentEventId: video.id },
)) as MockFeedMessage;
await pushMockFeedItems(page, [video, comment]);
await page.getByRole("button", { name: "Inbox", exact: true }).click();
const inboxRow = page.getByTestId(`home-inbox-item-${comment.id}`);
await expect(
inboxRow.getByTestId("video-review-comment-timecode"),
).toBeVisible();
await inboxRow.click();
await page
.getByTestId("home-inbox-detail")
.getByRole("button", { name: "Jump to 00:01" })
.click();
await expect(page.getByTestId("video-review-dialog")).toBeVisible();
});
test("message timecodes deterministically open the first attached video", async ({
+3
View File
@@ -192,6 +192,9 @@ importers:
lucide-react:
specifier: ^1.0.0
version: 1.16.0(react@19.2.8)
mdast-util-from-markdown:
specifier: ^2.0.3
version: 2.0.3
motion:
specifier: ^12.38.0
version: 12.40.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)