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
+72 -6
View File
@@ -3,7 +3,10 @@ use tauri::{AppHandle, State};
mod forum;
use forum::{forum_message_from_event, forum_reply_from_event};
use forum::{
apply_link_preview_suppression, fetch_agent_owner_pubkeys, forum_message_from_event,
forum_reply_from_event, link_preview_suppression_targets,
};
use crate::{
app_state::AppState,
@@ -113,9 +116,30 @@ pub async fn get_feed(
Vec::new()
};
let mention_ids = mention_events
.iter()
.map(|event| event.id.to_hex())
.collect::<Vec<_>>();
let mention_edits = if mention_ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({ "kinds": [40003], "#e": mention_ids })],
)
.await
.unwrap_or_default()
};
let mention_owner_pubkeys = fetch_agent_owner_pubkeys(&state, &mention_events).await;
let suppressed_mentions =
link_preview_suppression_targets(&mention_events, &mention_edits, &mention_owner_pubkeys);
let mentions: Vec<FeedItemInfo> = mention_events
.iter()
.map(|ev| feed_item_from_event(ev, "mentions"))
.map(|ev| {
let mut item = feed_item_from_event(ev, "mentions");
apply_link_preview_suppression(&mut item.tags, &item.id, &suppressed_mentions);
item
})
.collect();
let needs_action: Vec<FeedItemInfo> = approval_events
.iter()
@@ -223,9 +247,29 @@ pub async fn get_forum_posts(
}
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
let ids = events
.iter()
.map(|event| event.id.to_hex())
.collect::<Vec<_>>();
let edits = if ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({ "kinds": [40003], "#e": ids })],
)
.await
.unwrap_or_default()
};
let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await;
let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys);
let messages: Vec<ForumMessageInfo> = events
.iter()
.map(|ev| forum_message_from_event(ev, &channel_id))
.map(|ev| {
let mut message = forum_message_from_event(ev, &channel_id);
apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed);
message
})
.collect();
let next_cursor = messages.last().map(|m| m.created_at);
@@ -258,14 +302,34 @@ pub async fn get_forum_thread(
],
)
.await?;
let ids = events
.iter()
.map(|event| event.id.to_hex())
.collect::<Vec<_>>();
let edits = if ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({ "kinds": [40003], "#e": ids })],
)
.await
.unwrap_or_default()
};
let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await;
let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys);
let mut root: Option<ForumMessageInfo> = None;
let mut replies: Vec<ForumThreadReplyInfo> = Vec::new();
for ev in &events {
if ev.id.to_hex() == event_id {
root = Some(forum_message_from_event(ev, &channel_id));
} else {
replies.push(forum_reply_from_event(ev, &channel_id, &event_id));
let mut message = forum_message_from_event(ev, &channel_id);
apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed);
root = Some(message);
} else if ev.kind.as_u16() as u32 != 40003 {
let mut reply = forum_reply_from_event(ev, &channel_id, &event_id);
apply_link_preview_suppression(&mut reply.tags, &reply.event_id, &suppressed);
replies.push(reply);
}
}
let total_replies = replies.len() as u32;
@@ -930,6 +994,7 @@ pub async fn edit_message(
// edited body against the original). Only these get a `p` tag, so a typo-fix
// edit that leaves the mention set unchanged never re-wakes anyone.
mention_pubkeys: Option<Vec<String>>,
suppress_link_previews: Option<bool>,
state: State<'_, AppState>,
) -> Result<(), String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
@@ -951,6 +1016,7 @@ pub async fn edit_message(
&media_tags,
&emoji,
&mention_refs,
suppress_link_previews.unwrap_or(false),
)?;
submit_event(builder, &state).await?;
Ok(())
@@ -1,4 +1,35 @@
use crate::models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary};
use crate::{
app_state::AppState,
models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary},
};
pub(super) async fn fetch_agent_owner_pubkeys(
state: &AppState,
events: &[nostr::Event],
) -> std::collections::HashMap<String, String> {
let authors = events
.iter()
.map(|event| event.pubkey.to_hex())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if authors.is_empty() {
return std::collections::HashMap::new();
}
super::query_relay(
state,
&[serde_json::json!({ "kinds": [0], "authors": authors })],
)
.await
.unwrap_or_default()
.into_iter()
.filter_map(|profile| {
crate::nostr_convert::profile_valid_oa_owner_pubkey(&profile)
.map(|owner| (profile.pubkey.to_hex(), owner))
})
.collect()
}
fn tags_to_vec(event: &nostr::Event) -> Vec<Vec<String>> {
event
@@ -68,3 +99,101 @@ pub(super) fn forum_reply_from_event(
reactions: serde_json::Value::Null,
}
}
pub(super) fn link_preview_suppression_targets(
originals: &[nostr::Event],
edits: &[nostr::Event],
owner_pubkeys: &std::collections::HashMap<String, String>,
) -> std::collections::HashSet<String> {
let originals_by_id = originals
.iter()
.map(|event| (event.id.to_hex(), event))
.collect::<std::collections::HashMap<_, _>>();
edits
.iter()
.filter(|event| {
event.kind.as_u16() == 40003
&& event
.tags
.iter()
.any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()])
})
.filter_map(|edit| {
let target_id = edit.tags.iter().find_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("e"))
.then(|| values.get(1).cloned())
.flatten()
})?;
let target = originals_by_id.get(&target_id)?;
let author = target.pubkey.to_hex();
let signer = edit.pubkey.to_hex();
(signer == author || owner_pubkeys.get(&author) == Some(&signer)).then_some(target_id)
})
.collect()
}
pub(super) fn apply_link_preview_suppression(
tags: &mut Vec<Vec<String>>,
event_id: &str,
suppressed: &std::collections::HashSet<String>,
) {
if suppressed.contains(event_id)
&& !tags
.iter()
.any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()])
{
tags.push(vec!["link-preview".to_string(), "none".to_string()]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind};
fn signed_event(keys: &Keys, kind: u16, tags: Vec<Vec<String>>) -> nostr::Event {
let tags = tags
.into_iter()
.map(nostr::Tag::parse)
.collect::<Result<Vec<_>, _>>()
.expect("valid tags");
EventBuilder::new(Kind::Custom(kind), "body")
.tags(tags)
.sign_with_keys(keys)
.expect("event signs")
}
#[test]
fn suppression_targets_accepts_author_and_verified_owner_only() {
let author = Keys::generate();
let owner = Keys::generate();
let attacker = Keys::generate();
let original = signed_event(&author, 9, Vec::new());
let marker = vec!["link-preview".to_string(), "none".to_string()];
let target = vec!["e".to_string(), original.id.to_hex()];
let author_edit = signed_event(&author, 40003, vec![target.clone(), marker.clone()]);
let owner_edit = signed_event(&owner, 40003, vec![target.clone(), marker.clone()]);
let spoofed_edit = signed_event(&attacker, 40003, vec![target, marker]);
let owners = std::collections::HashMap::from([(
author.public_key().to_hex(),
owner.public_key().to_hex(),
)]);
for edit in [&author_edit, &owner_edit] {
assert!(link_preview_suppression_targets(
std::slice::from_ref(&original),
std::slice::from_ref(edit),
&owners,
)
.contains(&original.id.to_hex()));
}
assert!(link_preview_suppression_targets(
std::slice::from_ref(&original),
std::slice::from_ref(&spoofed_edit),
&owners,
)
.is_empty());
}
}
+8 -14
View File
@@ -180,7 +180,6 @@ pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, String> {
}
/// Kind 9002 — update channel name/description/visibility/ttl.
///
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
pub fn build_update_channel(
@@ -396,18 +395,8 @@ pub fn build_forum_comment(
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
}
/// Kind 40003 — edit a message. Carries the full new content AND a fresh
/// imeta tag set; the receiver overlays the imeta tags onto the original
/// event so the rendered message reflects exactly the edited state. NIP-30
/// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s
/// stay resolvable (the send path attaches these too).
///
/// `mentions` carries the pubkeys of mentions that are *newly added* by this
/// edit (the caller diffs the edited body against the original). Only those get
/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix
/// edit that leaves the mention set unchanged emits no `p` tags and never
/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup +
/// lowercase); the receiver overlays these onto the original event's audience.
/// Kind 40003 — edit a message with full content, media, emoji, mentions,
/// and optional monotonic link-preview suppression.
pub fn build_message_edit(
channel_id: Uuid,
target_event_id: EventId,
@@ -415,6 +404,7 @@ pub fn build_message_edit(
media_tags: &[Vec<String>],
custom_emoji_tags: &[Vec<String>],
mentions: &[&str],
suppress_link_previews: bool,
) -> Result<EventBuilder, String> {
check_content(content)?;
let mut tags = vec![
@@ -424,6 +414,9 @@ pub fn build_message_edit(
tags.extend(mention_tags(mentions)?);
imeta_tags(media_tags, &mut tags)?;
emoji_tags(custom_emoji_tags, &mut tags)?;
if suppress_link_previews {
tags.push(tag(vec!["link-preview", "none"])?);
}
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
}
@@ -948,7 +941,8 @@ mod tests {
let target =
EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1")
.unwrap();
let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap();
let builder =
build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap();
let secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000003",
)
@@ -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);