diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3..723331a9d 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -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::>(); + 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 = 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 = 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::>(); + 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 = 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::>(); + 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 = None; let mut replies: Vec = 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>, + suppress_link_previews: Option, 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(()) diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index ffcf3a62e..67ec7c4c6 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -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 { + let authors = events + .iter() + .map(|event| event.pubkey.to_hex()) + .collect::>() + .into_iter() + .collect::>(); + 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> { 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, +) -> std::collections::HashSet { + let originals_by_id = originals + .iter() + .map(|event| (event.id.to_hex(), event)) + .collect::>(); + + 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>, + event_id: &str, + suppressed: &std::collections::HashSet, +) { + 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>) -> nostr::Event { + let tags = tags + .into_iter() + .map(nostr::Tag::parse) + .collect::, _>>() + .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()); + } +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02..29df7b563 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -180,7 +180,6 @@ pub fn build_leave(channel_id: Uuid) -> Result { } /// 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], custom_emoji_tags: &[Vec], mentions: &[&str], + suppress_link_previews: bool, ) -> Result { 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", ) diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 0b18767df..ea8dce2af 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -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} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 5acf31b53..a62307b55 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -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} diff --git a/desktop/src/features/home/ui/FeedSection.tsx b/desktop/src/features/home/ui/FeedSection.tsx index ba3b1b72b..7e090b98a 100644 --- a/desktop/src/features/home/ui/FeedSection.tsx +++ b/desktop/src/features/home/ui/FeedSection.tsx @@ -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} /> diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 35aaeed8d..8ad4692d1 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -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} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60..ee4cc628f 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb7..ab35ecfcc 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -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(); + + // 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; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 3a23c3217..ffe47a088 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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( 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( 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} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455e..d9b9d597a 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -621,6 +621,7 @@ export async function editMessage( mediaTags?: string[][], emojiTags?: string[][], mentionPubkeys?: string[], + suppressLinkPreviews?: boolean, ): Promise { await invokeTauri("edit_message", { channelId, @@ -629,6 +630,7 @@ export async function editMessage( mediaTags: mediaTags ?? [], emojiTags: emojiTags ?? [], mentionPubkeys: mentionPubkeys ?? null, + suppressLinkPreviews: suppressLinkPreviews ?? false, }); } diff --git a/desktop/src/shared/lib/linkPreviewImageVisibility.test.mjs b/desktop/src/shared/lib/linkPreviewImageVisibility.test.mjs deleted file mode 100644 index 41a7d88c1..000000000 --- a/desktop/src/shared/lib/linkPreviewImageVisibility.test.mjs +++ /dev/null @@ -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 }, - ]); -}); diff --git a/desktop/src/shared/lib/linkPreviewImageVisibility.ts b/desktop/src/shared/lib/linkPreviewImageVisibility.ts deleted file mode 100644 index 08caf95ea..000000000 --- a/desktop/src/shared/lib/linkPreviewImageVisibility.ts +++ /dev/null @@ -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 = 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)), - ); -} diff --git a/desktop/src/shared/lib/linkPreviewVisibility.test.mjs b/desktop/src/shared/lib/linkPreviewVisibility.test.mjs new file mode 100644 index 000000000..836425d5c --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewVisibility.test.mjs @@ -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 }, + ]); +}); diff --git a/desktop/src/shared/lib/linkPreviewVisibility.ts b/desktop/src/shared/lib/linkPreviewVisibility.ts new file mode 100644 index 000000000..e4bde28a0 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewVisibility.ts @@ -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 = 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)), + ); +} diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 7e6e8cee5..cc01eee95 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -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 ( -
Automatic preview from {preview.imageDomain}
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b6029e96d..881d95f72 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -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({ ) : null} {resolvedLinkPreviews.length > 0 ? ( - + + {resolvedLinkPreviews.map((preview) => ( + + ))} + + {messageId ? ( +
+ + {onRemoveLinkPreviewsForEveryone ? ( + + ) : null} +
+ ) : null} + + ) : previewsDismissed && messageId && !previewsGloballySuppressed ? ( + + ) : null} + {onRemoveLinkPreviewsForEveryone ? ( + + + + + Remove previews for everyone? + + + This permanently removes all generated link previews from + this message for supporting clients. The links stay in the + message. This can't be undone. + + + + + + + + + + + + ) : null} @@ -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, diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index a4587b731..cb31ad5b8 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -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; searchQuery?: string; /** Display name shown in shared-agent card metadata. */ snapshotSharedBy?: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7566c8237..f31e94946 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8545,6 +8545,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + suppressLinkPreviews?: boolean; }, config: E2eConfig | undefined, ): Promise { @@ -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);