mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): adding rich link previews to messages (#3818)
## Overview **Category:** improvement **User impact:** Link previews appear in the composer and travel as privacy-safe sender-authored snapshots, so recipients never contact the linked site merely by opening a conversation. **Problem:** Cold-cache link paste could freeze the composer before the URL painted; recipient-side unfurling leaked visits; invalid or unresolved preview work could interfere with sending or leave dead cards behind. **Solution:** Paint pasted links before starting cold resolver work, resolve only in the sender's composer, attach only complete validated snapshots at Send, and render authored snapshots without recipient fallback fetching. ## Behavior - **Cold paste stays responsive:** bare and angle-bracket URL paste paths commit the visible link before resolver work begins. - **Sender-only fetching:** metadata is resolved while composing; recipients render only the sender-authored snapshot. - **Send never waits:** pending, failed, invalid, and unsendable previews are omitted. They do not block or cancel the message. - **Terminal misses disappear:** failed, timed-out, or 404 resolver results remove the composer card while preserving visible link text. - **Display-text links work:** Markdown links such as `[review the pull request](…)` produce and send the same snapshots as bare URLs. - **Compact and Rich presentation:** Compact remains the default; Rich preserves source description line breaks and paragraphs. - **Immediate draft-wide dismissal:** clicking × immediately hides all previews for the draft, suppresses links pasted later, and emits only `["link-preview", "none"]`. No confirmation detour. Suppression resets after send or clearing the draft. - **Zero recipient fallback:** missing, stale, malformed, off-relay, unsupported, or suppressed snapshots remain ordinary visible links; recipients never regenerate them. ## Implementation - Resolve previews from deferred composer URL state so paste can paint first. - Upload finished preview media to the active community relay and snapshot only valid, sendable media references. - Atomically capture ready snapshots at submit time; never append a late preview after send. - Validate snapshot and suppression tags in desktop/native and relay ingestion, rejecting duplicate or mixed forms. - Render composer previews as stable 55px attachment cards at desktop and narrow widths. - Add deterministic E2E coverage for cold paste, ready/pending/failed/invalid previews, display-text links, multiline Rich descriptions, immediate dismissal, later-pasted links, and suppression reset. ## Validation Validated head: `9807ba8952f190e76153834abf8ab61dd40be5e2` - Push hooks passed: `check-push-org`, branch skew, desktop check, mobile tests, desktop tests, Rust tests, and desktop Tauri checks. - Focused screenshot E2E at the validated head: 5/5 passed across Compact/Rich composer and recipient states, 800px/420px geometry, display-text links, multiline descriptions, and immediate dismissal. - PR CI was triggered for this exact head and is currently running; completed checks are green at the time of this update. - Worktree is clean and both PR head and validated branch resolve to `9807ba895…`. ## Screenshots ### Compact composer | Loading | Ready | |---|---| |  |  | ### Rich composer | Loading | Ready | |---|---| |  |  | ### Responsive composer | 800px loading | 800px ready | |---|---| |  |  | | 420px loading | 420px ready | |---|---| |  |  | ### Recipient presentation | Compact | Rich | |---|---| |  |  | ### Display-text Markdown link | Composer | Recipient | |---|---| |  |  | ### Rich multiline description  ### Immediate dismissal | Before × | Immediately after × | |---|---| |  |  | --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -212,6 +212,72 @@ pub fn reject_with_transport(transport: &'static str, reason: &'static str) {
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn valid_link_preview_text(value: &str, max: usize, allow_newlines: bool) -> bool {
|
||||
value.len() <= max
|
||||
&& !value
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !(allow_newlines && character == '\n'))
|
||||
}
|
||||
|
||||
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 suppressed = false;
|
||||
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 parts == ["link-preview", "none"] {
|
||||
if count > 1 {
|
||||
return Err("link-preview suppression cannot include snapshots".into());
|
||||
}
|
||||
suppressed = true;
|
||||
continue;
|
||||
}
|
||||
if suppressed
|
||||
|| 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, allow_newlines) in [
|
||||
(&parts[4], MAX_TITLE, false),
|
||||
(&parts[5], MAX_SITE, false),
|
||||
(&parts[6], MAX_DESCRIPTION, true),
|
||||
] {
|
||||
if !valid_link_preview_text(value, max, allow_newlines) {
|
||||
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.
|
||||
@@ -2647,6 +2713,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()
|
||||
@@ -2654,8 +2727,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)
|
||||
@@ -3632,6 +3703,109 @@ mod tests {
|
||||
assert!(validate_diff_event(&event).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_accepts_blanket_marker() {
|
||||
let event = make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&["link-preview", "none"]],
|
||||
);
|
||||
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_rejects_duplicate_marker() {
|
||||
let event = make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&["link-preview", "none"], &["link-preview", "none"]],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_link_preview_tags(&event, "https://media.example.com"),
|
||||
Err("link-preview suppression cannot include snapshots".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_rejects_mixed_snapshot_tags_in_either_order() {
|
||||
let snapshot = [
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
"https://example.com",
|
||||
"Example",
|
||||
"Example",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
for tags in [
|
||||
vec![&["link-preview", "none"][..], &snapshot[..]],
|
||||
vec![&snapshot[..], &["link-preview", "none"][..]],
|
||||
] {
|
||||
let event = make_event_with_tags(KIND_STREAM_MESSAGE, "https://example.com", &tags);
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn make_link_preview_event(title: &str, site: &str, description: &str) -> Event {
|
||||
make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&[
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
"https://example.com",
|
||||
title,
|
||||
site,
|
||||
description,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_snapshot_accepts_description_newlines() {
|
||||
let event = make_link_preview_event(
|
||||
"Example title",
|
||||
"Example site",
|
||||
"First paragraph\n\nSecond paragraph",
|
||||
);
|
||||
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_snapshot_rejects_title_and_site_newlines() {
|
||||
for (title, site) in [
|
||||
("Example\ntitle", "Example site"),
|
||||
("Example title", "Example\nsite"),
|
||||
] {
|
||||
let event = make_link_preview_event(title, site, "Description");
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_snapshot_rejects_non_newline_controls_in_all_text_fields() {
|
||||
for (title, site, description) in [
|
||||
("Example\ttitle", "Example site", "Description"),
|
||||
("Example title", "Example\rsite", "Description"),
|
||||
("Example title", "Example site", "Unsafe\tdescription"),
|
||||
] {
|
||||
let event = make_link_preview_event(title, site, description);
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dummy_event() -> Event {
|
||||
let keys = nostr::Keys::generate();
|
||||
nostr::EventBuilder::new(nostr::Kind::Custom(9), "")
|
||||
|
||||
Reference in New Issue
Block a user