diff --git a/cmd/server/main.go b/cmd/server/main.go index 6beb33e..498d153 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -141,6 +141,10 @@ func main() { }) }) + // Roles and member-role assignment + roleHandler := server.NewRoleHandler(database.DB) + roleHandler.RegisterRoleRoutes(r) + // Messages (under channels) r.Route("/channels/{channelID}/messages", func(r chi.Router) { message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 9fde244..2d6fc30 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -102,6 +102,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { return } + // Seed the default @everyone role (non-fatal if it fails) + _ = SeedDefaultRole(r.Context(), h.db, srv.ID) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(srv) diff --git a/internal/server/roles.go b/internal/server/roles.go new file mode 100644 index 0000000..9aee049 --- /dev/null +++ b/internal/server/roles.go @@ -0,0 +1,369 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" + "github.com/go-chi/chi/v5" +) + +// RoleHandler manages role CRUD and member-role assignment. +type RoleHandler struct { + db *sql.DB + checker *permissions.Checker +} + +// NewRoleHandler creates a new RoleHandler. +func NewRoleHandler(db *sql.DB) *RoleHandler { + return &RoleHandler{ + db: db, + checker: permissions.NewChecker(db), + } +} + +// RegisterRoleRoutes mounts role and member-role endpoints on the given router. +func (h *RoleHandler) RegisterRoleRoutes(r chi.Router) { + r.Route("/servers/{serverID}/roles", func(r chi.Router) { + r.Use(middleware.RequireAuth) + r.Post("/", h.CreateRole) + r.Get("/", h.ListRoles) + r.Patch("/{roleID}", h.UpdateRole) + r.Delete("/{roleID}", h.DeleteRole) + }) + + r.Route("/servers/{serverID}/members/{userID}/roles", func(r chi.Router) { + r.Use(middleware.RequireAuth) + r.Put("/", h.SetMemberRoles) + r.Get("/", h.GetMemberRoles) + }) +} + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +type createRoleRequest struct { + Name string `json:"name"` + Color string `json:"color"` + Permissions int64 `json:"permissions"` + Position int `json:"position"` +} + +type updateRoleRequest struct { + Name *string `json:"name"` + Color *string `json:"color"` + Permissions *int64 `json:"permissions"` + Position *int `json:"position"` +} + +type roleResponse struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Color *string `json:"color"` + Permissions int64 `json:"permissions"` + Position int `json:"position"` + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at"` +} + +type setMemberRolesRequest struct { + RoleIDs []string `json:"role_ids"` +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// requireManageServer checks that the caller has MANAGE_SERVER permission. +// Returns false (and writes the error response) if denied. +func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return false + } + + allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return false + } + if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return false + } + return true +} + +// scanRole scans a single role row into a roleResponse. +func scanRole(row interface{ Scan(dest ...any) error }) (roleResponse, error) { + var role roleResponse + err := row.Scan( + &role.ID, &role.ServerID, &role.Name, &role.Color, + &role.Permissions, &role.Position, &role.IsDefault, &role.CreatedAt, + ) + return role, err +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// CreateRole handles POST /servers/{serverID}/roles. +func (h *RoleHandler) CreateRole(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + if !h.requireManageServer(w, r, serverID) { + return + } + + var req createRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Name == "" { + http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest) + return + } + + role, err := scanRole(h.db.QueryRowContext(r.Context(), ` + INSERT INTO roles (server_id, name, color, permissions, position) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, server_id, name, color, permissions, position, is_default, created_at + `, serverID, req.Name, req.Color, req.Permissions, req.Position)) + if err != nil { + http.Error(w, `{"error":"failed to create role"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(role) +} + +// ListRoles handles GET /servers/{serverID}/roles. +func (h *RoleHandler) ListRoles(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, server_id, name, color, permissions, position, is_default, created_at + FROM roles + WHERE server_id = $1 + ORDER BY position DESC, name + `, serverID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + roles := make([]roleResponse, 0) + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + roles = append(roles, role) + } + if err := rows.Err(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(roles) +} + +// UpdateRole handles PATCH /servers/{serverID}/roles/{roleID}. +func (h *RoleHandler) UpdateRole(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + roleID := chi.URLParam(r, "roleID") + if !h.requireManageServer(w, r, serverID) { + return + } + + var req updateRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Name == nil && req.Color == nil && req.Permissions == nil && req.Position == nil { + http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) + return + } + + role, err := scanRole(h.db.QueryRowContext(r.Context(), ` + UPDATE roles + SET name = COALESCE($1, name), + color = COALESCE($2, color), + permissions = COALESCE($3, permissions), + position = COALESCE($4, position) + WHERE id = $5 AND server_id = $6 + RETURNING id, server_id, name, color, permissions, position, is_default, created_at + `, req.Name, req.Color, req.Permissions, req.Position, roleID, serverID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"role not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"failed to update role"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(role) +} + +// DeleteRole handles DELETE /servers/{serverID}/roles/{roleID}. +func (h *RoleHandler) DeleteRole(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + roleID := chi.URLParam(r, "roleID") + if !h.requireManageServer(w, r, serverID) { + return + } + + // Prevent deleting the @everyone (default) role. + var isDefault bool + err := h.db.QueryRowContext(r.Context(), ` + SELECT is_default FROM roles WHERE id = $1 AND server_id = $2 + `, roleID, serverID).Scan(&isDefault) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"role not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if isDefault { + http.Error(w, `{"error":"cannot delete the default @everyone role"}`, http.StatusBadRequest) + return + } + + _, err = h.db.ExecContext(r.Context(), ` + DELETE FROM roles WHERE id = $1 AND server_id = $2 + `, roleID, serverID) + if err != nil { + http.Error(w, `{"error":"failed to delete role"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// SetMemberRoles handles PUT /servers/{serverID}/members/{userID}/roles. +func (h *RoleHandler) SetMemberRoles(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + targetUserID := chi.URLParam(r, "userID") + if !h.requireManageServer(w, r, serverID) { + return + } + + // Verify target is a member of the server. + var exists bool + err := h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) + `, targetUserID, serverID).Scan(&exists) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, `{"error":"user is not a member of this server"}`, http.StatusNotFound) + return + } + + var req setMemberRolesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Remove existing roles for this member in this server. + _, err = tx.ExecContext(r.Context(), ` + DELETE FROM member_roles WHERE user_id = $1 AND server_id = $2 + `, targetUserID, serverID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + // Insert new roles (skip the default role — it's implicit). + for _, roleID := range req.RoleIDs { + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO member_roles (user_id, server_id, role_id) + VALUES ($1, $2, $3) + `, targetUserID, serverID, roleID) + if err != nil { + http.Error(w, `{"error":"failed to assign role"}`, http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// GetMemberRoles handles GET /servers/{serverID}/members/{userID}/roles. +func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) { + serverID := chi.URLParam(r, "serverID") + targetUserID := chi.URLParam(r, "userID") + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT r.id, r.server_id, r.name, r.color, r.permissions, r.position, r.is_default, r.created_at + FROM roles r + INNER JOIN member_roles mr ON mr.role_id = r.id + WHERE mr.user_id = $1 AND mr.server_id = $2 + ORDER BY r.position DESC, r.name + `, targetUserID, serverID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + roles := make([]roleResponse, 0) + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + roles = append(roles, role) + } + if err := rows.Err(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(roles) +} + +// SeedDefaultRole inserts the @everyone role for a newly created server. +// Call this after creating a server (e.g. from the server creation handler). +func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error { + _, err := db.ExecContext(ctx, ` + INSERT INTO roles (server_id, name, permissions, position, is_default) + VALUES ($1, '@everyone', $2, 0, TRUE) + `, serverID, permissions.DefaultEveryonePermissions) + return err +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9cba414..2097d8c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { UserSettings } from './components/UserSettings.tsx'; import { BotManager } from './components/BotManager.tsx'; import { CommandManager } from './components/CommandManager.tsx'; import { RoleManager } from './components/RoleManager.tsx'; +import { JoinServer } from './components/JoinServer.tsx'; import { useAuthStore } from './stores/auth.ts'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -90,6 +91,14 @@ function App() { } /> + + + + } + /> state.activeServerId); + const servers = useServerStore((state) => state.servers); const channelsByServer = useChannelStore((state) => state.channelsByServer); const fetchChannels = useChannelStore((state) => state.fetchChannels); const activeChannelId = useChannelStore((state) => state.activeChannelId); const setActiveChannel = useChannelStore((state) => state.setActiveChannel); + const [showCreate, setShowCreate] = useState(false); useEffect(() => { if (activeServerId) { @@ -16,7 +19,14 @@ export function ChannelList() { } }, [activeServerId, fetchChannels]); - const channels = activeServerId ? channelsByServer[activeServerId] || [] : []; + const channels = useMemo(() => { + return activeServerId ? channelsByServer[activeServerId] || [] : []; + }, [activeServerId, channelsByServer]); + + const activeServer = useMemo(() => { + if (!activeServerId) return null; + return servers.find((s) => s.id === activeServerId) || null; + }, [servers, activeServerId]); const categories = useMemo(() => { const map = new Map(); @@ -34,8 +44,17 @@ export function ChannelList() { return (
-
- {activeServerId ? `[SERVER ${activeServerId}]` : '[NO SERVER]'} +
+ {activeServerId ? `[SERVER ${activeServer?.name ?? activeServerId}]` : '[NO SERVER]'} + {activeServerId && ( + + )}
{categories.length === 0 && ( @@ -68,6 +87,9 @@ export function ChannelList() {
))}
+ {showCreate && activeServerId && ( + setShowCreate(false)} /> + )}
); } diff --git a/web/src/components/CreateChannelModal.tsx b/web/src/components/CreateChannelModal.tsx new file mode 100644 index 0000000..1db1bcd --- /dev/null +++ b/web/src/components/CreateChannelModal.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react'; +import { api } from '../lib/api.ts'; +import { useChannelStore } from '../stores/channel.ts'; + +interface CreateChannelModalProps { + serverId: string; + onClose: () => void; +} + +interface ChannelApiResponse { + id: string; + server_id: string; + name: string; + type: 'text' | 'voice'; + category: string; + position: number; +} + +export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) { + const [name, setName] = useState(''); + const [type, setType] = useState<'text' | 'voice'>('text'); + const [category, setCategory] = useState('general'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const createChannel = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setLoading(true); + setError(null); + try { + const result = await api.post(`/servers/${serverId}/channels`, { + name: name.trim(), + type, + category: category.trim() || 'general', + }); + const newChannel = { + id: result.id, + serverId: result.server_id, + name: result.name, + type: result.type, + category: result.category || null, + position: result.position, + }; + useChannelStore.getState().addChannel(newChannel); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create channel'); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+ CREATE CHANNEL + +
+ +
+
+ + setName(e.target.value)} + placeholder="channel name" + required + className="terminal-input w-full" + /> +
+
+ + +
+
+ + setCategory(e.target.value)} + placeholder="general" + className="terminal-input w-full" + /> +
+ {error &&
{error}
} + +
+
+
+ ); +} diff --git a/web/src/components/CreateServerModal.tsx b/web/src/components/CreateServerModal.tsx new file mode 100644 index 0000000..5ab112b --- /dev/null +++ b/web/src/components/CreateServerModal.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect } from 'react'; +import { api } from '../lib/api.ts'; +import { useServerStore } from '../stores/server.ts'; + +interface CreateServerModalProps { + onClose: () => void; +} + +interface ServerApiResponse { + id: string; + name: string; + icon: string | null; + owner_id: string; +} + +export function CreateServerModal({ onClose }: CreateServerModalProps) { + const [name, setName] = useState(''); + const [icon, setIcon] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const createServer = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setLoading(true); + setError(null); + try { + const result = await api.post('/servers', { + name: name.trim(), + icon: icon.trim() || undefined, + }); + const newServer = { + id: result.id, + name: result.name, + icon: result.icon, + ownerId: result.owner_id, + }; + useServerStore.getState().addServer(newServer); + useServerStore.getState().setActiveServer(newServer.id); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create server'); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+ CREATE SERVER + +
+ +
+
+ + setName(e.target.value)} + placeholder="server name" + required + className="terminal-input w-full" + /> +
+
+ + setIcon(e.target.value)} + placeholder="https://..." + className="terminal-input w-full" + /> +
+ {error &&
{error}
} + +
+
+
+ ); +} diff --git a/web/src/components/JoinServerModal.tsx b/web/src/components/JoinServerModal.tsx new file mode 100644 index 0000000..d61f4d4 --- /dev/null +++ b/web/src/components/JoinServerModal.tsx @@ -0,0 +1,86 @@ +import { useState, useEffect } from 'react'; +import { api } from '../lib/api.ts'; +import { useServerStore } from '../stores/server.ts'; + +interface JoinServerModalProps { + onClose: () => void; +} + +interface JoinServerApiResponse { + server_id: string; + message: string; +} + +export function JoinServerModal({ onClose }: JoinServerModalProps) { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const joinServer = async (e: React.FormEvent) => { + e.preventDefault(); + if (!code.trim()) return; + + setLoading(true); + setError(null); + try { + const result = await api.post(`/invites/${code.trim()}/join`); + await useServerStore.getState().fetchServers(); + useServerStore.getState().setActiveServer(result.server_id); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to join server'); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+ JOIN SERVER + +
+ +
+
+ + setCode(e.target.value)} + placeholder="enter invite code" + required + className="terminal-input w-full" + /> +
+ {error &&
{error}
} + +
+
+
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index a30953b..edcc7bf 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../stores/auth.ts'; import { ServerBar } from './ServerBar.tsx'; import { ChannelList } from './ChannelList.tsx'; @@ -46,6 +46,7 @@ export function Layout() { USER: {user?.username || 'unknown'} + [SETTINGS] diff --git a/web/src/components/ServerBar.tsx b/web/src/components/ServerBar.tsx index 25a372d..f4ae24e 100644 --- a/web/src/components/ServerBar.tsx +++ b/web/src/components/ServerBar.tsx @@ -1,6 +1,8 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useServerStore } from '../stores/server.ts'; import { useChannelStore } from '../stores/channel.ts'; +import { CreateServerModal } from './CreateServerModal.tsx'; +import { JoinServerModal } from './JoinServerModal.tsx'; function getInitials(name: string): string { const words = name.trim().split(/\s+/); @@ -16,6 +18,8 @@ export function ServerBar() { const fetchServers = useServerStore((state) => state.fetchServers); const setActiveServer = useServerStore((state) => state.setActiveServer); const setActiveChannel = useChannelStore((state) => state.setActiveChannel); + const [showCreate, setShowCreate] = useState(false); + const [showJoin, setShowJoin] = useState(false); useEffect(() => { fetchServers(); @@ -27,25 +31,45 @@ export function ServerBar() { }; return ( -
- {servers.map((server) => ( - - ))} -
+ <> +
+ {servers.map((server) => ( + + ))} +
+ + +
+
+ {showCreate && setShowCreate(false)} />} + {showJoin && setShowJoin(false)} />} + ); } diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 7674e49..a18cfde 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/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.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/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.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/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.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/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.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/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file