feat(messages): add link preview controls

Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-30 23:20:58 -07:00
co-authored by npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent 15b2b8dc0e
commit c4aaba97ce
19 changed files with 606 additions and 148 deletions
@@ -11,6 +11,7 @@ import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { formatRelativeTime } from "../lib/time";
@@ -122,6 +123,7 @@ export function ForumPostCard({
className="text-sm"
content={previewContent}
messageId={post.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
@@ -15,6 +15,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { Skeleton } from "@/shared/ui/skeleton";
import { formatRelativeTime } from "../lib/time";
@@ -116,6 +117,7 @@ function ReplyRow({
className="text-sm"
content={reply.content}
messageId={reply.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(reply.tags)}
imetaByUrl={parseImetaTags(reply.tags)}
mentionNames={replyMentionNames}
mentionPubkeysByName={replyMentionPubkeysByName}
@@ -260,6 +262,7 @@ export function ForumThreadPanel({
className="text-sm"
content={post.content}
messageId={post.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
imetaByUrl={parseImetaTags(post.tags)}
mentionNames={postMentionNames}
mentionPubkeysByName={postMentionPubkeysByName}
@@ -20,6 +20,7 @@ import {
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
@@ -229,6 +230,9 @@ export function FeedSection({
className="max-w-none text-sm leading-snug text-muted-foreground"
content={feedContent(item)}
messageId={item.id}
linkPreviewsSuppressed={hasLinkPreviewSuppression(
item.tags,
)}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
/>
@@ -16,6 +16,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { UserAvatar } from "@/shared/ui/UserAvatar";
export type InboxDisplayMessage = InboxContextMessage & {
@@ -213,6 +214,9 @@ export function InboxMessageRow({
)}
content={message.content}
messageId={message.id}
linkPreviewsSuppressed={hasLinkPreviewSuppression(
timelineMessage.tags,
)}
customEmoji={customEmoji}
mentionNames={message.mentionNames}
mentionPubkeysByName={message.mentionPubkeysByName}
@@ -674,3 +674,102 @@ test("CHANNEL_TIMELINE_CONTENT_KINDS matches isTimelineContentEvent", () => {
);
}
});
test("original message link-preview none marker suppresses all generated previews", () => {
const [message] = formatTimelineMessages(
[
streamMessage({
content: "https://one.example https://two.example",
tags: [
["h", CHANNEL_ID],
["link-preview", "none"],
],
}),
],
null,
undefined,
null,
);
assert.deepEqual(
message.tags.find((tag) => tag[0] === "link-preview"),
["link-preview", "none"],
);
});
test("authorized suppression edit remains monotonic across later body edits", () => {
const suppress = streamEdit(
HEX64_A,
"https://one.example https://two.example",
{
created_at: 1_700_000_001,
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
["link-preview", "none"],
],
},
);
const later = streamEdit(HEX64_A, "later body", {
id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
created_at: 1_700_000_002,
});
const [message] = formatTimelineMessages(
[streamMessage(), suppress, later],
null,
undefined,
null,
);
assert.equal(message.body, "later body");
assert.equal(
message.tags.some((tag) => tag[0] === "link-preview" && tag[1] === "none"),
true,
);
});
test("spoofed suppression edit cannot hide another author's previews", () => {
const spoof = streamEdit(HEX64_A, "spoofed", {
pubkey: PUBKEY_B,
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
["link-preview", "none"],
],
});
const [message] = formatTimelineMessages(
[streamMessage(), spoof],
null,
undefined,
null,
);
assert.equal(message.body, "hello world");
assert.equal(
message.tags.some((tag) => tag[0] === "link-preview"),
false,
);
});
test("verified agent owner may publish a suppression edit", () => {
const ownerEdit = streamEdit(HEX64_A, "owner edit", {
pubkey: PUBKEY_B,
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
["link-preview", "none"],
],
});
const profiles = {
[PUBKEY_A]: { ownerPubkey: PUBKEY_B },
};
const [message] = formatTimelineMessages(
[streamMessage(), ownerEdit],
null,
undefined,
null,
profiles,
);
assert.equal(message.body, "owner edit");
assert.equal(
message.tags.some((tag) => tag[0] === "link-preview"),
true,
);
});
@@ -181,6 +181,36 @@ function getAuthorAvatarUrl(input: {
return profiles?.[authorPubkey.toLowerCase()]?.avatarUrl ?? null;
}
export function hasLinkPreviewSuppression(
tags: string[][] | undefined,
): boolean {
return (
tags?.some(
(tag) =>
tag[0] === "link-preview" && tag[1] === "none" && tag.length === 2,
) ?? false
);
}
function isAuthorizedMessageEdit(
edit: RelayEvent,
target: RelayEvent,
profiles: UserProfileLookup | undefined,
relaySelfPubkey?: string | null,
): boolean {
const author = normalizePubkey(
resolveEventAuthorPubkey({
event: target,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
}),
);
const signer = normalizePubkey(edit.pubkey);
if (signer === author) return true;
return normalizePubkey(profiles?.[author]?.ownerPubkey ?? "") === signer;
}
export function formatTimelineMessages(
events: RelayEvent[],
channel: Channel | null,
@@ -219,8 +249,14 @@ export function formatTimelineMessages(
}
}
// Build a map of latest edit per original message: targetId → { content, tags, createdAt }.
// When multiple edits exist for the same message, the most recent one wins.
const timelineEventsById = new Map(
events.filter(isTimelineContentEvent).map((event) => [event.id, event]),
);
const previewSuppressedTargetIds = new Set<string>();
// Build a map of latest authorized edit per original message. Preview
// suppression is monotonic: any authorized edit carrying the marker wins
// forever, independent of which edit supplies the latest body.
// The edit's own tags are kept so the renderer can overlay imeta tags
// (attachments) from the edit onto the original event — non-imeta tags on
// the original (`h`, `p` mentions, etc.) stay untouched.
@@ -240,6 +276,16 @@ export function formatTimelineMessages(
if (!targetId || deletedEventIds.has(targetId)) {
continue;
}
const target = timelineEventsById.get(targetId);
if (
!target ||
!isAuthorizedMessageEdit(event, target, profiles, relaySelfPubkey)
) {
continue;
}
if (hasLinkPreviewSuppression(event.tags)) {
previewSuppressedTargetIds.add(targetId);
}
const existing = editsByTargetId.get(targetId);
if (!existing || event.created_at > existing.createdAt) {
@@ -469,7 +515,18 @@ export function formatTimelineMessages(
// imeta tags. All non-imeta tags on the original are preserved.
// Logic lives in `applyEditTagOverlay.mjs` so prod and tests share
// a single source.
tags: applyEditTagOverlay(event.tags, edit?.tags),
tags: (() => {
const effectiveTags = applyEditTagOverlay(event.tags, edit?.tags);
if (
hasLinkPreviewSuppression(event.tags) ||
previewSuppressedTargetIds.has(event.id)
) {
return hasLinkPreviewSuppression(effectiveTags)
? effectiveTags
: [...effectiveTags, ["link-preview", "none"]];
}
return effectiveTags;
})(),
reactions: (() => {
const reactions = reactionsByEventId.get(event.id);
if (!reactions) return undefined;
@@ -41,6 +41,9 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageActionBar } from "./MessageActionBar";
import { editMessage } from "@/shared/api/tauri";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { toast } from "sonner";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
@@ -149,6 +152,29 @@ export const MessageRow = React.memo(
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const linkPreviewsSuppressed = hasLinkPreviewSuppression(message.tags);
const removeLinkPreviewsForEveryone =
channelId && onEdit && !message.pending && !linkPreviewsSuppressed
? async () => {
const tags = message.tags ?? [];
try {
await editMessage(
channelId,
message.id,
message.body,
tags.filter((tag) => tag[0] === "imeta"),
tags.filter((tag) => tag[0] === "emoji"),
undefined,
true,
);
} catch (error) {
toast.error(
`Failed to remove previews: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
: undefined;
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
null,
);
@@ -371,6 +397,8 @@ export const MessageRow = React.memo(
)}
content={message.body}
messageId={message.id}
linkPreviewsSuppressed={linkPreviewsSuppressed}
onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
agentMentionPubkeysByName={agentMentionPubkeysByName}
+2
View File
@@ -621,6 +621,7 @@ export async function editMessage(
mediaTags?: string[][],
emojiTags?: string[][],
mentionPubkeys?: string[],
suppressLinkPreviews?: boolean,
): Promise<void> {
await invokeTauri("edit_message", {
channelId,
@@ -629,6 +630,7 @@ export async function editMessage(
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
mentionPubkeys: mentionPubkeys ?? null,
suppressLinkPreviews: suppressLinkPreviews ?? false,
});
}
@@ -1,28 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
linkPreviewImageKey,
readHiddenPreviewImages,
} from "./linkPreviewImageVisibility.ts";
test("preview image visibility keys are scoped to message and link", () => {
assert.notEqual(
linkPreviewImageKey("message-a", "https://example.com"),
linkPreviewImageKey("message-b", "https://example.com"),
);
});
test("hidden preview storage rejects malformed entries", () => {
const storage = {
getItem: () =>
JSON.stringify([
null,
{ key: 1, hiddenAt: "bad" },
{ key: "ok", hiddenAt: 2 },
]),
};
assert.deepEqual(readHiddenPreviewImages(storage), [
{ key: "ok", hiddenAt: 2 },
]);
});
@@ -1,41 +0,0 @@
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const STORAGE_KEY = "buzz-hidden-link-preview-images.v1";
const MAX_ENTRIES = 1_000;
type HiddenPreviewImage = { key: string; hiddenAt: number };
export function linkPreviewImageKey(messageId: string, href: string): string {
return `${messageId}:${href}`;
}
export function readHiddenPreviewImages(
storage: Pick<Storage, "getItem"> = window.localStorage,
): HiddenPreviewImage[] {
try {
const parsed: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(entry): entry is HiddenPreviewImage =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as HiddenPreviewImage).key === "string" &&
typeof (entry as HiddenPreviewImage).hiddenAt === "number",
)
.slice(-MAX_ENTRIES);
} catch {
return [];
}
}
export function hidePreviewImage(key: string): void {
const entries = readHiddenPreviewImages().filter(
(entry) => entry.key !== key,
);
entries.push({ key, hiddenAt: Date.now() });
setLocalStorageItemWithRecovery(
STORAGE_KEY,
JSON.stringify(entries.slice(-MAX_ENTRIES)),
);
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readDismissedLinkPreviews } from "./linkPreviewVisibility.ts";
test("dismissed preview storage rejects malformed entries", () => {
const storage = {
getItem: () =>
JSON.stringify([
null,
{ messageId: 1, dismissedAt: "bad" },
{ messageId: "message-a", dismissedAt: 2 },
]),
};
assert.deepEqual(readDismissedLinkPreviews(storage), [
{ messageId: "message-a", dismissedAt: 2 },
]);
});
@@ -0,0 +1,46 @@
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const STORAGE_KEY = "buzz-dismissed-link-previews.v1";
const MAX_ENTRIES = 1_000;
type DismissedPreview = { messageId: string; dismissedAt: number };
export function readDismissedLinkPreviews(
storage: Pick<Storage, "getItem"> = window.localStorage,
): DismissedPreview[] {
try {
const parsed: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(entry): entry is DismissedPreview =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as DismissedPreview).messageId === "string" &&
typeof (entry as DismissedPreview).dismissedAt === "number",
)
.slice(-MAX_ENTRIES);
} catch {
return [];
}
}
export function isLinkPreviewDismissed(messageId: string): boolean {
return readDismissedLinkPreviews().some(
(entry) => entry.messageId === messageId,
);
}
export function setLinkPreviewDismissed(
messageId: string,
dismissed: boolean,
): void {
const entries = readDismissedLinkPreviews().filter(
(entry) => entry.messageId !== messageId,
);
if (dismissed) entries.push({ messageId, dismissedAt: Date.now() });
setLocalStorageItemWithRecovery(
STORAGE_KEY,
JSON.stringify(entries.slice(-MAX_ENTRIES)),
);
}
@@ -1,14 +1,7 @@
import * as React from "react";
import { ExternalLink, Globe, EyeOff } from "lucide-react";
import { ExternalLink, Globe } from "lucide-react";
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import {
hidePreviewImage,
linkPreviewImageKey,
readHiddenPreviewImages,
} from "@/shared/lib/linkPreviewImageVisibility";
import { Button } from "@/shared/ui/button";
import {
Attachment,
AttachmentActions,
@@ -120,31 +113,12 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
export function LinkPreviewAttachment({
className,
messageId,
preview,
}: {
className?: string;
messageId?: string;
preview: SupportedLinkPreview;
}) {
const visibilityKey = messageId
? linkPreviewImageKey(messageId, preview.href)
: null;
const [imageHidden, setImageHidden] = React.useState(() =>
visibilityKey
? readHiddenPreviewImages().some((entry) => entry.key === visibilityKey)
: false,
);
React.useEffect(() => {
setImageHidden(
visibilityKey
? readHiddenPreviewImages().some((entry) => entry.key === visibilityKey)
: false,
);
}, [visibilityKey]);
const showImage = Boolean(
messageId && preview.imageDataUrl && preview.imageDomain && !imageHidden,
);
const showImage = Boolean(preview.imageDataUrl && preview.imageDomain);
return (
<Attachment
@@ -162,19 +136,6 @@ export function LinkPreviewAttachment({
className="aspect-[1.91/1] w-full object-cover"
src={preview.imageDataUrl ?? undefined}
/>
<Button
aria-label={`Hide automatic preview image from ${preview.imageDomain}`}
className="absolute right-2 top-2 z-20 bg-background/90 shadow-sm"
onClick={() => {
if (visibilityKey) hidePreviewImage(visibilityKey);
setImageHidden(true);
}}
size="icon-xs"
title="Hide preview image"
variant="secondary"
>
<EyeOff aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-6 text-2xs text-white">
Automatic preview from {preview.imageDomain}
</div>
+120 -13
View File
@@ -33,6 +33,21 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
import {
isLinkPreviewDismissed,
setLinkPreviewDismissed,
} from "@/shared/lib/linkPreviewVisibility";
import { Button } from "@/shared/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
computeConfigNudge,
@@ -1838,6 +1853,8 @@ function MarkdownInner({
agentMentionPubkeysByName,
mediaInset = false,
messageId,
linkPreviewsSuppressed = false,
onRemoveLinkPreviewsForEveryone,
mentionNames,
mentionPubkeysByName,
searchQuery,
@@ -1869,9 +1886,24 @@ function MarkdownInner({
},
[goChannel],
);
const [previewsDismissed, setPreviewsDismissed] = React.useState(() =>
messageId ? isLinkPreviewDismissed(messageId) : false,
);
const [removePreviewDialogOpen, setRemovePreviewDialogOpen] =
React.useState(false);
const [removedForEveryone, setRemovedForEveryone] = React.useState(false);
React.useEffect(() => {
setPreviewsDismissed(messageId ? isLinkPreviewDismissed(messageId) : false);
setRemovedForEveryone(false);
}, [messageId]);
const previewsGloballySuppressed =
linkPreviewsSuppressed || removedForEveryone;
const linkPreviews = React.useMemo(
() => (interactive ? extractSupportedLinkPreviews(content) : []),
[content, interactive],
() =>
interactive && !previewsGloballySuppressed && !previewsDismissed
? extractSupportedLinkPreviews(content)
: [],
[content, interactive, previewsGloballySuppressed, previewsDismissed],
);
const configNudge = React.useMemo(
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
@@ -1973,18 +2005,90 @@ function MarkdownInner({
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
<div className="space-y-1.5" data-link-preview-container="">
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment key={preview.href} preview={preview} />
))}
</AttachmentGroup>
{messageId ? (
<div className="flex flex-wrap gap-2 text-2xs text-muted-foreground">
<button
className="hover:text-foreground hover:underline"
onClick={() => {
setLinkPreviewDismissed(messageId, true);
setPreviewsDismissed(true);
}}
type="button"
>
Dismiss previews for me
</button>
{onRemoveLinkPreviewsForEveryone ? (
<button
className="hover:text-destructive hover:underline"
onClick={() => setRemovePreviewDialogOpen(true)}
type="button"
>
Remove previews for everyone
</button>
) : null}
</div>
) : null}
</div>
) : previewsDismissed && messageId && !previewsGloballySuppressed ? (
<button
className="text-2xs text-muted-foreground hover:text-foreground hover:underline"
onClick={() => {
setLinkPreviewDismissed(messageId, false);
setPreviewsDismissed(false);
}}
type="button"
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment
key={preview.href}
messageId={messageId}
preview={preview}
/>
))}
</AttachmentGroup>
Show dismissed link previews
</button>
) : null}
{onRemoveLinkPreviewsForEveryone ? (
<AlertDialog
onOpenChange={setRemovePreviewDialogOpen}
open={removePreviewDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove previews for everyone?
</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes all generated link previews from
this message for supporting clients. The links stay in the
message. This can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setRemovedForEveryone(true);
void onRemoveLinkPreviewsForEveryone().catch(() => {
setRemovedForEveryone(false);
});
}}
type="button"
variant="destructive"
>
Remove previews
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</VideoReviewMarkdownContext.Provider>
</MarkdownRuntimeContext.Provider>
@@ -2001,6 +2105,9 @@ export const Markdown = React.memo(
prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.messageId === next.messageId &&
prev.linkPreviewsSuppressed === next.linkPreviewsSuppressed &&
prev.onRemoveLinkPreviewsForEveryone ===
next.onRemoveLinkPreviewsForEveryone &&
shallowRecordEqual(
prev.agentMentionPubkeysByName,
next.agentMentionPubkeysByName,
+2
View File
@@ -61,6 +61,8 @@ export type MarkdownProps = {
mediaInset?: boolean;
/** Event/message identity used only for local preview-image visibility. */
messageId?: string;
linkPreviewsSuppressed?: boolean;
onRemoveLinkPreviewsForEveryone?: () => Promise<void>;
searchQuery?: string;
/** Display name shown in shared-agent card metadata. */
snapshotSharedBy?: string;
+6 -1
View File
@@ -8545,6 +8545,7 @@ async function handleSendChannelMessage(
mentionPubkeys?: string[];
mediaTags?: string[][] | null;
emojiTags?: string[][] | null;
suppressLinkPreviews?: boolean;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
@@ -8565,7 +8566,11 @@ async function handleSendChannelMessage(
// emoji renderer keeps resolving `:shortcode:` after the round-trip.
const emojiTags = args.emojiTags ?? [];
// Both kinds end up on the stored event's tag set, just like the real relay.
const extraTags = [...mediaTags, ...emojiTags];
const extraTags = [
...mediaTags,
...emojiTags,
...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []),
];
const identity = getIdentity(config);
if (!identity) {
const createdAt = Math.floor(Date.now() / 1000);