Files
dumpsterChat/web/src/components/ChannelList.tsx
T
hobokenchicken c57889e477 fix: invisible notification/settings icons were eating channel clicks
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.
2026-07-02 09:06:04 -04:00

409 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}