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 |
|---|---|
| ![Compact composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/compact-composer-loading.png)
| ![Compact composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/compact-composer-ready.png)
|

### Rich composer

| Loading | Ready |
|---|---|
| ![Rich composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-composer-loading.png)
| ![Rich composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-composer-ready.png)
|

### Responsive composer

| 800px loading | 800px ready |
|---|---|
| ![800px composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-800-loading.png)
| ![800px composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-800-ready.png)
|

| 420px loading | 420px ready |
|---|---|
| ![420px composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-420-loading.png)
| ![420px composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-420-ready.png)
|

### Recipient presentation

| Compact | Rich |
|---|---|
| ![Recipient
compact](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/recipient-compact.png)
| ![Recipient
rich](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/recipient-rich.png)
|

### Display-text Markdown link

| Composer | Recipient |
|---|---|
| ![Display-text link in
composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/display-text-composer.png)
| ![Display-text link with recipient
preview](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/display-text-recipient.png)
|

### Rich multiline description

![Rich preview preserving description
paragraphs](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-multiline-recipient.png)

### Immediate dismissal

| Before × | Immediately after × |
|---|---|
| ![Preview before immediate
dismissal](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/dismissal-before.png)
| ![Composer immediately after preview
dismissal](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/dismissal-after.png)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-07 10:56:08 -07:00
committed by GitHub
parent 742e8d1197
commit 1922d49cb2
55 changed files with 5148 additions and 865 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

@@ -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), "")