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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user