diff --git a/desktop/src/features/messages/lib/messageEmbed.test.mjs b/desktop/src/features/messages/lib/messageEmbed.test.mjs new file mode 100644 index 0000000000..64e450b0bd --- /dev/null +++ b/desktop/src/features/messages/lib/messageEmbed.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canReadMessageEmbedSource, + isEmbeddableMessageEvent, + isMatchingMessageEmbedEvent, + messageEmbedExcerpt, +} from "./messageEmbed.ts"; + +const link = { + channelId: "channel-1", + messageId: "event-1", + threadRootId: null, +}; +const event = { + id: "event-1", + pubkey: "author", + created_at: 1, + kind: 9, + tags: [["h", "channel-1"]], + content: "secret", + sig: "sig", +}; + +test("canReadMessageEmbedSource allows joined and open channels only", () => { + assert.equal(canReadMessageEmbedSource(undefined), false); + assert.equal( + canReadMessageEmbedSource({ isMember: false, visibility: "private" }), + false, + ); + assert.equal( + canReadMessageEmbedSource({ isMember: true, visibility: "private" }), + true, + ); + assert.equal( + canReadMessageEmbedSource({ isMember: false, visibility: "open" }), + true, + ); +}); + +test("isEmbeddableMessageEvent admits user-content kinds and rejects timeline auxiliaries", () => { + for (const kind of [9, 40002, 40008]) + assert.equal(isEmbeddableMessageEvent({ ...event, kind }), true); + for (const kind of [7, 40003, 40099, 43003, 47000]) + assert.equal(isEmbeddableMessageEvent({ ...event, kind }), false); +}); + +test("isMatchingMessageEmbedEvent requires requested id, channel, and user-content kind", () => { + assert.equal(isMatchingMessageEmbedEvent(event, link), true); + assert.equal( + isMatchingMessageEmbedEvent({ ...event, id: "other" }, link), + false, + ); + assert.equal( + isMatchingMessageEmbedEvent( + { ...event, tags: [["h", "private-other"]] }, + link, + ), + false, + ); + assert.equal(isMatchingMessageEmbedEvent({ ...event, kind: 7 }, link), false); +}); + +test("messageEmbedExcerpt normalizes whitespace without rendering nested markdown", () => { + assert.equal(messageEmbedExcerpt("hello\n\n world"), "hello world"); + assert.equal( + messageEmbedExcerpt("nested buzz://message?channel=x&id=y"), + "nested buzz://message?channel=x&id=y", + ); +}); diff --git a/desktop/src/features/messages/lib/messageEmbed.ts b/desktop/src/features/messages/lib/messageEmbed.ts new file mode 100644 index 0000000000..240b9c78ab --- /dev/null +++ b/desktop/src/features/messages/lib/messageEmbed.ts @@ -0,0 +1,44 @@ +import type { Channel, RelayEvent } from "@/shared/api/types"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_V2, +} from "@/shared/constants/kinds"; +import type { MessagePreviewCandidate } from "@/shared/lib/linkPreview"; + +import type { ParsedMessageLink } from "./messageLink"; + +export type MessageEmbedLink = MessagePreviewCandidate; + +export function canReadMessageEmbedSource( + channel: Pick | undefined, +): boolean { + return ( + channel !== undefined && (channel.isMember || channel.visibility === "open") + ); +} + +/** Only user-authored timeline prose is eligible for a quoted-message card. */ +export function isEmbeddableMessageEvent(event: RelayEvent): boolean { + return ( + event.kind === KIND_STREAM_MESSAGE || + event.kind === KIND_STREAM_MESSAGE_V2 || + event.kind === KIND_STREAM_MESSAGE_DIFF + ); +} + +export function isMatchingMessageEmbedEvent( + event: RelayEvent, + link: ParsedMessageLink, +): boolean { + const eventChannelId = event.tags.find((tag) => tag[0] === "h")?.[1]; + return ( + event.id === link.messageId && + eventChannelId === link.channelId && + isEmbeddableMessageEvent(event) + ); +} + +export function messageEmbedExcerpt(content: string): string { + return content.replace(/\s+/g, " ").trim(); +} diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index b4807f82fd..b3bff64bd2 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -534,3 +534,48 @@ test("extractSupportedLinkPreviews finds generic links and preserves exclusions" ], ); }); + +test("extractGeneratedPreviewCandidates shares exclusions and source order with message links", async () => { + const { extractGeneratedPreviewCandidates } = await import( + "./linkPreview.ts" + ); + const message = "buzz://message?channel=channel-1&id=event-1"; + const candidates = extractGeneratedPreviewCandidates( + [ + `\`${message}\``, + ` ${message}`, + `![hidden](${message})`, + `||${message}||`, + `[labeled](${message})`, + `https://example.com/first`, + `${message}.`, + ].join("\n"), + ); + assert.deepEqual( + candidates.map((candidate) => + candidate.kind === "message" + ? [candidate.kind, candidate.href] + : [candidate.kind, candidate.preview.href], + ), + [ + ["link", "https://example.com/first"], + ["message", message], + ], + ); +}); + +test("extractGeneratedPreviewCandidates applies one attempt budget across candidate kinds", async () => { + const { extractGeneratedPreviewCandidates, MAX_GENERATED_PREVIEW_ATTEMPTS } = + await import("./linkPreview.ts"); + const content = Array.from( + { length: MAX_GENERATED_PREVIEW_ATTEMPTS + 3 }, + (_, index) => + index % 2 === 0 + ? `buzz://message?channel=channel-${index}&id=event-${index}` + : `https://example.com/${index}`, + ).join(" "); + assert.equal( + extractGeneratedPreviewCandidates(content).length, + MAX_GENERATED_PREVIEW_ATTEMPTS, + ); +}); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 4a7772580b..7960f619c2 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -1,3 +1,8 @@ +import { + parseMessageLink, + type ParsedMessageLink, +} from "@/features/messages/lib/messageLink"; + import { buildIssueLink, buildPullRequestLink, @@ -46,10 +51,11 @@ export type SupportedLinkPreview = { // their distinctive path shape (`/git/<64-hex-pubkey>/`) rather than by // hostname, and require an explicit scheme. Generic previews remain HTTPS-only. const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])(buzz:\/\/message\?[^\s<>"'\]]+|https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; -const MAX_PREVIEWS = 8; + /!?\[([^\]\n]+)\]\((buzz:\/\/message\?[^)\s<>"']+|https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; +export const MAX_VISIBLE_GENERATED_PREVIEWS = 8; +export const MAX_GENERATED_PREVIEW_ATTEMPTS = 12; type HiddenRange = { start: number; @@ -612,19 +618,40 @@ type LinkPreviewCandidate = { order: number; }; -/** Extract supported link previews from message text, preserving first-seen order. */ -export function extractSupportedLinkPreviews( +export type MessagePreviewCandidate = ParsedMessageLink & { + href: string; + index: number; + kind: "message"; +}; + +export type LinkPreviewCandidateResult = { + index: number; + kind: "link"; + preview: SupportedLinkPreview; +}; + +export type GeneratedPreviewCandidate = + | LinkPreviewCandidateResult + | MessagePreviewCandidate; + +/** Extract generated-preview candidates through one hidden-range and policy scanner. */ +export function extractGeneratedPreviewCandidates( content: string, activeRelayOrigin?: string | null, -): SupportedLinkPreview[] { - const previews: SupportedLinkPreview[] = []; +): GeneratedPreviewCandidate[] { + const results: GeneratedPreviewCandidate[] = []; const seen = new Set(); const searchable = stripHiddenLinkPreviewContent(content); const candidates: LinkPreviewCandidate[] = []; + const markdownLinkRanges: HiddenRange[] = []; let order = 0; for (const match of searchable.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) { if (match[0]?.startsWith("!")) continue; + markdownLinkRanges.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + }); candidates.push({ href: match[2], index: match.index ?? 0, @@ -633,37 +660,72 @@ export function extractSupportedLinkPreviews( }); order += 1; } - for (const match of searchable.matchAll(SUPPORTED_URL_RE)) { const prefix = match[1] ?? ""; const href = match[2]; if (!href) continue; - candidates.push({ - href, - index: (match.index ?? 0) + prefix.length, - order, - }); + const index = (match.index ?? 0) + prefix.length; + if (isIndexInRanges(index, markdownLinkRanges)) continue; + candidates.push({ href, index, order }); order += 1; } - candidates.sort((a, b) => a.index - b.index || a.order - b.order); const relayOrigin = activeRelayOrigin ?? null; for (const candidate of candidates) { - const preview = parseSupportedLinkPreview(candidate.href, relayOrigin); - if (!preview || seen.has(preview.href)) continue; - - seen.add(preview.href); - previews.push( - withTitle( - preview, - candidate.label - ? titleFromMarkdownLabel(candidate.label, preview, relayOrigin) - : null, - ), - ); - if (previews.length >= MAX_PREVIEWS) break; + const normalizedHref = trimUrlCandidate(candidate.href); + const message = parseMessageLink(normalizedHref); + if (message.ok) { + // Authored labels stay labels. React Markdown serializes a bare custom + // scheme as `[url](url)`, so only an exact message-link label is an + // eligible autolink; mark other labels seen before the bare scanner pass. + if (candidate.label) { + const label = parseMessageLink(candidate.label.trim()); + if ( + !label.ok || + trimUrlCandidate(candidate.label.trim()) !== normalizedHref + ) { + continue; + } + } + if (seen.has(normalizedHref)) continue; + seen.add(normalizedHref); + results.push({ + ...message.value, + href: normalizedHref, + index: candidate.index, + kind: "message", + }); + } else { + const preview = parseSupportedLinkPreview(candidate.href, relayOrigin); + if (!preview || seen.has(preview.href)) continue; + seen.add(preview.href); + results.push({ + index: candidate.index, + kind: "link", + preview: withTitle( + preview, + candidate.label + ? titleFromMarkdownLabel(candidate.label, preview, relayOrigin) + : null, + ), + }); + } + if (results.length >= MAX_GENERATED_PREVIEW_ATTEMPTS) break; } - - return previews; + return results; +} + +/** Extract supported link previews from message text, preserving first-seen order. */ +export function extractSupportedLinkPreviews( + content: string, + activeRelayOrigin?: string | null, +): SupportedLinkPreview[] { + return extractGeneratedPreviewCandidates(content, activeRelayOrigin) + .filter( + (candidate): candidate is LinkPreviewCandidateResult => + candidate.kind === "link", + ) + .map((candidate) => candidate.preview) + .slice(0, MAX_VISIBLE_GENERATED_PREVIEWS); } diff --git a/desktop/src/shared/ui/link-preview-list.tsx b/desktop/src/shared/ui/link-preview-list.tsx index a389e3a136..9ddd0f8018 100644 --- a/desktop/src/shared/ui/link-preview-list.tsx +++ b/desktop/src/shared/ui/link-preview-list.tsx @@ -1,98 +1,44 @@ -import { useState } from "react"; - import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference"; import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; import { AttachmentGroup } from "@/shared/ui/attachment"; -import { Button } from "@/shared/ui/button"; import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; import type { LinkPreviewImageLightboxComponent } from "@/shared/ui/rich-link-preview-attachment"; export function LinkPreviewList({ ImageLightbox, onOpenByHref, - onRemoveForEveryone, + onRemove, previews, + showControls = false, }: { ImageLightbox: LinkPreviewImageLightboxComponent; onOpenByHref?: ReadonlyMap void>; - onRemoveForEveryone?: () => Promise; + onRemove?: () => void; previews: ResolvedLinkPreview[]; + showControls?: boolean; }) { - const [dialogOpen, setDialogOpen] = useState(false); - const [removed, setRemoved] = useState(false); const style = useLinkPreviewStyle(); - if (removed || previews.length === 0) return null; + if (previews.length === 0) return null; - const previewNoun = previews.length === 1 ? "preview" : "previews"; - const controlsIndex = 0; return ( - <> - - {previews.map((preview, index) => ( - setDialogOpen(true) - : undefined - } - preview={preview} - showControls={index === controlsIndex} - /> - ))} - - {onRemoveForEveryone ? ( - - - - Remove {previewNoun}? - - This removes{" "} - {previews.length === 1 ? "the preview" : "the previews"} for - everyone. - - - - - - - - - - - - - ) : null} - + + {previews.map((preview, index) => ( + + ))} + ); } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b71cc0947e..195f60c613 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -27,7 +27,6 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; -import { LinkPreviewList } from "@/shared/ui/link-preview-list"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { computeConfigNudge, @@ -45,8 +44,6 @@ import { classifyChildren, hasBlockMedia, isImageOnlyParagraph, - shallowArrayEqual, - shallowRecordEqual, } from "./markdownUtils"; import { CODE_BLOCK_CLASS, @@ -111,6 +108,8 @@ import { } from "./markdown/imageLightbox"; import { MarkdownTable } from "./markdown/MarkdownTable"; import { ProgressiveImage } from "./markdown/ProgressiveImage"; +import { GeneratedPreviewCoordinator } from "./markdown/GeneratedPreviewCoordinator"; +import { markdownPropsEqual } from "./markdown/markdownPropsEqual"; import { MessageLinkPill } from "./markdown/MessageLinkPill"; import { renderCachedMarkdown } from "./markdown/nodeCache"; import { useMessageLinkPreviews } from "./markdown/useMessageLinkPreviews"; @@ -1794,13 +1793,6 @@ function MarkdownInner({ const onOpenEntityLink = useOpenEntityLink(); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { - // Always route through `goChannel` with `messageId` set: the channel - // route already handles scroll-into-view + highlight via - // `useAnchoredScroll` + `getEventById` backfill, and works for - // both stream-message replies and forum threads. Detecting "the thread - // root is a forum post" up front would require an event lookup we don't - // currently have synchronously; the brief explicitly allows skipping - // that detection and falling through. void goChannel(link.channelId, { messageId: link.messageId, threadRootId: link.threadRootId, @@ -1809,7 +1801,10 @@ function MarkdownInner({ [goChannel], ); const relayOrigin = useRelayOrigin(); - const resolvedLinkPreviews = useMessageLinkPreviews({ + const { + candidates: generatedPreviewCandidates, + previews: resolvedLinkPreviews, + } = useMessageLinkPreviews({ content, interactive, linkPreviewTags, @@ -1922,10 +1917,13 @@ function MarkdownInner({ ) : null} - @@ -1935,26 +1933,6 @@ function MarkdownInner({ ); } -export const Markdown = React.memo( - MarkdownInner, - (prev, next) => - prev.content === next.content && - prev.className === next.className && - prev.customEmoji === next.customEmoji && - prev.interactive === next.interactive && - prev.mediaInset === next.mediaInset && - shallowRecordEqual( - prev.agentMentionPubkeysByName, - next.agentMentionPubkeysByName, - ) && - shallowRecordEqual(prev.mentionPubkeysByName, next.mentionPubkeysByName) && - shallowArrayEqual(prev.mentionNames, next.mentionNames) && - shallowArrayEqual(prev.channelNames, next.channelNames) && - prev.imetaByUrl === next.imetaByUrl && - prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey && - prev.searchQuery === next.searchQuery && - prev.snapshotSharedBy === next.snapshotSharedBy && - prev.videoReviewContext === next.videoReviewContext, -); +export const Markdown = React.memo(MarkdownInner, markdownPropsEqual); Markdown.displayName = "Markdown"; export { SyntaxHighlightedCode } from "./markdown/CodeBlock"; diff --git a/desktop/src/shared/ui/markdown/GeneratedPreviewCoordinator.tsx b/desktop/src/shared/ui/markdown/GeneratedPreviewCoordinator.tsx new file mode 100644 index 0000000000..438ce1db1b --- /dev/null +++ b/desktop/src/shared/ui/markdown/GeneratedPreviewCoordinator.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; + +import type { Channel } from "@/shared/api/types"; +import type { GeneratedPreviewCandidate } from "@/shared/lib/linkPreview"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { LinkPreviewList } from "@/shared/ui/link-preview-list"; +import type { LinkPreviewImageLightboxComponent } from "@/shared/ui/rich-link-preview-attachment"; +import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; + +import { + scheduleGeneratedPreviews, + type MessageAvailability, +} from "./generatedPreviewScheduling"; +import { MessageEmbedList } from "./MessageEmbedList"; + +export function GeneratedPreviewCoordinator({ + candidates, + channels, + ImageLightbox, + onOpenByHref, + onOpenMessageLink, + onRemoveForEveryone, + previews, +}: { + candidates: GeneratedPreviewCandidate[]; + channels: Channel[]; + ImageLightbox: LinkPreviewImageLightboxComponent; + onOpenByHref?: ReadonlyMap void>; + onOpenMessageLink: (link: ParsedMessageLink) => void; + onRemoveForEveryone?: () => Promise; + previews: ResolvedLinkPreview[]; +}) { + const [availability, setAvailability] = React.useState< + ReadonlyMap + >(() => new Map()); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [removed, setRemoved] = React.useState(false); + const previewsByHref = React.useMemo( + () => new Map(previews.map((preview) => [preview.href, preview])), + [previews], + ); + const { attemptedMessageHrefs, visibleCandidates } = React.useMemo( + () => scheduleGeneratedPreviews(candidates, previewsByHref, availability), + [availability, candidates, previewsByHref], + ); + const visibleMessageHrefs = React.useMemo( + () => + new Set( + visibleCandidates.flatMap((candidate) => + candidate.kind === "message" ? [candidate.href] : [], + ), + ), + [visibleCandidates], + ); + const visibleLinkPreviews = visibleCandidates.flatMap((candidate) => + candidate.kind === "link" + ? [previewsByHref.get(candidate.preview.href)].filter( + (preview): preview is ResolvedLinkPreview => preview !== undefined, + ) + : [], + ); + const messageCandidates = candidates.flatMap((candidate) => + candidate.kind === "message" && attemptedMessageHrefs.has(candidate.href) + ? [candidate] + : [], + ); + const firstVisible = visibleCandidates[0]; + const previewCount = visibleCandidates.length; + const previewNoun = previewCount === 1 ? "preview" : "previews"; + const requestRemove = onRemoveForEveryone + ? () => setDialogOpen(true) + : undefined; + + if (removed || candidates.length === 0) return null; + + return ( + <> + + { + const nextAvailability: MessageAvailability = + available === null + ? "pending" + : available + ? "available" + : "unavailable"; + setAvailability((current) => { + if (current.get(href) === nextAvailability) return current; + const next = new Map(current); + next.set(href, nextAvailability); + return next; + }); + }} + onOpenMessageLink={onOpenMessageLink} + onRemove={requestRemove} + showControls={firstVisible?.kind === "message"} + visibleHrefs={visibleMessageHrefs} + /> + {onRemoveForEveryone ? ( + + + + Remove {previewNoun}? + + This removes{" "} + {previewCount === 1 ? "the preview" : "the previews"} for + everyone. + + + + + + + + + + + + + ) : null} + + ); +} diff --git a/desktop/src/shared/ui/markdown/MessageEmbed.tsx b/desktop/src/shared/ui/markdown/MessageEmbed.tsx new file mode 100644 index 0000000000..07bb65a174 --- /dev/null +++ b/desktop/src/shared/ui/markdown/MessageEmbed.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { + canReadMessageEmbedSource, + isMatchingMessageEmbedEvent, + messageEmbedExcerpt, + type MessageEmbedLink, +} from "@/features/messages/lib/messageEmbed"; +import { getEventById } from "@/shared/api/tauri"; +import type { Channel } from "@/shared/api/types"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +export function MessageEmbed({ + channel, + link, + onAvailabilityChange, + onOpen, + visible, +}: { + channel: Channel | undefined; + link: MessageEmbedLink; + onAvailabilityChange: (available: boolean | null) => void; + onOpen: () => void; + visible: boolean; +}) { + const canRead = canReadMessageEmbedSource(channel); + const eventQuery = useQuery({ + queryKey: [ + "message-embed", + channel?.id, + channel?.isMember, + channel?.visibility, + link.messageId, + ], + queryFn: () => getEventById(link.messageId), + enabled: canRead, + retry: false, + staleTime: 60_000, + }); + const event = + canRead && + eventQuery.data && + isMatchingMessageEmbedEvent(eventQuery.data, link) + ? eventQuery.data + : null; + // Never resolve an author profile until channel access and event-channel + // integrity have both been established. + const profileQuery = useUserProfileQuery(event?.pubkey); + const profile = profileQuery.data; + const excerpt = event + ? messageEmbedExcerpt(event.content) || "Message has no text" + : ""; + const [expanded, setExpanded] = useState(false); + const [isClamped, setIsClamped] = useState(false); + const excerptRef = useRef(null); + const available = event + ? true + : eventQuery.isPending && canRead + ? null + : false; + + useEffect(() => { + onAvailabilityChange(available); + }, [available, onAvailabilityChange]); + + useEffect(() => { + const excerptElement = excerptRef.current; + if (!excerptElement) return; + const updateClampedState = () => + setIsClamped(excerptElement.scrollHeight > excerptElement.clientHeight); + updateClampedState(); + const observer = new ResizeObserver(updateClampedState); + observer.observe(excerptElement); + return () => observer.disconnect(); + }); + + if (!canRead || eventQuery.isError || (eventQuery.data && !event)) + return null; + + if (!visible) return null; + + if (!event) { + return ( +
+ Loading message preview +
+
+
+
+ ); + } + + const displayName = + profile?.displayName?.trim() || truncatePubkey(event.pubkey); + + return ( +
+
+ + + {displayName} + + + +
+ + {excerpt} + + {expanded || isClamped ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/shared/ui/markdown/MessageEmbedList.tsx b/desktop/src/shared/ui/markdown/MessageEmbedList.tsx new file mode 100644 index 0000000000..ea16635a25 --- /dev/null +++ b/desktop/src/shared/ui/markdown/MessageEmbedList.tsx @@ -0,0 +1,53 @@ +import type { MessageEmbedLink } from "@/features/messages/lib/messageEmbed"; +import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; +import type { Channel } from "@/shared/api/types"; +import { AttachmentGroup } from "@/shared/ui/attachment"; +import { LinkPreviewControls } from "@/shared/ui/link-preview-controls"; + +import { MessageEmbed } from "./MessageEmbed"; + +export function MessageEmbedList({ + channels, + links, + onAvailabilityChange, + onOpenMessageLink, + onRemove, + controlsHref, + showControls, + visibleHrefs, +}: { + channels: Channel[]; + links: MessageEmbedLink[]; + onAvailabilityChange: (href: string, available: boolean | null) => void; + onOpenMessageLink: (link: ParsedMessageLink) => void; + onRemove?: () => void; + controlsHref?: string; + showControls: boolean; + visibleHrefs: ReadonlySet; +}) { + if (links.length === 0) return null; + + return ( + + {links.map((link) => ( +
+ {showControls && link.href === controlsHref ? ( + + ) : null} + channel.id === link.channelId)} + link={link} + onAvailabilityChange={(available) => + onAvailabilityChange(link.href, available) + } + onOpen={() => onOpenMessageLink(link)} + visible={visibleHrefs.has(link.href)} + /> +
+ ))} +
+ ); +} diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index fad6904fa8..5050d61b7d 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -1,3 +1,4 @@ +import { canReadMessageEmbedSource } from "@/features/messages/lib/messageEmbed"; import { cn } from "@/shared/lib/cn"; import { MENTION_CHIP_BASE_CLASSES, @@ -14,16 +15,28 @@ export function MessageLinkPill({ onOpenMessageLink, }: MessageLinkPillProps) { const channel = channels.find((c) => c.id === link.channelId); - const channelLabel = channel?.name ?? "channel"; + const canRead = canReadMessageEmbedSource(channel); + const channelLabel = canRead + ? (channel?.name ?? "channel") + : "private channel"; const shortId = link.messageId.slice(0, 6); - const label = ( + const label = canRead ? ( <> #{channelLabel} · {shortId} + ) : ( + <>Private message or channel ); - if (!interactive) { - return {label}; + if (!interactive || !canRead) { + return ( + + {label} + + ); } return ( diff --git a/desktop/src/shared/ui/markdown/generatedPreviewScheduling.test.mjs b/desktop/src/shared/ui/markdown/generatedPreviewScheduling.test.mjs new file mode 100644 index 0000000000..29bfa9c001 --- /dev/null +++ b/desktop/src/shared/ui/markdown/generatedPreviewScheduling.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { scheduleGeneratedPreviews } from "./generatedPreviewScheduling.ts"; + +const message = (href, index) => ({ + kind: "message", + href, + index, + channelId: "channel", + messageId: href, +}); +const link = (href, index) => ({ + kind: "link", + index, + preview: { + kind: "generic-link", + href, + provider: "Web", + title: href, + typeLabel: "link", + }, +}); +const resolved = (href) => ({ + kind: "generic-link", + href, + provider: "Web", + title: href, + typeLabel: "link", +}); + +test("pending and unavailable messages do not hide a resolved later link", () => { + const messages = Array.from({ length: 8 }, (_, index) => + message(`buzz://message/${index}`, index), + ); + const laterLink = link("https://example.com", 8); + const availability = new Map( + messages.map((candidate, index) => [ + candidate.href, + index % 2 === 0 ? "pending" : "unavailable", + ]), + ); + const result = scheduleGeneratedPreviews( + [...messages, laterLink], + new Map([[laterLink.preview.href, resolved(laterLink.preview.href)]]), + availability, + ); + + assert.deepEqual(result.visibleCandidates, [laterLink]); + assert.deepEqual( + [...result.attemptedMessageHrefs], + messages.map(({ href }) => href), + ); +}); + +test("failed message attempts backfill until eight available previews are selected", () => { + const candidates = Array.from({ length: 12 }, (_, index) => + message(`buzz://message/${index}`, index), + ); + const availability = new Map( + candidates.map((candidate, index) => [ + candidate.href, + index < 2 ? "unavailable" : "available", + ]), + ); + const result = scheduleGeneratedPreviews(candidates, new Map(), availability); + + assert.deepEqual( + result.visibleCandidates.map(({ href }) => href), + candidates.slice(2, 10).map(({ href }) => href), + ); + assert.deepEqual( + [...result.attemptedMessageHrefs], + candidates.slice(0, 10).map(({ href }) => href), + ); +}); + +test("the first resolved candidate owns controls while an earlier message is pending", () => { + const pending = message("buzz://message/pending", 0); + const availableLink = link("https://example.com/ready", 1); + const availableMessage = message("buzz://message/ready", 2); + const result = scheduleGeneratedPreviews( + [pending, availableLink, availableMessage], + new Map([ + [availableLink.preview.href, resolved(availableLink.preview.href)], + ]), + new Map([ + [pending.href, "pending"], + [availableMessage.href, "available"], + ]), + ); + + assert.equal(result.visibleCandidates[0], availableLink); + assert.deepEqual(result.visibleCandidates, [availableLink, availableMessage]); +}); diff --git a/desktop/src/shared/ui/markdown/generatedPreviewScheduling.ts b/desktop/src/shared/ui/markdown/generatedPreviewScheduling.ts new file mode 100644 index 0000000000..61677083be --- /dev/null +++ b/desktop/src/shared/ui/markdown/generatedPreviewScheduling.ts @@ -0,0 +1,35 @@ +import { + MAX_VISIBLE_GENERATED_PREVIEWS, + type GeneratedPreviewCandidate, +} from "@/shared/lib/linkPreview"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; + +export type MessageAvailability = "pending" | "available" | "unavailable"; + +/** Pending messages remain attempts, but do not reserve visible slots or controls. */ +export function scheduleGeneratedPreviews( + candidates: GeneratedPreviewCandidate[], + previewsByHref: ReadonlyMap, + availability: ReadonlyMap, +): { + attemptedMessageHrefs: ReadonlySet; + visibleCandidates: GeneratedPreviewCandidate[]; +} { + const attemptedMessageHrefs = new Set(); + const visibleCandidates: GeneratedPreviewCandidate[] = []; + + for (const candidate of candidates) { + if (candidate.kind === "message") { + attemptedMessageHrefs.add(candidate.href); + if (availability.get(candidate.href) === "available") { + visibleCandidates.push(candidate); + } + } else if (previewsByHref.has(candidate.preview.href)) { + visibleCandidates.push(candidate); + } + + if (visibleCandidates.length === MAX_VISIBLE_GENERATED_PREVIEWS) break; + } + + return { attemptedMessageHrefs, visibleCandidates }; +} diff --git a/desktop/src/shared/ui/markdown/markdownPropsEqual.test.mjs b/desktop/src/shared/ui/markdown/markdownPropsEqual.test.mjs new file mode 100644 index 0000000000..ee7cdf9272 --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownPropsEqual.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { markdownPropsEqual } from "./markdownPropsEqual.ts"; + +const base = { content: "buzz://message?channel=c&id=m" }; + +test("identical content rerenders when generated previews become suppressed", () => { + assert.equal( + markdownPropsEqual( + { ...base, linkPreviewsSuppressed: false }, + { ...base, linkPreviewsSuppressed: true }, + ), + false, + ); +}); + +test("preview tags compare shallowly and invalidate on tag edits", () => { + assert.equal( + markdownPropsEqual( + { ...base, linkPreviewTags: [["link-preview", "none"]] }, + { ...base, linkPreviewTags: [["link-preview", "none"]] }, + ), + true, + ); + assert.equal( + markdownPropsEqual( + { ...base, linkPreviewTags: [["link-preview", "snapshot", "old"]] }, + { ...base, linkPreviewTags: [["link-preview", "none"]] }, + ), + false, + ); +}); + +test("message identity and removal callback invalidate memoized Markdown", () => { + const remove = async () => {}; + assert.equal( + markdownPropsEqual( + { ...base, messageId: "old", onRemoveLinkPreviewsForEveryone: remove }, + { ...base, messageId: "new", onRemoveLinkPreviewsForEveryone: remove }, + ), + false, + ); + assert.equal( + markdownPropsEqual( + { ...base, messageId: "same", onRemoveLinkPreviewsForEveryone: remove }, + { + ...base, + messageId: "same", + onRemoveLinkPreviewsForEveryone: async () => {}, + }, + ), + false, + ); +}); diff --git a/desktop/src/shared/ui/markdown/markdownPropsEqual.ts b/desktop/src/shared/ui/markdown/markdownPropsEqual.ts new file mode 100644 index 0000000000..4a26fe41ec --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownPropsEqual.ts @@ -0,0 +1,42 @@ +import { shallowArrayEqual, shallowRecordEqual } from "../markdownUtils"; +import type { MarkdownProps } from "./types"; + +function shallowTagsEqual( + a?: readonly (readonly string[])[], + b?: readonly (readonly string[])[], +): boolean { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + return a.every( + (tag, index) => + tag.length === b[index]?.length && + tag.every((value, valueIndex) => value === b[index]?.[valueIndex]), + ); +} + +export function markdownPropsEqual(prev: MarkdownProps, next: MarkdownProps) { + return ( + prev.content === next.content && + prev.className === next.className && + prev.customEmoji === next.customEmoji && + prev.interactive === next.interactive && + prev.mediaInset === next.mediaInset && + shallowRecordEqual( + prev.agentMentionPubkeysByName, + next.agentMentionPubkeysByName, + ) && + shallowRecordEqual(prev.mentionPubkeysByName, next.mentionPubkeysByName) && + shallowArrayEqual(prev.mentionNames, next.mentionNames) && + shallowArrayEqual(prev.channelNames, next.channelNames) && + prev.imetaByUrl === next.imetaByUrl && + prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey && + prev.searchQuery === next.searchQuery && + prev.snapshotSharedBy === next.snapshotSharedBy && + prev.videoReviewContext === next.videoReviewContext && + prev.linkPreviewsSuppressed === next.linkPreviewsSuppressed && + shallowTagsEqual(prev.linkPreviewTags, next.linkPreviewTags) && + prev.onRemoveLinkPreviewsForEveryone === + next.onRemoveLinkPreviewsForEveryone && + prev.messageId === next.messageId + ); +} diff --git a/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts index 0f76412a0d..643098fb89 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts @@ -1,7 +1,8 @@ import * as React from "react"; import { - extractSupportedLinkPreviews, + extractGeneratedPreviewCandidates, + type GeneratedPreviewCandidate, type SupportedLinkPreview, } from "@/shared/lib/linkPreview"; import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot"; @@ -61,13 +62,25 @@ export function useMessageLinkPreviews({ linkPreviewTags?: readonly (readonly string[])[]; linkPreviewsSuppressed: boolean; relayOrigin: string | null; -}): ResolvedLinkPreview[] { +}): { + candidates: GeneratedPreviewCandidate[]; + previews: ResolvedLinkPreview[]; +} { + const candidates = React.useMemo( + () => + interactive && !linkPreviewsSuppressed + ? extractGeneratedPreviewCandidates(content, relayOrigin) + : [], + [content, interactive, linkPreviewsSuppressed, relayOrigin], + ); const linkPreviewCandidates = React.useMemo( () => interactive && !linkPreviewsSuppressed - ? extractSupportedLinkPreviews(content, relayOrigin) + ? candidates.flatMap((candidate) => + candidate.kind === "link" ? [candidate.preview] : [], + ) : [], - [content, interactive, linkPreviewsSuppressed, relayOrigin], + [candidates, interactive, linkPreviewsSuppressed], ); const entityLinkPreviews = React.useMemo( () => linkPreviewCandidates.filter(isBuzzEntityPreview), @@ -80,7 +93,7 @@ export function useMessageLinkPreviews({ withEntityFallbacks(entityLinkPreviews, relayResolvedEntityLinkPreviews), [entityLinkPreviews, relayResolvedEntityLinkPreviews], ); - return React.useMemo(() => { + const previews = React.useMemo(() => { const snapshots = interactive && !linkPreviewsSuppressed ? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin) @@ -99,4 +112,5 @@ export function useMessageLinkPreviews({ relayOrigin, resolvedEntityLinkPreviews, ]); + return { candidates, previews }; } diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 18db55a50a..1d61e72f3f 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -357,9 +357,10 @@ test("message links to visible root messages open the thread panel", async ({ .filter({ hasText: "Root link repro" }) .last(); await expect(linkMessage).toBeVisible(); - await linkMessage - .getByRole("button", { name: "Open message in general" }) - .click(); + const embed = linkMessage.locator('[data-message-embed="resolved"]'); + await expect(embed).toContainText("Welcome to #general"); + await expect(embed).toContainText("#general"); + await embed.getByRole("button", { name: /Open message by/ }).click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -425,3 +426,109 @@ test("message deep links survive reload", async ({ page }) => { "Engineering shipped the desktop build.", ); }); + +test("unreadable message links stay redacted without fetching their event", async ({ + page, +}) => { + const channelId = "1be1dcdb-4c31-5a8c-81de-ac102552ca10"; + const messageId = "unreadable-message-target"; + const link = `buzz://message?channel=${channelId}&id=${messageId}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + ({ channelId, messageId }) => { + const viewer = "deadbeef".repeat(8); + window.__BUZZ_E2E_MUTATE_CHANNEL__?.({ + channelId, + removeMemberPubkey: viewer, + }); + window.__BUZZ_E2E_DEFER_GET_EVENT__ = messageId; + window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0; + }, + { channelId, messageId }, + ); + await page.evaluate(() => window.__BUZZ_E2E_INVALIDATE_CHANNELS__?.()); + await page.getByTestId("message-input").fill(`Private link ${link}`); + await page.getByTestId("send-message").click(); + + const row = page + .getByTestId("message-row") + .filter({ hasText: "Private link" }) + .last(); + await expect(row.locator("[data-message-link]")).toHaveText( + "Private message or channel", + ); + await expect(row.locator("[data-message-embed]")).toHaveCount(0); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__)) + .toBe(0); +}); + +test("invalid message-link targets keep their pills but render no cards", async ({ + page, +}) => { + const generalId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const wrongKindId = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const wrongChannelId = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + ({ wrongKindId, wrongChannelId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "not a message event", + id: wrongKindId, + kind: 7, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "announcements", + content: "belongs to another channel", + id: wrongChannelId, + }); + }, + { wrongKindId, wrongChannelId }, + ); + const links = [ + `buzz://message?channel=${generalId}&id=${wrongKindId}`, + `buzz://message?channel=${generalId}&id=${wrongChannelId}`, + ]; + await page + .getByTestId("message-input") + .fill(`Invalid links ${links.join(" ")}`); + await page.getByTestId("send-message").click(); + + const row = page + .getByTestId("message-row") + .filter({ hasText: "Invalid links" }) + .last(); + await expect(row.locator("[data-message-link]")).toHaveCount(2); + await expect(row.locator("[data-message-embed]")).toHaveCount(0); +}); + +test("removing generated message previews preserves the message-link pill", async ({ + page, +}) => { + const link = + "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(`Removable embed ${link}`); + await page.getByTestId("send-message").click(); + + const row = page + .getByTestId("message-row") + .filter({ hasText: "Removable embed" }) + .last(); + await expect(row.locator('[data-message-embed="resolved"]')).toBeVisible(); + await row.getByRole("button", { name: "Link display settings" }).click(); + await page.getByRole("menuitem", { name: "Remove preview" }).click(); + await page.getByRole("button", { name: "Remove preview" }).click(); + + await expect(row.locator("[data-message-embed]")).toHaveCount(0); + await expect(row.locator("[data-message-link]")).toBeVisible(); +});