mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
15b2b8dc0e
commit
c4aaba97ce
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user