feat: right-click context menus for channels, groups, and servers

ContextMenu: reusable hook-based component that positions a menu at
the cursor and auto-closes on outside click or Escape.

ChannelList:
- Right-click channel -> Edit Name, Permissions, Delete Channel
- Right-click group header -> Edit Name, Create Channel Here, Delete Group
- Inline rename: replacing name text with an input, commits on Enter/blur
- CreateChannelModal now accepts defaultGroupId to pre-select a group

ServerBar:
- Right-click server icon -> Server Settings, Invite People, Leave Server
- Leave calls DELETE /servers/{id}/members/me

Backend:
- Added LeaveServer handler (DELETE /servers/{serverID}/members/me)
- Server owner cannot leave; must transfer or delete
This commit is contained in:
2026-07-02 08:18:12 -04:00
parent ecb26cb731
commit 31cb295a24
6 changed files with 335 additions and 97 deletions
+36
View File
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/{serverID}", h.Get)
r.Patch("/{serverID}", h.Update)
r.Delete("/{serverID}", h.Delete)
r.Delete("/{serverID}/members/me", h.LeaveServer)
}
type createServerRequest struct {
@@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) LeaveServer(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")
// Cannot leave a server you own
var ownerID string
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
if err != nil {
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
return
}
if ownerID == userID {
http.Error(w, `{"error":"server owner cannot leave; transfer ownership or delete the server"}`, http.StatusForbidden)
return
}
result, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE user_id = $1 AND server_id = $2`, userID, serverID)
if err != nil {
http.Error(w, `{"error":"failed to leave server"}`, http.StatusInternalServerError)
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
http.Error(w, `{"error":"not a member"}`, http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
+172 -94
View File
@@ -7,6 +7,7 @@ import { InviteModal } from './InviteModal.tsx';
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
import { useReadStatesStore } from '../stores/readStates.ts';
import { useContextMenu } from './ContextMenu.tsx';
import { api } from '../lib/api.ts';
type NotifLevel = 'all' | 'mentions' | 'none';
@@ -59,12 +60,21 @@ export function ChannelList() {
const fetchChannels = useChannelStore((state) => state.fetchChannels);
const activeChannelId = useChannelStore((state) => state.activeChannelId);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const removeChannel = useChannelStore((state) => state.removeChannel);
const updateChannel = useChannelStore((state) => state.updateChannel);
const [showCreate, setShowCreate] = useState(false);
const [createInGroup, setCreateInGroup] = useState<string | null>(null);
const [showInvite, setShowInvite] = useState(false);
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [toggleTick, setToggleTick] = useState(0);
// Inline editing state
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
const [editingChannelName, setEditingChannelName] = useState('');
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editingGroupName, setEditingGroupName] = useState('');
const notifSettings = useNotificationSettingsStore((state) => state.settings);
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
@@ -72,12 +82,20 @@ export function ChannelList() {
const readStates = useReadStatesStore((state) => state.states);
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
useEffect(() => {
const { showMenu, MenuPortal } = useContextMenu();
const refreshGroups = () => {
if (activeServerId) {
fetchChannels(activeServerId);
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
.then(setGroups)
.catch(() => setGroups([]));
}
};
useEffect(() => {
if (activeServerId) {
fetchChannels(activeServerId);
refreshGroups();
} else {
setGroups([]);
}
@@ -157,6 +175,132 @@ export function ChannelList() {
return !(channelId in readStates);
};
// --- Channel context menu ---
const handleChannelContextMenu = (e: React.MouseEvent, channel: { id: string; name: string }) => {
showMenu(e, [
{ label: 'Edit Name', icon: '✏️', onClick: () => startEditChannel(channel) },
{ label: 'Permissions', icon: '🔒', onClick: () => setSettingsChannel({ id: channel.id, name: channel.name }) },
{ label: 'Delete Channel', icon: '🗑️', danger: true, onClick: () => deleteChannel(channel.id, channel.name) },
]);
};
const startEditChannel = (channel: { id: string; name: string }) => {
setEditingChannelId(channel.id);
setEditingChannelName(channel.name);
};
const commitEditChannel = async () => {
if (!editingChannelId || !editingChannelName.trim()) { setEditingChannelId(null); return; }
try {
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
`/servers/${activeServerId}/channels/${editingChannelId}`,
{ name: editingChannelName.trim() }
);
updateChannel(updated as any);
} catch { /* ignore */ }
setEditingChannelId(null);
};
const deleteChannel = async (channelId: string, channelName: string) => {
if (!confirm(`Delete #${channelName}? This cannot be undone.`)) return;
try {
await api.delete(`/servers/${activeServerId}/channels/${channelId}`);
removeChannel(channelId);
} catch { /* ignore */ }
};
// --- Group context menu ---
const handleGroupContextMenu = (e: React.MouseEvent, group: ServerGroup) => {
showMenu(e, [
{ label: 'Edit Name', icon: '✏️', onClick: () => startEditGroup(group) },
{ label: 'Create Channel Here', icon: '', onClick: () => { setCreateInGroup(group.id); setShowCreate(true); } },
{ label: 'Delete Group', icon: '🗑️', danger: true, onClick: () => deleteGroup(group.id, group.name) },
]);
};
const startEditGroup = (group: ServerGroup) => {
setEditingGroupId(group.id);
setEditingGroupName(group.name);
};
const commitEditGroup = async () => {
if (!editingGroupId || !editingGroupName.trim()) { setEditingGroupId(null); return; }
try {
await api.patch(`/servers/${activeServerId}/groups/${editingGroupId}`, { name: editingGroupName.trim() });
refreshGroups();
} catch { /* ignore */ }
setEditingGroupId(null);
};
const deleteGroup = async (groupId: string, groupName: string) => {
if (!confirm(`Delete group "${groupName}"? Channels will be moved to ungrouped.`)) return;
try {
await api.delete(`/servers/${activeServerId}/groups/${groupId}`);
refreshGroups();
if (activeServerId) fetchChannels(activeServerId);
} catch { /* ignore */ }
};
// --- Channel row renderer ---
const renderChannel = (channel: typeof channels[0]) => {
if (channel.type === 'voice') {
return (
<VoiceChannel key={channel.id} channelId={channel.id} channelName={channel.name} />
);
}
const isEditing = editingChannelId === channel.id;
return (
<div key={channel.id} className="group" onContextMenu={(e) => handleChannelContextMenu(e, channel)}>
<button
onClick={() => setActiveChannel(channel.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
<span className="text-gb-fg-f">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
{isEditing ? (
<input
autoFocus
value={editingChannelName}
onChange={(e) => setEditingChannelName(e.target.value)}
onBlur={commitEditChannel}
onKeyDown={(e) => { if (e.key === 'Enter') commitEditChannel(); if (e.key === 'Escape') setEditingChannelId(null); }}
className="flex-1 bg-gb-bg-t text-gb-fg px-1 py-0.5 text-sm outline-none border border-gb-orange"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="truncate flex-1">{channel.name}</span>
)}
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
</div>
);
};
return (
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
@@ -171,7 +315,7 @@ export function ChannelList() {
[INV]
</button>
<button
onClick={() => setShowCreate(true)}
onClick={() => { setCreateInGroup(null); setShowCreate(true); }}
className="terminal-button text-xs"
title="Create channel"
>
@@ -188,61 +332,33 @@ export function ChannelList() {
if (section.type === 'group') {
const grp = section.group;
const collapsed = isCollapsed(activeServerId || '', grp.id);
const isEditingGroup = editingGroupId === grp.id;
return (
<div key={'grp:' + grp.id} className="mb-3">
<div
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg"
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg flex items-center"
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }}
onContextMenu={(e) => handleGroupContextMenu(e, grp)}
>
{collapsed ? '[+]' : '[-]'} {grp.name}
<span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
{isEditingGroup ? (
<input
autoFocus
value={editingGroupName}
onChange={(e) => setEditingGroupName(e.target.value)}
onBlur={commitEditGroup}
onKeyDown={(e) => { if (e.key === 'Enter') commitEditGroup(); if (e.key === 'Escape') setEditingGroupId(null); }}
className="flex-1 bg-gb-bg-t text-gb-fg px-1 py-0.5 text-xs outline-none border border-gb-orange"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span>{grp.name}</span>
)}
</div>
{!collapsed && (
<>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{section.channels.map((channel) =>
channel.type === 'voice' ? (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
/>
) : (
<div key={channel.id} className="group">
<button
onClick={() => setActiveChannel(channel.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
<span className="text-gb-fg-f">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
</div>
)
)}
{section.channels.map((channel) => renderChannel(channel))}
</>
)}
</div>
@@ -253,56 +369,17 @@ export function ChannelList() {
<div key={'cat:' + section.name} className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">{section.name}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{section.channels.map((channel) =>
channel.type === 'voice' ? (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
/>
) : (
<div key={channel.id} className="group">
<button
onClick={() => setActiveChannel(channel.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
}`}
>
<span className="text-gb-fg-f">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
</div>
)
)}
{section.channels.map((channel) => renderChannel(channel))}
</div>
);
})}
</div>
{showCreate && activeServerId && (
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
<CreateChannelModal
serverId={activeServerId}
defaultGroupId={createInGroup}
onClose={() => { setShowCreate(false); setCreateInGroup(null); }}
/>
)}
{showInvite && activeServerId && activeServer && (
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
@@ -315,6 +392,7 @@ export function ChannelList() {
onClose={() => setSettingsChannel(null)}
/>
)}
{MenuPortal}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState, useCallback } from 'react';
interface ContextMenuItem {
label: string;
icon?: string;
danger?: boolean;
disabled?: boolean;
onClick: () => void;
}
interface ContextMenuState {
x: number;
y: number;
items: ContextMenuItem[];
}
export function useContextMenu() {
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const showMenu = useCallback((e: React.MouseEvent, items: ContextMenuItem[]) => {
e.preventDefault();
e.stopPropagation();
// Position: use cursor, but keep within viewport
const x = Math.min(e.clientX, window.innerWidth - 200);
const y = Math.min(e.clientY, window.innerHeight - (items.length * 32 + 16));
setMenu({ x, y, items });
}, []);
const hideMenu = useCallback(() => setMenu(null), []);
// Close on outside click
useEffect(() => {
if (!menu) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
hideMenu();
}
};
// Use mousedown so it fires before the click that opened a new menu
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menu, hideMenu]);
// Close on Escape
useEffect(() => {
if (!menu) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') hideMenu();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [menu, hideMenu]);
const MenuPortal = menu ? (
<div
ref={menuRef}
className="fixed z-[9999] bg-gb-bg border border-gb-bg-t font-mono text-xs min-w-[160px] shadow-lg"
style={{ left: menu.x, top: menu.y }}
>
{menu.items.map((item, i) => (
<button
key={i}
onClick={() => {
hideMenu();
if (!item.disabled) item.onClick();
}}
disabled={item.disabled}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 ${
item.disabled
? 'text-gb-fg-f cursor-not-allowed'
: item.danger
? 'text-gb-red hover:bg-gb-red hover:text-gb-bg'
: 'text-gb-fg hover:bg-gb-bg-t hover:text-gb-orange'
}`}
>
{item.icon && <span className="w-4 text-center">{item.icon}</span>}
{item.label}
</button>
))}
</div>
) : null;
return { showMenu, hideMenu, MenuPortal };
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useChannelStore } from '../stores/channel.ts';
interface CreateChannelModalProps {
serverId: string;
defaultGroupId?: string | null;
onClose: () => void;
}
@@ -36,13 +37,13 @@ const SLOWMODE_OPTIONS = [
{ label: '5 minutes', value: 300 },
];
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
export function CreateChannelModal({ serverId, defaultGroupId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
const [category, setCategory] = useState('general');
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || '');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
+36
View File
@@ -4,6 +4,10 @@ import { useChannelStore } from '../stores/channel.ts';
import { useLayoutStore } from '../stores/layout.ts';
import { CreateServerModal } from './CreateServerModal.tsx';
import { JoinServerModal } from './JoinServerModal.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
import { InviteModal } from './InviteModal.tsx';
import { useContextMenu } from './ContextMenu.tsx';
import { api } from '../lib/api.ts';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@@ -22,6 +26,10 @@ export function ServerBar() {
const { isDM, setDM } = useLayoutStore();
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
const [settingsServerId, setSettingsServerId] = useState<string | null>(null);
const [inviteServer, setInviteServer] = useState<{ id: string; name: string } | null>(null);
const { showMenu, MenuPortal } = useContextMenu();
useEffect(() => {
fetchServers();
@@ -39,6 +47,26 @@ export function ServerBar() {
setDM(true);
};
const handleServerContextMenu = (e: React.MouseEvent, server: { id: string; name: string }) => {
showMenu(e, [
{ label: 'Server Settings', icon: '⚙', onClick: () => setSettingsServerId(server.id) },
{ label: 'Invite People', icon: '📨', onClick: () => setInviteServer({ id: server.id, name: server.name }) },
{ label: 'Leave Server', icon: '🚪', danger: true, onClick: () => leaveServer(server.id, server.name) },
]);
};
const leaveServer = async (serverId: string, serverName: string) => {
if (!confirm(`Leave "${serverName}"? You'll need an invite to rejoin.`)) return;
try {
await api.delete(`/servers/${serverId}/members/me`);
fetchServers();
if (activeServerId === serverId) {
setActiveServer(null);
setDM(true);
}
} catch { /* ignore */ }
};
return (
<>
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
@@ -57,6 +85,7 @@ export function ServerBar() {
<button
key={server.id}
onClick={() => handleSelect(server.id)}
onContextMenu={(e) => handleServerContextMenu(e, server)}
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
(server.id === activeServerId && !isDM
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
@@ -89,6 +118,13 @@ export function ServerBar() {
</div>
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
{showJoin && <JoinServerModal onClose={() => setShowJoin(false)} />}
{settingsServerId && (
<ServerSettingsModal serverId={settingsServerId} onClose={() => setSettingsServerId(null)} />
)}
{inviteServer && (
<InviteModal serverId={inviteServer.id} serverName={inviteServer.name} onClose={() => setInviteServer(null)} />
)}
{MenuPortal}
</>
);
}
+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/CommandManager.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/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/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/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/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/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"}