Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
565e362
feat(desktop): enrich link previews with page metadata
Jul 28, 2026
7d6db33
fix(desktop): use globe for generic link previews
Jul 28, 2026
64caedc
test(desktop): target exact project guide link
Jul 28, 2026
65f9dde
feat(previews): add safe automatic preview images
Jul 28, 2026
bfd4ee2
feat(messages): add link preview controls
Jul 28, 2026
9492c4a
fix(messages): make link preview removal global only
Jul 28, 2026
5af599d
feat(previews): replace remove-previews text link with inline X on pr…
tellaho Jul 28, 2026
1f76fa6
refactor(previews): remove preview-image caption and type label from …
tellaho Jul 28, 2026
17efeab
refactor(desktop): use shared button for preview removal
Jul 28, 2026
260145e
fix(previews): parse bounded html prefixes
Jul 28, 2026
de311ca
fix(desktop): render compact link preview images
Jul 29, 2026
805a968
fix(desktop): remove link preview image padding
Jul 29, 2026
9ae57fb
feat(desktop): enrich link preview image cards
Jul 29, 2026
f7b1fb1
fix(desktop): label image previews by domain
Jul 29, 2026
da3bba5
feat(previews): unfurl safe link images
Jul 30, 2026
9ca7a00
refactor(desktop): extract link preview helpers
Jul 30, 2026
55ae106
refactor(desktop): group edit message input
Jul 30, 2026
a4b26c8
fix(desktop): simplify preview removal copy
Jul 30, 2026
92bbe84
feat(desktop): restore compact link preview cards
Jul 30, 2026
ea8f5d2
fix(desktop): restore approved compact preview layout
Jul 30, 2026
3990447
feat(desktop): show favicon beside preview hostname
Jul 30, 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
730 changes: 589 additions & 141 deletions desktop/src-tauri/src/commands/link_preview.rs

Large diffs are not rendered by default.

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
Loading