feat: feature requests board with voting

- [FEATURES] tab next to PINNED in channel header
- Create, vote/unvote, filter by status (open/planned/done/rejected)
- Sort by vote count (most voted first)
- Status selector for moderation
- DB: feature_requests + feature_request_votes tables
- Backend: full CRUD + vote endpoints under /servers/{id}/feature-requests
This commit is contained in:
2026-07-02 13:11:52 -04:00
parent 3002626ac4
commit 5ca5e018ca
7 changed files with 525 additions and 1 deletions
+6
View File
@@ -232,6 +232,12 @@ func main() {
pollHandler.RegisterRoutes(r)
})
// Feature requests
frHandler := message.NewFeatureRequestHandler(database.DB)
r.Route("/servers/{serverID}/feature-requests", func(r chi.Router) {
frHandler.RegisterRoutes(r)
})
// Per-channel notification settings
r.Route("/channels/{channelID}/notifications", func(r chi.Router) {
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
+22
View File
@@ -534,5 +534,27 @@ CREATE TABLE IF NOT EXISTS poll_votes (
UNIQUE (option_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_poll_votes_option ON poll_votes(option_id);
-- Feature requests
CREATE TABLE IF NOT EXISTS feature_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'planned', 'done', 'rejected')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_feature_requests_server ON feature_requests(server_id, created_at DESC);
CREATE TABLE IF NOT EXISTS feature_request_votes (
feature_request_id UUID NOT NULL REFERENCES feature_requests(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (feature_request_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
`
+216
View File
@@ -0,0 +1,216 @@
package message
import (
"database/sql"
"encoding/json"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type FeatureRequestHandler struct {
db *sql.DB
}
func NewFeatureRequestHandler(db *sql.DB) *FeatureRequestHandler {
return &FeatureRequestHandler{db: db}
}
func (h *FeatureRequestHandler) RegisterRoutes(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Post("/{id}/vote", h.Vote)
r.Delete("/{id}/vote", h.Unvote)
r.Patch("/{id}/status", h.UpdateStatus)
}
type featureRequestResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
ChannelID string `json:"channel_id"`
AuthorID string `json:"author_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
VoteCount int `json:"vote_count"`
Voters []string `json:"voters"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (h *FeatureRequestHandler) List(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
if serverID == "" {
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT fr.id, fr.server_id, fr.channel_id, fr.author_id, fr.title, fr.description, fr.status,
fr.created_at, fr.updated_at,
COALESCE((SELECT count(*) FROM feature_request_votes frv WHERE frv.feature_request_id = fr.id), 0) as vote_count
FROM feature_requests fr
WHERE fr.server_id = $1
ORDER BY vote_count DESC, fr.created_at DESC
`, serverID)
if err != nil {
http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var results []featureRequestResponse
for rows.Next() {
var fr featureRequestResponse
if err := rows.Scan(&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt, &fr.VoteCount); err != nil {
continue
}
// Fetch voters
voterRows, _ := h.db.QueryContext(r.Context(), `
SELECT user_id FROM feature_request_votes WHERE feature_request_id = $1
`, fr.ID)
if voterRows != nil {
for voterRows.Next() {
var vid string
if voterRows.Scan(&vid) == nil {
fr.Voters = append(fr.Voters, vid)
}
}
voterRows.Close()
}
if fr.Voters == nil {
fr.Voters = []string{}
}
results = append(results, fr)
}
if results == nil {
results = []featureRequestResponse{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
type createFeatureRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ChannelID string `json:"channel_id"`
}
func (h *FeatureRequestHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
serverID := chi.URLParam(r, "serverID")
if serverID == "" {
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
return
}
var req createFeatureRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Title == "" {
http.Error(w, `{"error":"title required"}`, http.StatusBadRequest)
return
}
if req.ChannelID == "" {
// Default to the first text channel in the server
h.db.QueryRowContext(r.Context(), `
SELECT id FROM channels WHERE server_id = $1 AND type = 'text' ORDER BY position LIMIT 1
`, serverID).Scan(&req.ChannelID)
}
var fr featureRequestResponse
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO feature_requests (server_id, channel_id, author_id, title, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, server_id, channel_id, author_id, title, description, status, created_at, updated_at
`, serverID, req.ChannelID, userID, req.Title, req.Description).Scan(
&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create feature request"}`, http.StatusInternalServerError)
return
}
fr.VoteCount = 0
fr.Voters = []string{}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(fr)
}
func (h *FeatureRequestHandler) Vote(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
_, err := h.db.ExecContext(r.Context(), `
INSERT INTO feature_request_votes (feature_request_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
`, id, userID)
if err != nil {
http.Error(w, `{"error":"vote failed"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}
func (h *FeatureRequestHandler) Unvote(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
id := chi.URLParam(r, "id")
h.db.ExecContext(r.Context(), `
DELETE FROM feature_request_votes WHERE feature_request_id = $1 AND user_id = $2
`, id, userID)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}
func (h *FeatureRequestHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
validStatuses := map[string]bool{"open": true, "planned": true, "done": true, "rejected": true}
if !validStatuses[req.Status] {
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(), `
UPDATE feature_requests SET status = $1, updated_at = NOW() WHERE id = $2
`, req.Status, id)
if err != nil {
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
}
+22
View File
@@ -18,6 +18,7 @@ import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.tsx";
import { useContextMenu } from "./ContextMenu.tsx";
import { PinnedMessages } from "./PinnedMessages.tsx";
import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx";
import { ReactionBar } from "./ReactionBar.tsx";
import { EmojiPicker } from "./EmojiPicker.tsx";
import { ReplyBar } from "./ReplyBar.tsx";
@@ -308,6 +309,7 @@ export function ChatArea() {
const markRead = useReadStatesStore((s) => s.markRead);
const [showPinned, setShowPinned] = useState(false);
const [showFeatureRequests, setShowFeatureRequests] = useState(false);
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
const [replyToMessage, setReplyToMessage] = useState<any | null>(null);
const pinMessage = useMessageStore((s) => s.pinMessage);
@@ -662,6 +664,7 @@ export function ChatArea() {
<button
onClick={() => {
setShowPinned(false);
setShowFeatureRequests(false);
setShowSearch((prev) => !prev);
}}
className={`text-xs font-mono ${showSearch ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
@@ -672,6 +675,7 @@ export function ChatArea() {
<button
onClick={() => {
setShowSearch(false);
setShowFeatureRequests(false);
setShowPinned((prev) => !prev);
}}
className={`text-xs font-mono ${showPinned ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
@@ -679,6 +683,17 @@ export function ChatArea() {
>
[PINNED{pinnedCount > 0 ? `:${pinnedCount}` : ''}]
</button>
<button
onClick={() => {
setShowSearch(false);
setShowPinned(false);
setShowFeatureRequests((prev) => !prev);
}}
className={`text-xs font-mono ${showFeatureRequests ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
title="Feature requests"
>
[FEATURES]
</button>
</div>
)}
</div>
@@ -739,6 +754,13 @@ export function ChatArea() {
onJump={handleJumpToMessage}
/>
)}
{showFeatureRequests && activeServerId && (
<FeatureRequestsPanel
serverId={activeServerId}
channelId={activeChannelId || undefined}
onClose={() => setShowFeatureRequests(false)}
/>
)}
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { useFeatureRequestStore, type FeatureRequest } from "../stores/featureRequest.ts";
import { useAuthStore } from "../stores/auth.ts";
interface FeatureRequestsPanelProps {
serverId: string;
channelId?: string;
onClose: () => void;
}
const STATUS_LABELS: Record<string, string> = {
open: "OPEN",
planned: "PLANNED",
done: "DONE",
rejected: "REJECTED",
};
const STATUS_COLORS: Record<string, string> = {
open: "text-gb-green",
planned: "text-gb-yellow",
done: "text-gb-aqua",
rejected: "text-gb-red",
};
export function FeatureRequestsPanel({ serverId, channelId, onClose }: FeatureRequestsPanelProps) {
const requests = useFeatureRequestStore((s) => s.requestsByServer[serverId] || []);
const fetchRequests = useFeatureRequestStore((s) => s.fetchRequests);
const createRequest = useFeatureRequestStore((s) => s.createRequest);
const vote = useFeatureRequestStore((s) => s.vote);
const unvote = useFeatureRequestStore((s) => s.unvote);
const updateStatus = useFeatureRequestStore((s) => s.updateStatus);
const currentUser = useAuthStore((s) => s.user);
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const [filter, setFilter] = useState<string>("all");
useEffect(() => {
fetchRequests(serverId);
}, [serverId, fetchRequests]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const handleCreate = async () => {
if (!title.trim()) return;
setSubmitting(true);
try {
await createRequest(serverId, title.trim(), description.trim(), channelId);
setTitle("");
setDescription("");
setShowCreate(false);
} catch {
// silent
} finally {
setSubmitting(false);
}
};
const handleVote = async (fr: FeatureRequest) => {
const hasVoted = currentUser ? fr.voters.includes(currentUser.id) : false;
if (hasVoted) {
await unvote(serverId, fr.id);
} else {
await vote(serverId, fr.id);
}
};
const filtered = filter === "all" ? requests : requests.filter((r) => r.status === filter);
return (
<div className="absolute inset-x-0 top-0 z-20 bg-gb-bg border-b border-gb-bg-t shadow-lg">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between">
<span className="text-xs text-gb-orange font-mono">FEATURE REQUESTS ({requests.length})</span>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate((p) => !p)}
className="text-xs text-gb-aqua hover:text-gb-orange font-mono"
>
[+NEW]
</button>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
</div>
</div>
{/* Filter tabs */}
<div className="px-3 py-1 border-b border-gb-bg-t flex gap-3 text-[10px] font-mono">
{["all", "open", "planned", "done", "rejected"].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`${filter === s ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}
>
{s.toUpperCase()}
</button>
))}
</div>
{/* Create form */}
{showCreate && (
<div className="px-3 py-2 border-b border-gb-bg-t">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Feature title..."
className="w-full bg-gb-bg-s border border-gb-bg-t text-gb-fg text-xs font-mono px-2 py-1 mb-1 outline-none focus:border-gb-orange"
maxLength={255}
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)..."
className="w-full bg-gb-bg-s border border-gb-bg-t text-gb-fg text-xs font-mono px-2 py-1 mb-1 outline-none focus:border-gb-orange resize-none h-16"
/>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={submitting || !title.trim()}
className="text-xs text-gb-green hover:text-gb-orange font-mono disabled:opacity-40"
>
[SUBMIT]
</button>
<button
onClick={() => { setShowCreate(false); setTitle(""); setDescription(""); }}
className="text-xs text-gb-fg-f hover:text-gb-red font-mono"
>
[CANCEL]
</button>
</div>
</div>
)}
{/* List */}
<div className="max-h-80 overflow-y-auto font-mono text-xs">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-gb-fg-f">
{requests.length === 0 ? "[no feature requests yet]" : "[none matching filter]"}
</div>
) : (
filtered.map((fr) => {
const hasVoted = currentUser ? fr.voters.includes(currentUser.id) : false;
return (
<div
key={fr.id}
className="w-full px-3 py-2 border-b border-gb-bg-t last:border-b-0 hover:bg-gb-bg-s/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<button
onClick={() => handleVote(fr)}
className={`shrink-0 px-1 border text-[10px] ${
hasVoted
? "border-gb-orange text-gb-orange bg-gb-orange/10"
: "border-gb-bg-t text-gb-fg-f hover:border-gb-orange hover:text-gb-orange"
}`}
title={hasVoted ? "Remove vote" : "Vote"}
>
{fr.vote_count}
</button>
<span className="text-gb-fg font-bold truncate">{fr.title}</span>
<span className={`text-[10px] ${STATUS_COLORS[fr.status]}`}>
[{STATUS_LABELS[fr.status]}]
</span>
</div>
{fr.description && (
<div className="text-gb-fg-f mt-1 pl-8 text-[11px] break-words whitespace-pre-wrap">
{fr.description}
</div>
)}
<div className="text-[10px] text-gb-fg-f mt-1 pl-8">
{new Date(fr.created_at).toLocaleDateString()}
</div>
</div>
{/* Status selector for admins - simple cycle */}
<select
value={fr.status}
onChange={(e) => updateStatus(serverId, fr.id, e.target.value)}
className="bg-gb-bg-s border border-gb-bg-t text-gb-fg-f text-[10px] font-mono px-1 py-0.5 cursor-pointer"
>
<option value="open">Open</option>
<option value="planned">Planned</option>
<option value="done">Done</option>
<option value="rejected">Rejected</option>
</select>
</div>
</div>
);
})
)}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { create } from "zustand";
import api from "../lib/api.ts";
export interface FeatureRequest {
id: string;
server_id: string;
channel_id: string;
author_id: string;
title: string;
description: string;
status: "open" | "planned" | "done" | "rejected";
vote_count: number;
voters: string[];
created_at: string;
updated_at: string;
}
interface FeatureRequestStore {
requestsByServer: Record<string, FeatureRequest[]>;
fetchRequests: (serverId: string) => Promise<void>;
createRequest: (serverId: string, title: string, description: string, channelId?: string) => Promise<void>;
vote: (serverId: string, requestId: string) => Promise<void>;
unvote: (serverId: string, requestId: string) => Promise<void>;
updateStatus: (serverId: string, requestId: string, status: string) => Promise<void>;
}
export const useFeatureRequestStore = create<FeatureRequestStore>((set, get) => ({
requestsByServer: {},
fetchRequests: async (serverId: string) => {
const data = await api.get<FeatureRequest[]>(`/servers/${serverId}/feature-requests`);
set((s) => ({
requestsByServer: { ...s.requestsByServer, [serverId]: data },
}));
},
createRequest: async (serverId, title, description, channelId) => {
await api.post(`/servers/${serverId}/feature-requests`, { title, description, channel_id: channelId });
await get().fetchRequests(serverId);
},
vote: async (serverId, requestId) => {
await api.post(`/servers/${serverId}/feature-requests/${requestId}/vote`);
await get().fetchRequests(serverId);
},
unvote: async (serverId, requestId) => {
await api.delete(`/servers/${serverId}/feature-requests/${requestId}/vote`);
await get().fetchRequests(serverId);
},
updateStatus: async (serverId, requestId, status) => {
await api.patch(`/servers/${serverId}/feature-requests/${requestId}/status`, { status });
await get().fetchRequests(serverId);
},
}));
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.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/ListView.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/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.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/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.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/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.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"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.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/ListView.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/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.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/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.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"}