-
-
- {isLoading &&
[loading...]
}
- {!isLoading && messages.length === 0 && (
-
[no messages in this channel]
- )}
- {messages.map((message) => (
-
selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
- className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
- >
- {selectMode && activeChannelId && (
-
- {selectedIds.has(message.id) ? '[x]' : '[ ]'}
-
+ {activeChannel && activeChannel.type === 'forum' && activeChannelId ? (
+
setActiveThread(t)} />
+ ) : (
+ <>
+
+
+ {isLoading &&
[loading...]
}
+ {!isLoading && messages.length === 0 && (
+
[no messages in this channel]
)}
-
[{formatTime(message.created_at)}]{" "}
-
<{message.author_username}>{" "}
-
{renderContent(message.content, memberUsernames)}
- {renderEmbeds(message.embeds)}
+ {messages.map((message) => (
+
selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
+ className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
+ >
+ {selectMode && activeChannelId && (
+
+ {selectedIds.has(message.id) ? '[x]' : '[ ]'}
+
+ )}
+ [{formatTime(message.created_at)}]{" "}
+ <{message.author_username}>{" "}
+ {renderContent(message.content, memberUsernames)}
+ {renderEmbeds(message.embeds)}
+
+ ))}
+
- ))}
-
-
- {showGifPicker && (
-
- setShowGifPicker(false)} />
-
- )}
- {error && (
-
- )}
- {slowmodeRemaining > 0 && (
-
- SLOWMODE: wait {slowmodeRemaining}s
-
- )}
-
-
- {activeThread && (
-
setActiveThread(null)} />
+ {activeThread && (
+ setActiveThread(null)} />
+ )}
+ >
)}
diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx
index 38c6b6b..5394337 100644
--- a/web/src/components/CreateChannelModal.tsx
+++ b/web/src/components/CreateChannelModal.tsx
@@ -11,9 +11,10 @@ interface ChannelApiResponse {
id: string;
server_id: string;
name: string;
- type: 'text' | 'voice';
+ type: 'text' | 'voice' | 'forum';
category: string;
position: number;
+ slowmode_seconds: number;
}
const SLOWMODE_OPTIONS = [
@@ -28,7 +29,7 @@ const SLOWMODE_OPTIONS = [
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState('');
- const [type, setType] = useState<'text' | 'voice'>('text');
+ const [type, setType] = useState<'text' | 'voice' | 'forum'>('text');
const [category, setCategory] = useState('general');
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
const [loading, setLoading] = useState(false);
@@ -65,6 +66,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
type: result.type,
category: result.category || null,
position: result.position,
+ slowmode_seconds: result.slowmode_seconds,
};
useChannelStore.getState().addChannel(newChannel);
onClose();
@@ -105,11 +107,12 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
diff --git a/web/src/components/ForumView.tsx b/web/src/components/ForumView.tsx
new file mode 100644
index 0000000..2f49680
--- /dev/null
+++ b/web/src/components/ForumView.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from 'react';
+import { useThreadStore, type Thread, type ForumTag } from '../stores/thread.ts';
+
+interface ForumViewProps {
+ channelId: string;
+ channelName: string;
+ onOpenThread: (thread: { id: string; name: string }) => void;
+}
+
+export function ForumView({ channelId, channelName, onOpenThread }: ForumViewProps) {
+ const threads = useThreadStore((s) => s.threadsByParent[channelId] || []);
+ const tags = useThreadStore((s) => s.tagsByForum[channelId] || []);
+ const fetchThreads = useThreadStore((s) => s.fetchThreads);
+ const fetchForumTags = useThreadStore((s) => s.fetchForumTags);
+ const createThread = useThreadStore((s) => s.createThread);
+ const createForumTag = useThreadStore((s) => s.createForumTag);
+ const [title, setTitle] = useState('');
+ const [body, setBody] = useState('');
+ const [selectedTagIds, setSelectedTagIds] = useState
([]);
+ const [tagName, setTagName] = useState('');
+ const [showForm, setShowForm] = useState(false);
+
+ useEffect(() => {
+ fetchThreads(channelId);
+ fetchForumTags(channelId);
+ }, [channelId, fetchThreads, fetchForumTags]);
+
+ const handlePost = async () => {
+ if (!title.trim()) return;
+ const t = await createThread(channelId, { name: title.trim(), tag_ids: selectedTagIds });
+ if (body.trim()) {
+ // ponytail: send first message via thread store after creation
+ const { sendThreadMessage } = useThreadStore.getState();
+ await sendThreadMessage(t.id, body.trim());
+ }
+ setTitle('');
+ setBody('');
+ setSelectedTagIds([]);
+ setShowForm(false);
+ };
+
+ const toggleTag = (id: string) => {
+ setSelectedTagIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
+ };
+
+ const handleCreateTag = async () => {
+ if (!tagName.trim()) return;
+ await createForumTag(channelId, { name: tagName.trim() });
+ setTagName('');
+ };
+
+ return (
+
+
+ ■ {channelName}
+
+
+ {showForm && (
+
+ )}
+
+ {threads.length === 0 &&
[no posts]
}
+ {threads.map((t: Thread) => (
+
+ ))}
+
+
+ );
+}
diff --git a/web/src/stores/channel.ts b/web/src/stores/channel.ts
index 3cb7605..9647a20 100644
--- a/web/src/stores/channel.ts
+++ b/web/src/stores/channel.ts
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
-export type ChannelType = 'text' | 'voice';
+export type ChannelType = 'text' | 'voice' | 'forum';
export interface Channel {
id: string;
@@ -10,6 +10,7 @@ export interface Channel {
type: ChannelType;
category: string | null;
position: number;
+ slowmode_seconds?: number;
}
interface ChannelState {
diff --git a/web/src/stores/thread.ts b/web/src/stores/thread.ts
index e90b566..a2f7053 100644
--- a/web/src/stores/thread.ts
+++ b/web/src/stores/thread.ts
@@ -15,15 +15,26 @@ export interface Thread {
message_count: number;
last_message_at: string | null;
created_at: string;
+ tag_ids?: string[];
+}
+
+export interface ForumTag {
+ id: string;
+ tag_id: string;
+ name: string;
+ emoji: string | null;
+ color: string | null;
}
export interface CreateThreadData {
name: string;
message_id?: string;
+ tag_ids?: string[];
}
interface ThreadState {
threadsByParent: Record;
+ tagsByForum: Record;
activeThreadId: string | null;
messagesByThread: Record;
isLoading: boolean;
@@ -35,10 +46,14 @@ interface ThreadState {
fetchThreadMessages: (threadId: string, before?: string) => Promise;
sendThreadMessage: (threadId: string, content: string) => Promise;
addThreadMessage: (message: Message) => void;
+ fetchForumTags: (forumChannelId: string) => Promise;
+ createForumTag: (forumChannelId: string, data: { name: string; emoji?: string; color?: string }) => Promise;
+ deleteForumTag: (tagId: string) => Promise;
}
export const useThreadStore = create((set) => ({
threadsByParent: {},
+ tagsByForum: {},
activeThreadId: null,
messagesByThread: {},
isLoading: false,
@@ -113,4 +128,31 @@ export const useThreadStore = create((set) => ({
if (list.some((m) => m.id === message.id)) return state;
return { messagesByThread: { ...state.messagesByThread, [message.channel_id]: [...list, message] } };
}),
+
+ fetchForumTags: async (forumChannelId) => {
+ const tags = await api.get(`/channels/${forumChannelId}/forum-tags`);
+ set((state) => ({
+ tagsByForum: { ...state.tagsByForum, [forumChannelId]: Array.isArray(tags) ? tags : [] },
+ }));
+ },
+
+ createForumTag: async (forumChannelId, data) => {
+ const tag = await api.post(`/channels/${forumChannelId}/forum-tags`, data);
+ set((state) => {
+ const list = state.tagsByForum[forumChannelId] || [];
+ return { tagsByForum: { ...state.tagsByForum, [forumChannelId]: [...list, tag] } };
+ });
+ return tag;
+ },
+
+ deleteForumTag: async (tagId) => {
+ await api.delete(`/forum-tags/${tagId}`);
+ set((state) => {
+ const next: Record = {};
+ for (const forumId of Object.keys(state.tagsByForum)) {
+ next[forumId] = state.tagsByForum[forumId].filter((t) => t.id !== tagId && t.tag_id !== tagId);
+ }
+ return { tagsByForum: next };
+ });
+ },
}));
diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo
index 819484a..91c1b73 100644
--- a/web/tsconfig.app.tsbuildinfo
+++ b/web/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
\ No newline at end of file