diff --git a/desktop/package.json b/desktop/package.json index 3601f25185e..060ee44bb65 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -66,6 +66,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", diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 42ed8e1a5a6..d1bffd1899c 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -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, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index c1ad2607528..0cc47016db5 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -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; canDelete: boolean; @@ -142,7 +153,11 @@ export function InboxDetailPane(props: InboxDetailPaneProps) { ); } - return ; + return ( + + + + ); } function InboxMessageDetailPane({ @@ -159,9 +174,9 @@ function InboxMessageDetailPane({ hasThreadContextLoadError = false, isThreadContextLoading = false, item, - messages = [], + messages = EMPTY_CONTEXT_MESSAGES, profiles, - replies = [], + replies = EMPTY_REPLIES, channel, contextChannelName = null, currentPubkey, @@ -197,7 +212,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. @@ -232,34 +246,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(() => { + 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, @@ -667,6 +747,12 @@ function InboxMessageDetailPane({ onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} showUnreadBoundary={hasUnreadBoundary} + videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get( + message.id, + )} + videoReviewContext={videoReviewPresentation.contextsByMessageId.get( + message.id, + )} /> ); })} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 17b06bf284d..b83a19a1673 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -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(); + 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", )} > - diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 039d418a148..d27c7a94c90 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -15,9 +15,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; @@ -40,6 +42,8 @@ type InboxMessageRowProps = { remove: boolean, ) => Promise; showUnreadBoundary?: boolean; + videoReviewCommentRootId?: string; + videoReviewContext?: VideoReviewContext; }; export function InboxMessageRow({ @@ -54,11 +58,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, @@ -201,7 +211,7 @@ export function InboxMessageRow({ )}
- { ); 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", diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 78401214a20..a63843c1ce3 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -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; -export function hasVideoAttachment(message: TimelineMessage): boolean { - if (message.body.includes("![video](")) return true; +type VideoRootPredicate = ( + message: Pick, +) => boolean; + +type MarkdownAstNode = { + children?: MarkdownAstNode[]; + identifier?: string; + type: string; + url?: string; +}; + +function markdownImageUrls(body: string): string[] { + if (!body.includes("![")) return []; + + const definitions = new Map(); + 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); + }; - return ( - message.tags?.some( - (tag) => - tag[0] === "imeta" && - tag.some((part) => part.toLowerCase().startsWith("m video/")), - ) ?? false + 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, +): boolean { + const imetaByUrl = parseImetaTags(message.tags ?? []); + return markdownImageUrls(message.body).some((src) => + isVideoMedia(src, imetaByUrl.get(src)?.m), ); } +export function hasVideoAttachment( + message: Pick, +): 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 { @@ -95,10 +160,11 @@ export function buildVideoReviewCommentsForRoot( export function buildVideoReviewCommentRootIdsByMessageId( messages: TimelineMessage[], + videoRootPredicate: VideoRootPredicate = hasVideoAttachment, ): ReadonlyMap { 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(); @@ -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 { const contexts = new Map(); - 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[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 >; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index f6be01d71be..d88bc594412 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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"; @@ -297,7 +294,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 @@ -407,12 +403,8 @@ export const MessageRow = React.memo( ); } - const reviewRootEventId = videoReviewCommentRootId; - const reviewTimecode = reviewRootEventId - ? parseVideoReviewTimecode(message.body) - : null; - const markdown = ( - ); - if (!reviewRootEventId || !reviewTimecode || !openVideoReviewAt) { - return markdown; - } - - return ( -
- { - event.stopPropagation(); - openVideoReviewAt(reviewRootEventId, reviewTimecode.seconds); - }} - /> -
{markdown}
-
- ); } } }; diff --git a/desktop/src/shared/lib/computeConfigNudge.ts b/desktop/src/shared/lib/computeConfigNudge.ts index 25040713d47..08e9d4447b0 100644 --- a/desktop/src/shared/lib/computeConfigNudge.ts +++ b/desktop/src/shared/lib/computeConfigNudge.ts @@ -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; +} diff --git a/desktop/src/shared/lib/rehypeLeadingInlineContent.ts b/desktop/src/shared/lib/rehypeLeadingInlineContent.ts new file mode 100644 index 00000000000..a2a0c4c9f04 --- /dev/null +++ b/desktop/src/shared/lib/rehypeLeadingInlineContent.ts @@ -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; + 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
  • 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()], + }); + }; +} diff --git a/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx b/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx new file mode 100644 index 00000000000..d1a08151239 --- /dev/null +++ b/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx @@ -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) => { + event.stopPropagation(); + if (reviewTimecode && videoReviewCommentRootId) { + openVideoReviewAt?.(videoReviewCommentRootId, reviewTimecode.seconds); + } + }, + [openVideoReviewAt, reviewTimecode, videoReviewCommentRootId], + ); + const leadingInlineContent = React.useMemo(() => { + if (!reviewTimecode) return undefined; + + const timecode = + interactive && openVideoReviewAt ? ( + + ) : ( + + ); + return <>{timecode} ; + }, [handleTimecodeClick, interactive, openVideoReviewAt, reviewTimecode]); + + if (!reviewTimecode) { + return ( + + ); + } + + return ( + + ); +} diff --git a/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx index 53904f886f1..5c1dedbe9c0 100644 --- a/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx +++ b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx @@ -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 (