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 37d8b82d5b
commit bf3bf2392c
6 changed files with 335 additions and 97 deletions
+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>
);
}