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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
use reqwest::header::RETRY_AFTER;
use url::Url;
pub(super) const MAX_IMAGE_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
const MAX_IMAGE_HOST_COOLDOWNS: usize = 128;
static IMAGE_HOST_COOLDOWNS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
pub(super) fn retry_after_duration(response: &reqwest::Response) -> Option<Duration> {
response
.headers()
.get(RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.map(|duration| duration.min(MAX_IMAGE_RETRY_AFTER))
}
pub(super) fn image_host_cooldown_remaining(url: &Url) -> Option<Duration> {
let host = url.host_str()?;
let cooldowns = IMAGE_HOST_COOLDOWNS.get_or_init(|| Mutex::new(HashMap::new()));
let Ok(mut cooldowns) = cooldowns.lock() else {
return None;
};
let expires_at = cooldowns.get(host).copied()?;
let now = Instant::now();
if expires_at <= now {
cooldowns.remove(host);
return None;
}
Some(expires_at.duration_since(now))
}
pub(super) fn set_image_host_cooldown(url: &Url, retry_after: Duration) {
let Some(host) = url.host_str() else {
return;
};
let now = Instant::now();
let Some(expires_at) = now.checked_add(retry_after) else {
return;
};
let cooldowns = IMAGE_HOST_COOLDOWNS.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(mut cooldowns) = cooldowns.lock() {
cooldowns.retain(|_, current_expiry| *current_expiry > now);
if cooldowns.len() >= MAX_IMAGE_HOST_COOLDOWNS && !cooldowns.contains_key(host) {
let oldest_host = cooldowns
.iter()
.min_by_key(|(_, current_expiry)| *current_expiry)
.map(|(current_host, _)| current_host.clone());
if let Some(oldest_host) = oldest_host {
cooldowns.remove(&oldest_host);
}
}
cooldowns.insert(host.to_string(), expires_at);
}
}

View File

@@ -3,16 +3,18 @@ use tauri::{AppHandle, State};
mod forum;
use forum::{forum_message_from_event, forum_reply_from_event};
use forum::{
apply_link_preview_suppression, fetch_agent_owner_pubkeys, link_preview_suppression_targets,
};
pub use forum::{get_forum_posts, get_forum_thread};
use crate::{
app_state::AppState,
events,
managed_agents::{find_managed_agent_mut, load_managed_agents, ManagedAgentRecord},
models::{
FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse,
ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse,
ThreadRepliesResponse,
FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse,
SendChannelMessageResponse, ThreadRepliesResponse,
},
nostr_convert,
relay::{query_relay, submit_event, submit_event_with_keys},
@@ -113,9 +115,30 @@ pub async fn get_feed(
Vec::new()
};
let mention_ids = mention_events
.iter()
.map(|event| event.id.to_hex())
.collect::<Vec<_>>();
let mention_edits = if mention_ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({ "kinds": [40003], "#e": mention_ids })],
)
.await
.unwrap_or_default()
};
let mention_owner_pubkeys = fetch_agent_owner_pubkeys(&state, &mention_events).await;
let suppressed_mentions =
link_preview_suppression_targets(&mention_events, &mention_edits, &mention_owner_pubkeys);
let mentions: Vec<FeedItemInfo> = mention_events
.iter()
.map(|ev| feed_item_from_event(ev, "mentions"))
.map(|ev| {
let mut item = feed_item_from_event(ev, "mentions");
apply_link_preview_suppression(&mut item.tags, &item.id, &suppressed_mentions);
item
})
.collect();
let needs_action: Vec<FeedItemInfo> = approval_events
.iter()
@@ -206,79 +229,6 @@ pub async fn search_messages(
Ok(nostr_convert::search_response_from_events(&events))
}
#[tauri::command]
pub async fn get_forum_posts(
channel_id: String,
limit: Option<u32>,
before: Option<i64>,
state: State<'_, AppState>,
) -> Result<ForumPostsResponse, String> {
let cap = limit.unwrap_or(20).min(100);
let mut filter = serde_json::Map::new();
filter.insert("kinds".to_string(), serde_json::json!([45001]));
filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()]));
filter.insert("limit".to_string(), serde_json::json!(cap));
if let Some(t) = before {
filter.insert("until".to_string(), serde_json::json!(t));
}
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
let messages: Vec<ForumMessageInfo> = events
.iter()
.map(|ev| forum_message_from_event(ev, &channel_id))
.collect();
let next_cursor = messages.last().map(|m| m.created_at);
Ok(ForumPostsResponse {
messages,
next_cursor,
})
}
#[tauri::command]
pub async fn get_forum_thread(
channel_id: String,
event_id: String,
limit: Option<u32>,
cursor: Option<String>,
state: State<'_, AppState>,
) -> Result<ForumThreadResponse, String> {
let _ = (limit, cursor);
// Two filters: the root event itself, plus any reply (kinds 9/45003)
// that references it via #e.
let events = query_relay(
&state,
&[
serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }),
serde_json::json!({
"kinds": [9, 45003],
"#e": [event_id.clone()],
"#h": [channel_id.clone()],
}),
],
)
.await?;
let mut root: Option<ForumMessageInfo> = None;
let mut replies: Vec<ForumThreadReplyInfo> = Vec::new();
for ev in &events {
if ev.id.to_hex() == event_id {
root = Some(forum_message_from_event(ev, &channel_id));
} else {
replies.push(forum_reply_from_event(ev, &channel_id, &event_id));
}
}
let total_replies = replies.len() as u32;
let root = root.ok_or_else(|| "forum thread root event not found".to_string())?;
Ok(ForumThreadResponse {
root,
replies,
total_replies,
next_cursor: None,
})
}
/// Fetch the full reply subtree under a thread root, server-side.
///
/// Unlike the channel timeline (which the desktop assembles from its local
@@ -535,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>,
@@ -546,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;
@@ -590,6 +543,8 @@ pub async fn send_channel_message(
&media,
&emoji,
&mention_refs_only,
&link_previews,
&relay_base,
)?
}
};
@@ -756,6 +711,8 @@ fn build_managed_agent_channel_message(
&[],
&[],
&[],
&[],
&crate::relay::relay_api_base_url(),
client_tags,
)
}
@@ -919,38 +876,48 @@ pub async fn remove_reaction(
Ok(())
}
#[tauri::command]
pub async fn edit_message(
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditMessageInput {
channel_id: String,
event_id: String,
content: String,
#[serde(default)]
media_tags: Vec<Vec<String>>,
emoji_tags: Option<Vec<Vec<String>>>,
// Pubkeys of mentions *newly added* by this edit (the composer diffs the
// edited body against the original). Only these get a `p` tag, so a typo-fix
// edit that leaves the mention set unchanged never re-wakes anyone.
mention_pubkeys: Option<Vec<String>>,
#[serde(default)]
emoji_tags: Vec<Vec<String>>,
// Pubkeys of mentions *newly added* by this edit. Only these get a `p`
// tag, so a typo-fix edit never re-wakes existing mentions.
#[serde(default)]
mention_pubkeys: Vec<String>,
#[serde(default)]
suppress_link_previews: bool,
}
#[tauri::command]
pub async fn edit_message(
input: EditMessageInput,
state: State<'_, AppState>,
) -> Result<(), String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
.map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let trimmed = content.trim();
let channel_uuid = uuid::Uuid::parse_str(&input.channel_id)
.map_err(|_| format!("invalid channel UUID: {}", input.channel_id))?;
let target_eid =
EventId::from_hex(&input.event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let trimmed = input.content.trim();
// Empty text is allowed when the edit still carries imeta attachments
// (a media-only edit). Reject only when both are empty.
if trimmed.is_empty() && media_tags.is_empty() {
if trimmed.is_empty() && input.media_tags.is_empty() {
return Err("edit must have content or attachments".into());
}
let emoji = emoji_tags.unwrap_or_default();
let mentions = mention_pubkeys.unwrap_or_default();
let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect();
let mention_refs: Vec<&str> = input.mention_pubkeys.iter().map(|s| s.as_str()).collect();
let builder = events::build_message_edit(
channel_uuid,
target_eid,
trimmed,
&media_tags,
&emoji,
&input.media_tags,
&input.emoji_tags,
&mention_refs,
input.suppress_link_previews,
)?;
submit_event(builder, &state).await?;
Ok(())

View File

@@ -1,4 +1,41 @@
use crate::models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary};
use tauri::State;
use crate::{
app_state::AppState,
models::{
ForumMessageInfo, ForumPostsResponse, ForumThreadReplyInfo, ForumThreadResponse,
ThreadSummary,
},
relay::query_relay,
};
pub(super) async fn fetch_agent_owner_pubkeys(
state: &AppState,
events: &[nostr::Event],
) -> std::collections::HashMap<String, String> {
let authors = events
.iter()
.map(|event| event.pubkey.to_hex())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if authors.is_empty() {
return std::collections::HashMap::new();
}
super::query_relay(
state,
&[serde_json::json!({ "kinds": [0], "authors": authors })],
)
.await
.unwrap_or_default()
.into_iter()
.filter_map(|profile| {
crate::nostr_convert::profile_valid_oa_owner_pubkey(&profile)
.map(|owner| (profile.pubkey.to_hex(), owner))
})
.collect()
}
fn tags_to_vec(event: &nostr::Event) -> Vec<Vec<String>> {
event
@@ -68,3 +105,214 @@ pub(super) fn forum_reply_from_event(
reactions: serde_json::Value::Null,
}
}
pub(super) fn link_preview_suppression_targets(
originals: &[nostr::Event],
edits: &[nostr::Event],
owner_pubkeys: &std::collections::HashMap<String, String>,
) -> std::collections::HashSet<String> {
let originals_by_id = originals
.iter()
.map(|event| (event.id.to_hex(), event))
.collect::<std::collections::HashMap<_, _>>();
edits
.iter()
.filter(|event| {
event.kind.as_u16() == 40003
&& event
.tags
.iter()
.any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()])
})
.filter_map(|edit| {
let target_id = edit.tags.iter().find_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("e"))
.then(|| values.get(1).cloned())
.flatten()
})?;
let target = originals_by_id.get(&target_id)?;
let author = target.pubkey.to_hex();
let signer = edit.pubkey.to_hex();
(signer == author || owner_pubkeys.get(&author) == Some(&signer)).then_some(target_id)
})
.collect()
}
pub(super) fn apply_link_preview_suppression(
tags: &mut Vec<Vec<String>>,
event_id: &str,
suppressed: &std::collections::HashSet<String>,
) {
if suppressed.contains(event_id)
&& !tags
.iter()
.any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()])
{
tags.push(vec!["link-preview".to_string(), "none".to_string()]);
}
}
#[tauri::command]
pub async fn get_forum_posts(
channel_id: String,
limit: Option<u32>,
before: Option<i64>,
state: State<'_, AppState>,
) -> Result<ForumPostsResponse, String> {
let cap = limit.unwrap_or(20).min(100);
let mut filter = serde_json::Map::new();
filter.insert("kinds".to_string(), serde_json::json!([45001]));
filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()]));
filter.insert("limit".to_string(), serde_json::json!(cap));
if let Some(t) = before {
filter.insert("until".to_string(), serde_json::json!(t));
}
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
let ids = events
.iter()
.map(|event| event.id.to_hex())
.collect::<Vec<_>>();
let edits = if ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({ "kinds": [40003], "#e": ids })],
)
.await
.unwrap_or_default()
};
let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await;
let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys);
let messages: Vec<ForumMessageInfo> = events
.iter()
.map(|ev| {
let mut message = forum_message_from_event(ev, &channel_id);
apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed);
message
})
.collect();
let next_cursor = messages.last().map(|m| m.created_at);
Ok(ForumPostsResponse {
messages,
next_cursor,
})
}
#[tauri::command]
pub async fn get_forum_thread(
channel_id: String,
event_id: String,
limit: Option<u32>,
cursor: Option<String>,
state: State<'_, AppState>,
) -> Result<ForumThreadResponse, String> {
let _ = (limit, cursor);
// Two filters: the root event itself, plus any reply (kinds 9/45003)
// that references it via #e.
let events = query_relay(
&state,
&[
serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }),
serde_json::json!({
"kinds": [9, 45003],
"#e": [event_id.clone()],
"#h": [channel_id.clone()],
}),
],
)
.await?;
let ids = events
.iter()
.map(|event| event.id.to_hex())
.collect::<Vec<_>>();
let edits = if ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({ "kinds": [40003], "#e": ids })],
)
.await
.unwrap_or_default()
};
let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await;
let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys);
let mut root: Option<ForumMessageInfo> = None;
let mut replies: Vec<ForumThreadReplyInfo> = Vec::new();
for ev in &events {
if ev.id.to_hex() == event_id {
let mut message = forum_message_from_event(ev, &channel_id);
apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed);
root = Some(message);
} else if ev.kind.as_u16() as u32 != 40003 {
let mut reply = forum_reply_from_event(ev, &channel_id, &event_id);
apply_link_preview_suppression(&mut reply.tags, &reply.event_id, &suppressed);
replies.push(reply);
}
}
let total_replies = replies.len() as u32;
let root = root.ok_or_else(|| "forum thread root event not found".to_string())?;
Ok(ForumThreadResponse {
root,
replies,
total_replies,
next_cursor: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind};
fn signed_event(keys: &Keys, kind: u16, tags: Vec<Vec<String>>) -> nostr::Event {
let tags = tags
.into_iter()
.map(nostr::Tag::parse)
.collect::<Result<Vec<_>, _>>()
.expect("valid tags");
EventBuilder::new(Kind::Custom(kind), "body")
.tags(tags)
.sign_with_keys(keys)
.expect("event signs")
}
#[test]
fn suppression_targets_accepts_author_and_verified_owner_only() {
let author = Keys::generate();
let owner = Keys::generate();
let attacker = Keys::generate();
let original = signed_event(&author, 9, Vec::new());
let marker = vec!["link-preview".to_string(), "none".to_string()];
let target = vec!["e".to_string(), original.id.to_hex()];
let author_edit = signed_event(&author, 40003, vec![target.clone(), marker.clone()]);
let owner_edit = signed_event(&owner, 40003, vec![target.clone(), marker.clone()]);
let spoofed_edit = signed_event(&attacker, 40003, vec![target, marker]);
let owners = std::collections::HashMap::from([(
author.public_key().to_hex(),
owner.public_key().to_hex(),
)]);
for edit in [&author_edit, &owner_edit] {
assert!(link_preview_suppression_targets(
std::slice::from_ref(&original),
std::slice::from_ref(edit),
&owners,
)
.contains(&original.id.to_hex()));
}
assert!(link_preview_suppression_targets(
std::slice::from_ref(&original),
std::slice::from_ref(&spoofed_edit),
&owners,
)
.is_empty());
}
}

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).
@@ -180,7 +178,6 @@ pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, String> {
}
/// Kind 9002 — update channel name/description/visibility/ttl.
///
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
pub fn build_update_channel(
@@ -295,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,
@@ -303,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,
@@ -312,6 +312,8 @@ pub fn build_message(
media_tags,
custom_emoji_tags,
mention_ref_tags,
link_preview_tags,
relay_base,
&[],
)
}
@@ -330,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)?;
@@ -341,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))
}
@@ -396,18 +401,8 @@ pub fn build_forum_comment(
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
}
/// Kind 40003 — edit a message. Carries the full new content AND a fresh
/// imeta tag set; the receiver overlays the imeta tags onto the original
/// event so the rendered message reflects exactly the edited state. NIP-30
/// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s
/// stay resolvable (the send path attaches these too).
///
/// `mentions` carries the pubkeys of mentions that are *newly added* by this
/// edit (the caller diffs the edited body against the original). Only those get
/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix
/// edit that leaves the mention set unchanged emits no `p` tags and never
/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup +
/// lowercase); the receiver overlays these onto the original event's audience.
/// Kind 40003 — edit a message with full content, media, emoji, mentions,
/// and optional monotonic link-preview suppression.
pub fn build_message_edit(
channel_id: Uuid,
target_event_id: EventId,
@@ -415,6 +410,7 @@ pub fn build_message_edit(
media_tags: &[Vec<String>],
custom_emoji_tags: &[Vec<String>],
mentions: &[&str],
suppress_link_previews: bool,
) -> Result<EventBuilder, String> {
check_content(content)?;
let mut tags = vec![
@@ -424,6 +420,9 @@ pub fn build_message_edit(
tags.extend(mention_tags(mentions)?);
imeta_tags(media_tags, &mut tags)?;
emoji_tags(custom_emoji_tags, &mut tags)?;
if suppress_link_previews {
tags.push(tag(vec!["link-preview", "none"])?);
}
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
}
@@ -948,7 +947,8 @@ mod tests {
let target =
EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1")
.unwrap();
let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap();
let builder =
build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap();
let secret = nostr::SecretKey::from_hex(
"0000000000000000000000000000000000000000000000000000000000000003",
)

View File

@@ -659,14 +659,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;
#[cfg(target_os = "macos")]
mod macos_notifications;
@@ -82,7 +83,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
@@ -672,7 +672,7 @@ pub fn run() {
get_relay_ws_url,
get_relay_http_url,
get_media_proxy_port,
fetch_link_preview_title,
fetch_link_preview_metadata,
discover_acp_auth_methods,
discover_acp_providers,
discover_git_bash_prerequisite,

View File

@@ -0,0 +1,189 @@
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, allow_newlines: bool) -> bool {
value.len() <= max
&& !value
.chars()
.any(|character| character.is_control() && !(allow_newlines && character == '\n'))
}
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 {
if preview_tag.as_slice() == ["link-preview", "none"] {
if preview_tags.len() != 1 {
return Err("link-preview suppression cannot include snapshots".into());
}
tags.push(
Tag::parse(["link-preview", "none"])
.map_err(|e| format!("invalid link-preview tag: {e}"))?,
);
continue;
}
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, false)
&& valid_text(&preview_tag[5], MAX_SITE, false)
&& valid_text(&preview_tag[6], MAX_DESCRIPTION, true)
&& 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_accepts_blanket_suppression_by_itself() {
let mut tags = Vec::new();
append(
&[vec!["link-preview".into(), "none".into()]],
BASE,
&mut tags,
)
.unwrap();
assert_eq!(tags[0].as_slice(), ["link-preview", "none"]);
assert!(append(
&[vec!["link-preview".into(), "none".into()], tag("", ""),],
BASE,
&mut Vec::new(),
)
.is_err());
}
#[test]
fn append_accepts_description_newlines() {
let mut preview_tag = tag("", "");
preview_tag[6] = "First paragraph\n\nSecond paragraph".into();
assert!(append(&[preview_tag], BASE, &mut Vec::new()).is_ok());
}
#[test]
fn append_rejects_other_control_characters() {
let mut preview_tag = tag("", "");
preview_tag[6] = "Unsafe\tdescription".into();
assert!(append(&[preview_tag], BASE, &mut Vec::new()).is_err());
}
#[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

@@ -13,7 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity";
import { clearTrayAgentActivity } from "@/shared/api/trayMenu";
import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { resetLinkPreviewTitleCache } from "@/shared/lib/useResolvedLinkPreviews";
import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import {
clearAllDrafts,
@@ -67,12 +67,12 @@ function resetCommunityState({
}
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetLinkPreviewMetadataCache();
resetVideoPlayerState();
resetRenderScopedReactionHydration();
resetBackgroundMediaUploads();
clearSearchHitEventCache();
clearMarkdownNodeCache();
resetLinkPreviewTitleCache();
}
type CommunityInitResult =

View File

@@ -11,6 +11,7 @@ import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { formatRelativeTime } from "../lib/time";
@@ -121,6 +122,9 @@ export function ForumPostCard({
<Markdown
className="text-sm"
content={previewContent}
messageId={post.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
linkPreviewTags={post.tags}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}

View File

@@ -15,6 +15,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { Skeleton } from "@/shared/ui/skeleton";
import { formatRelativeTime } from "../lib/time";
@@ -115,6 +116,9 @@ function ReplyRow({
channelNames={channelNames}
className="text-sm"
content={reply.content}
messageId={reply.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(reply.tags)}
linkPreviewTags={reply.tags}
imetaByUrl={parseImetaTags(reply.tags)}
mentionNames={replyMentionNames}
mentionPubkeysByName={replyMentionPubkeysByName}
@@ -258,6 +262,9 @@ export function ForumThreadPanel({
channelNames={channelNames}
className="text-sm"
content={post.content}
messageId={post.eventId}
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
linkPreviewTags={post.tags}
imetaByUrl={parseImetaTags(post.tags)}
mentionNames={postMentionNames}
mentionPubkeysByName={postMentionPubkeysByName}

View File

@@ -20,6 +20,7 @@ import {
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
@@ -228,6 +229,10 @@ export function FeedSection({
<Markdown
className="max-w-none text-sm leading-snug text-muted-foreground"
content={feedContent(item)}
messageId={item.id}
linkPreviewsSuppressed={hasLinkPreviewSuppression(
item.tags,
)}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
/>

View File

@@ -16,6 +16,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { UserAvatar } from "@/shared/ui/UserAvatar";
export type InboxDisplayMessage = InboxContextMessage & {
@@ -215,6 +216,10 @@ export function InboxMessageRow({
isKnownAgentPubkey,
)}
content={message.content}
messageId={message.id}
linkPreviewsSuppressed={hasLinkPreviewSuppression(
timelineMessage.tags,
)}
customEmoji={customEmoji}
mentionNames={message.mentionNames}
mentionPubkeysByName={message.mentionPubkeysByName}

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

@@ -674,3 +674,102 @@ test("CHANNEL_TIMELINE_CONTENT_KINDS matches isTimelineContentEvent", () => {
);
}
});
test("original message link-preview none marker suppresses all generated previews", () => {
const [message] = formatTimelineMessages(
[
streamMessage({
content: "https://one.example https://two.example",
tags: [
["h", CHANNEL_ID],
["link-preview", "none"],
],
}),
],
null,
undefined,
null,
);
assert.deepEqual(
message.tags.find((tag) => tag[0] === "link-preview"),
["link-preview", "none"],
);
});
test("authorized suppression edit remains monotonic across later body edits", () => {
const suppress = streamEdit(
HEX64_A,
"https://one.example https://two.example",
{
created_at: 1_700_000_001,
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
["link-preview", "none"],
],
},
);
const later = streamEdit(HEX64_A, "later body", {
id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
created_at: 1_700_000_002,
});
const [message] = formatTimelineMessages(
[streamMessage(), suppress, later],
null,
undefined,
null,
);
assert.equal(message.body, "later body");
assert.equal(
message.tags.some((tag) => tag[0] === "link-preview" && tag[1] === "none"),
true,
);
});
test("spoofed suppression edit cannot hide another author's previews", () => {
const spoof = streamEdit(HEX64_A, "spoofed", {
pubkey: PUBKEY_B,
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
["link-preview", "none"],
],
});
const [message] = formatTimelineMessages(
[streamMessage(), spoof],
null,
undefined,
null,
);
assert.equal(message.body, "hello world");
assert.equal(
message.tags.some((tag) => tag[0] === "link-preview"),
false,
);
});
test("verified agent owner may publish a suppression edit", () => {
const ownerEdit = streamEdit(HEX64_A, "owner edit", {
pubkey: PUBKEY_B,
tags: [
["h", CHANNEL_ID],
["e", HEX64_A],
["link-preview", "none"],
],
});
const profiles = {
[PUBKEY_A]: { ownerPubkey: PUBKEY_B },
};
const [message] = formatTimelineMessages(
[streamMessage(), ownerEdit],
null,
undefined,
null,
profiles,
);
assert.equal(message.body, "owner edit");
assert.equal(
message.tags.some((tag) => tag[0] === "link-preview"),
true,
);
});

View File

@@ -181,6 +181,36 @@ function getAuthorAvatarUrl(input: {
return profiles?.[authorPubkey.toLowerCase()]?.avatarUrl ?? null;
}
export function hasLinkPreviewSuppression(
tags: string[][] | undefined,
): boolean {
return (
tags?.some(
(tag) =>
tag[0] === "link-preview" && tag[1] === "none" && tag.length === 2,
) ?? false
);
}
function isAuthorizedMessageEdit(
edit: RelayEvent,
target: RelayEvent,
profiles: UserProfileLookup | undefined,
relaySelfPubkey?: string | null,
): boolean {
const author = normalizePubkey(
resolveEventAuthorPubkey({
event: target,
preferActorTag: true,
relaySelfPubkey,
requireChannelTagForPTags: true,
}),
);
const signer = normalizePubkey(edit.pubkey);
if (signer === author) return true;
return normalizePubkey(profiles?.[author]?.ownerPubkey ?? "") === signer;
}
export function formatTimelineMessages(
events: RelayEvent[],
channel: Channel | null,
@@ -219,8 +249,14 @@ export function formatTimelineMessages(
}
}
// Build a map of latest edit per original message: targetId → { content, tags, createdAt }.
// When multiple edits exist for the same message, the most recent one wins.
const timelineEventsById = new Map(
events.filter(isTimelineContentEvent).map((event) => [event.id, event]),
);
const previewSuppressedTargetIds = new Set<string>();
// Build a map of latest authorized edit per original message. Preview
// suppression is monotonic: any authorized edit carrying the marker wins
// forever, independent of which edit supplies the latest body.
// The edit's own tags are kept so the renderer can overlay imeta tags
// (attachments) from the edit onto the original event — non-imeta tags on
// the original (`h`, `p` mentions, etc.) stay untouched.
@@ -240,6 +276,16 @@ export function formatTimelineMessages(
if (!targetId || deletedEventIds.has(targetId)) {
continue;
}
const target = timelineEventsById.get(targetId);
if (
!target ||
!isAuthorizedMessageEdit(event, target, profiles, relaySelfPubkey)
) {
continue;
}
if (hasLinkPreviewSuppression(event.tags)) {
previewSuppressedTargetIds.add(targetId);
}
const existing = editsByTargetId.get(targetId);
if (!existing || event.created_at > existing.createdAt) {
@@ -469,7 +515,18 @@ export function formatTimelineMessages(
// imeta tags. All non-imeta tags on the original are preserved.
// Logic lives in `applyEditTagOverlay.mjs` so prod and tests share
// a single source.
tags: applyEditTagOverlay(event.tags, edit?.tags),
tags: (() => {
const effectiveTags = applyEditTagOverlay(event.tags, edit?.tags);
if (
hasLinkPreviewSuppression(event.tags) ||
previewSuppressedTargetIds.has(event.id)
) {
return hasLinkPreviewSuppression(effectiveTags)
? effectiveTags
: [...effectiveTags, ["link-preview", "none"]];
}
return effectiveTags;
})(),
reactions: (() => {
const reactions = reactionsByEventId.get(event.id);
if (!reactions) return undefined;

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

@@ -0,0 +1,24 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { buildPlainTextProjection } from "./plainTextProjection";
export function buildPreviewUpdate(
doc: ProseMirrorNode,
selectionAnchor: number,
) {
const projection = buildPlainTextProjection(doc);
const plainText = projection.text;
const hrefs = new Set<string>();
doc.descendants((node) => {
if (!node.isText || node.marks.some((mark) => mark.type.name === "spoiler"))
return;
const href = node.marks.find((mark) => mark.type.name === "link")?.attrs
.href;
if (typeof href === "string") hrefs.add(href);
});
return {
cursor: projection.mapPMToTextOffset(selectionAnchor),
linkPreviewContent: [plainText, ...hrefs].join("\n"),
text: plainText,
};
}

View File

@@ -113,36 +113,12 @@ export function useLinkEditor(richText: UseRichTextEditorResult) {
[richText.editor],
);
const showCard = React.useCallback(
(info: LinkSelectionInfo | null) => {
if (!info) {
setCardState(null);
return;
}
const rect = getLinkRect(info);
if (!rect) {
setCardState(null);
return;
}
setCardState((prev) => {
const sameLink =
prev?.info.href === info.href &&
prev.info.from === info.from &&
prev.info.to === info.to &&
prev.info.text === info.text;
const sameRect =
prev?.rect.left === rect.left &&
prev.rect.top === rect.top &&
prev.rect.width === rect.width &&
prev.rect.height === rect.height;
if (sameLink && sameRect) return prev;
return { info, rect };
});
},
[getLinkRect],
);
const showCard = React.useCallback((_info: LinkSelectionInfo | null) => {
// Keep clicks and caret movement inside composer links focused in the
// editor without opening contextual controls. Link editing remains
// available through the formatting toolbar and Cmd/Ctrl+K.
setCardState(null);
}, []);
const openDialogFromInfo = React.useCallback((info: LinkSelectionInfo) => {
setDraft({

View File

@@ -29,6 +29,8 @@ import {
import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode";
import { useComposerCustomEmoji } from "./useComposerCustomEmoji";
import { buildPlainTextProjection } from "./plainTextProjection";
import { parseSnapshotClipboardHtml } from "./agentSnapshotClipboard";
import { buildPreviewUpdate } from "./linkPreviewContent";
import { createLinkInteractionExtension } from "./linkInteractionExtension";
import {
CodeBlockAfterHardBreak,
@@ -76,7 +78,7 @@ export type AutocompleteEdit = {
export type RichTextEditorOptions = {
placeholder?: string;
onUpdate?: (info: { text: string; cursor: number }) => void;
onUpdate?: (info: ReturnType<typeof buildPreviewUpdate>) => void;
editable?: boolean;
mentionNames?: string[];
agentMentionNames?: string[];
@@ -135,6 +137,11 @@ function shouldAppendSpaceAfterPaste(text: string): boolean {
return PASTED_LINK_AT_END_RE.test(trimmedEnd);
}
function unwrapExactHttpLink(text: string): string | null {
const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text);
return match?.[1] ?? match?.[2] ?? null;
}
const LinkPasteTrailingSpace = Extension.create({
name: "linkPasteTrailingSpace",
@@ -487,6 +494,36 @@ export function useRichTextEditor({
}),
],
editorProps: {
handleDOMEvents: {
paste: (view, event) => {
const clipboard = (event as ClipboardEvent).clipboardData;
if (
parseSnapshotClipboardHtml(clipboard?.getData("text/html") ?? "")
)
return false;
const url = unwrapExactHttpLink(
clipboard?.getData("text/plain") ?? "",
);
if (!url) return false;
const link = view.state.schema.marks.link;
if (!link) return false;
const { from, to } = view.state.selection;
let transaction = view.state.tr.replaceRangeWith(
from,
to,
view.state.schema.text(url, [link.create({ href: url })]),
);
const end = transaction.mapping.map(to);
transaction = transaction.insertText(" ", end);
transaction = transaction.removeMark(end, end + 1, link);
transaction = transaction.setSelection(
TextSelection.create(transaction.doc, end + 1),
);
view.dispatch(transaction.setStoredMarks([]).scrollIntoView());
event.preventDefault();
return true;
},
},
attributes: {
autocapitalize: "none",
autocorrect: "off",
@@ -619,11 +656,9 @@ export function useRichTextEditor({
// still available through `getMarkdown()` for send/draft boundaries;
// per-keystroke consumers only need textarea-shaped plain text for
// autocomplete and empty/non-empty state.
const projection = buildPlainTextProjection(ed.state.doc);
onUpdateRef.current?.({
cursor: projection.mapPMToTextOffset(ed.state.selection.anchor),
text: projection.text,
});
onUpdateRef.current?.(
buildPreviewUpdate(ed.state.doc, ed.state.selection.anchor),
);
},
},
[],

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,12 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
syncContentRefFromEditorRef,
} = useComposerContentState();
const [previewContent, setPreviewContent] = React.useState("");
const deferredPreviewContent = React.useDeferredValue(previewContent);
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
} = useComposerLinkPreviews(deferredPreviewContent);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -255,8 +262,9 @@ function MessageComposerImpl({
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, text }) => {
onUpdate: ({ cursor, linkPreviewContent, text }) => {
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -499,7 +507,6 @@ function MessageComposerImpl({
richText.focus,
mentions.updateMentionQuery,
]);
// ── Submit message ──────────────────────────────────────────────────
const submitMessage = React.useCallback(async () => {
const trimmed = syncComposerContentFromEditor().trim();
// Edit mode
@@ -575,6 +582,7 @@ function MessageComposerImpl({
capturedThreadContext,
pendingImeta: currentPendingImeta,
queuedAttachments: currentQueuedAttachments,
linkPreviewTags: getReadyLinkPreviewTags(),
sentDraftKey: resolveSentDraftKey(
effectiveDraftKeyRef.current,
drafts.loadDraft,
@@ -595,6 +603,7 @@ function MessageComposerImpl({
customEmoji,
drafts.loadDraft,
emojiAutocomplete.clearEmojis,
getReadyLinkPreviewTags,
media.clearQueuedAttachments,
media.pendingImetaRef,
media.queuedAttachmentsRef,
@@ -699,7 +708,6 @@ function MessageComposerImpl({
}
return;
}
// Escape in edit mode
if (
event.key === "Escape" &&
@@ -725,14 +733,11 @@ function MessageComposerImpl({
onCancelEdit,
],
);
// ── Media paste + ⌘K link shortcut via Tiptap editorProps ──────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
React.useEffect(() => {
if (!richText.editor) return;
richText.editor.setOptions({
editorProps: {
...richText.editor.options.editorProps,
@@ -750,7 +755,6 @@ function MessageComposerImpl({
}
return true;
}
// --- Buzz code-block paste ---
// The code block copy button writes a small Buzz marker alongside
// plain text. Use it to paste back as a literal code block so Markdown
@@ -777,7 +781,6 @@ function MessageComposerImpl({
scrollComposerToBottom();
return true;
}
// Restore Buzz snapshots before normal styled-HTML normalization.
if (handleAgentSnapshotPaste(event, media.setPendingImeta))
return true;
@@ -789,18 +792,15 @@ function MessageComposerImpl({
_view.pasteHTML(cleanHtml);
return true;
}
const plainText = event.clipboardData?.getData("text/plain") ?? "";
if (plainText.includes("\n")) {
scrollComposerToBottom();
}
return false;
},
},
});
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
@@ -819,7 +819,6 @@ function MessageComposerImpl({
media.queuedAttachments.length,
],
);
const handleCaptureSelection = React.useCallback(() => {}, []);
const handlePaperclipClick = React.useCallback(() => {
@@ -951,6 +950,7 @@ function MessageComposerImpl({
</div>
) : null}
{composerLinkPreviews}
{(media.pendingImeta.length > 0 ||
media.queuedAttachments.length > 0 ||
media.isUploading) && (

View File

@@ -44,6 +44,9 @@ import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation";
import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode";
import { VideoReviewTimecodeButton } from "@/shared/ui/VideoReviewTimecodeButton";
import { MessageActionBar } from "./MessageActionBar";
import { editMessage } from "@/shared/api/tauri";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
import { toast } from "sonner";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
@@ -156,6 +159,29 @@ export const MessageRow = React.memo(
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const linkPreviewsSuppressed = hasLinkPreviewSuppression(message.tags);
const removeLinkPreviewsForEveryone =
channelId && onEdit && !message.pending && !linkPreviewsSuppressed
? async () => {
const tags = message.tags ?? [];
try {
await editMessage(
channelId,
message.id,
message.body,
tags.filter((tag) => tag[0] === "imeta"),
tags.filter((tag) => tag[0] === "emoji"),
undefined,
true,
);
} catch (error) {
toast.error(
`Failed to remove previews: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
: undefined;
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
null,
);
@@ -380,6 +406,10 @@ export const MessageRow = React.memo(
isKnownAgentPubkey,
)}
content={reviewTimecode?.text ?? message.body}
messageId={message.id}
linkPreviewsSuppressed={linkPreviewsSuppressed}
linkPreviewTags={message.tags}
onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone}
customEmoji={customEmoji}
imetaByUrl={imetaByUrl}
agentMentionPubkeysByName={agentMentionPubkeysByName}

View File

@@ -0,0 +1,252 @@
import * as React from "react";
import { ImageOff, LoaderCircle, X } from "lucide-react";
import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri";
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
import {
buildLinkPreviewSnapshotTag,
isValidLinkPreviewSnapshotCanonicalUrl,
} from "@/shared/lib/linkPreviewSnapshot";
import {
beginRelayOriginFetch,
getCachedRelayOrigin,
} from "@/shared/lib/mediaUrl";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/shared/ui/attachment";
import { Button } from "@/shared/ui/button";
function previewHostname(href: string): string {
try {
return new URL(href).hostname.replace(/^www\./, "");
} catch {
return href;
}
}
function ComposerLinkPreviewCard({
preview,
}: {
preview: ResolvedLinkPreview;
}) {
const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null;
const [failedImageSrc, setFailedImageSrc] = React.useState<string | null>(
null,
);
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
const hostname = previewHostname(preview.href);
let path = "";
try {
const url = new URL(preview.href);
path = `${url.pathname}${url.search}`;
} catch {}
return (
<Attachment
className="h-[55px] w-80 max-w-full gap-2 overflow-hidden p-0 pr-2 shadow-none"
data-image-state={preview.imageState}
data-link-preview={preview.kind}
data-link-preview-composer-card=""
state={preview.snapshotReady ? "done" : "processing"}
>
<AttachmentMedia
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
data-link-preview-thumbnail=""
variant="image"
>
{showImage ? (
<img
alt=""
className="h-full w-full object-cover"
onError={() => setFailedImageSrc(imageSrc ?? null)}
src={imageSrc ?? undefined}
/>
) : preview.imageState === "pending" ? (
<LoaderCircle
aria-label="Loading link preview"
className="size-4 animate-spin"
/>
) : preview.faviconDataUrl ? (
<img
alt=""
className="size-7 rounded-md object-contain opacity-70"
src={preview.faviconDataUrl}
/>
) : (
<ImageOff aria-hidden="true" className="size-4 opacity-60" />
)}
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="line-clamp-1" data-link-preview-hostname="">
{preview.snapshotReady ? preview.title : hostname}
</AttachmentTitle>
<AttachmentDescription>
{preview.snapshotReady
? preview.provider || hostname
: path && path !== "/"
? path
: preview.typeLabel}
</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger asChild>
<a
aria-label={`Open ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">Open {preview.title}</span>
</a>
</AttachmentTrigger>
</Attachment>
);
}
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 [suppressed, setSuppressed] = React.useState(false);
const candidates = React.useMemo(
() =>
extractSupportedLinkPreviews(content).filter((preview) =>
preview.href.startsWith("buzz://")
? true
: isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
),
[content],
);
const previews = useResolvedLinkPreviews(suppressed ? [] : candidates);
React.useEffect(() => {
if (candidates.length === 0) setSuppressed(false);
}, [candidates.length]);
const [readyTags, setReadyTags] = React.useState<Record<string, string[]>>(
{},
);
const readyTagsRef = React.useRef<string[][]>([]);
const readyTagsByHrefRef = React.useRef(readyTags);
readyTagsByHrefRef.current = readyTags;
const suppressedRef = React.useRef(suppressed);
suppressedRef.current = suppressed;
const uploadsRef = React.useRef(new Set<string>());
const activeHrefsRef = React.useRef(new Set<string>());
activeHrefsRef.current = new Set(candidates.map((preview) => preview.href));
React.useEffect(() => {
if (getCachedRelayOrigin()) return;
const publishRelayOrigin = beginRelayOriginFetch();
void getRelayHttpUrl()
.then((url) => publishRelayOrigin(url))
.catch(() => publishRelayOrigin(null));
}, []);
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(() => {
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 (!activeHrefsRef.current.has(preview.href)) 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,
});
if (!tag) return;
setReadyTags((current) => ({ ...current, [preview.href]: tag }));
})
.catch(() => {})
.finally(() => uploadsRef.current.delete(preview.href));
}
}, [previews, readyTags]);
readyTagsRef.current = suppressed
? [["link-preview", "none"]]
: candidates.flatMap((candidate) =>
readyTags[candidate.href] ? [readyTags[candidate.href]] : [],
);
const hideAll = React.useCallback(() => setSuppressed(true), []);
const previewList = previews.length ? (
<div
className="mb-2"
data-composer-link-previews=""
data-ready-snapshot-count={readyTagsRef.current.length}
>
<div className="flex max-w-full items-start gap-1">
<AttachmentGroup className="max-w-full flex-row flex-wrap items-start overflow-visible pb-0">
{previews.map((preview) => (
<ComposerLinkPreviewCard key={preview.href} preview={preview} />
))}
</AttachmentGroup>
<Button
aria-label="Hide all link previews"
className="mt-1 size-5 shrink-0 rounded-full text-muted-foreground hover:text-foreground [&_svg]:size-3"
data-testid="composer-hide-link-previews"
onClick={hideAll}
size="icon-xs"
title="Hide previews"
type="button"
variant="ghost"
>
<X aria-hidden="true" />
</Button>
</div>
</div>
) : null;
const getReadyTags = React.useCallback(() => {
if (suppressedRef.current) return [["link-preview", "none"]];
return [...activeHrefsRef.current].flatMap((href) => {
const tag = readyTagsByHrefRef.current[href];
return tag ? [tag] : [];
});
}, []);
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

@@ -187,7 +187,6 @@ export function useMentionSendFlow({
pubkeys: [] as string[],
};
}
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
for (const agent of preparedManagedAgents) {
managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent);
@@ -198,13 +197,11 @@ export function useMentionSendFlow({
]);
const errors: string[] = [];
const pubkeys: string[] = [];
for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) {
const agent = managedAgentsByPubkey.get(pubkey);
if (!agent) {
continue;
}
try {
if (participantPubkeys.has(pubkey)) {
if (isProviderBackedAgent(agent)) {
@@ -231,7 +228,6 @@ export function useMentionSendFlow({
);
}
}
return {
errors,
pubkeys: uniqueNormalizedPubkeys(pubkeys),
@@ -713,6 +709,7 @@ export function useMentionSendFlow({
capturedThreadContext = null,
pendingImeta,
queuedAttachments = [],
linkPreviewTags = [],
sentDraftKey,
recoveryDraftKey,
spoileredAttachmentUrls = new Set(),
@@ -775,7 +772,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

@@ -37,6 +37,11 @@ import {
useThreadViewMode,
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import {
setLinkPreviewStyle,
useLinkPreviewStyle,
type LinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { cn } from "@/shared/lib/cn";
import { useCommunities } from "@/features/communities/useCommunities";
import { Badge } from "@/shared/ui/badge";
@@ -694,11 +699,86 @@ function ThemeSettingsCard() {
</AnimatePresence>
)}
<LinkPreviewStyleSetting />
<ThreadLayoutSetting />
</section>
);
}
const LINK_PREVIEW_STYLE_OPTIONS: {
value: LinkPreviewStyle;
label: string;
description: string;
}[] = [
{
value: "compact",
label: "Compact",
description: "Show links as compact horizontal cards",
},
{
value: "rich",
label: "Rich",
description: "Unfurl links with larger images and descriptions",
},
];
function LinkPreviewStyleSetting() {
const style = useLinkPreviewStyle();
const activeOption =
LINK_PREVIEW_STYLE_OPTIONS.find((option) => option.value === style) ??
LINK_PREVIEW_STYLE_OPTIONS[0];
return (
<SettingsOptionGroup className="mt-8">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Links</p>
<p className="text-sm font-normal text-muted-foreground">
{activeOption.description}
</p>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 min-w-28 justify-between gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
data-testid="link-preview-style-trigger"
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{activeOption.label}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-72">
<DropdownMenuRadioGroup
onValueChange={(next) =>
setLinkPreviewStyle(next as LinkPreviewStyle)
}
value={style}
>
{LINK_PREVIEW_STYLE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`link-preview-style-${option.value}`}
key={option.value}
value={option.value}
>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-2xs text-muted-foreground">
{option.description}
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</SettingsOptionRow>
</SettingsOptionGroup>
);
}
const THREAD_VIEW_MODE_OPTIONS: {
value: ThreadViewMode;
label: string;

View File

@@ -0,0 +1,23 @@
import { invokeTauri } from "@/shared/api/tauri";
export async function editMessage(
channelId: string,
eventId: string,
content: string,
mediaTags?: string[][],
emojiTags?: string[][],
mentionPubkeys?: string[],
suppressLinkPreviews?: boolean,
): Promise<void> {
await invokeTauri("edit_message", {
input: {
channelId,
eventId,
content,
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
mentionPubkeys: mentionPubkeys ?? [],
suppressLinkPreviews: suppressLinkPreviews ?? false,
},
});
}

View File

@@ -549,6 +549,7 @@ export async function sendChannelMessage(
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
linkPreviewTags?: string[][],
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -559,6 +560,7 @@ export async function sendChannelMessage(
mediaTags: mediaTags ?? null,
emojiTags: emojiTags ?? null,
mentionTags: mentionTags ?? null,
linkPreviewTags,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
},
@@ -615,23 +617,7 @@ export async function uploadMediaBytes(
});
}
export async function editMessage(
channelId: string,
eventId: string,
content: string,
mediaTags?: string[][],
emojiTags?: string[][],
mentionPubkeys?: string[],
): Promise<void> {
await invokeTauri("edit_message", {
channelId,
eventId,
content,
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
mentionPubkeys: mentionPubkeys ?? null,
});
}
export { editMessage } from "@/shared/api/editMessage";
export async function deleteMessage(
channelId: string,

View File

@@ -71,12 +71,12 @@ test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => {
typeLabel: "repo",
},
);
// Same URL without a matching origin stays external.
// Same URL without a matching origin stays an ordinary external preview.
assert.equal(
parseSupportedLinkPreview(
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
),
null,
)?.kind,
"generic-link",
);
});
@@ -108,10 +108,10 @@ test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => {
// Deeper transport paths are not repo links.
`https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`,
]) {
// Even with a matching origin, structural issues return null.
// Structural non-matches remain ordinary external previews.
assert.equal(
parseSupportedLinkPreview(href, "https://relay.example"),
null,
parseSupportedLinkPreview(href, "https://relay.example")?.kind,
"generic-link",
href,
);
}
@@ -123,8 +123,8 @@ test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () =>
parseSupportedLinkPreview(
`https://evil.example/git/${BUZZ_OWNER}/my-repo`,
"https://buzz.block.builderlab.xyz",
),
null,
)?.kind,
"generic-link",
);
// github.com sharing the path shape must never become a Buzz repo card.
assert.equal(
@@ -139,8 +139,8 @@ test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () =>
parseSupportedLinkPreview(
`https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world`,
null,
),
null,
)?.kind,
"generic-link",
);
});
@@ -288,8 +288,8 @@ test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () =
assert.deepEqual(
extractSupportedLinkPreviews(
`clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`,
),
[],
).map((preview) => preview.kind),
["generic-link"],
);
});
@@ -323,6 +323,7 @@ test("clone URLs and buzz://repo links for the same repo dedupe to one card", ()
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
].join(" "),
"https://relay.example",
).map((preview) => preview.href),
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
);
@@ -412,7 +413,7 @@ test("extractSupportedLinkPreviews skips markdown image link URLs", () => {
);
});
test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
test("extractSupportedLinkPreviews treats other absolute HTTPS URLs as generic", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
[
@@ -421,7 +422,7 @@ test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
"(https://github.com/block/sprout/pull/2)",
].join(" "),
).map((preview) => preview.title),
["block/sprout #2"],
["evil-github.com", "example.com", "block/sprout #2"],
);
});
@@ -467,105 +468,38 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
});
// ── useResolvedLinkPreviews: behavioral regression pins ──────────────────────
//
// These tests pin the three behaviors that were implemented without tests in
// the initial fix round. They use the exported pure helpers directly so no
// React hook environment is required.
import {
getLinkPreviewCacheGeneration,
resetLinkPreviewTitleCache,
shouldResolveTitle,
} from "./useResolvedLinkPreviews.ts";
import { buzzEntityFallbackTitle } from "./linkPreview.ts";
const OWNER_HEX =
"71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_HEX =
"c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
function makePrPreview(title) {
return {
kind: "buzz-pull-request",
href: `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
title,
provider: "Buzz",
typeLabel: "pr",
};
}
// 1. Cache epoch: stale promise cannot seed the new generation.
// `resetLinkPreviewTitleCache` must increment the generation counter so that
// a promise captured before the reset sees a different generation and skips
// writing back.
test("resetLinkPreviewTitleCache_incrementsGenerationCounter", () => {
const before = getLinkPreviewCacheGeneration();
resetLinkPreviewTitleCache();
const after = getLinkPreviewCacheGeneration();
assert.equal(after, before + 1, "each reset must bump the generation by 1");
resetLinkPreviewTitleCache();
assert.equal(
getLinkPreviewCacheGeneration(),
before + 2,
"second reset must increment again",
test("parseSupportedLinkPreview parses generic HTTPS URLs", () => {
assert.deepEqual(
parseSupportedLinkPreview("https://example.com/articles/rich-previews"),
{
kind: "generic-link",
href: "https://example.com/articles/rich-previews",
provider: "example.com",
title: "example.com",
typeLabel: "link",
},
);
});
// 2. Mismatched a-tag: shouldResolveTitle uses buzzEntityFallbackTitle to
// decide whether to attempt a relay lookup. When the link's href parses to a
// PR/issue with the expected fallback title, resolution should proceed. When
// the title has already been set to something else (explicit label or earlier
// relay result), shouldResolveTitle must return false so the label wins.
test("shouldResolveTitle_fallbackTitle_returnsTrue", () => {
const parsed = {
ok: true,
value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" },
};
// Construct the expected fallback title and verify shouldResolveTitle allows lookup.
const fallback = buzzEntityFallbackTitle(parsed.value);
const preview = makePrPreview(fallback);
test("parseSupportedLinkPreview rejects generic HTTP URLs", () => {
assert.equal(
shouldResolveTitle(preview),
true,
"fallback title should trigger relay lookup",
parseSupportedLinkPreview("http://example.com/articles/rich-previews"),
null,
);
});
test("shouldResolveTitle_customLabel_returnsFalse_labelMustWin", () => {
// User has written `[My custom PR title](buzz://pr?...)` — the label must
// win; shouldResolveTitle must return false to skip writing the relay title.
const preview = makePrPreview("My custom PR title");
assert.equal(
shouldResolveTitle(preview),
false,
"custom label must suppress relay title lookup (label-must-win invariant)",
);
});
// 3. Label-rerender: converting a bare link to `[label](link)` changes the
// preview title away from the fallback — shouldResolveTitle transitions
// from true to false, so a cached relay title is not applied.
test("shouldResolveTitle_transitionsFromTrueToFalseWhenLabelApplied", () => {
const parsed = {
ok: true,
value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" },
};
const fallback = buzzEntityFallbackTitle(parsed.value);
// Before the label: bare link with fallback title — should resolve.
const barePreview = makePrPreview(fallback);
assert.equal(
shouldResolveTitle(barePreview),
true,
"bare link should resolve",
);
// After the label: same href but title is now the user's label — must NOT resolve.
const labeledPreview = makePrPreview("My labeled PR");
assert.equal(
shouldResolveTitle(labeledPreview),
false,
"labeled link must not overwrite label with cached relay title",
test("extractSupportedLinkPreviews finds generic links and preserves exclusions", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
[
"Read https://example.com/article first.",
"`https://hidden.example.com/secret`",
"then [the details](https://docs.example.org/details)",
].join(" "),
).map(({ kind, title }) => ({ kind, title })),
[
{ kind: "generic-link", title: "example.com" },
{ kind: "generic-link", title: "the details" },
],
);
});

View File

@@ -19,20 +19,17 @@ export type SupportedLinkPreviewKind =
| "google-drive-folder"
| "google-docs-document"
| "google-sheets-spreadsheet"
| "google-slides-presentation";
| "google-slides-presentation"
| "generic-link";
export type SupportedLinkPreview = {
kind: SupportedLinkPreviewKind;
href: string;
provider:
| "Buzz"
| "GitHub"
| "Linear"
| "Google Drive"
| "Google Docs"
| "Google Sheets"
| "Google Slides";
provider: string;
title: string;
/** Sanitized native-fetched bitmap; never a remote URL. */
imageDataUrl?: string | null;
imageDomain?: string | null;
typeLabel:
| "PR"
| "issue"
@@ -41,16 +38,17 @@ export type SupportedLinkPreview = {
| "folder"
| "document"
| "spreadsheet"
| "presentation";
| "presentation"
| "link";
};
// Buzz relay hosts differ per community, so relay git URLs are recognized by
// their distinctive path shape (`/git/<64-hex-pubkey>/<repo>`) rather than by
// hostname, and require an explicit scheme.
// hostname, and require an explicit scheme. Generic previews remain HTTPS-only.
const SUPPORTED_URL_RE =
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+)/gi;
/(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
const MARKDOWN_SUPPORTED_LINK_RE =
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+)\)/gi;
/!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
const MAX_PREVIEWS = 8;
type HiddenRange = {
@@ -543,13 +541,28 @@ export function parseSupportedLinkPreview(
return null;
}
return (
const recognized =
parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ??
parseGithubLink(parsed) ??
parseLinearIssue(parsed) ??
parseGoogleDriveLink(parsed) ??
parseGoogleDocsLink(parsed)
);
parseGoogleDocsLink(parsed);
if (recognized) return recognized;
const hostname = normalizeHostname(parsed);
if (
parsed.protocol !== "https:" ||
[
"github.com",
"linear.app",
"drive.google.com",
"docs.google.com",
].includes(hostname)
) {
return null;
}
const provider = hostname;
return createPreview("generic-link", parsed, provider, "link", provider);
}
export function isSupportedLinkAutolinkLabel(

View File

@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildLinkPreviewSnapshotTag,
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("snapshot tags sanitize control characters and enforce UTF-8 byte limits", () => {
const tag = buildLinkPreviewSnapshotTag({
canonicalUrl: URL,
title: `Title\n${"😀".repeat(100)}`,
siteName: `Linear\u0000${"é".repeat(100)}`,
description: `First\nSecond\t${"😀".repeat(300)}`,
imageUrl: "",
imageSha256: "",
faviconUrl: "",
faviconSha256: "",
});
assert.ok(tag);
assert.equal(tag[6].startsWith("First\nSecond"), true);
assert.equal(tag[6].includes("\t"), false);
for (const value of tag.slice(4, 6)) {
assert.ok(
Array.from(value).every((char) => {
const code = char.charCodeAt(0);
return code > 0x1f && code !== 0x7f;
}),
);
}
assert.ok(Buffer.byteLength(tag[4], "utf8") <= 300);
assert.ok(Buffer.byteLength(tag[5], "utf8") <= 100);
assert.ok(Buffer.byteLength(tag[6], "utf8") <= 1000);
});
test("snapshot tags omit canonical URLs rejected by native validation", () => {
for (const canonicalUrl of [
`${URL}#`,
`${URL}#details`,
`http://linear.app/acme/issue/ABC-123/example`,
`https://user@linear.app/acme/issue/ABC-123/example`,
]) {
assert.equal(
buildLinkPreviewSnapshotTag({
canonicalUrl,
title: "Example",
siteName: "Linear",
description: "Description",
imageUrl: "",
imageSha256: "",
faviconUrl: "",
faviconSha256: "",
}),
null,
canonicalUrl,
);
}
});
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,179 @@
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 function isValidLinkPreviewSnapshotCanonicalUrl(value: string): boolean {
if (value.includes("#")) return false;
try {
const url = new URL(value);
return (
url.protocol === "https:" && !url.username && !url.password && !url.hash
);
} catch {
return false;
}
}
export type LinkPreviewSnapshot = {
canonicalUrl: string;
title: string;
siteName: string;
description: string;
imageUrl: string;
imageSha256: string;
faviconUrl: string;
faviconSha256: string;
};
function isControlCharacter(char: string, allowNewlines = false): boolean {
if (allowNewlines && char === "\n") return false;
const code = char.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
}
function sanitizeSnapshotText(
value: string,
maxBytes: number,
allowNewlines = false,
): string {
let result = "";
let byteLength = 0;
for (const rawChar of value) {
const char = isControlCharacter(rawChar, allowNewlines) ? " " : rawChar;
const charBytes = new TextEncoder().encode(char).length;
if (byteLength + charBytes > maxBytes) break;
result += char;
byteLength += charBytes;
}
return result;
}
function validText(value: string, max: number, allowNewlines = false): boolean {
return (
new TextEncoder().encode(value).length <= max &&
!Array.from(value).some((char) => {
return isControlCharacter(char, allowNewlines);
})
);
}
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, true)
)
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[] | null {
if (!isValidLinkPreviewSnapshotCanonicalUrl(snapshot.canonicalUrl))
return null;
return [
"link-preview",
"snapshot",
LINK_PREVIEW_SNAPSHOT_VERSION,
snapshot.canonicalUrl,
sanitizeSnapshotText(snapshot.title, 300),
sanitizeSnapshotText(snapshot.siteName, 100),
sanitizeSnapshotText(snapshot.description, 1000, true),
snapshot.imageUrl,
snapshot.imageSha256,
snapshot.faviconUrl,
snapshot.faviconSha256,
];
}

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";
const values = new Map();
globalThis.localStorage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
};
const preference = await import("./linkPreviewStylePreference.ts");
test("defaults invalid and missing link preview styles to compact", () => {
assert.equal(preference.parseLinkPreviewStyle(null), "compact");
assert.equal(preference.parseLinkPreviewStyle("expanded"), "compact");
assert.equal(preference.parseLinkPreviewStyle("compact"), "compact");
assert.equal(preference.parseLinkPreviewStyle("rich"), "rich");
});
test("persists and exposes the selected link preview style", () => {
preference.setLinkPreviewStyle("rich");
assert.equal(preference.getLinkPreviewStyle(), "rich");
assert.equal(values.get(preference.LINK_PREVIEW_STYLE_STORAGE_KEY), "rich");
});

View File

@@ -0,0 +1,56 @@
import * as React from "react";
/** User preference for how link previews are presented. */
export type LinkPreviewStyle = "compact" | "rich";
export const LINK_PREVIEW_STYLE_STORAGE_KEY =
"buzz.appearance.linkPreviewStyle";
export const DEFAULT_LINK_PREVIEW_STYLE: LinkPreviewStyle = "compact";
const listeners = new Set<() => void>();
let linkPreviewStyle = readStoredLinkPreviewStyle();
export function parseLinkPreviewStyle(
value: string | null | undefined,
): LinkPreviewStyle {
return value === "rich" || value === "compact"
? value
: DEFAULT_LINK_PREVIEW_STYLE;
}
function readStoredLinkPreviewStyle(): LinkPreviewStyle {
try {
return parseLinkPreviewStyle(
globalThis.localStorage?.getItem(LINK_PREVIEW_STYLE_STORAGE_KEY),
);
} catch {
return DEFAULT_LINK_PREVIEW_STYLE;
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getLinkPreviewStyle(): LinkPreviewStyle {
return linkPreviewStyle;
}
export function setLinkPreviewStyle(style: LinkPreviewStyle): void {
linkPreviewStyle = style;
try {
globalThis.localStorage?.setItem(LINK_PREVIEW_STYLE_STORAGE_KEY, style);
} catch {
// Persistence is best-effort; the in-memory preference still applies.
}
for (const listener of listeners) listener();
}
export function useLinkPreviewStyle(): LinkPreviewStyle {
return React.useSyncExternalStore(
subscribe,
getLinkPreviewStyle,
() => DEFAULT_LINK_PREVIEW_STYLE,
);
}

View File

@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
__linkPreviewMetadataTest,
resolveLinkPreview,
} from "./useResolvedLinkPreviews.ts";
const preview = {
kind: "generic-link",
href: "https://example.com/story",
provider: "example.com",
title: "example.com/story",
typeLabel: "link",
};
function metadata(overrides = {}) {
return {
title: "A story",
siteName: "Example",
description: "Story description",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "none",
imageRetryAfterMs: null,
...overrides,
};
}
test("pending metadata reserves the image treatment", () => {
assert.deepEqual(resolveLinkPreview(preview, undefined), {
...preview,
imageState: "pending",
});
});
test("resolved image metadata keeps the reserved image treatment", () => {
const resolved = resolveLinkPreview(preview, {
title: "A story",
siteName: "Example",
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "cdn.example.com",
});
assert.equal(resolved.imageState, "image");
assert.equal(resolved.provider, "Example");
assert.equal(resolved.imageDomain, "cdn.example.com");
});
test("resolved metadata without a complete image collapses to the compact treatment", () => {
const resolved = resolveLinkPreview(preview, {
title: "A story",
siteName: "Example",
imageDataUrl: null,
imageDomain: null,
});
assert.equal(resolved.imageState, "none");
assert.equal(resolved.imageDataUrl, null);
assert.equal(resolved.imageDomain, null);
});
test("transient and rejected image fetches use the stable fallback treatment", () => {
const transient = resolveLinkPreview(
preview,
metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
}),
);
const rejected = resolveLinkPreview(
preview,
metadata({ imageFetchState: "rejected" }),
);
assert.equal(transient.imageState, "fallback");
assert.equal(rejected.imageState, "fallback");
});
test("metadata cache keys deduplicate URL fragments", () => {
assert.equal(
__linkPreviewMetadataTest.metadataCacheKey(
"https://github.com/block/buzz/pull/3834#issuecomment-1",
),
"https://github.com/block/buzz/pull/3834",
);
});
test("transient metadata expires at the server retry boundary", () => {
assert.equal(
__linkPreviewMetadataTest.metadataExpiry(
metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
}),
1_000,
),
901_000,
);
});
test("metadata loader retries transient images after the server cooldown", async () => {
let now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
return calls === 1
? metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 10_000,
})
: metadata({
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "images.example.com",
imageFetchState: "image",
});
},
now: () => now,
});
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"transient_failure",
);
assert.equal(calls, 1);
now += 10_000;
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"image",
);
assert.equal(calls, 2);
});
test("metadata loader retries rejected requests after the negative-cache TTL", async () => {
let now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
if (calls === 1) throw new Error("temporary failure");
return metadata();
},
now: () => now,
});
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal(calls, 1);
now += 5 * 60_000;
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
assert.equal(calls, 2);
});
test("metadata loader coalesces fragment variants and bounds concurrency", async () => {
let active = 0;
let maxActive = 0;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
concurrency: 2,
fetcher: async () => {
calls += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return metadata();
},
});
await Promise.all([
loader.load("https://example.com/one#first"),
loader.load("https://example.com/one#second"),
loader.load("https://example.com/two"),
loader.load("https://example.com/three"),
]);
assert.equal(calls, 3);
assert.equal(maxActive, 2);
});

View File

@@ -13,189 +13,380 @@ import {
type SupportedLinkPreview,
} from "./linkPreview";
const GOOGLE_FALLBACK_TITLES = new Set([
"Drive file",
"Drive folder",
"Document",
"Spreadsheet",
"Presentation",
]);
type LinkPreviewImageFetchState =
| "none"
| "image"
| "transient_failure"
| "rejected";
const titleCache = new Map<string, Promise<string | null> | string | null>();
/**
* Generation counter incremented on every `resetLinkPreviewTitleCache` call.
* Each in-flight promise captures the generation at creation time and only
* writes back if no reset has happened since — preventing a resolved promise
* from a previous community from repopulating stale entries into the fresh
* cache of a new community.
*/
let cacheGeneration = 0;
export type LinkPreviewMetadata = {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: LinkPreviewImageFetchState;
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
};
type MetadataCacheEntry = {
expiresAt: number | null;
metadata: LinkPreviewMetadata | null;
};
type MetadataLoadResult = MetadataCacheEntry & {
key: string;
};
const DEFAULT_TRANSIENT_RETRY_MS = 30_000;
const NULL_METADATA_RETRY_MS = 5 * 60_000;
const MAX_CONCURRENT_METADATA_FETCHES = 2;
/**
* Buzz entity titles come from relay events, so they are community-scoped —
* wired into `resetCommunityState()` (see useCommunityInit.ts) to avoid
* leaking titles across community switches.
* React may flush an interaction-triggered effect before the browser paints.
* Start uncached preview I/O after a frame plus a task boundary so the pasted
* text and loading card are visible before native IPC work begins.
*/
export function resetLinkPreviewTitleCache(): void {
cacheGeneration += 1;
titleCache.clear();
}
function scheduleAfterPaint(task: () => void): () => void {
let frameId: number | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const run = () => {
timeoutId = setTimeout(task, 0);
};
/**
* Returns the current cache generation counter. Used in tests to verify that
* `resetLinkPreviewTitleCache` increments the generation so stale in-flight
* promises cannot seed the new cache.
*
* @internal test-only export
*/
export function getLinkPreviewCacheGeneration(): number {
return cacheGeneration;
}
function fetchLinkPreviewTitle(href: string): Promise<string | null> {
return invokeTauri<string | null>("fetch_link_preview_title", { href });
}
/**
* Resolve a Buzz PR/issue card title from the relay event's `subject` tag
* (first content line as fallback — the same precedence the projects views
* use).
*
* Security: the fetched event's canonical `a` tag must equal
* `30617:<owner>:<d>` from the link before we adopt its title. Without this
* check, a crafted link could pair the real title of a legitimate PR with an
* unrelated repository destination.
*/
async function fetchBuzzEntityTitle(href: string): Promise<string | null> {
const parsed = parseEntityLink(href);
if (!parsed.ok || parsed.value.type === "repo") return null;
const { id, owner, dtag } = parsed.value;
const expectedCoordinate = `30617:${owner}:${dtag}`;
const events = await relayClient.fetchEvents({
kinds: [
parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE,
],
ids: [id],
limit: 1,
});
const event = events[0];
if (!event) return null;
// Verify the event belongs to the claimed repository coordinate.
const aTag = event.tags.find(
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
);
if (!aTag) return null;
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
return subject || event.content.split("\n")[0] || null;
}
/**
* Returns true when the preview's current title is still the auto-generated
* fallback and a relay lookup should be attempted to replace it. Returns false
* once the user has applied a markdown label (`[My label](link)`) so that the
* label wins over any cached relay title.
*
* Exported for unit testing of the label-must-win invariant.
*/
export function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") {
// A markdown-label override replaces the fallback title and must win
// over the relay lookup.
const parsed = parseEntityLink(preview.href);
return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value);
if (typeof requestAnimationFrame === "function") {
frameId = requestAnimationFrame(run);
} else {
run();
}
return (
preview.kind.startsWith("google-") &&
GOOGLE_FALLBACK_TITLES.has(preview.title)
return () => {
if (frameId !== null) cancelAnimationFrame(frameId);
if (timeoutId !== null) clearTimeout(timeoutId);
};
}
function metadataCacheKey(href: string): string {
try {
const url = new URL(href);
url.hash = "";
return url.href;
} catch {
return href.split("#", 1)[0] ?? href;
}
}
function metadataExpiry(
metadata: LinkPreviewMetadata | null,
now: number,
): number | null {
if (metadata === null) return now + NULL_METADATA_RETRY_MS;
if (metadata.imageFetchState !== "transient_failure") return null;
const retryAfterMs =
typeof metadata.imageRetryAfterMs === "number" &&
Number.isFinite(metadata.imageRetryAfterMs)
? Math.max(1_000, metadata.imageRetryAfterMs)
: DEFAULT_TRANSIENT_RETRY_MS;
return now + retryAfterMs;
}
function createTaskScheduler(concurrency: number) {
const pending: Array<() => void> = [];
let active = 0;
const drain = () => {
while (active < concurrency) {
const run = pending.shift();
if (!run) return;
active += 1;
run();
}
};
return <T>(task: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
pending.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
active -= 1;
drain();
});
});
drain();
});
}
function createMetadataLoader({
concurrency = MAX_CONCURRENT_METADATA_FETCHES,
fetcher,
now = Date.now,
}: {
concurrency?: number;
fetcher: (href: string) => Promise<LinkPreviewMetadata | null>;
now?: () => number;
}) {
const cache = new Map<
string,
MetadataCacheEntry | Promise<MetadataLoadResult>
>();
const schedule = createTaskScheduler(Math.max(1, concurrency));
let generation = 0;
const peek = (href: string): MetadataLoadResult | undefined => {
const key = metadataCacheKey(href);
const cached = cache.get(key);
if (!cached || cached instanceof Promise) return undefined;
if (cached.expiresAt !== null && cached.expiresAt <= now()) {
cache.delete(key);
return undefined;
}
return { key, ...cached };
};
const load = (href: string): Promise<MetadataLoadResult> => {
const key = metadataCacheKey(href);
const cached = cache.get(key);
if (cached instanceof Promise) return cached;
if (cached) {
if (cached.expiresAt === null || cached.expiresAt > now()) {
return Promise.resolve({ key, ...cached });
}
cache.delete(key);
}
const requestGeneration = generation;
const promise = schedule(() => fetcher(href))
.catch(() => null)
.then((metadata) => {
const entry = {
expiresAt: metadataExpiry(metadata, now()),
metadata,
};
if (requestGeneration === generation) {
cache.set(key, entry);
}
return { key, ...entry };
});
cache.set(key, promise);
return promise;
};
return {
deleteKey(key: string) {
cache.delete(key);
},
load,
peek,
reset() {
generation += 1;
cache.clear();
},
};
}
function fetchLinkPreviewMetadata(
href: string,
): Promise<LinkPreviewMetadata | null> {
return invokeTauri<LinkPreviewMetadata | null>(
"fetch_link_preview_metadata",
{
href,
},
);
}
function resolveTitle(preview: SupportedLinkPreview): Promise<string | null> {
return preview.href.startsWith("buzz://")
? fetchBuzzEntityTitle(preview.href)
: fetchLinkPreviewTitle(preview.href);
const metadataLoader = createMetadataLoader({
fetcher: fetchLinkPreviewMetadata,
});
const entityTitleLoader = createMetadataLoader({
fetcher: async (href) => {
const parsed = parseEntityLink(href);
if (!parsed.ok || parsed.value.type === "repo") return null;
const { id, owner, dtag } = parsed.value;
const expectedCoordinate = `30617:${owner}:${dtag}`;
const events = await relayClient.fetchEvents({
kinds: [
parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE,
],
ids: [id],
limit: 1,
});
const event = events[0];
if (
!event?.tags.some(
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
)
) {
return null;
}
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
const title = subject || event.content.split("\n")[0] || null;
return title
? {
title,
siteName: "Buzz",
description: null,
imageDataUrl: null,
imageDomain: null,
}
: null;
},
});
/** Clear ephemeral metadata when the active relay/community changes. */
export function resetLinkPreviewMetadataCache(): void {
metadataLoader.reset();
entityTitleLoader.reset();
}
function cacheTitle(preview: SupportedLinkPreview): Promise<string | null> {
const cached = titleCache.get(preview.href);
if (cached instanceof Promise) return cached;
if (cached !== undefined) return Promise.resolve(cached);
export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none";
const generation = cacheGeneration;
const promise = resolveTitle(preview)
.then((title) => {
// Only write back if no community switch has happened since we started.
if (cacheGeneration === generation) {
titleCache.set(preview.href, title);
}
return title;
})
.catch(() => {
if (cacheGeneration === generation) {
titleCache.set(preview.href, null);
}
return null;
});
titleCache.set(preview.href, promise);
return promise;
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<
string,
LinkPreviewMetadata | null | undefined
>;
/** Only auto-generated titles may be replaced; explicit markdown labels win. */
export function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
if (preview.kind !== "buzz-pull-request" && preview.kind !== "buzz-issue") {
return true;
}
const parsed = parseEntityLink(preview.href);
return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value);
}
export function resolveLinkPreview(
preview: SupportedLinkPreview,
metadata: LinkPreviewMetadata | null | undefined,
): ResolvedLinkPreview {
if (metadata === undefined) {
return { ...preview, imageState: "pending" };
}
if (metadata === null) {
return { ...preview, imageState: "none" };
}
const hasImage = Boolean(metadata.imageDataUrl && metadata.imageDomain);
const imageState: LinkPreviewImageState = hasImage
? "image"
: metadata.imageFetchState === "image" ||
metadata.imageFetchState === "transient_failure" ||
metadata.imageFetchState === "rejected"
? "fallback"
: "none";
return {
...preview,
snapshotReady: !preview.href.startsWith("buzz://"),
title: shouldResolveTitle(preview) ? metadata.title : preview.title,
description: metadata.description,
faviconDataUrl: metadata.faviconDataUrl,
provider:
preview.kind === "generic-link" && metadata.siteName
? metadata.siteName
: preview.provider,
imageDataUrl: hasImage ? metadata.imageDataUrl : null,
imageDomain: hasImage ? metadata.imageDomain : null,
imageState,
};
}
export function useResolvedLinkPreviews(
previews: SupportedLinkPreview[],
): SupportedLinkPreview[] {
const [resolvedTitles, setResolvedTitles] = React.useState<
Record<string, string>
>({});
): ResolvedLinkPreview[] {
const [resolvedMetadata, setResolvedMetadata] =
React.useState<ResolvedMetadataByHref>({});
const [retryGeneration, setRetryGeneration] = React.useState(0);
React.useEffect(() => {
let cancelled = false;
const pending = previews.filter(shouldResolveTitle);
if (pending.length === 0) return undefined;
let retryAt = Number.POSITIVE_INFINITY;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
for (const preview of pending) {
const cached = titleCache.get(preview.href);
if (typeof cached === "string" && cached) {
setResolvedTitles((current) =>
current[preview.href] === cached
const scheduleRetry = (
{ expiresAt, key }: Pick<MetadataLoadResult, "expiresAt" | "key">,
loader: typeof metadataLoader,
) => {
if (expiresAt === null || expiresAt >= retryAt) return;
retryAt = expiresAt;
if (retryTimer !== null) clearTimeout(retryTimer);
retryTimer = setTimeout(
() => {
loader.deleteKey(key);
setResolvedMetadata((current) => {
if (!(key in current)) return current;
const next = { ...current };
delete next[key];
return next;
});
setRetryGeneration(retryGeneration + 1);
},
Math.max(0, expiresAt - Date.now()),
);
};
const cancelScheduledLoads: Array<() => void> = [];
for (const preview of previews) {
const loader = preview.href.startsWith("buzz://")
? entityTitleLoader
: metadataLoader;
const cached = loader.peek(preview.href);
if (cached !== undefined) {
setResolvedMetadata((current) =>
current[cached.key] === cached.metadata
? current
: { ...current, [preview.href]: cached },
: { ...current, [cached.key]: cached.metadata },
);
scheduleRetry(cached, loader);
continue;
}
void cacheTitle(preview).then((title) => {
if (cancelled || !title) return;
setResolvedTitles((current) =>
current[preview.href] === title
? current
: { ...current, [preview.href]: title },
);
});
cancelScheduledLoads.push(
scheduleAfterPaint(() => {
void loader.load(preview.href).then((result) => {
if (cancelled) return;
setResolvedMetadata((current) =>
current[result.key] === result.metadata
? current
: { ...current, [result.key]: result.metadata },
);
scheduleRetry(result, loader);
});
}),
);
}
return () => {
cancelled = true;
for (const cancel of cancelScheduledLoads) cancel();
if (retryTimer !== null) clearTimeout(retryTimer);
};
}, [previews]);
}, [previews, retryGeneration]);
return React.useMemo(
() =>
previews.map((preview) => {
const title = resolvedTitles[preview.href];
// Only apply a relay-resolved title while the preview still has the
// fallback title (i.e. no markdown-label override). If the user edits
// a bare link into `[My label](same-link)`, `shouldResolveTitle`
// returns false and the label wins — the cached relay title is not
// applied to prevent it from silently overriding the explicit label.
return title && shouldResolveTitle(preview)
? { ...preview, title }
: preview;
previews.flatMap((preview) => {
const metadata = resolvedMetadata[metadataCacheKey(preview.href)];
return metadata === null ? [] : [resolveLinkPreview(preview, metadata)];
}),
[previews, resolvedTitles],
[previews, resolvedMetadata],
);
}
export const __linkPreviewMetadataTest = {
createMetadataLoader,
createTaskScheduler,
metadataCacheKey,
metadataExpiry,
};

View File

@@ -0,0 +1,176 @@
import { ImageOff } from "lucide-react";
import { useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/shared/ui/attachment";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
function getHostname(preview: ResolvedLinkPreview): string {
try {
return new URL(preview.href).hostname.replace(/^www\./, "");
} catch {
return preview.provider;
}
}
function LinkPreviewImageFallback({
preview,
}: {
preview: ResolvedLinkPreview;
}) {
return (
<div
aria-hidden="true"
className="flex h-full w-full items-center justify-center bg-muted text-muted-foreground"
data-link-preview-image-fallback=""
>
{preview.faviconDataUrl ? (
<img
alt=""
className="size-7 rounded-md object-contain opacity-70"
src={preview.faviconDataUrl}
/>
) : (
<ImageOff className="size-5 opacity-60" />
)}
</div>
);
}
export function CompactLinkPreviewAttachment({
className,
onOpen,
onRemove,
preview,
showControls = false,
}: {
className?: string;
onOpen?: () => void;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
}) {
const reserveImage = preview.imageState !== "none";
const imageSrc =
preview.imageState === "image" ? (preview.imageDataUrl ?? null) : null;
const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
const showFallback =
preview.imageState === "fallback" || Boolean(imageSrc && !showImage);
const hostname = getHostname(preview);
return (
<div className={cn("relative w-96 max-w-full shrink-0", className)}>
<Attachment
className={cn(
"h-21 min-h-21 max-h-21 w-full bg-transparent no-underline shadow-none hover:bg-transparent",
reserveImage
? "gap-0 border-0 p-0 hover:border-transparent"
: "rounded-none border-0 border-l-[3px] border-border px-0 py-1 pl-3 hover:border-border",
)}
data-image-state={preview.imageState}
data-link-preview={preview.kind}
orientation="horizontal"
>
{reserveImage ? (
<AttachmentMedia
aria-hidden={showImage ? undefined : "true"}
className="aspect-auto h-full min-h-0 w-30 min-w-30 max-w-30 self-stretch rounded-xl bg-muted sm:w-34 sm:min-w-34 sm:max-w-34"
data-link-preview-thumbnail=""
variant="image"
>
{showImage ? (
<img
alt={`Preview from ${preview.imageDomain}`}
className="h-full w-full object-cover"
onError={() => setFailedImageSrc(imageSrc)}
src={imageSrc ?? undefined}
/>
) : showFallback ? (
<LinkPreviewImageFallback preview={preview} />
) : (
<div
className="h-full w-full animate-pulse bg-muted-foreground/10"
data-link-preview-skeleton=""
/>
)}
</AttachmentMedia>
) : null}
<AttachmentContent className={reserveImage ? "px-2 py-2" : undefined}>
<a
className="relative z-20 flex w-fit max-w-full min-w-0 items-center gap-1.5 text-xs font-normal leading-4 text-muted-foreground/70 group-hover/attachment:underline"
data-link-preview-hostname=""
href={preview.href}
onClick={
onOpen
? (event) => {
event.preventDefault();
onOpen();
}
: undefined
}
rel="noreferrer"
target="_blank"
>
{preview.faviconDataUrl ? (
<img
alt=""
aria-hidden="true"
className="size-3 shrink-0 rounded-sm object-contain"
data-link-preview-hostname-favicon=""
src={preview.faviconDataUrl}
/>
) : null}
<span className="truncate">{hostname}</span>
</a>
<AttachmentTitle className="line-clamp-2 whitespace-normal group-hover/attachment:underline">
{preview.title}
</AttachmentTitle>
{preview.description ? (
<AttachmentDescription className="text-muted-foreground/70">
{preview.description}
</AttachmentDescription>
) : null}
</AttachmentContent>
{onOpen ? (
<AttachmentTrigger
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
className={reserveImage ? undefined : "rounded-none"}
onClick={onOpen}
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</AttachmentTrigger>
) : (
<AttachmentTrigger
asChild
className={reserveImage ? undefined : "rounded-none"}
>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
)}
</Attachment>
{showControls ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
</div>
);
}

View File

@@ -1,181 +1,47 @@
import { ExternalLink } from "lucide-react";
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
import { CompactLinkPreviewAttachment } from "@/shared/ui/compact-link-preview-attachment";
import {
Attachment,
AttachmentActions,
AttachmentContent,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/shared/ui/attachment";
function LinearLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
);
}
function GitHubLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}
function GoogleDriveLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
</svg>
);
}
function GoogleDocsLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
</svg>
);
}
function GoogleSheetsLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
</svg>
);
}
function GoogleSlidesLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
</svg>
);
}
function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
switch (preview.kind) {
case "buzz-issue":
case "buzz-pull-request":
case "buzz-repository":
return <BuzzMark className="h-4 w-4" />;
case "github-issue":
case "github-pull-request":
case "github-repository":
return <GitHubLogo className="h-4 w-4" />;
case "linear-issue":
return <LinearLogo className="h-4 w-4" />;
case "google-drive-file":
case "google-drive-folder":
return <GoogleDriveLogo className="h-4 w-4" />;
case "google-docs-document":
return <GoogleDocsLogo className="h-4 w-4" />;
case "google-sheets-spreadsheet":
return <GoogleSheetsLogo className="h-4 w-4" />;
case "google-slides-presentation":
return <GoogleSlidesLogo className="h-4 w-4" />;
}
}
type LinkPreviewImageLightboxComponent,
RichLinkPreviewAttachment,
} from "@/shared/ui/rich-link-preview-attachment";
export function LinkPreviewAttachment({
className,
ImageLightbox,
onOpen,
onRemove,
preview,
showControls,
}: {
className?: string;
/**
* In-app navigation handler for links the OS cannot open (e.g. `buzz://`
* entity deep links). When set, the card renders a button trigger instead
* of an external anchor.
*/
ImageLightbox: LinkPreviewImageLightboxComponent;
onOpen?: () => void;
preview: SupportedLinkPreview;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
}) {
const style = useLinkPreviewStyle();
if (style === "rich") {
return (
<RichLinkPreviewAttachment
className={className}
ImageLightbox={ImageLightbox}
onOpen={onOpen}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}
return (
<Attachment
className={cn(
"w-80 max-w-full shrink-0 no-underline shadow-none",
className,
)}
data-link-preview={preview.kind}
>
<AttachmentMedia className="link-preview-media">
<LinkPreviewLogo preview={preview} />
</AttachmentMedia>
<AttachmentContent>
<div className="truncate text-xs font-medium leading-4 text-muted-foreground">
{preview.provider}
<span aria-hidden="true"> · </span>
{preview.typeLabel}
</div>
<AttachmentTitle>{preview.title}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<ExternalLink
aria-hidden="true"
className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
/>
</AttachmentActions>
{onOpen ? (
<AttachmentTrigger
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
onClick={onOpen}
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</AttachmentTrigger>
) : (
<AttachmentTrigger asChild>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
)}
</Attachment>
<CompactLinkPreviewAttachment
className={className}
onOpen={onOpen}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}

View File

@@ -0,0 +1,125 @@
import { EllipsisVertical, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { useAppShell } from "@/app/AppShellContext";
import {
setLinkPreviewStyle,
type LinkPreviewStyle,
useLinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
const CONTROL_BUTTON_CLASS =
"h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/message:opacity-100 data-[state=open]:opacity-100";
const LINK_PREVIEW_STYLE_OPTIONS: {
value: LinkPreviewStyle;
label: string;
}[] = [
{ value: "rich", label: "Rich" },
{ value: "compact", label: "Compact" },
];
export function LinkPreviewControls({
onRemove,
placement = "right",
}: {
onRemove?: () => void;
placement?: "left" | "right";
}) {
const style = useLinkPreviewStyle();
const { onOpenSettings } = useAppShell();
const handleStyleChange = (nextStyle: string) => {
if (
(nextStyle !== "rich" && nextStyle !== "compact") ||
nextStyle === style
) {
return;
}
setLinkPreviewStyle(nextStyle);
toast.success(
`Link previews set to ${nextStyle === "rich" ? "Rich" : "Compact"}.`,
{
action: onOpenSettings
? {
label: "Appearance",
onClick: () => onOpenSettings("appearance"),
}
: undefined,
description:
"You can always modify this and other settings in Appearance.",
},
);
};
return (
<div
className={cn(
"absolute top-0 z-20 flex flex-col",
placement === "left" ? "right-full" : "left-full ml-1",
)}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Link display settings"
className={CONTROL_BUTTON_CLASS}
size="icon-xs"
title="Link display settings"
type="button"
variant="ghost"
>
<EllipsisVertical aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Display</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={handleStyleChange}
value={style}
>
{LINK_PREVIEW_STYLE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{onRemove ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
>
<EyeOff aria-hidden="true" />
Remove preview
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from "react";
import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { Button } from "@/shared/ui/button";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
import type { LinkPreviewImageLightboxComponent } from "@/shared/ui/rich-link-preview-attachment";
export function LinkPreviewList({
ImageLightbox,
onOpenByHref,
onRemoveForEveryone,
previews,
}: {
ImageLightbox: LinkPreviewImageLightboxComponent;
onOpenByHref?: ReadonlyMap<string, () => void>;
onRemoveForEveryone?: () => Promise<void>;
previews: ResolvedLinkPreview[];
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [removed, setRemoved] = useState(false);
const style = useLinkPreviewStyle();
if (removed || previews.length === 0) return null;
const previewNoun = previews.length === 1 ? "preview" : "previews";
const controlsIndex = 0;
return (
<>
<AttachmentGroup
className={
style === "compact"
? "max-w-full flex-row flex-wrap items-start overflow-visible pb-0"
: "max-w-full flex-col items-start overflow-visible pb-0"
}
data-link-preview-list=""
>
{previews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
ImageLightbox={ImageLightbox}
onOpen={onOpenByHref?.get(preview.href)}
onRemove={
onRemoveForEveryone && index === controlsIndex
? () => setDialogOpen(true)
: undefined
}
preview={preview}
showControls={index === controlsIndex}
/>
))}
</AttachmentGroup>
{onRemoveForEveryone ? (
<AlertDialog onOpenChange={setDialogOpen} open={dialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {previewNoun}?</AlertDialogTitle>
<AlertDialogDescription>
This removes{" "}
{previews.length === 1 ? "the preview" : "the previews"} for
everyone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setRemoved(true);
void onRemoveForEveryone().catch(() => setRemoved(false));
}}
type="button"
variant="destructive"
>
Remove {previewNoun}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</>
);
}

View File

@@ -22,16 +22,13 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
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 { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
computeConfigNudge,
@@ -66,6 +63,7 @@ import {
import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor";
import { FileCard } from "./markdown/FileCard";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { createLinkPreviewImageLightbox } from "./markdown/LinkPreviewImageLightbox";
import { MarkdownInput } from "./markdown/MarkdownInput";
import {
MediaContextMenu,
@@ -663,16 +661,10 @@ function ImageZoomOverlay({
const frameCornerRadii = isReturning
? returnCornerRadii
: imageLightboxExpandedCornerRadii();
// Once fully settled at 1x, drop the transform to `none` so the wrapper
// leaves the GPU-composited path and the <img> repaints through WebKit's
// high-quality paint rasterizer — matching inline-image sharpness. An
// identity `translate3d` would keep it composited, so it must be `none`.
const atRest =
isOpen &&
hasEntered &&
zoom === IMAGE_LIGHTBOX_MIN_ZOOM &&
// Holds composited through the trackpad gesture-end idle window: after a
// pinch settles back to exactly 1x, `isAdjustingZoom` stays true for
// IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS, avoiding a demote/re-promote thrash.
!isAdjustingZoom;
const transform = atRest
@@ -999,6 +991,9 @@ function ImageZoomOverlay({
);
}
export const LinkPreviewImageLightbox =
createLinkPreviewImageLightbox(ImageZoomOverlay);
/**
* Inline image embed with click-to-zoom lightbox and right-click download.
*
@@ -1777,6 +1772,10 @@ function MarkdownInner({
interactive = true,
agentMentionPubkeysByName,
mediaInset = false,
messageId,
linkPreviewsSuppressed = false,
linkPreviewTags,
onRemoveLinkPreviewsForEveryone,
mentionNames,
mentionPubkeysByName,
searchQuery,
@@ -1810,10 +1809,18 @@ function MarkdownInner({
[goChannel],
);
const relayOrigin = useRelayOrigin();
const linkPreviews = React.useMemo(
const resolvedLinkPreviews = React.useMemo(
() =>
interactive ? extractSupportedLinkPreviews(content, relayOrigin) : [],
[content, interactive, relayOrigin],
interactive && !linkPreviewsSuppressed
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
: [],
[
content,
interactive,
linkPreviewTags,
linkPreviewsSuppressed,
relayOrigin,
],
);
const configNudge = React.useMemo(
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
@@ -1868,7 +1875,6 @@ function MarkdownInner({
processedContent = `${processedContent}\u200B`;
}
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
const entityCardOpenHandlers = useEntityCardOpenHandlers(
resolvedLinkPreviews,
onOpenEntityLink,
@@ -1922,20 +1928,13 @@ function MarkdownInner({
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment
key={preview.href}
onOpen={entityCardOpenHandlers.get(preview.href)}
preview={preview}
/>
))}
</AttachmentGroup>
) : null}
<LinkPreviewList
ImageLightbox={LinkPreviewImageLightbox}
key={messageId}
onOpenByHref={entityCardOpenHandlers}
onRemoveForEveryone={onRemoveLinkPreviewsForEveryone}
previews={resolvedLinkPreviews}
/>
</VideoReviewMarkdownContext.Provider>
</MarkdownRuntimeContext.Provider>
</div>

View File

@@ -0,0 +1,118 @@
import type { ComponentType } from "react";
import { useRef, useState } from "react";
import { cn } from "@/shared/lib/cn";
import type { LinkPreviewImageLightboxProps } from "@/shared/ui/rich-link-preview-attachment";
import {
type ImageGalleryItem,
type ImageLightboxBox,
type ImageLightboxCornerRadii,
imageLightboxBoxFromRect,
imageLightboxCornerRadiiFromElement,
visibleImageGalleryForTrigger,
} from "./imageLightbox";
type ImageZoomOverlayProps = {
alt: string | undefined;
galleryIndex?: number;
galleryItems?: ImageGalleryItem[];
onClose: () => void;
onCopy: (src: string | undefined) => void;
onDownload: (src: string | undefined) => void;
resolvedSrc: string;
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
sourceScope?: Element | null;
src: string | undefined;
};
const ignoreUnavailableImageAction = () => undefined;
export function createLinkPreviewImageLightbox(
ImageZoomOverlay: ComponentType<ImageZoomOverlayProps>,
): ComponentType<LinkPreviewImageLightboxProps> {
return function LinkPreviewImageLightbox({ alt, children, className, src }) {
const [lightboxState, setLightboxState] = useState<{
galleryIndex: number;
galleryItems?: ImageGalleryItem[];
sourceBox: ImageLightboxBox;
sourceCornerRadii: ImageLightboxCornerRadii;
sourceScope: Element | null;
} | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const openLightbox = () => {
const trigger = triggerRef.current;
const image = trigger?.querySelector("img");
if (!trigger || !image) return;
const rect = image.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const sourceBox = imageLightboxBoxFromRect(rect);
const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image);
const sourceScope = trigger.closest("[data-link-preview-list]");
const dim =
image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
: undefined;
const gallery = visibleImageGalleryForTrigger(
trigger,
{
alt,
dim,
resolvedSrc: src,
src: undefined,
thumbnailBox: sourceBox,
thumbnailCornerRadii: sourceCornerRadii,
},
sourceScope,
);
setLightboxState({
galleryIndex: gallery.galleryIndex,
galleryItems: gallery.galleryItems,
sourceBox,
sourceCornerRadii,
sourceScope,
});
};
return (
<>
<button
aria-label={`Zoom image: ${alt}`}
className={cn(
"cursor-zoom-in border-0 p-0 text-left focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring/50",
lightboxState && "opacity-0",
className,
)}
data-image-lightbox-alt={alt}
data-image-lightbox-resolved-src={src}
data-image-lightbox-trigger=""
onClick={openLightbox}
ref={triggerRef}
type="button"
>
{children}
</button>
{lightboxState ? (
<ImageZoomOverlay
alt={alt}
galleryIndex={lightboxState.galleryIndex}
galleryItems={lightboxState.galleryItems}
onClose={() => setLightboxState(null)}
onCopy={ignoreUnavailableImageAction}
onDownload={ignoreUnavailableImageAction}
resolvedSrc={src}
sourceBox={lightboxState.sourceBox}
sourceCornerRadii={lightboxState.sourceCornerRadii}
sourceScope={lightboxState.sourceScope}
src={undefined}
/>
) : null}
</>
);
};
}

View File

@@ -316,9 +316,15 @@ function imageGalleryItemFromTrigger(
return null;
}
const image = trigger.querySelector("img");
const inferredDim =
image && image.naturalWidth > 0 && image.naturalHeight > 0
? `${image.naturalWidth}x${image.naturalHeight}`
: undefined;
return {
alt: trigger.dataset.imageLightboxAlt || undefined,
dim: trigger.dataset.imageLightboxDim || undefined,
dim: trigger.dataset.imageLightboxDim || inferredDim,
resolvedSrc,
src: trigger.dataset.imageLightboxSrc || undefined,
thumbnailBox: thumbnail?.box,
@@ -403,3 +409,25 @@ export function visibleImageGalleryForTrigger(
galleryItems: galleryItems.length > 1 ? galleryItems : undefined,
};
}
export function getImageLightboxFocusableElements(
container: HTMLElement,
): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
[
"a[href]",
"button:not(:disabled)",
"input:not(:disabled)",
"select:not(:disabled)",
"textarea:not(:disabled)",
"[tabindex]:not([tabindex='-1'])",
].join(","),
),
).filter(
(element) =>
!element.hasAttribute("disabled") &&
element.getAttribute("aria-hidden") !== "true" &&
element.getClientRects().length > 0,
);
}

View File

@@ -68,6 +68,11 @@ export type MarkdownProps = {
mentionNames?: string[];
mentionPubkeysByName?: Record<string, string>;
mediaInset?: boolean;
/** 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. */
snapshotSharedBy?: string;

View File

@@ -0,0 +1,350 @@
import { ChevronDown, ChevronUp, ImageOff } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import { useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
export type LinkPreviewImageLightboxProps = {
alt: string;
children: ReactNode;
className?: string;
src: string;
};
export type LinkPreviewImageLightboxComponent =
ComponentType<LinkPreviewImageLightboxProps>;
function LinkPreviewIdentity({ preview }: { preview: ResolvedLinkPreview }) {
if (preview.faviconDataUrl) {
return (
<img
alt=""
aria-hidden="true"
className="size-4 rounded-sm object-contain"
data-link-preview-favicon=""
src={preview.faviconDataUrl}
/>
);
}
return null;
}
function getHostname(preview: ResolvedLinkPreview): string {
try {
return new URL(preview.href).hostname.replace(/^www\./, "");
} catch {
return preview.provider;
}
}
function isTweetPreview(preview: ResolvedLinkPreview): boolean {
try {
const url = new URL(preview.href);
return (
(url.hostname === "x.com" || url.hostname === "twitter.com") &&
/^\/[^/]+\/status\/\d+/.test(url.pathname)
);
} catch {
return false;
}
}
function LinkPreviewImage({
aspectClassName,
className,
ImageLightbox,
preview,
}: {
aspectClassName: string;
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
preview: ResolvedLinkPreview;
}) {
const imageSrc =
preview.imageState === "image" ? preview.imageDataUrl : undefined;
const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
const imageFailed = Boolean(imageSrc && failedImageSrc === imageSrc);
const showFallback = preview.imageState === "fallback" || imageFailed;
const alt = `Preview from ${preview.imageDomain}`;
if (!imageSrc || imageFailed) {
return (
<div
className={cn("overflow-hidden rounded-xl bg-muted", className)}
data-link-preview-thumbnail=""
>
{showFallback ? (
<div
aria-hidden="true"
className={cn(
"flex w-full items-center justify-center bg-muted text-muted-foreground",
aspectClassName,
)}
data-link-preview-image-fallback=""
>
{preview.faviconDataUrl ? (
<img
alt=""
className="size-10 rounded-lg object-contain opacity-70"
src={preview.faviconDataUrl}
/>
) : (
<ImageOff className="size-7 opacity-60" />
)}
</div>
) : (
<div
className={cn(
"w-full animate-pulse bg-muted-foreground/10",
aspectClassName,
)}
data-link-preview-skeleton=""
/>
)}
</div>
);
}
return (
<ImageLightbox
alt={alt}
className={cn("block overflow-hidden rounded-xl bg-muted", className)}
src={imageSrc}
>
<div
className={cn("w-full", aspectClassName)}
data-link-preview-thumbnail=""
>
<img
alt={alt}
className="h-full w-full rounded-xl object-cover"
onError={() => setFailedImageSrc(imageSrc)}
src={imageSrc}
/>
</div>
</ImageLightbox>
);
}
function LinkPreviewDescription({
className,
description,
}: {
className?: string;
description: string;
}) {
return (
<div
className={cn("mt-1 space-y-2 text-sm leading-5", className)}
data-slot="attachment-description"
>
{description.split(/\n{2,}/).map((paragraph) => (
<p className="whitespace-pre-line" key={paragraph}>
{paragraph}
</p>
))}
</div>
);
}
function TweetPreview({
className,
ImageLightbox,
onRemove,
preview,
showControls,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls: boolean;
}) {
const [contentExpanded, setContentExpanded] = useState(true);
const reserveImage = preview.imageState !== "none";
const hasExpandableContent = Boolean(preview.description) || reserveImage;
const hostname = getHostname(preview);
return (
<div
className={cn(
"relative w-lg max-w-full shrink-0 border-l-[3px] border-border py-1 pl-3",
className,
)}
data-image-state={preview.imageState}
data-link-preview={preview.kind}
data-tweet-preview=""
>
<a
className="block w-fit max-w-full truncate text-xs leading-4 text-muted-foreground/70 hover:underline"
data-link-preview-hostname=""
href={preview.href}
rel="noreferrer"
target="_blank"
>
{hostname}
</a>
<a
className="mt-0.5 line-clamp-2 whitespace-normal text-sm font-semibold leading-5 text-foreground hover:underline"
href={preview.href}
rel="noreferrer"
target="_blank"
>
{preview.title}
</a>
{contentExpanded && preview.description ? (
<LinkPreviewDescription
className="text-foreground"
description={preview.description}
/>
) : null}
{contentExpanded && reserveImage ? (
<LinkPreviewImage
aspectClassName="aspect-video"
className="mt-2 w-full max-w-96"
ImageLightbox={ImageLightbox}
preview={preview}
/>
) : null}
{hasExpandableContent ? (
<button
aria-expanded={contentExpanded}
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
onClick={() => setContentExpanded((expanded) => !expanded)}
type="button"
>
{contentExpanded ? (
<ChevronUp aria-hidden="true" className="size-3" />
) : (
<ChevronDown aria-hidden="true" className="size-3" />
)}
{contentExpanded ? "Show less" : "Show more"}
</button>
) : null}
{showControls ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
</div>
);
}
export function RichLinkPreviewAttachment({
className,
ImageLightbox,
onOpen,
onRemove,
preview,
showControls = false,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onOpen?: () => void;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
}) {
const [contentExpanded, setContentExpanded] = useState(true);
if (isTweetPreview(preview)) {
return (
<TweetPreview
className={className}
ImageLightbox={ImageLightbox}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}
const reserveImage = preview.imageState !== "none";
const hasExpandableContent = Boolean(preview.description) || reserveImage;
const hostname = getHostname(preview);
return (
<div
className={cn(
"relative w-lg max-w-full shrink-0 border-l-[3px] border-border py-1 pl-3",
className,
)}
data-image-state={preview.imageState}
data-link-preview={preview.kind}
data-link-preview-inline=""
>
<div
className={cn(contentExpanded && reserveImage && "min-h-[3.875rem]")}
>
<a
className="flex w-fit max-w-full items-center gap-1.5 text-xs leading-4 text-muted-foreground/70 hover:underline"
data-link-preview-identity=""
data-link-preview-hostname=""
href={preview.href}
onClick={
onOpen
? (event) => {
event.preventDefault();
onOpen();
}
: undefined
}
rel="noreferrer"
target="_blank"
>
<LinkPreviewIdentity preview={preview} />
<span className="truncate">{hostname}</span>
</a>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
className="mt-0.5 block text-sm font-semibold leading-5 text-foreground hover:underline"
href={preview.href}
onClick={
onOpen
? (event) => {
event.preventDefault();
onOpen();
}
: undefined
}
rel="noreferrer"
target="_blank"
>
<span className="line-clamp-2">{preview.title}</span>
</a>
{contentExpanded && preview.description ? (
<LinkPreviewDescription
className="text-muted-foreground"
description={preview.description}
/>
) : null}
</div>
{contentExpanded && reserveImage ? (
<LinkPreviewImage
aspectClassName="aspect-[1.91/1]"
className="mt-2 w-full max-w-96"
ImageLightbox={ImageLightbox}
preview={preview}
/>
) : null}
{hasExpandableContent ? (
<button
aria-expanded={contentExpanded}
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
onClick={() => setContentExpanded((expanded) => !expanded)}
type="button"
>
{contentExpanded ? (
<ChevronUp aria-hidden="true" className="size-3" />
) : (
<ChevronDown aria-hidden="true" className="size-3" />
)}
{contentExpanded ? "Show less" : "Show more"}
</button>
) : null}
{showControls ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
</div>
);
}

View File

@@ -62,6 +62,10 @@ import type {
RuntimeFileConfigSubset,
} from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
isValidLinkPreviewSnapshotCanonicalUrl,
parseLinkPreviewSnapshots,
} from "@/shared/lib/linkPreviewSnapshot";
type TestIdentity = {
privateKey: string;
@@ -335,6 +339,32 @@ type E2eConfig = {
profileHasEvent?: boolean;
profileUpdateError?: string;
profileUpdateErrors?: string[];
linkPreviewMetadata?: {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null;
linkPreviewMetadataByHref?: Record<
string,
{
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null
>;
linkPreviewMetadataDelayMs?: number;
/** Simulates native cold-cache startup work before the async response. */
linkPreviewMetadataStartBlockMs?: number;
searchProfiles?: MockSearchProfileSeed[];
updateAvailable?: boolean;
updateChannelDelayMs?: number;
@@ -8912,12 +8942,30 @@ async function resolveMockUploadDescriptorForBytes(
value.toString(16).padStart(2, "0"),
).join("");
const filename = args.filename ?? "upload.bin";
const isAgentJson = filename.toLowerCase().endsWith(".agent.json");
const normalizedFilename = filename.toLowerCase();
const isAgentJson = normalizedFilename.endsWith(".agent.json");
const extension = isAgentJson
? "json"
: normalizedFilename.endsWith(".png")
? "png"
: normalizedFilename.endsWith(".jpg") ||
normalizedFilename.endsWith(".jpeg")
? "jpg"
: normalizedFilename.endsWith(".gif")
? "gif"
: normalizedFilename.endsWith(".webp")
? "webp"
: "bin";
const type = isAgentJson
? "application/json"
: extension === "bin"
? "application/octet-stream"
: `image/${extension === "jpg" ? "jpeg" : extension}`;
return {
url: `https://mock.relay/media/${sha256}${isAgentJson ? ".json" : ".bin"}`,
url: `${getRelayHttpUrl(config)}/media/${sha256}.${extension}`,
sha256,
size: bytes.length,
type: isAgentJson ? "application/json" : "application/octet-stream",
type,
uploaded: Math.floor(Date.now() / 1000),
filename,
};
@@ -8933,6 +8981,8 @@ async function handleSendChannelMessage(
mediaTags?: string[][] | null;
emojiTags?: string[][] | null;
mentionTags?: string[][] | null;
linkPreviewTags?: string[][] | null;
suppressLinkPreviews?: boolean;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
@@ -8955,7 +9005,44 @@ async function handleSendChannelMessage(
// Reference-only mentions are already part of the outbound event. Preserve
// them in the mock event too so local echoes match the complete sent tag set.
const mentionTags = args.mentionTags ?? [];
const extraTags = [...mediaTags, ...emojiTags, ...mentionTags];
// Sender-authored link preview snapshots are independently validated by the
// real command and echoed on the stored event. Preserve them in the mock so
// E2E recipient rendering exercises the same authored-snapshot path.
const linkPreviewTags = args.linkPreviewTags ?? [];
if (linkPreviewTags.length > 0) {
const suppression = linkPreviewTags.some(
(tag) =>
tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none",
);
const snapshots = linkPreviewTags.filter(
(tag) => tag[0] === "link-preview" && tag[1] === "snapshot",
);
const validSnapshots = parseLinkPreviewSnapshots(
snapshots,
args.content,
new URL(getRelayHttpUrl(config)).origin,
);
if (
(suppression && linkPreviewTags.length !== 1) ||
(!suppression &&
(snapshots.length !== linkPreviewTags.length ||
validSnapshots.length !== snapshots.length ||
snapshots.some(
(tag) => !isValidLinkPreviewSnapshotCanonicalUrl(tag[3] ?? ""),
)))
) {
throw new Error("invalid link-preview snapshot tag");
}
}
// All independently validated kinds end up on the stored event's tag set,
// just like the real relay.
const extraTags = [
...mediaTags,
...emojiTags,
...mentionTags,
...linkPreviewTags,
...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []),
];
const identity = getIdentity(config);
if (!identity) {
const createdAt = Math.floor(Date.now() / 1000);
@@ -11122,6 +11209,26 @@ export function maybeInstallE2eTauriMocks() {
return;
case "fetch_join_policy":
return activeConfig?.mock?.joinPolicy ?? null;
case "fetch_link_preview_metadata": {
const startBlockMs =
activeConfig?.mock?.linkPreviewMetadataStartBlockMs ?? 0;
if (startBlockMs > 0) {
const stopAt = performance.now() + startBlockMs;
while (performance.now() < stopAt) {
// Deliberately block to model uncached native command startup.
}
}
const delayMs = activeConfig?.mock?.linkPreviewMetadataDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
const href = (payload as { href?: string }).href;
const metadataByHref = activeConfig?.mock?.linkPreviewMetadataByHref;
if (href && metadataByHref && Object.hasOwn(metadataByHref, href)) {
return metadataByHref[href];
}
return activeConfig?.mock?.linkPreviewMetadata ?? null;
}
case "apply_workspace": {
const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0;
if (applyDelayMs > 0) {
@@ -12502,7 +12609,7 @@ export function maybeInstallE2eTauriMocks() {
return null;
case "edit_message":
return handleEditMessage(
payload as Parameters<typeof handleEditMessage>[0],
(payload as { input: Parameters<typeof handleEditMessage>[0] }).input,
activeConfig,
);
case "add_reaction":

View File

@@ -61,10 +61,11 @@ test("⌘K with caret inside an existing link opens the edit-link dialog", async
"docs",
);
// Click into the linked text to place the caret inside it, then re-trigger
// the shortcut. (The click also surfaces the composer link hover card —
// ⌘K must open the full dialog from that state.)
// Click into the linked text to place the caret inside it. This must not
// surface contextual controls; editing stays accessible through ⌘K.
await input.locator('a[href="https://example.com"]').click();
await expect(page.getByRole("button", { name: "Edit link" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0);
await page.keyboard.press("ControlOrMeta+k");
const editDialog = page.getByRole("dialog", { name: "Edit link" });

View File

@@ -290,17 +290,19 @@ test("editing an immediate attachment reply preserves its media tags", async ({
});
expect(editPayload).toEqual(
expect.objectContaining({
eventId: replyId,
mediaTags: [
[
"imeta",
`url ${ATTACHMENT_URL}`,
"m application/pdf",
`x ${"a".repeat(64)}`,
"size 12345",
`filename ${ATTACHMENT_FILENAME}`,
input: expect.objectContaining({
eventId: replyId,
mediaTags: [
[
"imeta",
`url ${ATTACHMENT_URL}`,
"m application/pdf",
`x ${"a".repeat(64)}`,
"size 12345",
`filename ${ATTACHMENT_FILENAME}`,
],
],
],
}),
}),
);

View File

@@ -1,9 +1,20 @@
import { expect, test, type Locator } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
import { openSettings } from "../helpers/settings";
async function waitForReadyComposerSnapshots(
page: import("@playwright/test").Page,
count = 1,
) {
await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute(
"data-ready-snapshot-count",
String(count),
);
}
async function expectThreadReplyUnobscured(row: Locator) {
await expect
.poll(async () =>
@@ -93,7 +104,7 @@ async function measureThreadSummaryGeometry(summaryRow: Locator) {
}
test.beforeEach(async ({ page }, testInfo) => {
const mock = testInfo.title.includes("agent owner label")
const baseMock = testInfo.title.includes("agent owner label")
? {
searchProfiles: [
{
@@ -108,7 +119,137 @@ test.beforeEach(async ({ page }, testInfo) => {
},
],
}
: undefined;
: testInfo.title.includes("cardless short tweet preview")
? {
linkPreviewMetadata: {
title: "jack (@jack) on X",
siteName: "X (formerly Twitter)",
description: "just setting up my twttr",
imageDataUrl: null,
imageDomain: null,
},
}
: testInfo.title.includes("cardless tweet preview")
? {
linkPreviewMetadata: {
title: "Buzz (@buzz) on X",
siteName: "X (formerly Twitter)",
description:
"This is a real tweet-style description long enough to wrap across several lines while preserving the message-like treatment. It keeps going with enough distinct words to exceed five rendered lines at the preview width, proving that the overflow-aware control appears only when the content is genuinely clipped rather than relying on a brittle character-count guess.",
imageDataUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%231d9bf0'/%3E%3C/svg%3E",
imageDomain: "pbs.twimg.com",
},
}
: testInfo.title.includes("mixed link preview image outcomes")
? {
linkPreviewMetadataByHref: {
"https://github.com/block/buzz/pull/4001": {
title: "Loaded preview image",
siteName: "GitHub",
description: "The image request completed.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
imageFetchState: "image",
imageRetryAfterMs: null,
},
"https://github.com/block/buzz/pull/4002": {
title: "Rate-limited preview image",
siteName: "GitHub",
description: "Metadata remains available during cooldown.",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
},
},
}
: testInfo.title.includes("link preview browser image error")
? {
linkPreviewMetadata: {
title: "Invalid decoded preview image",
siteName: "GitHub",
description: "The browser should replace this image.",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "rejected",
imageRetryAfterMs: null,
},
}
: testInfo.title.includes("link preview image geometry")
? {
linkPreviewMetadata: {
title:
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
siteName: "GitHub",
description: "A polished, stable preview for shared links.",
imageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageDomain: "opengraph.githubassets.com",
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 800,
}
: testInfo.title.includes("link preview no-image layout") ||
testInfo.title.includes("composer no-image link embeds")
? {
linkPreviewMetadata: {
title: "Buzz",
siteName: "GitHub",
description:
"Open-source collaboration for the Buzz app.",
imageDataUrl: null,
imageDomain: null,
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 2_000,
}
: testInfo.title.includes(
"rich link preview preserves description newlines",
)
? {
linkPreviewMetadata: {
title: "Buzz pull request",
siteName: "GitHub",
description:
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
imageDataUrl: null,
imageDomain: null,
},
}
: testInfo.title.includes("link preview") ||
testInfo.title.includes("supported Compact")
? {
linkPreviewMetadata: {
title: "Buzz pull request",
siteName: "GitHub",
description: "A sender-authored preview snapshot.",
imageDataUrl: null,
imageDomain: null,
},
linkPreviewMetadataDelayMs: testInfo.title.includes(
"loading card before cold resolver work",
)
? 10_000
: testInfo.title.includes("style defaults") ||
testInfo.title.includes("send does not wait") ||
testInfo.title.includes("attachment-sized")
? 1_500
: undefined,
linkPreviewMetadataStartBlockMs:
testInfo.title.includes(
"loading card before cold resolver work",
)
? 150
: undefined,
}
: undefined;
const mock = testInfo.title.includes("unresolvable preview")
? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 150 }
: baseMock;
await installMockBridge(page, mock);
});
@@ -250,7 +391,529 @@ test("markdown tables overflow wide content and fill the message when narrow", a
.toBeLessThanOrEqual(1);
});
test("supported link previews keep the message link visible", async ({
test("link preview style defaults to compact and Rich unfurls descriptions", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=1";
await page.setViewportSize({ width: 800, height: 900 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(previewUrl);
const composerPreview = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');
await expect(composerPreview).toHaveAttribute("data-image-state", "pending");
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-loading.png`,
});
}
await waitForReadyComposerSnapshots(page);
await expect(composerPreview).toHaveAttribute("data-image-state", "none");
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-ready.png`,
});
}
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const compactPreview = row.locator(
'[data-link-preview="github-pull-request"]',
);
await expect(compactPreview).toHaveCSS("border-top-left-radius", "0px");
await expect(compactPreview).toHaveCSS("border-left-width", "3px");
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-compact.png`,
});
}
await openSettings(page, "appearance");
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
"Compact",
);
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
"Rich",
);
await expect
.poll(() =>
page.evaluate(() =>
localStorage.getItem("buzz.appearance.linkPreviewStyle"),
),
)
.toBe("rich");
await page.getByTestId("settings-back-to-app").click();
const richPreview = row.locator(
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
);
await expect(richPreview).toBeVisible();
const richHostname = richPreview.locator("[data-link-preview-hostname]");
await expect(richHostname).toHaveText("github.com");
await expect(richHostname).toHaveAttribute("href", previewUrl);
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-rich.png`,
});
const richComposerUrl = `${previewUrl}&composer=rich`;
await page.getByTestId("message-input").fill(richComposerUrl);
const richComposerPreview = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');
await expect(richComposerPreview).toHaveAttribute(
"data-image-state",
"pending",
);
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-loading.png`,
});
await waitForReadyComposerSnapshots(page);
await expect(richComposerPreview).toHaveAttribute(
"data-image-state",
"none",
);
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-ready.png`,
});
await page.getByTestId("message-input").fill("");
}
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-compact").click();
});
for (const [pasteShape, wrapUrl] of [
["bare", (url: string) => url],
["angle-bracket", (url: string) => `<${url}>`],
] as const) {
test(`${pasteShape} link preview paste paints before cold resolver work`, async ({
page,
}) => {
const previewUrl = `https://github.com/block/buzz/pull/3246?paste=${pasteShape}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.focus();
const pasteText = wrapUrl(previewUrl);
const firstPaint = await input.evaluate(async (element, pasteText) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", pasteText);
const startedAt = performance.now();
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
return {
elapsedMs: performance.now() - startedAt,
resolverStarted: (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some(
(entry) => entry.command === "fetch_link_preview_metadata",
),
text: element.textContent,
};
}, pasteText);
expect(firstPaint.text).toContain(previewUrl);
expect(firstPaint.resolverStarted).toBe(false);
expect(firstPaint.elapsedMs).toBeLessThan(100);
const composerPreview = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');
await expect(input).toContainText(previewUrl, { timeout: 1_000 });
await expect(composerPreview).toHaveAttribute(
"data-state",
/^(processing|done)$/,
{ timeout: 1_000 },
);
await expect(page.getByTestId("send-message")).toBeEnabled();
});
}
test("display-text link preview produces and sends its preview", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?display=text";
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("review the pull request");
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("ControlOrMeta+k");
const dialog = page.getByRole("dialog", { name: "Add link" });
await dialog.getByLabel("URL").fill(previewUrl);
await dialog.getByRole("button", { name: "Save" }).click();
await expect(input.locator(`a[href="${previewUrl}"]`)).toHaveText(
"review the pull request",
);
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
await waitForReadyComposerSnapshots(page);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
await expect(
row.getByRole("link", { name: "review the pull request", exact: true }),
).toHaveAttribute("href", previewUrl);
await expect(row.locator("[data-link-preview]")).toBeVisible();
const linkPreviewTags = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
return (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
});
expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]);
});
test("rich link preview preserves description newlines after sending", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?newlines=1";
await page.addInitScript(() =>
localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich"),
);
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(previewUrl);
await waitForReadyComposerSnapshots(page);
await page.getByTestId("send-message").click();
const description = page
.getByTestId("message-row")
.last()
.locator('[data-link-preview-inline] [data-slot="attachment-description"]');
await expect(description.locator("p")).toHaveCount(2);
await expect(description).toHaveText(
"First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.",
{ useInnerText: true },
);
});
test("completed link previews send when one URL has an unsnapshotable fragment", async ({
page,
}) => {
const previewUrls = [
"https://twitter.com/tellaho",
"https://github.com/block/buzz/pull/3246",
"https://x.com/tellaho/status/1884289176381841506#",
];
const pastedText = previewUrls.join("\n");
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.focus();
await input.evaluate((element, text) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, pastedText);
const composerPreviewCards = page.locator(
"[data-link-preview-composer-card]",
);
await expect(composerPreviewCards).toHaveCount(2);
await expect(
composerPreviewCards.locator(`a[href="${previewUrls[2]}"]`),
).toHaveCount(0);
await waitForReadyComposerSnapshots(page, 2);
const send = page.getByTestId("send-message");
await expect(send).toBeEnabled();
await send.click();
await expect(page.getByTestId("message-input")).toHaveText("");
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
await page.waitForTimeout(250);
await expect(page.getByTestId("message-input")).toHaveText("");
const calls = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "send_channel_message",
),
);
expect(calls).toHaveLength(1);
expect(
(
calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags?.map((tag) => tag[3]),
).toEqual(previewUrls.slice(0, 2));
});
test("unresolvable preview disappears after the terminal miss", async ({
page,
}) => {
const previewUrl = "https://x.com/tellaho/status";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(previewUrl);
const composerPreviews = page.locator("[data-composer-link-previews]");
await expect(composerPreviews).toBeVisible();
await expect(
composerPreviews.locator("[data-link-preview-composer-card]"),
).toHaveAttribute("data-image-state", "pending");
await expect(composerPreviews).toHaveCount(0);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
});
test("send does not wait for a pending link preview snapshot", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?send=pending";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(previewUrl);
const composerPreviews = page.locator("[data-composer-link-previews]");
await expect(composerPreviews).toHaveAttribute(
"data-ready-snapshot-count",
"0",
);
await expect(
composerPreviews.locator('[data-link-preview="github-pull-request"]'),
).toHaveAttribute("data-image-state", "pending");
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(previewUrl);
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
const linkPreviewTags = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
return (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
});
expect(linkPreviewTags ?? []).toEqual([]);
});
test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({
page,
}) => {
const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all";
const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(firstUrl);
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
await waitForReadyComposerSnapshots(page);
await page.getByTestId("composer-hide-link-previews").click();
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`);
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
await expect(row).toContainText(firstUrl);
await expect(row).toContainText(secondUrl);
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
const linkPreviewTags = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "send_channel_message");
return (
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags;
});
expect(linkPreviewTags).toEqual([["link-preview", "none"]]);
await page.getByTestId("message-input").fill(firstUrl);
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
});
test("composer link preview embeds stay attachment-sized while loading and ready", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246";
for (const width of [800, 420]) {
await page.setViewportSize({ width: 800, height: 700 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.setViewportSize({ width, height: 700 });
await page
.getByTestId("message-input")
.fill(`${previewUrl}?viewport=${width}`);
const card = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');
await expect(card).toBeVisible();
const initial = await card.evaluate((element) => ({
height: element.getBoundingClientRect().height,
width: element.getBoundingClientRect().width,
thumbnailHeight: element
.querySelector("[data-link-preview-thumbnail]")
?.getBoundingClientRect().height,
thumbnailWidth: element
.querySelector("[data-link-preview-thumbnail]")
?.getBoundingClientRect().width,
}));
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-loading.png`,
});
}
await expect
.poll(() =>
card.evaluate((element) => element.getAttribute("data-state")),
)
.toBe("done");
const ready = await card.evaluate((element) => ({
height: element.getBoundingClientRect().height,
width: element.getBoundingClientRect().width,
thumbnailHeight: element
.querySelector("[data-link-preview-thumbnail]")
?.getBoundingClientRect().height,
thumbnailWidth: element
.querySelector("[data-link-preview-thumbnail]")
?.getBoundingClientRect().width,
}));
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
await waitForAnimations(page);
await page.screenshot({
animations: "disabled",
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-ready.png`,
});
}
expect(initial.height).toBe(55);
expect(initial.width).toBe(320);
expect(initial.thumbnailHeight).toBe(55);
expect(initial.thumbnailWidth).toBe(55);
expect(ready).toEqual(initial);
}
});
test("composer no-image link embeds keep the attachment footprint", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(previewUrl);
const card = page
.locator("[data-composer-link-previews]")
.locator('[data-link-preview="github-pull-request"]');
await expect(card).toHaveAttribute("data-image-state", "none");
await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible();
await expect(card.locator('[data-slot="attachment-title"]')).toContainText(
/github\.com|Buzz/,
);
await expect(card).toHaveCSS("height", "55px");
});
test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", async ({
page,
}) => {
const loadedUrl = "https://github.com/block/buzz/pull/4001";
const rateLimitedUrl = "https://github.com/block/buzz/pull/4002";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page
.getByTestId("message-input")
.fill(`${loadedUrl}\n${rateLimitedUrl}`);
await waitForReadyComposerSnapshots(page, 2);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const compactCards = row.locator('[data-link-preview="github-pull-request"]');
await expect(compactCards).toHaveCount(2);
await expect(compactCards.nth(0)).toHaveAttribute(
"data-image-state",
"image",
);
await expect(
compactCards.nth(0).locator("[data-link-preview-thumbnail]"),
).toBeVisible();
await expect(compactCards.nth(1)).toHaveAttribute("data-image-state", "none");
await expect(
compactCards.nth(1).locator("[data-link-preview-image-fallback]"),
).toHaveCount(0);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await page.getByTestId("settings-back-to-app").click();
const richCards = row.locator(
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
);
await expect(richCards).toHaveCount(2);
await expect(
richCards.nth(1).locator("[data-link-preview-image-fallback]"),
).toHaveCount(0);
});
test("link preview browser image errors render a fallback", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await page
.getByTestId("message-input")
.fill("https://github.com/block/buzz/pull/4003");
await waitForReadyComposerSnapshots(page);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const compactCard = row.locator('[data-link-preview="github-pull-request"]');
await expect(compactCard).toHaveAttribute("data-image-state", "none");
await expect(
compactCard.locator("[data-link-preview-image-fallback]"),
).toHaveCount(0);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await page.getByTestId("settings-back-to-app").click();
const richCard = row.locator(
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
);
await expect(
richCard.locator("[data-link-preview-image-fallback]"),
).toHaveCount(0);
});
test("supported Compact link previews keep the message link visible with square outer corners", async ({
page,
}) => {
const previewUrl = "https://github.com/block/sprout/pull/1334";
@@ -260,6 +923,7 @@ test("supported link previews keep the message link visible", async ({
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-input").fill(previewUrl);
await waitForReadyComposerSnapshots(page);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
@@ -268,8 +932,7 @@ test("supported link previews keep the message link visible", async ({
).toBeVisible();
const previewCard = row.locator('[data-link-preview="github-pull-request"]');
await expect(previewCard).toBeVisible();
await expectCornerRadiusPx(previewCard, 16);
await expectSmoothCorners(previewCard);
await expectCornerRadiusPx(previewCard, 0);
});
test("send multiple messages in sequence", async ({ page }) => {

View File

@@ -527,7 +527,7 @@ test("commit detail opens from the commits feed with a diff", async ({
page.getByRole("button", { name: "Copy commit hash" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "project guide" }),
page.getByRole("link", { name: "project guide", exact: true }),
).toHaveAttribute("href", "https://example.com/project-guide");
await expect(
page.getByRole("button", { name: "Architecture" }),

View File

@@ -295,6 +295,32 @@ type MockBridgeOptions = {
profileHasEvent?: boolean;
profileUpdateError?: string;
profileUpdateErrors?: string[];
linkPreviewMetadata?: {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null;
linkPreviewMetadataByHref?: Record<
string,
{
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null
>;
linkPreviewMetadataDelayMs?: number;
/** Simulates native cold-cache startup work before the async response. */
linkPreviewMetadataStartBlockMs?: number;
searchProfiles?: MockSearchProfileSeed[];
updateAvailable?: boolean;
updateChannelDelayMs?: number;