feat(desktop): send authored link preview snapshots

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-08-03 14:39:17 -07:00
parent f192cf3b56
commit 0871f36398
24 changed files with 734 additions and 50 deletions

View File

@@ -369,6 +369,55 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> {
}
}
/// Validate an authored link-preview media URL/hash pair.
///
/// Empty pairs are allowed. Non-empty media must be an exact, credential-free
/// URL on this tenant's media origin with a content-addressed image path.
pub fn validate_local_image_media_pair(
media_url: &str,
sha256: &str,
media_base_url: &str,
) -> bool {
const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
if media_url.is_empty() && sha256.is_empty() {
return true;
}
if media_url.is_empty()
|| sha256.len() != 64
|| !sha256.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
{
return false;
}
let Ok(parsed) = url::Url::parse(media_url) else {
return false;
};
let Ok(base) = url::Url::parse(media_base_url) else {
return false;
};
if parsed.scheme() != base.scheme()
|| parsed.host_str() != base.host_str()
|| parsed.port_or_known_default() != base.port_or_known_default()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return false;
}
let Some(filename) = parsed.path().strip_prefix("/media/") else {
return false;
};
let Some((path_hash, ext)) = filename.split_once('.') else {
return false;
};
!filename.contains('/')
&& !filename.contains('%')
&& path_hash == sha256
&& IMAGE_EXTS.contains(&ext)
}
/// Validate that a URL references a valid local media blob path.
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
// A safe extension token: 1–8 lowercase alphanumeric chars. Covers media
@@ -423,6 +472,48 @@ mod tests {
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const BASE: &str = "https://relay.example.com/media";
#[test]
fn link_preview_media_pair_requires_exact_local_image_url_and_hash() {
let hash = HASH;
let valid = format!("{BASE}/{hash}.png");
assert!(validate_local_image_media_pair(&valid, hash, BASE));
assert!(!validate_local_image_media_pair(
&format!("https://evil.example/media/{hash}.png"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png?token=leak"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png#fragment"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("https://user@relay.example.com/media/{hash}.png"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.svg"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png/extra"),
hash,
BASE
));
assert!(!validate_local_image_media_pair(
&format!("{BASE}/{hash}.png"),
&"0".repeat(64),
BASE
));
}
#[test]
fn test_local_media_url_relative() {
assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE));

View File

@@ -162,6 +162,52 @@ pub fn reject_with_transport(transport: &'static str, reason: &'static str) {
.increment(1);
}
fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), String> {
const MAX_SNAPSHOTS: usize = 8;
const MAX_TITLE: usize = 300;
const MAX_SITE: usize = 100;
const MAX_DESCRIPTION: usize = 1000;
let mut count = 0;
let mut seen = std::collections::HashSet::new();
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(String::as_str) != Some("link-preview") {
continue;
}
count += 1;
if count > MAX_SNAPSHOTS || parts.len() != 11 || parts[1] != "snapshot" || parts[2] != "1" {
return Err("invalid link-preview snapshot tag".into());
}
let canonical =
url::Url::parse(&parts[3]).map_err(|_| "invalid link-preview canonical URL")?;
if canonical.scheme() != "https"
|| !canonical.username().is_empty()
|| canonical.password().is_some()
|| canonical.fragment().is_some()
|| !seen.insert(parts[3].clone())
|| !event.content.contains(&parts[3])
{
return Err("invalid link-preview canonical URL".into());
}
for (value, max) in [
(&parts[4], MAX_TITLE),
(&parts[5], MAX_SITE),
(&parts[6], MAX_DESCRIPTION),
] {
if value.len() > max || value.chars().any(char::is_control) {
return Err("invalid link-preview snapshot text".into());
}
}
if !super::imeta::validate_local_image_media_pair(&parts[7], &parts[8], media_base_url)
|| !super::imeta::validate_local_image_media_pair(&parts[9], &parts[10], media_base_url)
{
return Err("link-preview media must reference matching local image blobs".into());
}
}
Ok(())
}
/// Successful ingestion result.
pub struct IngestResult {
/// Hex-encoded event ID.
@@ -2596,6 +2642,13 @@ async fn ingest_event_inner(
});
}
let tenant_media_base =
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
if kind_u32 == KIND_STREAM_MESSAGE {
validate_link_preview_tags(&event, &tenant_media_base)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
let imeta_tags: Vec<Vec<String>> = event
.tags
.iter()
@@ -2603,8 +2656,6 @@ async fn ingest_event_inner(
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
if !imeta_tags.is_empty() {
let tenant_media_base =
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage)

View File

@@ -485,6 +485,7 @@ pub async fn send_channel_message(
media_tags: Option<Vec<Vec<String>>>,
emoji_tags: Option<Vec<Vec<String>>>,
mention_tags: Option<Vec<Vec<String>>>,
link_preview_tags: Option<Vec<Vec<String>>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
state: State<'_, AppState>,
@@ -496,6 +497,8 @@ pub async fn send_channel_message(
let media = media_tags.unwrap_or_default();
let emoji = emoji_tags.unwrap_or_default();
let mention_refs_only = mention_tags.unwrap_or_default();
let link_previews = link_preview_tags.unwrap_or_default();
let relay_base = crate::relay::relay_api_base_url_with_override(&state);
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
let mut resolved_root: Option<String> = None;
@@ -540,6 +543,8 @@ pub async fn send_channel_message(
&media,
&emoji,
&mention_refs_only,
&link_previews,
&relay_base,
)?
}
};
@@ -706,6 +711,8 @@ fn build_managed_agent_channel_message(
&[],
&[],
&[],
&[],
&crate::relay::relay_api_base_url(),
client_tags,
)
}

View File

@@ -156,14 +156,34 @@ async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() {
fn boundary_huddle_stt_blocks_ncryptsec() {
let keys = nostr::Keys::generate();
let channel = uuid::Uuid::new_v4();
let builder =
crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap();
let builder = crate::events::build_message(
channel,
NCRYPTSEC,
None,
&[],
&[],
&[],
&[],
&[],
&crate::relay::relay_api_base_url(),
)
.unwrap();
let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err();
assert_guard_error(&err);
// Clean transcripts pass through the same seam.
let builder =
crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap();
let builder = crate::events::build_message(
channel,
"hello huddle",
None,
&[],
&[],
&[],
&[],
&[],
&crate::relay::relay_api_base_url(),
)
.unwrap();
assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok());
}

View File

@@ -8,11 +8,9 @@
//!
//! Each function validates inputs and returns a nostr::EventBuilder.
//! Signing and submission happen in relay::submit_event.
use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST};
use nostr::{EventBuilder, EventId, Kind, Tag};
use uuid::Uuid;
// ── Constants ────────────────────────────────────────────────────────────────
/// Maximum content size — matches buzz-sdk (64 KiB).
@@ -294,6 +292,7 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result<Even
// ── Messages ─────────────────────────────────────────────────────────────────
/// Kind 9 — stream message.
#[allow(clippy::too_many_arguments)]
pub fn build_message(
channel_id: Uuid,
content: &str,
@@ -302,6 +301,8 @@ pub fn build_message(
media_tags: &[Vec<String>],
custom_emoji_tags: &[Vec<String>],
mention_ref_tags: &[Vec<String>],
link_preview_tags: &[Vec<String>],
relay_base: &str,
) -> Result<EventBuilder, String> {
build_message_with_client_tags(
channel_id,
@@ -311,6 +312,8 @@ pub fn build_message(
media_tags,
custom_emoji_tags,
mention_ref_tags,
link_preview_tags,
relay_base,
&[],
)
}
@@ -329,6 +332,8 @@ pub fn build_message_with_client_tags(
media_tags: &[Vec<String>],
custom_emoji_tags: &[Vec<String>],
mention_ref_tags: &[Vec<String>],
link_preview_tags: &[Vec<String>],
relay_base: &str,
client_tags: &[Vec<String>],
) -> Result<EventBuilder, String> {
check_content(content)?;
@@ -340,6 +345,7 @@ pub fn build_message_with_client_tags(
imeta_tags(media_tags, &mut tags)?;
emoji_tags(custom_emoji_tags, &mut tags)?;
mention_reference_tags(mention_ref_tags, &mut tags)?;
crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?;
append_client_tags(client_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
}

View File

@@ -647,14 +647,23 @@ pub(crate) fn spawn_transcription_task(
.clone();
let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect();
let builder =
match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) {
Ok(b) => b,
Err(e) => {
eprintln!("buzz-desktop: STT build_message: {e}");
continue;
}
};
let builder = match events::build_message(
channel_uuid,
&t,
None,
&p_tags,
&[],
&[],
&[],
&[],
&crate::relay::relay_api_base_url(),
) {
Ok(b) => b,
Err(e) => {
eprintln!("buzz-desktop: STT build_message: {e}");
continue;
}
};
// Wait before signing: the relay enforces NIP-98 freshness (±60s)
// and the gate may hold for up to MAX_HINT_SECONDS (300s). Sign
// the kind event and build NIP-98 auth after the wait so both

View File

@@ -12,6 +12,7 @@ mod huddle;
mod identity_storage;
mod initial_window;
mod key_backup;
mod link_preview_tags;
mod linux_media;
mod managed_agents;
mod media_proxy;
@@ -79,7 +80,6 @@ use tauri::{Emitter, Manager, RunEvent, WindowEvent};
use tauri_plugin_window_state::StateFlags;
#[cfg(target_os = "macos")]
use tray_menu::show_main_window;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// mesh-llm's async chains (model download, node start/join) overflow

View File

@@ -0,0 +1,144 @@
use nostr::Tag;
use std::collections::HashSet;
const MAX_SNAPSHOTS: usize = 8;
const MAX_TITLE: usize = 300;
const MAX_SITE: usize = 100;
const MAX_DESCRIPTION: usize = 1000;
fn valid_text(value: &str, max: usize) -> bool {
value.len() <= max && !value.chars().any(char::is_control)
}
fn valid_sha256(value: &str) -> bool {
value.len() == 64 && value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
}
fn valid_media_pair(url: &str, hash: &str, relay_base: &url::Url) -> bool {
if url.is_empty() && hash.is_empty() {
return true;
}
if url.is_empty() || !valid_sha256(hash) {
return false;
}
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
if parsed.origin() != relay_base.origin()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return false;
}
let Some(filename) = parsed.path().strip_prefix("/media/") else {
return false;
};
if filename.contains('/') || filename.contains('%') {
return false;
}
let Some((path_hash, ext)) = filename.split_once('.') else {
return false;
};
path_hash == hash && valid_sha256(path_hash) && matches!(ext, "jpg" | "png" | "gif" | "webp")
}
pub fn append(
preview_tags: &[Vec<String>],
relay_base: &str,
tags: &mut Vec<Tag>,
) -> Result<(), String> {
if preview_tags.len() > MAX_SNAPSHOTS {
return Err(format!(
"too many link preview snapshots (max {MAX_SNAPSHOTS})"
));
}
let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL")?;
let mut seen = HashSet::new();
for preview_tag in preview_tags {
let valid = preview_tag.len() == 11
&& preview_tag[0] == "link-preview"
&& preview_tag[1] == "snapshot"
&& preview_tag[2] == "1"
&& url::Url::parse(&preview_tag[3]).is_ok_and(|url| {
url.scheme() == "https"
&& url.username().is_empty()
&& url.password().is_none()
&& url.fragment().is_none()
})
&& seen.insert(preview_tag[3].clone())
&& valid_text(&preview_tag[4], MAX_TITLE)
&& valid_text(&preview_tag[5], MAX_SITE)
&& valid_text(&preview_tag[6], MAX_DESCRIPTION)
&& valid_media_pair(&preview_tag[7], &preview_tag[8], &base)
&& valid_media_pair(&preview_tag[9], &preview_tag[10], &base);
if !valid {
return Err("invalid link-preview snapshot tag".into());
}
let parts: Vec<&str> = preview_tag.iter().map(String::as_str).collect();
tags.push(Tag::parse(parts).map_err(|e| format!("invalid link-preview tag: {e}"))?);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const BASE: &str = "https://relay.example";
fn tag(image_url: &str, image_hash: &str) -> Vec<String> {
[
"link-preview",
"snapshot",
"1",
"https://linear.app/acme/issue/ABC-123/example",
"Example",
"Linear",
"Description",
image_url,
image_hash,
"",
"",
]
.map(str::to_string)
.to_vec()
}
#[test]
fn append_accepts_complete_local_snapshot() {
let mut tags = Vec::new();
append(
&[tag(&format!("{BASE}/media/{HASH}.png"), HASH)],
BASE,
&mut tags,
)
.unwrap();
assert_eq!(tags.len(), 1);
}
#[test]
fn append_rejects_untrusted_or_malformed_snapshot_media() {
for url in [
format!("https://evil.example/media/{HASH}.png"),
format!("{BASE}/media/{HASH}.png?token=leak"),
format!("{BASE}/media/{HASH}.png#fragment"),
format!("https://user@relay.example/media/{HASH}.png"),
format!("{BASE}/media/{HASH}.svg"),
format!("{BASE}/media/{HASH}.png/extra"),
] {
assert!(
append(&[tag(&url, HASH)], BASE, &mut Vec::new()).is_err(),
"{url}"
);
}
assert!(append(
&[tag(&format!("{BASE}/media/{HASH}.png"), &"b".repeat(64))],
BASE,
&mut Vec::new(),
)
.is_err());
}
}

View File

@@ -124,6 +124,7 @@ export function ForumPostCard({
content={previewContent}
messageId={post.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
linkPreviewTags={post.tags}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}

View File

@@ -118,6 +118,7 @@ function ReplyRow({
content={reply.content}
messageId={reply.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(reply.tags)}
linkPreviewTags={reply.tags}
imetaByUrl={parseImetaTags(reply.tags)}
mentionNames={replyMentionNames}
mentionPubkeysByName={replyMentionPubkeysByName}
@@ -263,6 +264,7 @@ export function ForumThreadPanel({
content={post.content}
messageId={post.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
linkPreviewTags={post.tags}
imetaByUrl={parseImetaTags(post.tags)}
mentionNames={postMentionNames}
mentionPubkeysByName={postMentionPubkeysByName}

View File

@@ -458,6 +458,7 @@ export function useSendMessageMutation(
mediaTags: imetaTags,
emojiTags,
mentionTags,
linkPreviewTags,
} = splitOutgoingTags(mediaTags);
const recipientPubkeys = messageMentionPubkeys(
effectiveChannel,
@@ -468,7 +469,12 @@ export function useSendMessageMutation(
// Messages carrying media OR custom-emoji tags MUST go through REST so
// the relay's tag validation runs. The WebSocket path emits no extra
// tags, so emoji-only messages would otherwise lose their emoji tag.
if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) {
if (
parentEventId ||
imetaTags.length > 0 ||
emojiTags.length > 0 ||
linkPreviewTags.length > 0
) {
const cachedMessages =
queryClient.getQueryData<RelayEvent[]>(
channelMessagesKey(effectiveChannel.id),
@@ -482,6 +488,7 @@ export function useSendMessageMutation(
undefined,
emojiTags,
mentionTags,
linkPreviewTags,
);
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji.
@@ -519,6 +526,7 @@ export function useSendMessageMutation(
...imetaTags,
...emojiTags,
...mentionTags,
...linkPreviewTags,
],
content: content.trim(),
sig: "",

View File

@@ -662,6 +662,7 @@ test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size",
const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"];
const EMOJI_A = ["emoji", "shipit", "https://relay/s.png"];
const EMOJI_B = ["emoji", "party", "https://relay/p.gif"];
const LINK_PREVIEW = ["link-preview", "snapshot", "1", "https://example.com/"];
const MENTION_REF = [
"mention",
"1111111111111111111111111111111111111111111111111111111111111111",
@@ -672,55 +673,64 @@ test("splitOutgoingTags: undefined input yields three empty arrays", () => {
mediaTags: [],
emojiTags: [],
mentionTags: [],
linkPreviewTags: [],
});
});
test("splitOutgoingTags: separates emoji tags from imeta tags", () => {
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
IMETA,
EMOJI_A,
EMOJI_B,
]);
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
splitOutgoingTags([IMETA, EMOJI_A, EMOJI_B]);
assert.deepEqual(mediaTags, [IMETA]);
assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]);
assert.deepEqual(mentionTags, []);
assert.deepEqual(linkPreviewTags, []);
});
test("splitOutgoingTags: emoji-only set leaves mediaTags empty", () => {
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([EMOJI_A]);
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
splitOutgoingTags([EMOJI_A]);
assert.deepEqual(mediaTags, []);
assert.deepEqual(emojiTags, [EMOJI_A]);
assert.deepEqual(mentionTags, []);
assert.deepEqual(linkPreviewTags, []);
});
test("splitOutgoingTags: separates reference-only mention tags", () => {
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
IMETA,
MENTION_REF,
EMOJI_A,
]);
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
splitOutgoingTags([IMETA, MENTION_REF, EMOJI_A]);
assert.deepEqual(mediaTags, [IMETA]);
assert.deepEqual(emojiTags, [EMOJI_A]);
assert.deepEqual(mentionTags, [MENTION_REF]);
assert.deepEqual(linkPreviewTags, []);
});
test("splitOutgoingTags: separates authored link-preview snapshots", () => {
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
splitOutgoingTags([IMETA, LINK_PREVIEW]);
assert.deepEqual(mediaTags, [IMETA]);
assert.deepEqual(emojiTags, []);
assert.deepEqual(mentionTags, []);
assert.deepEqual(linkPreviewTags, [LINK_PREVIEW]);
});
test("splitOutgoingTags: unknown prefixes stay with mediaTags (injection defense)", () => {
// A forged ["p", ...] must NOT be misrouted to the emoji channel; it stays on
// mediaTags where the server-side imeta guard rejects it.
const forged = ["p", "deadbeef"];
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
forged,
EMOJI_A,
]);
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
splitOutgoingTags([forged, EMOJI_A]);
assert.deepEqual(mediaTags, [forged]);
assert.deepEqual(emojiTags, [EMOJI_A]);
assert.deepEqual(mentionTags, []);
assert.deepEqual(linkPreviewTags, []);
});
test("splitOutgoingTags is the inverse of mergeOutgoingTags", () => {
const merged = mergeOutgoingTags([IMETA], [EMOJI_A, EMOJI_B]);
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags(merged);
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
splitOutgoingTags(merged);
assert.deepEqual(mediaTags, [IMETA]);
assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]);
assert.deepEqual(mentionTags, []);
assert.deepEqual(linkPreviewTags, []);
});

View File

@@ -359,18 +359,22 @@ export function splitOutgoingTags(tags: string[][] | undefined): {
mediaTags: string[][];
emojiTags: string[][];
mentionTags: string[][];
linkPreviewTags: string[][];
} {
const mediaTags: string[][] = [];
const emojiTags: string[][] = [];
const mentionTags: string[][] = [];
const linkPreviewTags: string[][] = [];
for (const tag of tags ?? []) {
if (tag[0] === "emoji") {
emojiTags.push(tag);
} else if (tag[0] === "mention") {
mentionTags.push(tag);
} else if (tag[0] === "link-preview") {
linkPreviewTags.push(tag);
} else {
mediaTags.push(tag);
}
}
return { mediaTags, emojiTags, mentionTags };
return { mediaTags, emojiTags, mentionTags, linkPreviewTags };
}

View File

@@ -57,6 +57,7 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH
import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
import { submitMessageEdit } from "./submitMessageEdit";
import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
import type { MessageComposerProps } from "./MessageComposer.types";
function MessageComposerImpl({
audienceContext = null,
@@ -98,6 +99,11 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
syncContentRefFromEditorRef,
} = useComposerContentState();
const [previewContent, setPreviewContent] = React.useState("");
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
} = useComposerLinkPreviews(previewContent);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -257,6 +263,7 @@ function MessageComposerImpl({
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, text }) => {
setComposerContentFromText(text);
setPreviewContent(text);
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -574,6 +581,7 @@ function MessageComposerImpl({
capturedThreadContext,
pendingImeta: currentPendingImeta,
queuedAttachments: currentQueuedAttachments,
linkPreviewTags: getReadyLinkPreviewTags(),
sentDraftKey: resolveSentDraftKey(
effectiveDraftKeyRef.current,
drafts.loadDraft,
@@ -594,6 +602,7 @@ function MessageComposerImpl({
customEmoji,
drafts.loadDraft,
emojiAutocomplete.clearEmojis,
getReadyLinkPreviewTags,
media.clearQueuedAttachments,
media.pendingImetaRef,
media.queuedAttachmentsRef,
@@ -951,6 +960,7 @@ function MessageComposerImpl({
</div>
) : null}
{composerLinkPreviews}
{(media.pendingImeta.length > 0 ||
media.queuedAttachments.length > 0 ||
media.isUploading) && (

View File

@@ -400,6 +400,7 @@ export const MessageRow = React.memo(
content={message.body}
messageId={message.id}
linkPreviewsSuppressed={linkPreviewsSuppressed}
linkPreviewTags={message.tags}
onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}

View File

@@ -0,0 +1,101 @@
import * as React from "react";
import { uploadMediaBytes } from "@/shared/api/tauri";
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
import { buildLinkPreviewSnapshotTag } from "@/shared/lib/linkPreviewSnapshot";
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
import { LinkPreviewImageLightbox } from "@/shared/ui/markdown";
function dataUrlBytes(dataUrl: string): Uint8Array | null {
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
if (!match) return null;
try {
return Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0));
} catch {
return null;
}
}
async function uploadDataUrl(
dataUrl: string | null | undefined,
filename: string,
) {
if (!dataUrl) return { url: "", sha256: "" };
const bytes = dataUrlBytes(dataUrl);
if (!bytes) throw new Error("invalid preview media data");
const uploaded = await uploadMediaBytes([...bytes], filename);
return { url: uploaded.url, sha256: uploaded.sha256 };
}
export function useComposerLinkPreviews(content: string) {
const candidates = React.useMemo(
() => extractSupportedLinkPreviews(content),
[content],
);
const previews = useResolvedLinkPreviews(candidates);
const [readyTags, setReadyTags] = React.useState<Record<string, string[]>>(
{},
);
const readyTagsRef = React.useRef<string[][]>([]);
const uploadsRef = React.useRef(new Set<string>());
React.useEffect(() => {
const active = new Set(candidates.map((preview) => preview.href));
setReadyTags((current) =>
Object.fromEntries(
Object.entries(current).filter(([href]) => active.has(href)),
),
);
}, [candidates]);
React.useEffect(() => {
let cancelled = false;
for (const preview of previews) {
if (
!preview.snapshotReady ||
readyTags[preview.href] ||
uploadsRef.current.has(preview.href)
)
continue;
uploadsRef.current.add(preview.href);
void Promise.all([
uploadDataUrl(preview.imageDataUrl, "link-preview-image.png"),
uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png"),
])
.then(([image, favicon]) => {
if (cancelled) return;
const tag = buildLinkPreviewSnapshotTag({
canonicalUrl: preview.href,
title: preview.title,
siteName: preview.provider,
description: preview.description ?? "",
imageUrl: image.url,
imageSha256: image.sha256,
faviconUrl: favicon.url,
faviconSha256: favicon.sha256,
});
setReadyTags((current) => ({ ...current, [preview.href]: tag }));
})
.catch(() => {})
.finally(() => uploadsRef.current.delete(preview.href));
}
return () => {
cancelled = true;
};
}, [previews, readyTags]);
readyTagsRef.current = candidates.flatMap((candidate) =>
readyTags[candidate.href] ? [readyTags[candidate.href]] : [],
);
const previewList = previews.length ? (
<div className="message-markdown mb-2" data-composer-link-previews="">
<LinkPreviewList
ImageLightbox={LinkPreviewImageLightbox}
previews={previews}
/>
</div>
) : null;
const getReadyTags = React.useCallback(() => readyTagsRef.current, []);
return { previewList, getReadyTags };
}

View File

@@ -36,6 +36,7 @@ export type SendMessageWithMentionFlowInput = {
capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"];
pendingImeta: ImetaMedia[];
queuedAttachments?: QueuedMediaAttachment[];
linkPreviewTags?: string[][];
sentDraftKey: string | null | undefined;
recoveryDraftKey: string | null | undefined;
spoileredAttachmentUrls?: ReadonlySet<string>;

View File

@@ -726,6 +726,7 @@ export function useMentionSendFlow({
capturedThreadContext = null,
pendingImeta,
queuedAttachments = [],
linkPreviewTags = [],
sentDraftKey,
recoveryDraftKey,
spoileredAttachmentUrls = new Set(),
@@ -788,7 +789,10 @@ export function useMentionSendFlow({
createdPersonaAgentPubkeySet.has(pubkey),
);
const pubkeys = explicitMentionPubkeys;
const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji);
const outgoingTags = [
...buildCustomEmojiTags(trimmed, customEmoji),
...linkPreviewTags,
];
const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys);
let promptNonMemberPubkeys = nonMemberPubkeys.filter(
(pubkey) =>

View File

@@ -550,6 +550,7 @@ export async function sendChannelMessage(
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
linkPreviewTags?: string[][],
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -560,6 +561,7 @@ export async function sendChannelMessage(
mediaTags: mediaTags ?? null,
emojiTags: emojiTags ?? null,
mentionTags: mentionTags ?? null,
linkPreviewTags,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
},

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseLinkPreviewSnapshots } from "./linkPreviewSnapshot.ts";
const HASH = "a".repeat(64);
const ORIGIN = "https://relay.example";
const CONTENT = "Read https://linear.app/acme/issue/ABC-123/example";
const URL = "https://linear.app/acme/issue/ABC-123/example";
const valid = [
"link-preview",
"snapshot",
"1",
URL,
"Example",
"Linear",
"Description",
`${ORIGIN}/media/${HASH}.png`,
HASH,
"",
"",
];
test("authored snapshots render only exact local media tied to message content", () => {
assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, ORIGIN).length, 1);
assert.equal(
parseLinkPreviewSnapshots([valid], "no matching link", ORIGIN).length,
0,
);
assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, null).length, 0);
});
test("authored snapshots reject remote, malformed, credentialed, and hash-mismatched media", () => {
for (const image of [
`https://evil.example/media/${HASH}.png`,
`${ORIGIN}/media/${HASH}.png?token=leak`,
`${ORIGIN}/media/${HASH}.png#fragment`,
`https://user@relay.example/media/${HASH}.png`,
`${ORIGIN}/media/${HASH}.svg`,
`${ORIGIN}/media/${HASH}.png/extra`,
]) {
const tag = [...valid];
tag[7] = image;
assert.equal(
parseLinkPreviewSnapshots([tag], CONTENT, ORIGIN).length,
0,
image,
);
}
const mismatch = [...valid];
mismatch[8] = "b".repeat(64);
assert.equal(
parseLinkPreviewSnapshots([mismatch], CONTENT, ORIGIN).length,
0,
);
});
test("messages without authored snapshots never create recipient previews", () => {
assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []);
assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []);
});

View File

@@ -0,0 +1,143 @@
import {
extractSupportedLinkPreviews,
parseSupportedLinkPreview,
} from "./linkPreview";
import type { ResolvedLinkPreview } from "./useResolvedLinkPreviews";
export const LINK_PREVIEW_SNAPSHOT_VERSION = "1";
const MAX_SNAPSHOTS = 8;
const SHA256_RE = /^[0-9a-f]{64}$/;
const IMAGE_EXT_RE = /^(?:jpg|png|gif|webp)$/;
export type LinkPreviewSnapshot = {
canonicalUrl: string;
title: string;
siteName: string;
description: string;
imageUrl: string;
imageSha256: string;
faviconUrl: string;
faviconSha256: string;
};
function validText(value: string, max: number): boolean {
return (
value.length <= max &&
!Array.from(value).some((char) => {
const code = char.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
})
);
}
function isRelayMediaPair(
url: string,
sha256: string,
relayOrigin: string,
): boolean {
if (!url && !sha256) return true;
if (!url || !SHA256_RE.test(sha256)) return false;
try {
const parsed = new URL(url);
if (
parsed.origin !== relayOrigin ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
)
return false;
const match = /^\/media\/([0-9a-f]{64})\.([a-z0-9]{1,8})$/.exec(
parsed.pathname,
);
return Boolean(
match && match[1] === sha256 && IMAGE_EXT_RE.test(match[2] ?? ""),
);
} catch {
return false;
}
}
export function parseLinkPreviewSnapshots(
tags: readonly (readonly string[])[] | undefined,
content: string,
relayOrigin: string | null,
): ResolvedLinkPreview[] {
if (!relayOrigin || !tags) return [];
const contentUrls = new Set(
extractSupportedLinkPreviews(content).map((preview) => preview.href),
);
const seen = new Set<string>();
const snapshots: ResolvedLinkPreview[] = [];
for (const tag of tags) {
if (tag[0] !== "link-preview" || tag[1] !== "snapshot") continue;
if (
snapshots.length >= MAX_SNAPSHOTS ||
tag.length !== 11 ||
tag[2] !== LINK_PREVIEW_SNAPSHOT_VERSION
)
continue;
const [
,
,
,
canonicalUrl,
title,
siteName,
description,
imageUrl,
imageSha256,
faviconUrl,
faviconSha256,
] = tag;
const parsed = parseSupportedLinkPreview(canonicalUrl);
if (
!parsed ||
parsed.href !== canonicalUrl ||
!contentUrls.has(canonicalUrl) ||
seen.has(canonicalUrl)
)
continue;
if (
!validText(title, 300) ||
!validText(siteName, 100) ||
!validText(description, 1000)
)
continue;
if (
!isRelayMediaPair(imageUrl, imageSha256, relayOrigin) ||
!isRelayMediaPair(faviconUrl, faviconSha256, relayOrigin)
)
continue;
seen.add(canonicalUrl);
snapshots.push({
...parsed,
title: title || parsed.title,
provider: siteName || parsed.provider,
description: description || null,
faviconDataUrl: faviconUrl || null,
imageDataUrl: imageUrl || null,
imageDomain: imageUrl ? new URL(imageUrl).hostname : null,
imageState: imageUrl ? "image" : "none",
});
}
return snapshots;
}
export function buildLinkPreviewSnapshotTag(
snapshot: LinkPreviewSnapshot,
): string[] {
return [
"link-preview",
"snapshot",
LINK_PREVIEW_SNAPSHOT_VERSION,
snapshot.canonicalUrl,
snapshot.title,
snapshot.siteName,
snapshot.description,
snapshot.imageUrl,
snapshot.imageSha256,
snapshot.faviconUrl,
snapshot.faviconSha256,
];
}

View File

@@ -10,7 +10,7 @@ type LinkPreviewImageFetchState =
| "transient_failure"
| "rejected";
type LinkPreviewMetadata = {
export type LinkPreviewMetadata = {
title: string;
siteName: string | null;
description: string | null;
@@ -179,6 +179,8 @@ export type ResolvedLinkPreview = SupportedLinkPreview & {
description?: string | null;
faviconDataUrl?: string | null;
imageState: LinkPreviewImageState;
/** Metadata extraction completed successfully; safe to snapshot after media uploads. */
snapshotReady?: boolean;
};
type ResolvedMetadataByHref = Record<
@@ -207,6 +209,7 @@ export function resolveLinkPreview(
: "none";
return {
...preview,
snapshotReady: true,
title: metadata.title,
description: metadata.description,
faviconDataUrl: metadata.faviconDataUrl,

View File

@@ -24,11 +24,9 @@ import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
extractSupportedLinkPreviews,
parseSupportedLinkPreview,
} from "@/shared/lib/linkPreview";
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
@@ -975,7 +973,7 @@ function ImageZoomOverlay({
);
}
const LinkPreviewImageLightbox =
export const LinkPreviewImageLightbox =
createLinkPreviewImageLightbox(ImageZoomOverlay);
/**
@@ -1822,6 +1820,7 @@ function MarkdownInner({
mediaInset = false,
messageId,
linkPreviewsSuppressed = false,
linkPreviewTags,
onRemoveLinkPreviewsForEveryone,
mentionNames,
mentionPubkeysByName,
@@ -1854,13 +1853,19 @@ function MarkdownInner({
},
[goChannel],
);
const previewsGloballySuppressed = linkPreviewsSuppressed;
const linkPreviews = React.useMemo(
const relayOrigin = useRelayOrigin();
const resolvedLinkPreviews = React.useMemo(
() =>
interactive && !previewsGloballySuppressed
? extractSupportedLinkPreviews(content)
interactive && !linkPreviewsSuppressed
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
: [],
[content, interactive, previewsGloballySuppressed],
[
content,
interactive,
linkPreviewTags,
linkPreviewsSuppressed,
relayOrigin,
],
);
const configNudge = React.useMemo(
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
@@ -1905,8 +1910,6 @@ function MarkdownInner({
processedContent = `${processedContent}\u200B`;
}
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
// When a config-nudge suppresses the prose (selectProseOrNudge returns
// null), skip the parse entirely — it would be thrown away unrendered.
const componentSet = getMarkdownComponents(interactive, mediaInset);
@@ -1977,6 +1980,7 @@ export const Markdown = React.memo(
prev.mediaInset === next.mediaInset &&
prev.messageId === next.messageId &&
prev.linkPreviewsSuppressed === next.linkPreviewsSuppressed &&
prev.linkPreviewTags === next.linkPreviewTags &&
prev.onRemoveLinkPreviewsForEveryone ===
next.onRemoveLinkPreviewsForEveryone &&
shallowRecordEqual(

View File

@@ -62,6 +62,7 @@ export type MarkdownProps = {
/** Event/message identity used only for local preview-image visibility. */
messageId?: string;
linkPreviewsSuppressed?: boolean;
linkPreviewTags?: readonly (readonly string[])[];
onRemoveLinkPreviewsForEveryone?: () => Promise<void>;
searchQuery?: string;
/** Display name shown in shared-agent card metadata. */