mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
feat(desktop): adding rich link previews to messages (#3818)
## Overview **Category:** improvement **User impact:** Link previews appear in the composer and travel as privacy-safe sender-authored snapshots, so recipients never contact the linked site merely by opening a conversation. **Problem:** Cold-cache link paste could freeze the composer before the URL painted; recipient-side unfurling leaked visits; invalid or unresolved preview work could interfere with sending or leave dead cards behind. **Solution:** Paint pasted links before starting cold resolver work, resolve only in the sender's composer, attach only complete validated snapshots at Send, and render authored snapshots without recipient fallback fetching. ## Behavior - **Cold paste stays responsive:** bare and angle-bracket URL paste paths commit the visible link before resolver work begins. - **Sender-only fetching:** metadata is resolved while composing; recipients render only the sender-authored snapshot. - **Send never waits:** pending, failed, invalid, and unsendable previews are omitted. They do not block or cancel the message. - **Terminal misses disappear:** failed, timed-out, or 404 resolver results remove the composer card while preserving visible link text. - **Display-text links work:** Markdown links such as `[review the pull request](…)` produce and send the same snapshots as bare URLs. - **Compact and Rich presentation:** Compact remains the default; Rich preserves source description line breaks and paragraphs. - **Immediate draft-wide dismissal:** clicking × immediately hides all previews for the draft, suppresses links pasted later, and emits only `["link-preview", "none"]`. No confirmation detour. Suppression resets after send or clearing the draft. - **Zero recipient fallback:** missing, stale, malformed, off-relay, unsupported, or suppressed snapshots remain ordinary visible links; recipients never regenerate them. ## Implementation - Resolve previews from deferred composer URL state so paste can paint first. - Upload finished preview media to the active community relay and snapshot only valid, sendable media references. - Atomically capture ready snapshots at submit time; never append a late preview after send. - Validate snapshot and suppression tags in desktop/native and relay ingestion, rejecting duplicate or mixed forms. - Render composer previews as stable 55px attachment cards at desktop and narrow widths. - Add deterministic E2E coverage for cold paste, ready/pending/failed/invalid previews, display-text links, multiline Rich descriptions, immediate dismissal, later-pasted links, and suppression reset. ## Validation Validated head: `9807ba8952f190e76153834abf8ab61dd40be5e2` - Push hooks passed: `check-push-org`, branch skew, desktop check, mobile tests, desktop tests, Rust tests, and desktop Tauri checks. - Focused screenshot E2E at the validated head: 5/5 passed across Compact/Rich composer and recipient states, 800px/420px geometry, display-text links, multiline descriptions, and immediate dismissal. - PR CI was triggered for this exact head and is currently running; completed checks are green at the time of this update. - Worktree is clean and both PR head and validated branch resolve to `9807ba895…`. ## Screenshots ### Compact composer | Loading | Ready | |---|---| |  |  | ### Rich composer | Loading | Ready | |---|---| |  |  | ### Responsive composer | 800px loading | 800px ready | |---|---| |  |  | | 420px loading | 420px ready | |---|---| |  |  | ### Recipient presentation | Compact | Rich | |---|---| |  |  | ### Display-text Markdown link | Composer | Recipient | |---|---| |  |  | ### Rich multiline description  ### Immediate dismissal | Before × | Immediately after × | |---|---| |  |  | --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -369,6 +369,55 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an authored link-preview media URL/hash pair.
|
||||
///
|
||||
/// Empty pairs are allowed. Non-empty media must be an exact, credential-free
|
||||
/// URL on this tenant's media origin with a content-addressed image path.
|
||||
pub fn validate_local_image_media_pair(
|
||||
media_url: &str,
|
||||
sha256: &str,
|
||||
media_base_url: &str,
|
||||
) -> bool {
|
||||
const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
|
||||
if media_url.is_empty() && sha256.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if media_url.is_empty()
|
||||
|| sha256.len() != 64
|
||||
|| !sha256.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(parsed) = url::Url::parse(media_url) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(base) = url::Url::parse(media_base_url) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != base.scheme()
|
||||
|| parsed.host_str() != base.host_str()
|
||||
|| parsed.port_or_known_default() != base.port_or_known_default()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(filename) = parsed.path().strip_prefix("/media/") else {
|
||||
return false;
|
||||
};
|
||||
let Some((path_hash, ext)) = filename.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
!filename.contains('/')
|
||||
&& !filename.contains('%')
|
||||
&& path_hash == sha256
|
||||
&& IMAGE_EXTS.contains(&ext)
|
||||
}
|
||||
|
||||
/// Validate that a URL references a valid local media blob path.
|
||||
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
|
||||
// A safe extension token: 1–8 lowercase alphanumeric chars. Covers media
|
||||
@@ -423,6 +472,48 @@ mod tests {
|
||||
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
const BASE: &str = "https://relay.example.com/media";
|
||||
|
||||
#[test]
|
||||
fn link_preview_media_pair_requires_exact_local_image_url_and_hash() {
|
||||
let hash = HASH;
|
||||
let valid = format!("{BASE}/{hash}.png");
|
||||
assert!(validate_local_image_media_pair(&valid, hash, BASE));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("https://evil.example/media/{hash}.png"),
|
||||
hash,
|
||||
BASE
|
||||
));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("{BASE}/{hash}.png?token=leak"),
|
||||
hash,
|
||||
BASE
|
||||
));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("{BASE}/{hash}.png#fragment"),
|
||||
hash,
|
||||
BASE
|
||||
));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("https://user@relay.example.com/media/{hash}.png"),
|
||||
hash,
|
||||
BASE
|
||||
));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("{BASE}/{hash}.svg"),
|
||||
hash,
|
||||
BASE
|
||||
));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("{BASE}/{hash}.png/extra"),
|
||||
hash,
|
||||
BASE
|
||||
));
|
||||
assert!(!validate_local_image_media_pair(
|
||||
&format!("{BASE}/{hash}.png"),
|
||||
&"0".repeat(64),
|
||||
BASE
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_media_url_relative() {
|
||||
assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE));
|
||||
|
||||
@@ -212,6 +212,72 @@ pub fn reject_with_transport(transport: &'static str, reason: &'static str) {
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn valid_link_preview_text(value: &str, max: usize, allow_newlines: bool) -> bool {
|
||||
value.len() <= max
|
||||
&& !value
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !(allow_newlines && character == '\n'))
|
||||
}
|
||||
|
||||
fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), String> {
|
||||
const MAX_SNAPSHOTS: usize = 8;
|
||||
const MAX_TITLE: usize = 300;
|
||||
const MAX_SITE: usize = 100;
|
||||
const MAX_DESCRIPTION: usize = 1000;
|
||||
|
||||
let mut count = 0;
|
||||
let mut suppressed = false;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(String::as_str) != Some("link-preview") {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if parts == ["link-preview", "none"] {
|
||||
if count > 1 {
|
||||
return Err("link-preview suppression cannot include snapshots".into());
|
||||
}
|
||||
suppressed = true;
|
||||
continue;
|
||||
}
|
||||
if suppressed
|
||||
|| count > MAX_SNAPSHOTS
|
||||
|| parts.len() != 11
|
||||
|| parts[1] != "snapshot"
|
||||
|| parts[2] != "1"
|
||||
{
|
||||
return Err("invalid link-preview snapshot tag".into());
|
||||
}
|
||||
let canonical =
|
||||
url::Url::parse(&parts[3]).map_err(|_| "invalid link-preview canonical URL")?;
|
||||
if canonical.scheme() != "https"
|
||||
|| !canonical.username().is_empty()
|
||||
|| canonical.password().is_some()
|
||||
|| canonical.fragment().is_some()
|
||||
|| !seen.insert(parts[3].clone())
|
||||
|| !event.content.contains(&parts[3])
|
||||
{
|
||||
return Err("invalid link-preview canonical URL".into());
|
||||
}
|
||||
for (value, max, allow_newlines) in [
|
||||
(&parts[4], MAX_TITLE, false),
|
||||
(&parts[5], MAX_SITE, false),
|
||||
(&parts[6], MAX_DESCRIPTION, true),
|
||||
] {
|
||||
if !valid_link_preview_text(value, max, allow_newlines) {
|
||||
return Err("invalid link-preview snapshot text".into());
|
||||
}
|
||||
}
|
||||
if !super::imeta::validate_local_image_media_pair(&parts[7], &parts[8], media_base_url)
|
||||
|| !super::imeta::validate_local_image_media_pair(&parts[9], &parts[10], media_base_url)
|
||||
{
|
||||
return Err("link-preview media must reference matching local image blobs".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Successful ingestion result.
|
||||
pub struct IngestResult {
|
||||
/// Hex-encoded event ID.
|
||||
@@ -2647,6 +2713,13 @@ async fn ingest_event_inner(
|
||||
});
|
||||
}
|
||||
|
||||
let tenant_media_base =
|
||||
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
|
||||
if kind_u32 == KIND_STREAM_MESSAGE {
|
||||
validate_link_preview_tags(&event, &tenant_media_base)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
let imeta_tags: Vec<Vec<String>> = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -2654,8 +2727,6 @@ async fn ingest_event_inner(
|
||||
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
|
||||
.collect();
|
||||
if !imeta_tags.is_empty() {
|
||||
let tenant_media_base =
|
||||
crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host());
|
||||
crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage)
|
||||
@@ -3632,6 +3703,109 @@ mod tests {
|
||||
assert!(validate_diff_event(&event).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_accepts_blanket_marker() {
|
||||
let event = make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&["link-preview", "none"]],
|
||||
);
|
||||
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_rejects_duplicate_marker() {
|
||||
let event = make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&["link-preview", "none"], &["link-preview", "none"]],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_link_preview_tags(&event, "https://media.example.com"),
|
||||
Err("link-preview suppression cannot include snapshots".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_rejects_mixed_snapshot_tags_in_either_order() {
|
||||
let snapshot = [
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
"https://example.com",
|
||||
"Example",
|
||||
"Example",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
for tags in [
|
||||
vec![&["link-preview", "none"][..], &snapshot[..]],
|
||||
vec![&snapshot[..], &["link-preview", "none"][..]],
|
||||
] {
|
||||
let event = make_event_with_tags(KIND_STREAM_MESSAGE, "https://example.com", &tags);
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn make_link_preview_event(title: &str, site: &str, description: &str) -> Event {
|
||||
make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&[
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
"https://example.com",
|
||||
title,
|
||||
site,
|
||||
description,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_snapshot_accepts_description_newlines() {
|
||||
let event = make_link_preview_event(
|
||||
"Example title",
|
||||
"Example site",
|
||||
"First paragraph\n\nSecond paragraph",
|
||||
);
|
||||
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_snapshot_rejects_title_and_site_newlines() {
|
||||
for (title, site) in [
|
||||
("Example\ntitle", "Example site"),
|
||||
("Example title", "Example\nsite"),
|
||||
] {
|
||||
let event = make_link_preview_event(title, site, "Description");
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_snapshot_rejects_non_newline_controls_in_all_text_fields() {
|
||||
for (title, site, description) in [
|
||||
("Example\ttitle", "Example site", "Description"),
|
||||
("Example title", "Example\rsite", "Description"),
|
||||
("Example title", "Example site", "Unsafe\tdescription"),
|
||||
] {
|
||||
let event = make_link_preview_event(title, site, description);
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dummy_event() -> Event {
|
||||
let keys = nostr::Keys::generate();
|
||||
nostr::EventBuilder::new(nostr::Kind::Custom(9), "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
62
desktop/src-tauri/src/commands/link_preview_rate_limit.rs
Normal file
62
desktop/src-tauri/src/commands/link_preview_rate_limit.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,14 +156,34 @@ async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() {
|
||||
fn boundary_huddle_stt_blocks_ncryptsec() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let channel = uuid::Uuid::new_v4();
|
||||
let builder =
|
||||
crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap();
|
||||
let builder = crate::events::build_message(
|
||||
channel,
|
||||
NCRYPTSEC,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&crate::relay::relay_api_base_url(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err();
|
||||
assert_guard_error(&err);
|
||||
|
||||
// Clean transcripts pass through the same seam.
|
||||
let builder =
|
||||
crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap();
|
||||
let builder = crate::events::build_message(
|
||||
channel,
|
||||
"hello huddle",
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
&crate::relay::relay_api_base_url(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok());
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
//!
|
||||
//! Each function validates inputs and returns a nostr::EventBuilder.
|
||||
//! Signing and submission happen in relay::submit_event.
|
||||
|
||||
use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST};
|
||||
use nostr::{EventBuilder, EventId, Kind, Tag};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum content size — matches buzz-sdk (64 KiB).
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
189
desktop/src-tauri/src/link_preview_tags.rs
Normal file
189
desktop/src-tauri/src/link_preview_tags.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -662,6 +662,7 @@ test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size",
|
||||
const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"];
|
||||
const EMOJI_A = ["emoji", "shipit", "https://relay/s.png"];
|
||||
const EMOJI_B = ["emoji", "party", "https://relay/p.gif"];
|
||||
const LINK_PREVIEW = ["link-preview", "snapshot", "1", "https://example.com/"];
|
||||
const MENTION_REF = [
|
||||
"mention",
|
||||
"1111111111111111111111111111111111111111111111111111111111111111",
|
||||
@@ -672,55 +673,64 @@ test("splitOutgoingTags: undefined input yields three empty arrays", () => {
|
||||
mediaTags: [],
|
||||
emojiTags: [],
|
||||
mentionTags: [],
|
||||
linkPreviewTags: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("splitOutgoingTags: separates emoji tags from imeta tags", () => {
|
||||
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
|
||||
IMETA,
|
||||
EMOJI_A,
|
||||
EMOJI_B,
|
||||
]);
|
||||
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
|
||||
splitOutgoingTags([IMETA, EMOJI_A, EMOJI_B]);
|
||||
assert.deepEqual(mediaTags, [IMETA]);
|
||||
assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]);
|
||||
assert.deepEqual(mentionTags, []);
|
||||
assert.deepEqual(linkPreviewTags, []);
|
||||
});
|
||||
|
||||
test("splitOutgoingTags: emoji-only set leaves mediaTags empty", () => {
|
||||
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([EMOJI_A]);
|
||||
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
|
||||
splitOutgoingTags([EMOJI_A]);
|
||||
assert.deepEqual(mediaTags, []);
|
||||
assert.deepEqual(emojiTags, [EMOJI_A]);
|
||||
assert.deepEqual(mentionTags, []);
|
||||
assert.deepEqual(linkPreviewTags, []);
|
||||
});
|
||||
|
||||
test("splitOutgoingTags: separates reference-only mention tags", () => {
|
||||
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
|
||||
IMETA,
|
||||
MENTION_REF,
|
||||
EMOJI_A,
|
||||
]);
|
||||
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
|
||||
splitOutgoingTags([IMETA, MENTION_REF, EMOJI_A]);
|
||||
assert.deepEqual(mediaTags, [IMETA]);
|
||||
assert.deepEqual(emojiTags, [EMOJI_A]);
|
||||
assert.deepEqual(mentionTags, [MENTION_REF]);
|
||||
assert.deepEqual(linkPreviewTags, []);
|
||||
});
|
||||
|
||||
test("splitOutgoingTags: separates authored link-preview snapshots", () => {
|
||||
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
|
||||
splitOutgoingTags([IMETA, LINK_PREVIEW]);
|
||||
assert.deepEqual(mediaTags, [IMETA]);
|
||||
assert.deepEqual(emojiTags, []);
|
||||
assert.deepEqual(mentionTags, []);
|
||||
assert.deepEqual(linkPreviewTags, [LINK_PREVIEW]);
|
||||
});
|
||||
|
||||
test("splitOutgoingTags: unknown prefixes stay with mediaTags (injection defense)", () => {
|
||||
// A forged ["p", ...] must NOT be misrouted to the emoji channel; it stays on
|
||||
// mediaTags where the server-side imeta guard rejects it.
|
||||
const forged = ["p", "deadbeef"];
|
||||
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([
|
||||
forged,
|
||||
EMOJI_A,
|
||||
]);
|
||||
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
|
||||
splitOutgoingTags([forged, EMOJI_A]);
|
||||
assert.deepEqual(mediaTags, [forged]);
|
||||
assert.deepEqual(emojiTags, [EMOJI_A]);
|
||||
assert.deepEqual(mentionTags, []);
|
||||
assert.deepEqual(linkPreviewTags, []);
|
||||
});
|
||||
|
||||
test("splitOutgoingTags is the inverse of mergeOutgoingTags", () => {
|
||||
const merged = mergeOutgoingTags([IMETA], [EMOJI_A, EMOJI_B]);
|
||||
const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags(merged);
|
||||
const { mediaTags, emojiTags, mentionTags, linkPreviewTags } =
|
||||
splitOutgoingTags(merged);
|
||||
assert.deepEqual(mediaTags, [IMETA]);
|
||||
assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]);
|
||||
assert.deepEqual(mentionTags, []);
|
||||
assert.deepEqual(linkPreviewTags, []);
|
||||
});
|
||||
|
||||
@@ -359,18 +359,22 @@ export function splitOutgoingTags(tags: string[][] | undefined): {
|
||||
mediaTags: string[][];
|
||||
emojiTags: string[][];
|
||||
mentionTags: string[][];
|
||||
linkPreviewTags: string[][];
|
||||
} {
|
||||
const mediaTags: string[][] = [];
|
||||
const emojiTags: string[][] = [];
|
||||
const mentionTags: string[][] = [];
|
||||
const linkPreviewTags: string[][] = [];
|
||||
for (const tag of tags ?? []) {
|
||||
if (tag[0] === "emoji") {
|
||||
emojiTags.push(tag);
|
||||
} else if (tag[0] === "mention") {
|
||||
mentionTags.push(tag);
|
||||
} else if (tag[0] === "link-preview") {
|
||||
linkPreviewTags.push(tag);
|
||||
} else {
|
||||
mediaTags.push(tag);
|
||||
}
|
||||
}
|
||||
return { mediaTags, emojiTags, mentionTags };
|
||||
return { mediaTags, emojiTags, mentionTags, linkPreviewTags };
|
||||
}
|
||||
|
||||
24
desktop/src/features/messages/lib/linkPreviewContent.ts
Normal file
24
desktop/src/features/messages/lib/linkPreviewContent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
252
desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
Normal file
252
desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
desktop/src/shared/api/editMessage.ts
Normal file
23
desktop/src/shared/api/editMessage.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
116
desktop/src/shared/lib/linkPreviewSnapshot.test.mjs
Normal file
116
desktop/src/shared/lib/linkPreviewSnapshot.test.mjs
Normal 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), []);
|
||||
});
|
||||
179
desktop/src/shared/lib/linkPreviewSnapshot.ts
Normal file
179
desktop/src/shared/lib/linkPreviewSnapshot.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
23
desktop/src/shared/lib/linkPreviewStylePreference.test.mjs
Normal file
23
desktop/src/shared/lib/linkPreviewStylePreference.test.mjs
Normal 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");
|
||||
});
|
||||
56
desktop/src/shared/lib/linkPreviewStylePreference.ts
Normal file
56
desktop/src/shared/lib/linkPreviewStylePreference.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
182
desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs
Normal file
182
desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
176
desktop/src/shared/ui/compact-link-preview-attachment.tsx
Normal file
176
desktop/src/shared/ui/compact-link-preview-attachment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
125
desktop/src/shared/ui/link-preview-controls.tsx
Normal file
125
desktop/src/shared/ui/link-preview-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
desktop/src/shared/ui/link-preview-list.tsx
Normal file
98
desktop/src/shared/ui/link-preview-list.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
118
desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx
Normal file
118
desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
350
desktop/src/shared/ui/rich-link-preview-attachment.tsx
Normal file
350
desktop/src/shared/ui/rich-link-preview-attachment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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}`,
|
||||
],
|
||||
],
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user