mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
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:
parent
f192cf3b56
commit
0871f36398
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
144
desktop/src-tauri/src/link_preview_tags.rs
Normal file
144
desktop/src-tauri/src/link_preview_tags.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
101
desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
Normal file
101
desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
61
desktop/src/shared/lib/linkPreviewSnapshot.test.mjs
Normal file
61
desktop/src/shared/lib/linkPreviewSnapshot.test.mjs
Normal 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), []);
|
||||
});
|
||||
143
desktop/src/shared/lib/linkPreviewSnapshot.ts
Normal file
143
desktop/src/shared/lib/linkPreviewSnapshot.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user