refactor(desktop): extract link preview helpers

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-30 16:39:23 -07:00
co-authored by Taylor Ho
parent da3bba54ec
commit 9ca7a00709
6 changed files with 242 additions and 229 deletions
+4 -118
View File
@@ -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<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,
})
}
/// Fetch the full reply subtree under a thread root, server-side.
///
/// Unlike the channel timeline (which the desktop assembles from its local
@@ -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<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::*;
+21
View File
@@ -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<void> {
await invokeTauri("edit_message", {
channelId,
eventId,
content,
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
mentionPubkeys: mentionPubkeys ?? null,
suppressLinkPreviews: suppressLinkPreviews ?? false,
});
}
+1 -19
View File
@@ -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<void> {
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,
@@ -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<void>;
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 (
<>
<AttachmentGroup
className="max-w-full flex-col items-start overflow-visible pb-0"
data-link-preview-list=""
>
{previews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
onRemove={
onRemoveForEveryone && index === 0
? () => setDialogOpen(true)
: undefined
}
preview={preview}
/>
))}
</AttachmentGroup>
{onRemoveForEveryone ? (
<AlertDialog onOpenChange={setDialogOpen} open={dialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {previewNoun} for everyone?
</AlertDialogTitle>
<AlertDialogDescription>
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.
</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}
</>
);
}
+7 -91
View File
@@ -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({
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-col items-start overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
onRemove={
onRemoveLinkPreviewsForEveryone && index === 0
? () => setRemovePreviewDialogOpen(true)
: undefined
}
preview={preview}
/>
))}
</AttachmentGroup>
) : null}
{onRemoveLinkPreviewsForEveryone ? (
<AlertDialog
onOpenChange={setRemovePreviewDialogOpen}
open={removePreviewDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {previewNoun} for everyone?
</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setRemovedPreviewMessageId(messageId ?? null);
void onRemoveLinkPreviewsForEveryone().catch(() => {
setRemovedPreviewMessageId(null);
});
}}
type="button"
variant="destructive"
>
Remove {previewNoun}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
<LinkPreviewList
key={messageId}
onRemoveForEveryone={onRemoveLinkPreviewsForEveryone}
previews={resolvedLinkPreviews}
/>
</VideoReviewMarkdownContext.Provider>
</MarkdownRuntimeContext.Provider>
</div>