diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 723331a9d..8396068d0 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -4,18 +4,17 @@ use tauri::{AppHandle, State}; mod forum; use forum::{ - apply_link_preview_suppression, fetch_agent_owner_pubkeys, forum_message_from_event, - forum_reply_from_event, link_preview_suppression_targets, + 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}, @@ -230,119 +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, - before: Option, - state: State<'_, AppState>, -) -> Result { - 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::>(); - 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 = 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, - cursor: Option, - state: State<'_, AppState>, -) -> Result { - 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::>(); - 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 = None; - let mut replies: Vec = 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, - }) -} - /// Fetch the full reply subtree under a thread root, server-side. /// /// Unlike the channel timeline (which the desktop assembles from its local diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index 67ec7c4c6..086e8c9f7 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -1,6 +1,12 @@ +use tauri::State; + use crate::{ app_state::AppState, - models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary}, + models::{ + ForumMessageInfo, ForumPostsResponse, ForumThreadReplyInfo, ForumThreadResponse, + ThreadSummary, + }, + relay::query_relay, }; pub(super) async fn fetch_agent_owner_pubkeys( @@ -148,6 +154,119 @@ pub(super) fn apply_link_preview_suppression( } } +#[tauri::command] +pub async fn get_forum_posts( + channel_id: String, + limit: Option, + before: Option, + state: State<'_, AppState>, +) -> Result { + 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::>(); + 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 = 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, + cursor: Option, + state: State<'_, AppState>, +) -> Result { + 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::>(); + 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 = None; + let mut replies: Vec = 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::*; diff --git a/desktop/src/shared/api/editMessage.ts b/desktop/src/shared/api/editMessage.ts new file mode 100644 index 000000000..ec4742322 --- /dev/null +++ b/desktop/src/shared/api/editMessage.ts @@ -0,0 +1,21 @@ +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 { + await invokeTauri("edit_message", { + channelId, + eventId, + content, + mediaTags: mediaTags ?? [], + emojiTags: emojiTags ?? [], + mentionPubkeys: mentionPubkeys ?? null, + suppressLinkPreviews: suppressLinkPreviews ?? false, + }); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index d9b9d597a..a42cca1df 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -614,25 +614,7 @@ export async function uploadMediaBytes( }); } -export async function editMessage( - channelId: string, - eventId: string, - content: string, - mediaTags?: string[][], - emojiTags?: string[][], - mentionPubkeys?: string[], - suppressLinkPreviews?: boolean, -): Promise { - await invokeTauri("edit_message", { - channelId, - eventId, - content, - mediaTags: mediaTags ?? [], - emojiTags: emojiTags ?? [], - mentionPubkeys: mentionPubkeys ?? null, - suppressLinkPreviews: suppressLinkPreviews ?? false, - }); -} +export { editMessage } from "@/shared/api/editMessage"; export async function deleteMessage( channelId: string, diff --git a/desktop/src/shared/ui/link-preview-list.tsx b/desktop/src/shared/ui/link-preview-list.tsx new file mode 100644 index 000000000..5b6af4dea --- /dev/null +++ b/desktop/src/shared/ui/link-preview-list.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; + +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"; + +export function LinkPreviewList({ + onRemoveForEveryone, + previews, +}: { + onRemoveForEveryone?: () => Promise; + previews: ResolvedLinkPreview[]; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + const [removed, setRemoved] = useState(false); + if (removed || previews.length === 0) return null; + + const previewNoun = previews.length === 1 ? "preview" : "previews"; + return ( + <> + + {previews.map((preview, index) => ( + setDialogOpen(true) + : undefined + } + preview={preview} + /> + ))} + + {onRemoveForEveryone ? ( + + + + + Remove {previewNoun} for everyone? + + + No one will see{" "} + {previews.length === 1 ? "the preview" : "the previews"} on this + message anymore.{" "} + {previews.length === 1 + ? "The link itself will stay" + : "The links themselves will stay"}{" "} + in the message. This can't be undone. + + + + + + + + + + + + + ) : null} + + ); +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b92e5498d..4a2aa802e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -32,18 +32,7 @@ import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; -import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; -import { Button } from "@/shared/ui/button"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; +import { LinkPreviewList } from "@/shared/ui/link-preview-list"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { computeConfigNudge, @@ -1882,13 +1871,7 @@ function MarkdownInner({ }, [goChannel], ); - const [removePreviewDialogOpen, setRemovePreviewDialogOpen] = - React.useState(false); - const [removedPreviewMessageId, setRemovedPreviewMessageId] = React.useState< - string | null - >(null); - const previewsGloballySuppressed = - linkPreviewsSuppressed || removedPreviewMessageId === messageId; + const previewsGloballySuppressed = linkPreviewsSuppressed; const linkPreviews = React.useMemo( () => interactive && !previewsGloballySuppressed @@ -1931,12 +1914,6 @@ function MarkdownInner({ ); let processedContent = content; - - // Note: stripping the sentinel here is intentionally omitted. When - // configNudge !== null, selectProseOrNudge() returns null — suppressing - // the prose node entirely — so processedContent is never rendered and - // stripConfigNudgeSentinel would be dead work on that path. - if (/^(?:\s{2}\n)+/.test(processedContent)) { processedContent = `\u200B${processedContent}`; } @@ -1946,8 +1923,6 @@ function MarkdownInner({ } const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); - const previewNoun = - resolvedLinkPreviews.length === 1 ? "preview" : "previews"; // When a config-nudge suppresses the prose (selectProseOrNudge returns // null), skip the parse entirely — it would be thrown away unrendered. @@ -1997,70 +1972,11 @@ function MarkdownInner({ ) : null} - {resolvedLinkPreviews.length > 0 ? ( - - {resolvedLinkPreviews.map((preview, index) => ( - setRemovePreviewDialogOpen(true) - : undefined - } - preview={preview} - /> - ))} - - ) : null} - {onRemoveLinkPreviewsForEveryone ? ( - - - - - Remove {previewNoun} for everyone? - - - No one will see{" "} - {resolvedLinkPreviews.length === 1 - ? "the preview" - : "the previews"}{" "} - on this message anymore.{" "} - {resolvedLinkPreviews.length === 1 - ? "The link itself will stay" - : "The links themselves will stay"}{" "} - in the message. This can't be undone. - - - - - - - - - - - - - ) : null} +