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:
Wes
2026-03-18 11:40:35 -07:00
committed by GitHub
parent e7ffb0e50f
commit 42cf6c1dc8
17 changed files with 1773 additions and 12 deletions

View File

@@ -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],

View File

@@ -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(

View File

@@ -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,

View File

@@ -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>,

View File

@@ -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,

View 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),
});
}
},
});
}

View 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`;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View File

@@ -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

View 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,
};
}

View File

@@ -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,
},
);

View File

@@ -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;
};

View File

@@ -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 = [