mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
da3bba54ec
commit
9ca7a00709
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user