mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
71
desktop/src/features/messages/lib/messageEmbed.test.mjs
Normal file
71
desktop/src/features/messages/lib/messageEmbed.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
44
desktop/src/features/messages/lib/messageEmbed.ts
Normal file
44
desktop/src/features/messages/lib/messageEmbed.ts
Normal 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();
|
||||
}
|
||||
@@ -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}`,
|
||||
``,
|
||||
`||${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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
156
desktop/src/shared/ui/markdown/GeneratedPreviewCoordinator.tsx
Normal file
156
desktop/src/shared/ui/markdown/GeneratedPreviewCoordinator.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
desktop/src/shared/ui/markdown/MessageEmbed.tsx
Normal file
151
desktop/src/shared/ui/markdown/MessageEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
desktop/src/shared/ui/markdown/MessageEmbedList.tsx
Normal file
53
desktop/src/shared/ui/markdown/MessageEmbedList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
35
desktop/src/shared/ui/markdown/generatedPreviewScheduling.ts
Normal file
35
desktop/src/shared/ui/markdown/generatedPreviewScheduling.ts
Normal 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 };
|
||||
}
|
||||
55
desktop/src/shared/ui/markdown/markdownPropsEqual.test.mjs
Normal file
55
desktop/src/shared/ui/markdown/markdownPropsEqual.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
42
desktop/src/shared/ui/markdown/markdownPropsEqual.ts
Normal file
42
desktop/src/shared/ui/markdown/markdownPropsEqual.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user