Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cb6fdff
feat(desktop): enrich link previews with page metadata
Jul 28, 2026
ae09d4e
fix(desktop): use globe for generic link previews
Jul 28, 2026
84a281d
test(desktop): target exact project guide link
Jul 28, 2026
15b2b8d
feat(previews): add safe automatic preview images
Jul 28, 2026
c4aaba9
feat(messages): add link preview controls
Jul 28, 2026
a7f0299
fix(messages): make link preview removal global only
Jul 28, 2026
7e92404
feat(previews): replace remove-previews text link with inline X on pr…
tellaho Jul 28, 2026
37076b5
refactor(previews): remove preview-image caption and type label from …
tellaho Jul 28, 2026
271a594
refactor(desktop): use shared button for preview removal
Jul 28, 2026
814d1ee
fix(previews): parse bounded html prefixes
Jul 28, 2026
008a1d5
fix(desktop): render compact link preview images
Jul 29, 2026
9f10166
fix(desktop): remove link preview image padding
Jul 29, 2026
6b40a62
feat(desktop): enrich link preview image cards
Jul 29, 2026
822664f
fix(desktop): label image previews by domain
Jul 29, 2026
2f988aa
feat(previews): unfurl safe link images
Jul 30, 2026
2ac9528
refactor(desktop): extract link preview helpers
Jul 30, 2026
8bc6854
refactor(desktop): group edit message input
Jul 30, 2026
0a5eb87
fix(desktop): simplify preview removal copy
Jul 30, 2026
b3c2ea5
feat(desktop): restore compact link preview cards
Jul 30, 2026
2c5b232
fix(desktop): restore approved compact preview layout
Jul 30, 2026
9b119dd
feat(desktop): show favicon beside preview hostname
Jul 30, 2026
cc20f19
feat(desktop): add rich link preview preference
Jul 30, 2026
7d0705e
test(desktop): isolate link preview style coverage
Jul 30, 2026
ff3a54f
test(desktop): await compact preview geometry
Jul 30, 2026
53dc813
feat(desktop): simplify rich preview expansion
tellaho Jul 31, 2026
e7805b8
feat(desktop): refine rich link preview presentation
tellaho Jul 31, 2026
56f3771
feat(desktop): preserve rich preview description structure
tellaho Jul 31, 2026
2cabbbc
feat(desktop): integrate rich previews with the markdown lightbox
tellaho Jul 31, 2026
984eb69
feat(desktop): add inline link preview display controls
tellaho Jul 31, 2026
9f39435
feat(desktop): refine compact link preview cards
tellaho Jul 31, 2026
bab244e
feat(desktop): refine compact preview content hierarchy
tellaho Jul 31, 2026
fb0eca6
refactor(desktop): unify link preview identity styling
tellaho Jul 31, 2026
a2ffdf8
feat(desktop): unify compact preview hover treatment
tellaho Jul 31, 2026
a4c887e
test(desktop): expect square compact link previews
Jul 31, 2026
00c52e3
Merge remote-tracking branch 'origin/main' into tho/link-preview-rich…
Jul 31, 2026
0689951
fix(desktop): stabilize link preview thumbnails
tellaho Aug 2, 2026
c1539f8
chore: merge latest main into link preview branch
tellaho Aug 2, 2026
60d348e
fix(desktop): scope preview retry constant to tests
tellaho Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
939 changes: 799 additions & 140 deletions desktop/src-tauri/src/commands/link_preview.rs

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions desktop/src-tauri/src/commands/link_preview_rate_limit.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
150 changes: 55 additions & 95 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -919,38 +869,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(())
Expand Down
Loading