mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(forum): Discourse-style forum view with posts, threads, and management (#105)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,11 +30,11 @@ const rules = [
|
||||
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src/app/AppShell.tsx", 700],
|
||||
["src/app/AppShell.tsx", 750],
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog)
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 650],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state
|
||||
["src/shared/api/tauri.ts", 975],
|
||||
|
||||
@@ -4,7 +4,8 @@ use tauri::State;
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{
|
||||
AddReactionBody, FeedResponse, GetFeedQuery, SearchQueryParams, SearchResponse,
|
||||
AddReactionBody, FeedResponse, ForumPostsResponse, ForumThreadResponse, GetFeedQuery,
|
||||
GetForumPostsQuery, GetForumThreadQuery, SearchQueryParams, SearchResponse,
|
||||
SendChannelMessageBody, SendChannelMessageResponse,
|
||||
},
|
||||
relay::{build_authed_request, relay_error_message, send_empty_request, send_json_request},
|
||||
@@ -47,6 +48,7 @@ pub async fn send_channel_message(
|
||||
parent_event_id: Option<String>,
|
||||
media_tags: Option<Vec<Vec<String>>>,
|
||||
mention_pubkeys: Option<Vec<String>>,
|
||||
kind: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/messages");
|
||||
@@ -59,6 +61,7 @@ pub async fn send_channel_message(
|
||||
broadcast_to_channel: false,
|
||||
media_tags,
|
||||
mention_pubkeys: mention_refs,
|
||||
kind,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -96,6 +99,49 @@ pub async fn remove_reaction(
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_forum_posts(
|
||||
channel_id: String,
|
||||
limit: Option<u32>,
|
||||
before: Option<i64>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ForumPostsResponse, String> {
|
||||
let path = format!("/api/channels/{channel_id}/messages");
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query(
|
||||
&GetForumPostsQuery {
|
||||
limit,
|
||||
before,
|
||||
with_threads: true,
|
||||
},
|
||||
);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[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 path = format!("/api/channels/{channel_id}/threads/{event_id}");
|
||||
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query(
|
||||
&GetForumThreadQuery { limit, cursor },
|
||||
);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let path = format!("/api/messages/{event_id}");
|
||||
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
|
||||
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<String, String> {
|
||||
let request = build_authed_request(
|
||||
|
||||
@@ -133,6 +133,9 @@ pub fn run() {
|
||||
get_feed,
|
||||
search_messages,
|
||||
send_channel_message,
|
||||
get_forum_posts,
|
||||
get_forum_thread,
|
||||
delete_message,
|
||||
add_reaction,
|
||||
remove_reaction,
|
||||
get_event,
|
||||
|
||||
@@ -191,6 +191,8 @@ pub struct SendChannelMessageBody<'a> {
|
||||
pub media_tags: Option<Vec<Vec<String>>>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub mention_pubkeys: Vec<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -307,6 +309,77 @@ pub struct GetUsersBatchBody<'a> {
|
||||
pub pubkeys: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ThreadSummary {
|
||||
pub reply_count: u32,
|
||||
pub descendant_count: u32,
|
||||
pub last_reply_at: Option<i64>,
|
||||
pub participants: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ForumMessageInfo {
|
||||
pub event_id: String,
|
||||
pub pubkey: String,
|
||||
pub content: String,
|
||||
pub kind: u32,
|
||||
pub created_at: i64,
|
||||
pub channel_id: String,
|
||||
pub tags: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub thread_summary: Option<ThreadSummary>,
|
||||
#[serde(default)]
|
||||
pub reactions: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ForumPostsResponse {
|
||||
pub messages: Vec<ForumMessageInfo>,
|
||||
pub next_cursor: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ForumThreadReplyInfo {
|
||||
pub event_id: String,
|
||||
pub pubkey: String,
|
||||
pub content: String,
|
||||
pub kind: u32,
|
||||
pub created_at: i64,
|
||||
pub channel_id: String,
|
||||
pub tags: Vec<Vec<String>>,
|
||||
pub parent_event_id: Option<String>,
|
||||
pub root_event_id: Option<String>,
|
||||
pub depth: u32,
|
||||
pub broadcast: bool,
|
||||
#[serde(default)]
|
||||
pub reactions: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ForumThreadResponse {
|
||||
pub root: ForumMessageInfo,
|
||||
pub replies: Vec<ForumThreadReplyInfo>,
|
||||
pub total_replies: u32,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetForumPostsQuery {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub before: Option<i64>,
|
||||
pub with_threads: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetForumThreadQuery {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ChannelPane } from "@/app/ChannelPane";
|
||||
import { AgentsView } from "@/features/agents/ui/AgentsView";
|
||||
import { ForumView } from "@/features/forum/ui/ForumView";
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import {
|
||||
channelsQueryKey,
|
||||
@@ -94,6 +95,7 @@ export function AppShell() {
|
||||
null,
|
||||
);
|
||||
const createChannelMutation = useCreateChannelMutation();
|
||||
const createForumMutation = useCreateChannelMutation();
|
||||
const activeChannel = selectedView === "channel" ? selectedChannel : null;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
const { unreadChannelIds } = useUnreadChannels(channels, activeChannel);
|
||||
@@ -201,9 +203,7 @@ export function AppShell() {
|
||||
activeChannel.topic,
|
||||
activeChannel.description,
|
||||
activeChannel.purpose,
|
||||
activeChannel.channelType === "forum"
|
||||
? "Forum channels are listed, but this first pass only wires message streams and DMs."
|
||||
: null,
|
||||
null,
|
||||
]
|
||||
.filter((value) => value && value.trim().length > 0)
|
||||
.join(" ") || "Channel details and activity."
|
||||
@@ -495,6 +495,7 @@ export function AppShell() {
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({ description, name }) => {
|
||||
@@ -510,6 +511,19 @@ export function AppShell() {
|
||||
setSelectedView("channel");
|
||||
});
|
||||
}}
|
||||
onCreateForum={async ({ description, name }) => {
|
||||
const createdForum = await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "forum",
|
||||
visibility: "open",
|
||||
});
|
||||
|
||||
React.startTransition(() => {
|
||||
setSelectedChannelId(createdForum.id);
|
||||
setSelectedView("channel");
|
||||
});
|
||||
}}
|
||||
onOpenBrowseChannels={() => {
|
||||
setIsBrowseChannelsOpen(true);
|
||||
void refetchChannels();
|
||||
@@ -601,6 +615,11 @@ export function AppShell() {
|
||||
/>
|
||||
) : selectedView === "agents" ? (
|
||||
<AgentsView />
|
||||
) : activeChannel?.channelType === "forum" ? (
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
@@ -631,9 +650,7 @@ export function AppShell() {
|
||||
);
|
||||
}}
|
||||
onToggleReaction={
|
||||
activeChannel &&
|
||||
activeChannel.archivedAt === null &&
|
||||
activeChannel.channelType !== "forum"
|
||||
activeChannel && activeChannel.archivedAt === null
|
||||
? async (message, emoji, remove) => {
|
||||
await toggleReactionMutation.mutateAsync({
|
||||
emoji,
|
||||
|
||||
162
desktop/src/features/forum/hooks.ts
Normal file
162
desktop/src/features/forum/hooks.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
deleteMessage,
|
||||
getForumPosts,
|
||||
getForumThread,
|
||||
} from "@/shared/api/forum";
|
||||
import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type {
|
||||
Channel,
|
||||
ForumPostsResponse,
|
||||
ForumThreadResponse,
|
||||
} from "@/shared/api/types";
|
||||
import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
|
||||
|
||||
export function forumPostsQueryKey(channelId: string) {
|
||||
return ["forum-posts", channelId] as const;
|
||||
}
|
||||
|
||||
export function forumThreadQueryKey(channelId: string, eventId: string) {
|
||||
return ["forum-thread", channelId, eventId] as const;
|
||||
}
|
||||
|
||||
export function useForumPostsQuery(channel: Channel | null) {
|
||||
const channelId = channel?.id ?? "";
|
||||
|
||||
return useQuery<ForumPostsResponse>({
|
||||
enabled: channel !== null && channel.channelType === "forum",
|
||||
queryKey: forumPostsQueryKey(channelId),
|
||||
queryFn: () => getForumPosts(channelId, 50),
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useForumThreadQuery(
|
||||
channelId: string | null,
|
||||
eventId: string | null,
|
||||
) {
|
||||
return useQuery<ForumThreadResponse>({
|
||||
enabled: channelId !== null && eventId !== null,
|
||||
queryKey: forumThreadQueryKey(channelId ?? "", eventId ?? ""),
|
||||
queryFn: () => getForumThread(channelId ?? "", eventId ?? ""),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateForumPostMutation(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
content,
|
||||
mentionPubkeys,
|
||||
}: {
|
||||
content: string;
|
||||
mentionPubkeys?: string[];
|
||||
}) => {
|
||||
if (!channel) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
return sendChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
null,
|
||||
undefined,
|
||||
mentionPubkeys,
|
||||
KIND_FORUM_POST,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (channel) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumPostsQueryKey(channel.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteForumPostMutation(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ eventId }: { eventId: string }) => {
|
||||
await deleteMessage(eventId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (channel) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumPostsQueryKey(channel.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteForumReplyMutation(
|
||||
channel: Channel | null,
|
||||
rootEventId: string | null,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ eventId }: { eventId: string }) => {
|
||||
await deleteMessage(eventId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (channel) {
|
||||
if (rootEventId) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumThreadQueryKey(channel.id, rootEventId),
|
||||
});
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumPostsQueryKey(channel.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateForumReplyMutation(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
content,
|
||||
parentEventId,
|
||||
mentionPubkeys,
|
||||
}: {
|
||||
content: string;
|
||||
parentEventId: string;
|
||||
mentionPubkeys?: string[];
|
||||
}) => {
|
||||
if (!channel) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
return sendChannelMessage(
|
||||
channel.id,
|
||||
content,
|
||||
parentEventId,
|
||||
undefined,
|
||||
mentionPubkeys,
|
||||
KIND_FORUM_COMMENT,
|
||||
);
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (channel) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumThreadQueryKey(channel.id, variables.parentEventId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumPostsQueryKey(channel.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
25
desktop/src/features/forum/lib/time.ts
Normal file
25
desktop/src/features/forum/lib/time.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
|
||||
export function formatRelativeTime(unixSeconds: number): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const delta = Math.max(0, now - unixSeconds);
|
||||
|
||||
if (delta < MINUTE) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
if (delta < HOUR) {
|
||||
const minutes = Math.floor(delta / MINUTE);
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
|
||||
if (delta < DAY) {
|
||||
const hours = Math.floor(delta / HOUR);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
const days = Math.floor(delta / DAY);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
134
desktop/src/features/forum/ui/ForumComposer.tsx
Normal file
134
desktop/src/features/forum/ui/ForumComposer.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Send } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import { MentionAutocomplete } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type ForumComposerProps = {
|
||||
channelId?: string | null;
|
||||
placeholder: string;
|
||||
submitLabel: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
onCancel?: () => void;
|
||||
onSubmit: (content: string, mentionPubkeys: string[]) => void;
|
||||
};
|
||||
|
||||
export function ForumComposer({
|
||||
channelId = null,
|
||||
placeholder,
|
||||
submitLabel,
|
||||
disabled,
|
||||
isSending,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ForumComposerProps) {
|
||||
const [value, setValue] = React.useState("");
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const mentions = useMentions(channelId);
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
const pubkeys = mentions.extractMentionPubkeys(trimmed);
|
||||
onSubmit(trimmed, pubkeys);
|
||||
setValue("");
|
||||
mentions.clearMentions();
|
||||
}
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const next = event.target.value;
|
||||
setValue(next);
|
||||
mentions.updateMentionQuery(next, event.target.selectionStart);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const { handled, suggestion } = mentions.handleMentionKeyDown(event);
|
||||
if (handled) {
|
||||
if (suggestion) {
|
||||
const textarea = textareaRef.current;
|
||||
const result = mentions.insertMention(
|
||||
suggestion,
|
||||
value,
|
||||
textarea?.selectionEnd ?? value.length,
|
||||
);
|
||||
setValue(result.nextContent);
|
||||
requestAnimationFrame(() => {
|
||||
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
const pubkeys = mentions.extractMentionPubkeys(trimmed);
|
||||
onSubmit(trimmed, pubkeys);
|
||||
setValue("");
|
||||
mentions.clearMentions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="relative flex flex-col gap-2" onSubmit={handleSubmit}>
|
||||
<MentionAutocomplete
|
||||
onSelect={(suggestion) => {
|
||||
const textarea = textareaRef.current;
|
||||
const result = mentions.insertMention(
|
||||
suggestion,
|
||||
value,
|
||||
textarea?.selectionEnd ?? value.length,
|
||||
);
|
||||
setValue(result.nextContent);
|
||||
requestAnimationFrame(() => {
|
||||
textarea?.setSelectionRange(result.nextCursor, result.nextCursor);
|
||||
textarea?.focus();
|
||||
});
|
||||
}}
|
||||
selectedIndex={mentions.mentionSelectedIndex}
|
||||
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-[100px] resize-none bg-background/80"
|
||||
disabled={disabled || isSending}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel ? (
|
||||
<Button
|
||||
disabled={isSending}
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={disabled || isSending || value.trim().length === 0}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isSending ? "Sending..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
172
desktop/src/features/forum/ui/ForumPostCard.tsx
Normal file
172
desktop/src/features/forum/ui/ForumPostCard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { ForumPost } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
|
||||
import { formatRelativeTime } from "../lib/time";
|
||||
|
||||
type ForumPostCardProps = {
|
||||
post: ForumPost;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
isActive?: boolean;
|
||||
canDelete?: boolean;
|
||||
isDeleting?: boolean;
|
||||
onClick: (post: ForumPost) => void;
|
||||
onDelete?: (eventId: string) => void;
|
||||
};
|
||||
|
||||
export function ForumPostCard({
|
||||
post,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
isActive,
|
||||
canDelete,
|
||||
isDeleting,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: ForumPostCardProps) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
|
||||
const authorLabel = resolveUserLabel({
|
||||
pubkey: post.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
const summary = post.threadSummary;
|
||||
const previewContent =
|
||||
post.content.length > 200
|
||||
? `${post.content.slice(0, 200)}...`
|
||||
: post.content;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group w-full cursor-pointer rounded-xl border border-border/60 bg-card p-4 text-left transition-colors hover:border-border hover:bg-accent/40",
|
||||
isActive && "border-primary/40 bg-accent/60",
|
||||
isDeleting && "pointer-events-none opacity-50",
|
||||
)}
|
||||
onClick={() => onClick(post)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-6 w-6 rounded-full text-[10px]"
|
||||
iconClassName="h-3 w-3"
|
||||
label={authorLabel}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{authorLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
|
||||
{canDelete && onDelete ? (
|
||||
<div
|
||||
className="ml-auto opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClickCapture={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete post
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
open={isDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete post?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this post and cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => onDelete(post.eventId)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete post
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Markdown compact content={previewContent} />
|
||||
</div>
|
||||
|
||||
{summary && summary.replyCount > 0 ? (
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{summary.replyCount}{" "}
|
||||
{summary.replyCount === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
{summary.lastReplyAt ? (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>last {formatRelativeTime(summary.lastReplyAt)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
330
desktop/src/features/forum/ui/ForumThreadPanel.tsx
Normal file
330
desktop/src/features/forum/ui/ForumThreadPanel.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { ArrowLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import { formatRelativeTime } from "../lib/time";
|
||||
import { ForumComposer } from "./ForumComposer";
|
||||
|
||||
type ForumThreadPanelProps = {
|
||||
thread: ForumThreadResponse | undefined;
|
||||
isLoading: boolean;
|
||||
isSendingReply: boolean;
|
||||
channelId: string;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
onBack: () => void;
|
||||
onReply: (content: string, mentionPubkeys: string[]) => void;
|
||||
onDeletePost?: (eventId: string) => void;
|
||||
onDeleteReply?: (eventId: string) => void;
|
||||
canDeletePost?: boolean;
|
||||
isDeletingPost?: boolean;
|
||||
};
|
||||
|
||||
function canDeleteReply(
|
||||
reply: ThreadReply,
|
||||
currentPubkey: string | undefined,
|
||||
): boolean {
|
||||
if (!currentPubkey) return false;
|
||||
return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
label,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {label}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this {label} and cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={onConfirm} type="button" variant="destructive">
|
||||
Delete {label}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyRow({
|
||||
reply,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
onDelete,
|
||||
}: {
|
||||
reply: ThreadReply;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
onDelete?: (eventId: string) => void;
|
||||
}) {
|
||||
const [isDeleteOpen, setIsDeleteOpen] = React.useState(false);
|
||||
const replyAuthorLabel = resolveUserLabel({
|
||||
pubkey: reply.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const replyAvatarUrl =
|
||||
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
|
||||
|
||||
return (
|
||||
<div className="group px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
className="h-6 w-6 rounded-full text-[10px]"
|
||||
iconClassName="h-3 w-3"
|
||||
label={replyAuthorLabel}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{replyAuthorLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(reply.createdAt)}
|
||||
</span>
|
||||
|
||||
{showDelete ? (
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete reply
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteConfirmDialog
|
||||
label="reply"
|
||||
onConfirm={() => onDelete(reply.eventId)}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
open={isDeleteOpen}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 pl-8">
|
||||
<Markdown compact content={reply.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumThreadPanel({
|
||||
thread,
|
||||
isLoading,
|
||||
isSendingReply,
|
||||
channelId,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
onBack,
|
||||
onReply,
|
||||
onDeletePost,
|
||||
onDeleteReply,
|
||||
canDeletePost,
|
||||
isDeletingPost,
|
||||
}: ForumThreadPanelProps) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false);
|
||||
|
||||
if (isLoading || !thread) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to posts
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 p-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { post, replies } = thread;
|
||||
const postAuthorLabel = resolveUserLabel({
|
||||
pubkey: post.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const postAvatarUrl =
|
||||
profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to posts
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollRef}>
|
||||
{/* Original post */}
|
||||
<div
|
||||
className={cn(
|
||||
"group border-b border-border/60 p-4",
|
||||
isDeletingPost && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar
|
||||
avatarUrl={postAvatarUrl}
|
||||
className="h-8 w-8 rounded-full text-xs"
|
||||
iconClassName="h-4 w-4"
|
||||
label={postAuthorLabel}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{postAuthorLabel}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canDeletePost && onDeletePost ? (
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeletePostOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete post
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteConfirmDialog
|
||||
label="post"
|
||||
onConfirm={() => onDeletePost(post.eventId)}
|
||||
onOpenChange={setIsDeletePostOpen}
|
||||
open={isDeletePostOpen}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Markdown content={post.content} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replies header */}
|
||||
<div className="flex items-center gap-1.5 border-b border-border/60 px-4 py-2.5 text-sm font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{replies.length} {replies.length === 1 ? "reply" : "replies"}
|
||||
</div>
|
||||
|
||||
{/* Reply list */}
|
||||
<div className="divide-y divide-border/40">
|
||||
{replies.map((reply) => (
|
||||
<ReplyRow
|
||||
currentPubkey={currentPubkey}
|
||||
key={reply.eventId}
|
||||
onDelete={onDeleteReply}
|
||||
profiles={profiles}
|
||||
reply={reply}
|
||||
/>
|
||||
))}
|
||||
|
||||
{replies.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No replies yet. Be the first to respond.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reply composer */}
|
||||
<div className="border-t border-border/60 p-4">
|
||||
<ForumComposer
|
||||
channelId={channelId}
|
||||
isSending={isSendingReply}
|
||||
onSubmit={onReply}
|
||||
placeholder="Reply to this post..."
|
||||
submitLabel="Reply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
desktop/src/features/forum/ui/ForumView.tsx
Normal file
205
desktop/src/features/forum/ui/ForumView.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import {
|
||||
useCreateForumPostMutation,
|
||||
useCreateForumReplyMutation,
|
||||
useDeleteForumPostMutation,
|
||||
useDeleteForumReplyMutation,
|
||||
useForumPostsQuery,
|
||||
useForumThreadQuery,
|
||||
} from "../hooks";
|
||||
import { ForumComposer } from "./ForumComposer";
|
||||
import { ForumPostCard } from "./ForumPostCard";
|
||||
import { ForumThreadPanel } from "./ForumThreadPanel";
|
||||
|
||||
type ForumViewProps = {
|
||||
channel: Channel;
|
||||
currentPubkey?: string;
|
||||
};
|
||||
|
||||
function canDelete(postPubkey: string, currentPubkey?: string): boolean {
|
||||
if (!currentPubkey) return false;
|
||||
// Author can always delete their own posts. Admin check would need
|
||||
// channel member role data — for now, author-only is sufficient.
|
||||
return postPubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
}
|
||||
|
||||
export function ForumView({ channel, currentPubkey }: ForumViewProps) {
|
||||
const [expandedPostId, setExpandedPostId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isComposerOpen, setIsComposerOpen] = React.useState(false);
|
||||
|
||||
const postsQuery = useForumPostsQuery(channel);
|
||||
const threadQuery = useForumThreadQuery(
|
||||
expandedPostId ? channel.id : null,
|
||||
expandedPostId,
|
||||
);
|
||||
const createPostMutation = useCreateForumPostMutation(channel);
|
||||
const createReplyMutation = useCreateForumReplyMutation(channel);
|
||||
const deletePostMutation = useDeleteForumPostMutation(channel);
|
||||
const deleteReplyMutation = useDeleteForumReplyMutation(
|
||||
channel,
|
||||
expandedPostId,
|
||||
);
|
||||
|
||||
const posts = postsQuery.data?.posts ?? [];
|
||||
|
||||
// Collect all pubkeys from posts and thread for profile resolution
|
||||
const allPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set<string>();
|
||||
for (const post of posts) {
|
||||
pubkeys.add(post.pubkey);
|
||||
if (post.threadSummary?.participants) {
|
||||
for (const pk of post.threadSummary.participants) {
|
||||
pubkeys.add(pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (threadQuery.data) {
|
||||
pubkeys.add(threadQuery.data.post.pubkey);
|
||||
for (const reply of threadQuery.data.replies) {
|
||||
pubkeys.add(reply.pubkey);
|
||||
}
|
||||
}
|
||||
return [...pubkeys];
|
||||
}, [posts, threadQuery.data]);
|
||||
|
||||
const profilesQuery = useUsersBatchQuery(allPubkeys, {
|
||||
enabled: allPubkeys.length > 0,
|
||||
});
|
||||
|
||||
// Reset expanded post when channel changes
|
||||
const previousChannelIdRef = React.useRef(channel.id);
|
||||
if (previousChannelIdRef.current !== channel.id) {
|
||||
previousChannelIdRef.current = channel.id;
|
||||
setExpandedPostId(null);
|
||||
setIsComposerOpen(false);
|
||||
}
|
||||
|
||||
if (expandedPostId) {
|
||||
const threadPost = threadQuery.data?.post;
|
||||
const canDeleteExpandedPost = threadPost
|
||||
? canDelete(threadPost.pubkey, currentPubkey)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<ForumThreadPanel
|
||||
canDeletePost={canDeleteExpandedPost}
|
||||
currentPubkey={currentPubkey}
|
||||
isDeletingPost={deletePostMutation.isPending}
|
||||
isLoading={threadQuery.isLoading}
|
||||
isSendingReply={createReplyMutation.isPending}
|
||||
onBack={() => setExpandedPostId(null)}
|
||||
onDeletePost={(eventId) => {
|
||||
deletePostMutation.mutate(
|
||||
{ eventId },
|
||||
{ onSuccess: () => setExpandedPostId(null) },
|
||||
);
|
||||
}}
|
||||
onDeleteReply={(eventId) => {
|
||||
deleteReplyMutation.mutate({ eventId });
|
||||
}}
|
||||
channelId={channel.id}
|
||||
onReply={(content, mentionPubkeys) => {
|
||||
createReplyMutation.mutate({
|
||||
content,
|
||||
parentEventId: expandedPostId,
|
||||
mentionPubkeys,
|
||||
});
|
||||
}}
|
||||
profiles={profilesQuery.data?.profiles}
|
||||
thread={threadQuery.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* New post area */}
|
||||
<div className="border-b border-border/60 p-4">
|
||||
{isComposerOpen ? (
|
||||
<ForumComposer
|
||||
channelId={channel.id}
|
||||
isSending={createPostMutation.isPending}
|
||||
onCancel={() => setIsComposerOpen(false)}
|
||||
onSubmit={(content, mentionPubkeys) => {
|
||||
createPostMutation.mutate(
|
||||
{ content, mentionPubkeys },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsComposerOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
placeholder="Write your post..."
|
||||
submitLabel="Post"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="w-full rounded-xl border border-dashed border-border/80 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:border-border hover:bg-accent/30 hover:text-foreground"
|
||||
disabled={!channel.isMember || channel.archivedAt !== null}
|
||||
onClick={() => setIsComposerOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
{channel.archivedAt
|
||||
? "This forum is archived."
|
||||
: !channel.isMember
|
||||
? "Join this forum to create posts."
|
||||
: "Start a new post..."}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{postsQuery.isLoading ? (
|
||||
<div className="space-y-3 p-4">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<MessageSquareText className="h-10 w-10 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground/70">
|
||||
No posts yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Start a discussion by creating the first post.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{posts.map((post) => (
|
||||
<ForumPostCard
|
||||
canDelete={canDelete(post.pubkey, currentPubkey)}
|
||||
currentPubkey={currentPubkey}
|
||||
isActive={false}
|
||||
isDeleting={
|
||||
deletePostMutation.isPending &&
|
||||
deletePostMutation.variables?.eventId === post.eventId
|
||||
}
|
||||
key={post.eventId}
|
||||
onClick={() => setExpandedPostId(post.eventId)}
|
||||
onDelete={(eventId) => {
|
||||
deletePostMutation.mutate({ eventId });
|
||||
}}
|
||||
post={post}
|
||||
profiles={profilesQuery.data?.profiles}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
desktop/src/features/messages/lib/useMentions.ts
Normal file
209
desktop/src/features/messages/lib/useMentions.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
|
||||
function detectMentionQuery(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
): { query: string; startIndex: number } | null {
|
||||
const beforeCursor = value.slice(0, cursorPosition);
|
||||
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = match[1];
|
||||
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
|
||||
return { query, startIndex };
|
||||
}
|
||||
|
||||
export function useMentions(channelId: string | null) {
|
||||
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
|
||||
const [mentionStartIndex, setMentionStartIndex] = React.useState(0);
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
|
||||
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
const membersQuery = useChannelMembersQuery(channelId);
|
||||
const members = membersQuery.data ?? [];
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const managedAgentNamesByPubkey = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(managedAgentsQuery.data ?? []).map((agent) => [
|
||||
agent.pubkey.toLowerCase(),
|
||||
agent.name,
|
||||
]),
|
||||
),
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
|
||||
const suggestions = React.useMemo<MentionSuggestion[]>(() => {
|
||||
if (mentionQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerQuery = mentionQuery.toLowerCase();
|
||||
return members
|
||||
.map((member) => {
|
||||
const fallbackName =
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
member.pubkey.slice(0, 8);
|
||||
|
||||
return {
|
||||
member,
|
||||
label: member.displayName ?? fallbackName,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
({ label, member }) =>
|
||||
label.toLowerCase().includes(lowerQuery) ||
|
||||
member.pubkey.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map(({ member, label }) => ({
|
||||
pubkey: member.pubkey,
|
||||
displayName: label,
|
||||
role: member.role === "admin" ? "admin" : null,
|
||||
}));
|
||||
}, [managedAgentNamesByPubkey, members, mentionQuery]);
|
||||
|
||||
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
|
||||
|
||||
const insertMention = React.useCallback(
|
||||
(
|
||||
suggestion: MentionSuggestion,
|
||||
content: string,
|
||||
selectionEnd: number,
|
||||
): { nextContent: string; nextCursor: number } => {
|
||||
const displayName = suggestion.displayName;
|
||||
const before = content.slice(0, mentionStartIndex);
|
||||
const after = content.slice(selectionEnd);
|
||||
const inserted = `@${displayName} `;
|
||||
const nextContent = `${before}${inserted}${after}`;
|
||||
const nextCursor = before.length + inserted.length;
|
||||
|
||||
mentionMapRef.current.set(displayName, suggestion.pubkey);
|
||||
setMentionQuery(null);
|
||||
setMentionSelectedIndex(0);
|
||||
|
||||
return { nextContent, nextCursor };
|
||||
},
|
||||
[mentionStartIndex],
|
||||
);
|
||||
|
||||
const updateMentionQuery = React.useCallback(
|
||||
(value: string, cursorPosition: number) => {
|
||||
const mention = detectMentionQuery(value, cursorPosition);
|
||||
if (mention) {
|
||||
setMentionQuery(mention.query);
|
||||
setMentionStartIndex(mention.startIndex);
|
||||
setMentionSelectedIndex(0);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const extractMentionPubkeys = React.useCallback(
|
||||
(text: string): string[] => {
|
||||
const pubkeys: string[] = [];
|
||||
|
||||
const hasMention = (name: string): boolean => {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(
|
||||
`(?:^|\\s)@${escaped}(?=[\\s,;.!?:)\\]}]|$)`,
|
||||
"i",
|
||||
);
|
||||
return pattern.test(text);
|
||||
};
|
||||
|
||||
for (const [displayName, pubkey] of mentionMapRef.current) {
|
||||
if (hasMention(displayName)) {
|
||||
pubkeys.push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (pubkeys.includes(member.pubkey)) {
|
||||
continue;
|
||||
}
|
||||
const name =
|
||||
member.displayName ??
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
if (name && hasMention(name)) {
|
||||
pubkeys.push(member.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(pubkeys)];
|
||||
},
|
||||
[members, managedAgentNamesByPubkey],
|
||||
);
|
||||
|
||||
const clearMentions = React.useCallback(() => {
|
||||
mentionMapRef.current.clear();
|
||||
setMentionQuery(null);
|
||||
setMentionSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
const handleMentionKeyDown = React.useCallback(
|
||||
(
|
||||
event: React.KeyboardEvent,
|
||||
): { handled: boolean; suggestion?: MentionSuggestion } => {
|
||||
if (!isMentionOpen) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex((current) =>
|
||||
current < suggestions.length - 1 ? current + 1 : 0,
|
||||
);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex((current) =>
|
||||
current > 0 ? current - 1 : suggestions.length - 1,
|
||||
);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Tab" ||
|
||||
(event.key === "Enter" &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
return { handled: true, suggestion: suggestions[mentionSelectedIndex] };
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMentionQuery(null);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
},
|
||||
[isMentionOpen, mentionSelectedIndex, suggestions],
|
||||
);
|
||||
|
||||
return {
|
||||
clearMentions,
|
||||
extractMentionPubkeys,
|
||||
handleMentionKeyDown,
|
||||
insertMention,
|
||||
isMentionOpen,
|
||||
mentionSelectedIndex,
|
||||
suggestions,
|
||||
updateMentionQuery,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type AppSidebarProps = {
|
||||
fallbackDisplayName?: string;
|
||||
isLoading: boolean;
|
||||
isCreatingChannel: boolean;
|
||||
isCreatingForum: boolean;
|
||||
profile?: Profile;
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
errorMessage?: string;
|
||||
@@ -53,6 +54,10 @@ type AppSidebarProps = {
|
||||
name: string;
|
||||
description?: string;
|
||||
}) => Promise<void>;
|
||||
onCreateForum: (input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}) => Promise<void>;
|
||||
onOpenBrowseChannels: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onSelectAgents: () => void;
|
||||
@@ -291,12 +296,138 @@ function StreamsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ForumsSection({
|
||||
items,
|
||||
isCreateOpen,
|
||||
isCreatingForum,
|
||||
draftName,
|
||||
draftDescription,
|
||||
createInputRef,
|
||||
createErrorMessage,
|
||||
onToggleCreate,
|
||||
onChangeName,
|
||||
onChangeDescription,
|
||||
onCreateForum,
|
||||
onCancelCreate,
|
||||
onSelectChannel,
|
||||
isActiveChannel,
|
||||
selectedChannelId,
|
||||
unreadChannelIds,
|
||||
}: {
|
||||
items: Channel[];
|
||||
isCreateOpen: boolean;
|
||||
isCreatingForum: boolean;
|
||||
draftName: string;
|
||||
draftDescription: string;
|
||||
createInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
createErrorMessage?: string;
|
||||
onToggleCreate: () => void;
|
||||
onChangeName: (value: string) => void;
|
||||
onChangeDescription: (value: string) => void;
|
||||
onCreateForum: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
onCancelCreate: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
isActiveChannel: boolean;
|
||||
selectedChannelId: string | null;
|
||||
unreadChannelIds: Set<string>;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Forums</SidebarGroupLabel>
|
||||
<SidebarGroupAction
|
||||
aria-expanded={isCreateOpen}
|
||||
aria-label={isCreateOpen ? "Close new forum form" : "New forum"}
|
||||
className="top-3 text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
|
||||
onClick={onToggleCreate}
|
||||
type="button"
|
||||
>
|
||||
<Plus
|
||||
className={
|
||||
isCreateOpen
|
||||
? "rotate-45 transition-transform"
|
||||
: "transition-transform"
|
||||
}
|
||||
/>
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>
|
||||
{isCreateOpen ? (
|
||||
<form
|
||||
className="mb-2 space-y-2 rounded-lg border border-sidebar-border/70 bg-sidebar-accent/60 p-2"
|
||||
data-testid="create-forum-form"
|
||||
onSubmit={onCreateForum}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-forum-name"
|
||||
disabled={isCreatingForum}
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
placeholder="design-discussions"
|
||||
ref={createInputRef}
|
||||
spellCheck={false}
|
||||
value={draftName}
|
||||
/>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-forum-description"
|
||||
disabled={isCreatingForum}
|
||||
onChange={(event) => onChangeDescription(event.target.value)}
|
||||
placeholder="What this forum is for"
|
||||
value={draftDescription}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isCreatingForum || draftName.trim().length === 0}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{isCreatingForum ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isCreatingForum}
|
||||
onClick={onCancelCreate}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{createErrorMessage ? (
|
||||
<p className="text-sm text-destructive">{createErrorMessage}</p>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<SidebarMenu data-testid="forum-list">
|
||||
{items.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={isActiveChannel && selectedChannelId === channel.id}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
) : null}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
channels,
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isLoading,
|
||||
isCreatingChannel,
|
||||
isCreatingForum,
|
||||
profile,
|
||||
selfPresenceStatus,
|
||||
errorMessage,
|
||||
@@ -304,6 +435,7 @@ export function AppSidebar({
|
||||
selectedView,
|
||||
unreadChannelIds,
|
||||
onCreateChannel,
|
||||
onCreateForum,
|
||||
onOpenBrowseChannels,
|
||||
onOpenSearch,
|
||||
onSelectAgents,
|
||||
@@ -313,12 +445,19 @@ export function AppSidebar({
|
||||
}: AppSidebarProps) {
|
||||
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [isForumCreateOpen, setIsForumCreateOpen] = React.useState(false);
|
||||
const [draftName, setDraftName] = React.useState("");
|
||||
const [draftDescription, setDraftDescription] = React.useState("");
|
||||
const [forumDraftName, setForumDraftName] = React.useState("");
|
||||
const [forumDraftDescription, setForumDraftDescription] = React.useState("");
|
||||
const [createErrorMessage, setCreateErrorMessage] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [forumCreateErrorMessage, setForumCreateErrorMessage] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const createInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const forumCreateInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const streamChannels = channels.filter(
|
||||
(channel) => channel.channelType === "stream",
|
||||
);
|
||||
@@ -403,6 +542,41 @@ export function AppSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateForum(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = forumDraftName.trim();
|
||||
const description = forumDraftDescription.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForumCreateErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
await onCreateForum({
|
||||
name,
|
||||
description: description || undefined,
|
||||
});
|
||||
|
||||
setForumDraftName("");
|
||||
setForumDraftDescription("");
|
||||
setIsForumCreateOpen(false);
|
||||
} catch (error) {
|
||||
setForumCreateErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to create forum.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isForumCreateOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
forumCreateInputRef.current?.focus();
|
||||
}, [isForumCreateOpen]);
|
||||
|
||||
function handleDragPointerDown(e: React.PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -542,13 +716,38 @@ export function AppSidebar({
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarSection
|
||||
<ForumsSection
|
||||
createErrorMessage={forumCreateErrorMessage}
|
||||
createInputRef={forumCreateInputRef}
|
||||
draftDescription={forumDraftDescription}
|
||||
draftName={forumDraftName}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
isCreateOpen={isForumCreateOpen}
|
||||
isCreatingForum={isCreatingForum}
|
||||
items={forumChannels}
|
||||
onCancelCreate={() => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setForumDraftName("");
|
||||
setForumDraftDescription("");
|
||||
setIsForumCreateOpen(false);
|
||||
}}
|
||||
onChangeDescription={(value) => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setForumDraftDescription(value);
|
||||
}}
|
||||
onChangeName={(value) => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setForumDraftName(value);
|
||||
}}
|
||||
onCreateForum={(event) => {
|
||||
void handleCreateForum(event);
|
||||
}}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onToggleCreate={() => {
|
||||
setForumCreateErrorMessage(undefined);
|
||||
setIsForumCreateOpen((current) => !current);
|
||||
}}
|
||||
selectedChannelId={selectedChannelId}
|
||||
testId="forum-list"
|
||||
title="Forums"
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
<SidebarSection
|
||||
|
||||
137
desktop/src/shared/api/forum.ts
Normal file
137
desktop/src/shared/api/forum.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
ForumPost,
|
||||
ForumPostsResponse,
|
||||
ForumThreadResponse,
|
||||
ThreadReply,
|
||||
} from "@/shared/api/types";
|
||||
import { KIND_FORUM_POST } from "@/shared/constants/kinds";
|
||||
|
||||
import { invokeTauri } from "./tauri";
|
||||
|
||||
type RawThreadSummary = {
|
||||
reply_count: number;
|
||||
descendant_count: number;
|
||||
last_reply_at: number | null;
|
||||
participants: string[];
|
||||
};
|
||||
|
||||
type RawForumPost = {
|
||||
event_id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
kind: number;
|
||||
created_at: number;
|
||||
channel_id: string;
|
||||
tags: string[][];
|
||||
thread_summary: RawThreadSummary | null;
|
||||
reactions: unknown;
|
||||
};
|
||||
|
||||
type RawForumPostsResponse = {
|
||||
messages: RawForumPost[];
|
||||
next_cursor: number | null;
|
||||
};
|
||||
|
||||
type RawThreadReply = {
|
||||
event_id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
kind: number;
|
||||
created_at: number;
|
||||
channel_id: string;
|
||||
tags: string[][];
|
||||
parent_event_id: string | null;
|
||||
root_event_id: string | null;
|
||||
depth: number;
|
||||
broadcast: boolean;
|
||||
reactions: unknown;
|
||||
};
|
||||
|
||||
type RawForumThreadResponse = {
|
||||
root: RawForumPost;
|
||||
replies: RawThreadReply[];
|
||||
total_replies: number;
|
||||
next_cursor: string | null;
|
||||
};
|
||||
|
||||
function fromRawForumPost(post: RawForumPost): ForumPost {
|
||||
return {
|
||||
eventId: post.event_id,
|
||||
pubkey: post.pubkey,
|
||||
content: post.content,
|
||||
kind: post.kind,
|
||||
createdAt: post.created_at,
|
||||
channelId: post.channel_id,
|
||||
tags: post.tags,
|
||||
threadSummary: post.thread_summary
|
||||
? {
|
||||
replyCount: post.thread_summary.reply_count,
|
||||
descendantCount: post.thread_summary.descendant_count,
|
||||
lastReplyAt: post.thread_summary.last_reply_at,
|
||||
participants: post.thread_summary.participants,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRawThreadReply(reply: RawThreadReply): ThreadReply {
|
||||
return {
|
||||
eventId: reply.event_id,
|
||||
pubkey: reply.pubkey,
|
||||
content: reply.content,
|
||||
kind: reply.kind,
|
||||
createdAt: reply.created_at,
|
||||
channelId: reply.channel_id,
|
||||
tags: reply.tags,
|
||||
parentEventId: reply.parent_event_id,
|
||||
rootEventId: reply.root_event_id,
|
||||
depth: reply.depth,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteMessage(eventId: string): Promise<void> {
|
||||
await invokeTauri("delete_message", { eventId });
|
||||
}
|
||||
|
||||
export async function getForumPosts(
|
||||
channelId: string,
|
||||
limit?: number,
|
||||
before?: number,
|
||||
): Promise<ForumPostsResponse> {
|
||||
const response = await invokeTauri<RawForumPostsResponse>("get_forum_posts", {
|
||||
channelId,
|
||||
limit: limit ?? null,
|
||||
before: before ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
posts: response.messages
|
||||
.filter((m) => m.kind === KIND_FORUM_POST)
|
||||
.map(fromRawForumPost),
|
||||
nextCursor: response.next_cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getForumThread(
|
||||
channelId: string,
|
||||
eventId: string,
|
||||
limit?: number,
|
||||
cursor?: string,
|
||||
): Promise<ForumThreadResponse> {
|
||||
const response = await invokeTauri<RawForumThreadResponse>(
|
||||
"get_forum_thread",
|
||||
{
|
||||
channelId,
|
||||
eventId,
|
||||
limit: limit ?? null,
|
||||
cursor: cursor ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
post: fromRawForumPost(response.root),
|
||||
replies: response.replies.map(fromRawThreadReply),
|
||||
totalReplies: response.total_replies,
|
||||
nextCursor: response.next_cursor,
|
||||
};
|
||||
}
|
||||
@@ -632,6 +632,7 @@ export async function sendChannelMessage(
|
||||
parentEventId?: string | null,
|
||||
mediaTags?: string[][],
|
||||
mentionPubkeys?: string[],
|
||||
kind?: number,
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
@@ -641,6 +642,7 @@ export async function sendChannelMessage(
|
||||
parentEventId,
|
||||
mediaTags: mediaTags ?? null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
kind: kind ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -360,3 +360,48 @@ export type UpdateManagedAgentInput = {
|
||||
model?: string | null;
|
||||
systemPrompt?: string | null;
|
||||
};
|
||||
|
||||
// ── Forum types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThreadSummary = {
|
||||
replyCount: number;
|
||||
descendantCount: number;
|
||||
lastReplyAt: number | null;
|
||||
participants: string[];
|
||||
};
|
||||
|
||||
export type ForumPost = {
|
||||
eventId: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
kind: number;
|
||||
createdAt: number;
|
||||
channelId: string;
|
||||
tags: string[][];
|
||||
threadSummary: ThreadSummary | null;
|
||||
};
|
||||
|
||||
export type ForumPostsResponse = {
|
||||
posts: ForumPost[];
|
||||
nextCursor: number | null;
|
||||
};
|
||||
|
||||
export type ThreadReply = {
|
||||
eventId: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
kind: number;
|
||||
createdAt: number;
|
||||
channelId: string;
|
||||
tags: string[][];
|
||||
parentEventId: string | null;
|
||||
rootEventId: string | null;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type ForumThreadResponse = {
|
||||
post: ForumPost;
|
||||
replies: ThreadReply[];
|
||||
totalReplies: number;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ export const KIND_STREAM_MESSAGE_V2 = 40002;
|
||||
export const KIND_STREAM_MESSAGE_EDIT = 40003;
|
||||
export const KIND_STREAM_MESSAGE_DIFF = 40008;
|
||||
export const KIND_SYSTEM_MESSAGE = 40099;
|
||||
export const KIND_FORUM_POST = 45001;
|
||||
export const KIND_FORUM_COMMENT = 45003;
|
||||
export const KIND_TYPING_INDICATOR = 20002;
|
||||
|
||||
export const CHANNEL_EVENT_KINDS = [
|
||||
|
||||
Reference in New Issue
Block a user