import { useEffect, useMemo, useState } from 'react'; import { useServerStore } from '../stores/server.ts'; import { useChannelStore } from '../stores/channel.ts'; import { VoiceChannel } from './VoiceChannel.tsx'; import { CreateChannelModal } from './CreateChannelModal.tsx'; 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'; const NOTIF_LABELS: Record = { all: '🔔 All', mentions: '🔔 @', none: '🔕 Muted', }; const NOTIF_CYCLE: NotifLevel[] = ['all', 'mentions', 'none']; interface ServerGroup { id: string; server_id: string; name: string; position: number; } function collapsedKey(serverId: string, groupId: string) { return 'collapsed:' + serverId + ':' + groupId; } function isCollapsed(serverId: string, groupId: string): boolean { try { return localStorage.getItem(collapsedKey(serverId, groupId)) === '1'; } catch { return false; } } function toggleCollapsed(serverId: string, groupId: string) { const key = collapsedKey(serverId, groupId); const now = isCollapsed(serverId, groupId); try { if (now) { localStorage.removeItem(key); } else { localStorage.setItem(key, '1'); } } catch { // ignore } } export function ChannelList() { const activeServerId = useServerStore((state) => 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 removeChannel = useChannelStore((state) => state.removeChannel); const updateChannel = useChannelStore((state) => state.updateChannel); const [showCreate, setShowCreate] = useState(false); const [createInGroup, setCreateInGroup] = useState(null); const [showInvite, setShowInvite] = useState(false); const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null); const [groups, setGroups] = useState([]); const [toggleTick, setToggleTick] = useState(0); // Inline editing state const [editingChannelId, setEditingChannelId] = useState(null); const [editingChannelName, setEditingChannelName] = useState(''); const [editingGroupId, setEditingGroupId] = useState(null); const [editingGroupName, setEditingGroupName] = useState(''); const notifSettings = useNotificationSettingsStore((state) => state.settings); const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings); const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel); const readStates = useReadStatesStore((state) => state.states); const fetchReadStates = useReadStatesStore((state) => state.fetchStates); const { showMenu, MenuPortal } = useContextMenu(); const refreshGroups = () => { if (activeServerId) { api.get('/servers/' + activeServerId + '/groups') .then(setGroups) .catch(() => setGroups([])); } }; useEffect(() => { if (activeServerId) { fetchChannels(activeServerId); refreshGroups(); } else { setGroups([]); } }, [activeServerId, fetchChannels]); useEffect(() => { fetchNotifSettings(); fetchReadStates(); }, [fetchNotifSettings, fetchReadStates]); 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]); // Build ordered sections: sorted groups first, then ungroupped channels by category const sections = useMemo(() => { type Section = { type: 'group'; group: ServerGroup; channels: typeof channels } | { type: 'category'; name: string; channels: typeof channels }; const grouped: Record = {}; const ungrouped: Record = {}; for (const channel of channels) { if (channel.group_id) { const list = grouped[channel.group_id] || []; list.push(channel); grouped[channel.group_id] = list; } else { const cat = channel.category || 'TEXT CHANNELS'; const list = ungrouped[cat] || []; list.push(channel); ungrouped[cat] = list; } } const result: Section[] = []; // Sorted groups const sortedGroups = [...groups].sort((a, b) => a.position - b.position); for (const grp of sortedGroups) { const chs = (grouped[grp.id] || []).sort((a, b) => a.position - b.position); result.push({ type: 'group', group: grp, channels: chs }); } // Ungroupped channels by category const sortedCats = Object.keys(ungrouped).sort(); for (const cat of sortedCats) { result.push({ type: 'category', name: cat, channels: ungrouped[cat].sort((a, b) => a.position - b.position) }); } return result; }, [channels, groups, toggleTick]); const getLevel = (channelId: string): NotifLevel => { return notifSettings[channelId] || 'all'; }; const handleNotifClick = (e: React.MouseEvent, channelId: string) => { e.stopPropagation(); const current = getLevel(channelId); const idx = NOTIF_CYCLE.indexOf(current); const next = NOTIF_CYCLE[(idx + 1) % NOTIF_CYCLE.length]; setNotifLevel(channelId, next); }; const notifIcon = (channelId: string) => { const level = getLevel(channelId); if (level === 'none') return '🔕'; return level === 'mentions' ? '🔔@' : '🔔'; }; const hasUnread = (channelId: string): boolean => { 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 () => { const id = editingChannelId; const name = editingChannelName.trim(); setEditingChannelId(null); if (!id || !name) 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/${id}`, { name } ); updateChannel(updated as any); } catch (err) { console.error('Rename channel failed:', err); } }; 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 () => { const id = editingGroupId; const name = editingGroupName.trim(); setEditingGroupId(null); if (!id || !name) return; try { await api.patch(`/servers/${activeServerId}/groups/${id}`, { name }); refreshGroups(); } catch (err) { console.error('Rename group failed:', err); } }; 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 ( ); } const isEditing = editingChannelId === channel.id; return (
handleChannelContextMenu(e, channel)}> {isEditing ? (
# 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" />
) : ( )}
); }; return (
{activeServerId ? `[SERVER ${activeServer?.name ?? activeServerId}]` : '[NO SERVER]'} {activeServerId && (
)}
{sections.length === 0 && (

[no channels]

)} {sections.map((section) => { if (section.type === 'group') { const grp = section.group; const collapsed = isCollapsed(activeServerId || '', grp.id); const isEditingGroup = editingGroupId === grp.id; return (
{isEditingGroup ? (
{collapsed ? '[+]' : '[-]'} 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" />
) : (
{ toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }} onContextMenu={(e) => handleGroupContextMenu(e, grp)} > {collapsed ? '[+]' : '[-]'} {grp.name}
)} {!collapsed && ( <>
---
{section.channels.map((channel) => renderChannel(channel))} )}
); } // category section (fallback for ungrouped channels) return (
{section.name}
---
{section.channels.map((channel) => renderChannel(channel))}
); })}
{showCreate && activeServerId && ( { setShowCreate(false); setCreateInGroup(null); }} /> )} {showInvite && activeServerId && activeServer && ( setShowInvite(false)} /> )} {settingsChannel && activeServerId && ( setSettingsChannel(null)} /> )} {MenuPortal}
); }