From 5755c0e49a67c758214e3cc9a4b2f974f04577ec Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 13 Aug 2026 08:29:24 +0100 Subject: [PATCH 01/12] Fix video comment effect wrapping Signed-off-by: kenny lopez --- .../src/features/home/lib/inboxViewHelpers.ts | 3 + .../src/features/home/ui/InboxDetailPane.tsx | 47 ++++++++++- .../src/features/home/ui/InboxListPane.tsx | 37 ++++++++- .../src/features/home/ui/InboxMessageRow.tsx | 11 ++- .../messages/lib/videoReviewContext.ts | 4 +- .../src/features/messages/ui/MessageRow.tsx | 34 ++------ .../shared/ui/VideoReviewCommentMarkdown.tsx | 72 +++++++++++++++++ .../shared/ui/VideoReviewTimecodeButton.tsx | 49 ++++++++++-- desktop/src/shared/ui/markdown.tsx | 37 ++++----- desktop/src/shared/ui/markdown/types.ts | 5 ++ desktop/tests/e2e/video-attachment.spec.ts | 77 ++++++++++++++++++- 11 files changed, 306 insertions(+), 70 deletions(-) create mode 100644 desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx 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..f6a00db9f7b 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,7 @@ 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 } 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 +50,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, @@ -142,7 +147,11 @@ export function InboxDetailPane(props: InboxDetailPaneProps) { ); } - return ; + return ( + + + + ); } function InboxMessageDetailPane({ @@ -255,11 +264,39 @@ function InboxMessageDetailPane({ isSelected: true, mentionNames: item.mentionNames, mentionPubkeysByName: item.mentionPubkeysByName, + kind: item.item.kind, + parentId: getThreadReference(item.item.tags).parentId, + rootId: getThreadReference(item.item.tags).rootId, + tags: item.item.tags, timeLabel: formatTime(item.item.createdAt), }, ...pendingReplyMessages, ] : pendingReplyMessages; + const videoReviewChannelType = + item?.item.channelType === "dm" || + item?.item.channelType === "stream" || + item?.item.channelType === "forum" + ? item.item.channelType + : null; + const videoReviewPresentation = buildVideoReviewPresentationByMessageId({ + channelId: item?.item.channelId, + channelName: contextChannelName ?? item?.channelLabel ?? undefined, + channelType: videoReviewChannelType, + isSendingVideoReviewComment: isSendingReply, + messages: displayMessages.map(toTimelineMessage), + onSendVideoReviewComment: canReply + ? (message, content, mentionPubkeys, mediaTags, parentEventId) => + onSendReply({ + content, + mediaTags, + mentionPubkeys, + parentEventId: parentEventId ?? message.id, + }) + : undefined, + onToggleReaction, + profiles, + }); const { onScroll } = useAnchoredScroll({ channelId: conversationId, contentRef, @@ -667,6 +704,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..0451e65a4ce 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 { hasVideoAttachment } from "@/features/messages/lib/videoReviewContext"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, @@ -30,7 +32,8 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/shared/ui/context-menu"; -import { Markdown } from "@/shared/ui/markdown"; +import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown"; +import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode"; import { MENTION_CHIP_BASE_CLASSES, MESSAGE_MARKDOWN_CLASS, @@ -121,6 +124,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) => + hasVideoAttachment({ 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 parseVideoReviewTimecode(item.preview) && + getThreadReference(item.item.tags).parentId + ? item.conversationId + : undefined; +} + function PersonalItemRow({ id, location, @@ -274,6 +305,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 +440,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..e61ac920d44 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -15,9 +15,10 @@ 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"; export type InboxDisplayMessage = InboxContextMessage & { depth: number; @@ -40,6 +41,8 @@ type InboxMessageRowProps = { remove: boolean, ) => Promise; showUnreadBoundary?: boolean; + videoReviewCommentRootId?: string; + videoReviewContext?: VideoReviewContext; }; export function InboxMessageRow({ @@ -54,6 +57,8 @@ export function InboxMessageRow({ onSelectReplyTarget, onToggleReaction, showUnreadBoundary = false, + videoReviewCommentRootId, + videoReviewContext, }: InboxMessageRowProps) { const timelineMessage = React.useMemo( () => toTimelineMessage(message), @@ -201,7 +206,7 @@ export function InboxMessageRow({ )}
- Promise; -export function hasVideoAttachment(message: TimelineMessage): boolean { +export function hasVideoAttachment( + message: Pick, +): boolean { if (message.body.includes("![video](")) return true; return ( 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/ui/VideoReviewCommentMarkdown.tsx b/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx new file mode 100644 index 00000000000..4f85742b7ae --- /dev/null +++ b/desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx @@ -0,0 +1,72 @@ +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 = videoReviewCommentRootId + ? parseVideoReviewTimecode(content) + : null; + const handleTimecodeClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (reviewTimecode && videoReviewCommentRootId) { + openVideoReviewAt?.(videoReviewCommentRootId, reviewTimecode.seconds); + } + }, + [openVideoReviewAt, reviewTimecode, videoReviewCommentRootId], + ); + + if (!reviewTimecode) { + return ( + + ); + } + + const timecode = + interactive && openVideoReviewAt ? ( + + ) : ( + + ); + + return ( + {timecode} } + /> + ); +} diff --git a/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx index 53904f886f1..0d9ee14f3bf 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 (