mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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: "" }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasVideoAttachment(
|
||||
message({ body: "" }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasVideoAttachment(
|
||||
message({
|
||||
body: "",
|
||||
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\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(") 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
["", "<img"],
|
||||
["```js\nconst answer = 42;\n```\n\nlater note", "<pre"],
|
||||
["\n\nlater note", "<img"],
|
||||
["> ```js\nconst answer = 42;\n```\n\n> later note", "<pre"],
|
||||
["> \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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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",
|
||||
``,
|
||||
``,
|
||||
{
|
||||
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 ({
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user