Skip to content
Merged
3 changes: 3 additions & 0 deletions desktop/src/features/home/lib/inboxViewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
146 changes: 113 additions & 33 deletions desktop/src/features/home/ui/InboxDetailPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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,
Expand All @@ -64,6 +69,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;
Expand Down Expand Up @@ -142,7 +150,11 @@ export function InboxDetailPane(props: InboxDetailPaneProps) {
);
}

return <InboxMessageDetailPane {...props} />;
return (
<VideoReviewNavigationProvider>
<InboxMessageDetailPane {...props} />
</VideoReviewNavigationProvider>
);
}

function InboxMessageDetailPane({
Expand All @@ -159,9 +171,9 @@ function InboxMessageDetailPane({
hasThreadContextLoadError = false,
isThreadContextLoading = false,
item,
messages = [],
messages = EMPTY_CONTEXT_MESSAGES,
profiles,
replies = [],
replies = EMPTY_REPLIES,
channel,
contextChannelName = null,
currentPubkey,
Expand Down Expand Up @@ -197,7 +209,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.
Expand Down Expand Up @@ -232,34 +243,97 @@ 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({
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
channelId: item?.item.channelId,
channelName: contextChannelName ?? item?.channelLabel ?? undefined,
channelType: videoReviewChannelType,
isSendingVideoReviewComment: isSendingReply,
messages: videoReviewMessages,
onSendVideoReviewComment: canReply
? handleSendVideoReviewComment
: undefined,
onToggleReaction,
profiles,
}),
[
canReply,
contextChannelName,
handleSendVideoReviewComment,
isSendingReply,
item,
onToggleReaction,
profiles,
videoReviewChannelType,
videoReviewMessages,
],
);
const { onScroll } = useAnchoredScroll({
channelId: conversationId,
contentRef,
Expand Down Expand Up @@ -667,6 +741,12 @@ function InboxMessageDetailPane({
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
showUnreadBoundary={hasUnreadBoundary}
videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get(
message.id,
)}
videoReviewContext={videoReviewPresentation.contextsByMessageId.get(
message.id,
)}
/>
);
})}
Expand Down
33 changes: 31 additions & 2 deletions desktop/src/features/home/ui/InboxListPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -121,6 +123,31 @@ 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 }),
)
Comment thread
klopez4212 marked this conversation as resolved.
Comment thread
klopez4212 marked this conversation as resolved.
Comment thread
klopez4212 marked this conversation as resolved.
.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,
Expand Down Expand Up @@ -274,6 +301,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;
Expand Down Expand Up @@ -408,11 +436,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>
Expand Down
17 changes: 15 additions & 2 deletions desktop/src/features/home/ui/InboxMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +42,8 @@ type InboxMessageRowProps = {
remove: boolean,
) => Promise<void>;
showUnreadBoundary?: boolean;
videoReviewCommentRootId?: string;
videoReviewContext?: VideoReviewContext;
};

export function InboxMessageRow({
Expand All @@ -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,
Expand Down Expand Up @@ -201,7 +211,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 &&
Expand All @@ -221,8 +231,11 @@ export function InboxMessageRow({
timelineMessage.tags,
)}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
mentionNames={message.mentionNames}
mentionPubkeysByName={message.mentionPubkeysByName}
videoReviewCommentRootId={videoReviewCommentRootId}
videoReviewContext={videoReviewContext}
Comment thread
klopez4212 marked this conversation as resolved.
/>
<MessageReactions
canToggle={canToggleReactions}
Expand Down
24 changes: 24 additions & 0 deletions desktop/src/features/messages/lib/videoReviewContext.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ test("hasVideoAttachment detects markdown and imeta videos", () => {
assert.equal(hasVideoAttachment(message({ body: "plain text" })), 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,
);
});

test("buildVideoReviewCommentsByRootId includes nested descendants", () => {
const video = message({
id: "video",
Expand Down
Loading
Loading