feat(desktop): embed linked buzz messages

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-10 22:22:14 -07:00
parent be48ce98bd
commit bf78dca7f5
16 changed files with 1019 additions and 152 deletions

View File

@@ -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",
);
});

View File

@@ -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<Channel, "isMember" | "visibility"> | 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();
}

View File

@@ -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,
);
});

View File

@@ -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>/<repo>`) 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<string>();
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);
}

View File

@@ -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<string, () => void>;
onRemoveForEveryone?: () => Promise<void>;
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 (
<>
<AttachmentGroup
className={
style === "compact"
? "max-w-full flex-row flex-wrap items-start overflow-visible pb-0"
: "max-w-full flex-col items-start overflow-visible pb-0"
}
data-link-preview-list=""
>
{previews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
ImageLightbox={ImageLightbox}
onOpen={onOpenByHref?.get(preview.href)}
onRemove={
onRemoveForEveryone && index === controlsIndex
? () => setDialogOpen(true)
: undefined
}
preview={preview}
showControls={index === controlsIndex}
/>
))}
</AttachmentGroup>
{onRemoveForEveryone ? (
<AlertDialog onOpenChange={setDialogOpen} open={dialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {previewNoun}?</AlertDialogTitle>
<AlertDialogDescription>
This removes{" "}
{previews.length === 1 ? "the preview" : "the previews"} for
everyone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setRemoved(true);
void onRemoveForEveryone().catch(() => setRemoved(false));
}}
type="button"
variant="destructive"
>
Remove {previewNoun}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</>
<AttachmentGroup
className={
style === "compact"
? "max-w-full flex-row flex-wrap items-start overflow-visible pb-0"
: "max-w-full flex-col items-start overflow-visible pb-0"
}
data-link-preview-list=""
>
{previews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
ImageLightbox={ImageLightbox}
onOpen={onOpenByHref?.get(preview.href)}
onRemove={showControls && index === 0 ? onRemove : undefined}
preview={preview}
showControls={showControls && index === 0}
/>
))}
</AttachmentGroup>
);
}

View File

@@ -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({
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
<LinkPreviewList
<GeneratedPreviewCoordinator
candidates={generatedPreviewCandidates}
channels={channels}
ImageLightbox={LinkPreviewImageLightbox}
key={messageId}
onOpenByHref={entityCardOpenHandlers}
onOpenMessageLink={onOpenMessageLink}
onRemoveForEveryone={onRemoveLinkPreviewsForEveryone}
previews={resolvedLinkPreviews}
/>
@@ -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";

View File

@@ -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<string, () => void>;
onOpenMessageLink: (link: ParsedMessageLink) => void;
onRemoveForEveryone?: () => Promise<void>;
previews: ResolvedLinkPreview[];
}) {
const [availability, setAvailability] = React.useState<
ReadonlyMap<string, MessageAvailability>
>(() => 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 (
<>
<LinkPreviewList
ImageLightbox={ImageLightbox}
onOpenByHref={onOpenByHref}
onRemove={requestRemove}
previews={visibleLinkPreviews}
showControls={firstVisible?.kind === "link"}
/>
<MessageEmbedList
channels={channels}
controlsHref={
firstVisible?.kind === "message" ? firstVisible.href : undefined
}
links={messageCandidates}
onAvailabilityChange={(href, available) => {
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 ? (
<AlertDialog onOpenChange={setDialogOpen} open={dialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {previewNoun}?</AlertDialogTitle>
<AlertDialogDescription>
This removes{" "}
{previewCount === 1 ? "the preview" : "the previews"} for
everyone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setRemoved(true);
void onRemoveForEveryone().catch(() => setRemoved(false));
}}
type="button"
variant="destructive"
>
Remove {previewNoun}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</>
);
}

View File

@@ -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<HTMLSpanElement>(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 (
<div
className="w-full max-w-2xl animate-pulse border-l-[3px] border-border py-1 pl-3"
data-message-embed="loading"
>
<span className="sr-only">Loading message preview</span>
<div className="mb-1 h-4 w-36 rounded bg-muted" />
<div className="h-4 w-full rounded bg-muted" />
<div className="mt-1 h-4 w-2/3 rounded bg-muted" />
</div>
);
}
const displayName =
profile?.displayName?.trim() || truncatePubkey(event.pubkey);
return (
<div
className="w-full max-w-2xl border-l-[3px] border-border py-1 pl-3"
data-message-embed="resolved"
>
<div className="mb-0.5 flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="shrink-0"
displayName={displayName}
fallbackDelayMs={0}
size="xs"
/>
<span className="min-w-0 truncate text-xs font-semibold text-foreground">
{displayName}
</span>
<span aria-hidden="true" className="text-xs text-muted-foreground">
·
</span>
<button
aria-label={`Open message by ${displayName} in ${channel?.name ?? "channel"}`}
className="min-w-0 truncate text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onOpen}
type="button"
>
#{channel?.name}
</button>
</div>
<span
className={
expanded
? "block text-sm leading-5 text-foreground"
: "line-clamp-3 text-sm leading-5 text-foreground"
}
ref={excerptRef}
>
{excerpt}
</span>
{expanded || isClamped ? (
<button
className="mt-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setExpanded((value) => !value)}
type="button"
>
{expanded ? "Show less" : "Show more"}
</button>
) : null}
</div>
);
}

View File

@@ -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<string>;
}) {
if (links.length === 0) return null;
return (
<AttachmentGroup
className="w-full max-w-2xl flex-col items-stretch overflow-visible pb-0"
data-message-embed-list=""
>
{links.map((link) => (
<div className="relative" key={link.href}>
{showControls && link.href === controlsHref ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
<MessageEmbed
channel={channels.find((channel) => channel.id === link.channelId)}
link={link}
onAvailabilityChange={(available) =>
onAvailabilityChange(link.href, available)
}
onOpen={() => onOpenMessageLink(link)}
visible={visibleHrefs.has(link.href)}
/>
</div>
))}
</AttachmentGroup>
);
}

View File

@@ -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 <span data-message-link="">{label}</span>;
if (!interactive || !canRead) {
return (
<span
className={cn(!canRead && MENTION_CHIP_BASE_CLASSES)}
data-message-link=""
>
{label}
</span>
);
}
return (

View File

@@ -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]);
});

View File

@@ -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<string, ResolvedLinkPreview>,
availability: ReadonlyMap<string, MessageAvailability>,
): {
attemptedMessageHrefs: ReadonlySet<string>;
visibleCandidates: GeneratedPreviewCandidate[];
} {
const attemptedMessageHrefs = new Set<string>();
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 };
}

View File

@@ -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,
);
});

View File

@@ -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
);
}

View File

@@ -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 };
}

View File

@@ -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();
});