c57889e477
The notification bell and settings gear spans had opacity-0 but pointer-events were still active. Their stopPropagation handlers prevented the parent button's onClick from firing when clicking in the icon area. Added pointer-events-none to the container, re-enabled on group-hover.
409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
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<NotifLevel, string> = {
|
||
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<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);
|
||
|
||
const readStates = useReadStatesStore((state) => state.states);
|
||
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
||
|
||
const { showMenu, MenuPortal } = useContextMenu();
|
||
|
||
const refreshGroups = () => {
|
||
if (activeServerId) {
|
||
api.get<ServerGroup[]>('/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<string, typeof channels> = {};
|
||
const ungrouped: Record<string, typeof channels> = {};
|
||
|
||
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 (
|
||
<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)}>
|
||
{isEditing ? (
|
||
<div className="w-full text-left px-2 py-1 rounded-sm flex items-center gap-2">
|
||
<span className="text-gb-fg-f">#</span>
|
||
<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"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<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 pointer-events-none group-hover:pointer-events-auto">
|
||
<span
|
||
onClick={(e) => handleNotifClick(e, channel.id)}
|
||
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
|
||
>
|
||
{notifIcon(channel.id)}
|
||
</span>
|
||
<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 cursor-pointer"
|
||
title="Channel settings"
|
||
>
|
||
[⚙]
|
||
</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">
|
||
<span>{activeServerId ? `[SERVER ${activeServer?.name ?? activeServerId}]` : '[NO SERVER]'}</span>
|
||
{activeServerId && (
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => setShowInvite(true)}
|
||
className="terminal-button text-xs"
|
||
title="Invite people"
|
||
>
|
||
[INV]
|
||
</button>
|
||
<button
|
||
onClick={() => { setCreateInGroup(null); setShowCreate(true); }}
|
||
className="terminal-button text-xs"
|
||
title="Create channel"
|
||
>
|
||
[+]
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
||
{sections.length === 0 && (
|
||
<p className="text-gb-fg-f">[no channels]</p>
|
||
)}
|
||
{sections.map((section) => {
|
||
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">
|
||
{isEditingGroup ? (
|
||
<div className="text-gb-fg-t text-xs uppercase mb-1 flex items-center">
|
||
<span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
|
||
<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"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
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)}
|
||
>
|
||
<span className="mr-1">{collapsed ? '[+]' : '[-]'}</span>
|
||
<span>{grp.name}</span>
|
||
</div>
|
||
)}
|
||
{!collapsed && (
|
||
<>
|
||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||
{section.channels.map((channel) => renderChannel(channel))}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
// category section (fallback for ungrouped channels)
|
||
return (
|
||
<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) => renderChannel(channel))}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{showCreate && activeServerId && (
|
||
<CreateChannelModal
|
||
serverId={activeServerId}
|
||
defaultGroupId={createInGroup}
|
||
onClose={() => { setShowCreate(false); setCreateInGroup(null); }}
|
||
/>
|
||
)}
|
||
{showInvite && activeServerId && activeServer && (
|
||
<InviteModal serverId={activeServerId} serverName={activeServer.name} onClose={() => setShowInvite(false)} />
|
||
)}
|
||
{settingsChannel && activeServerId && (
|
||
<ChannelSettingsModal
|
||
serverId={activeServerId}
|
||
channelId={settingsChannel.id}
|
||
channelName={settingsChannel.name}
|
||
onClose={() => setSettingsChannel(null)}
|
||
/>
|
||
)}
|
||
{MenuPortal}
|
||
</div>
|
||
);
|
||
}
|