refactor: migrate ChannelSidebar context menus to centralized store
This commit is contained in:
@@ -16,7 +16,7 @@ import { AudioManager } from '../../audio/AudioManager';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { parseFederatedUsername, isSelf } from '../../utils/identity';
|
||||
import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
|
||||
import { ContextMenu } from '../ui/ContextMenu';
|
||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { DmSearchBar } from './DmSearchBar';
|
||||
|
||||
@@ -108,10 +108,8 @@ export function ChannelSidebar() {
|
||||
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
|
||||
const [deleteCategoryLoading, setDeleteCategoryLoading] = useState(false);
|
||||
|
||||
// Sidebar background context menu state
|
||||
const [sidebarMenuOpen, setSidebarMenuOpen] = useState(false);
|
||||
const [sidebarMenuPos, setSidebarMenuPos] = useState({ x: 0, y: 0 });
|
||||
const sidebarMenuRef = useRef<HTMLDivElement>(null);
|
||||
// Centralized context menu
|
||||
const openContextMenu = useContextMenuStore((s) => s.open);
|
||||
|
||||
// Collapse state — persisted in localStorage
|
||||
const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`;
|
||||
@@ -162,35 +160,86 @@ export function ChannelSidebar() {
|
||||
return chs.some(ch => unreadChannels.has(ch.id));
|
||||
}, [channelsByCategory, unreadChannels]);
|
||||
|
||||
// Sidebar background context menu: close on click/scroll, viewport clamp
|
||||
useEffect(() => {
|
||||
if (!sidebarMenuOpen) return;
|
||||
const close = () => setSidebarMenuOpen(false);
|
||||
document.addEventListener('click', close);
|
||||
document.addEventListener('scroll', close, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', close);
|
||||
document.removeEventListener('scroll', close, true);
|
||||
};
|
||||
}, [sidebarMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarMenuOpen && sidebarMenuRef.current) {
|
||||
const rect = sidebarMenuRef.current.getBoundingClientRect();
|
||||
const pos = { ...sidebarMenuPos };
|
||||
if (rect.right > window.innerWidth) pos.x = window.innerWidth - rect.width - 8;
|
||||
if (rect.bottom > window.innerHeight) pos.y = window.innerHeight - rect.height - 8;
|
||||
if (pos.x < 8) pos.x = 8;
|
||||
if (pos.y < 8) pos.y = 8;
|
||||
if (pos.x !== sidebarMenuPos.x || pos.y !== sidebarMenuPos.y) setSidebarMenuPos(pos);
|
||||
}
|
||||
}, [sidebarMenuOpen, sidebarMenuPos]);
|
||||
|
||||
const handleSidebarContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setSidebarMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setSidebarMenuOpen(true);
|
||||
}, []);
|
||||
e.stopPropagation();
|
||||
const items: ContextMenuItem[] = [];
|
||||
if (canManageChannels) {
|
||||
items.push({
|
||||
key: 'create-channel',
|
||||
type: 'action',
|
||||
label: 'Create Channel',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
|
||||
<path d="M5.72 12.885l.18-.085V3.2L2.1 4.9v8.5l3.62-1.515zM7.1 3.2v9.6l3.8-1.6V2.7L7.1 3.2z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => openModal('createChannel'),
|
||||
});
|
||||
items.push({
|
||||
key: 'create-category',
|
||||
type: 'action',
|
||||
label: 'Create Category',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => openModal('createCategory'),
|
||||
});
|
||||
}
|
||||
if (canCreateInvite) {
|
||||
items.push({
|
||||
key: 'invite',
|
||||
type: 'action',
|
||||
label: 'Invite People',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => openModal('invite'),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: 'settings',
|
||||
type: 'action',
|
||||
label: 'Space Settings',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => openModal('spaceSettings'),
|
||||
});
|
||||
openContextMenu({ x: e.clientX, y: e.clientY }, items);
|
||||
}, [canManageChannels, canCreateInvite, openModal, openContextMenu]);
|
||||
|
||||
const handleDmContextMenu = useCallback((e: React.MouseEvent, dmId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openContextMenu({ x: e.clientX, y: e.clientY }, [
|
||||
{
|
||||
key: 'leave-group',
|
||||
type: 'action',
|
||||
label: 'Leave Group',
|
||||
danger: true,
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5a2 2 0 00-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
if (currentChannelId === dmId) {
|
||||
navigate('/channels/@me');
|
||||
setCurrentChannel(null);
|
||||
}
|
||||
useSpaceStore.getState().leaveDm(dmId);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, [openContextMenu, currentChannelId, navigate, setCurrentChannel]);
|
||||
|
||||
// DnD handlers
|
||||
const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => {
|
||||
@@ -537,29 +586,9 @@ export function ChannelSidebar() {
|
||||
|
||||
if (isGroup) {
|
||||
return (
|
||||
<ContextMenu
|
||||
key={dm.id}
|
||||
items={[
|
||||
{
|
||||
label: 'Leave Group',
|
||||
danger: true,
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10.09 15.59L11.5 17l5-5-5-5-1.41 1.41L12.67 11H3v2h9.67l-2.58 2.59zM19 3H5a2 2 0 00-2 2v4h2V5h14v14H5v-4H3v4a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
if (currentChannelId === dm.id) {
|
||||
navigate('/channels/@me');
|
||||
setCurrentChannel(null);
|
||||
}
|
||||
useSpaceStore.getState().leaveDm(dm.id);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div key={dm.id} onContextMenu={(e) => handleDmContextMenu(e, dm.id)}>
|
||||
{dmItem}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -706,9 +735,13 @@ export function ChannelSidebar() {
|
||||
<div key={category.id} className="mb-[19px]">
|
||||
{/* Category header */}
|
||||
{canManageChannels ? (
|
||||
<ContextMenu
|
||||
items={[
|
||||
<div onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openContextMenu({ x: e.clientX, y: e.clientY }, [
|
||||
{
|
||||
key: 'delete-category',
|
||||
type: 'action',
|
||||
label: 'Delete Category',
|
||||
danger: true,
|
||||
icon: (
|
||||
@@ -718,10 +751,10 @@ export function ChannelSidebar() {
|
||||
),
|
||||
onClick: () => setDeleteCategoryId(category.id),
|
||||
},
|
||||
]}
|
||||
>
|
||||
]);
|
||||
}}>
|
||||
{categoryHeader}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
) : categoryHeader}
|
||||
|
||||
{/* Category channels (hidden when collapsed, unless active) */}
|
||||
@@ -791,70 +824,6 @@ export function ChannelSidebar() {
|
||||
|
||||
</div>
|
||||
{floatingPanel}
|
||||
{/* Sidebar background context menu */}
|
||||
{sidebarMenuOpen && (
|
||||
<div
|
||||
ref={sidebarMenuRef}
|
||||
className="fixed z-[200] min-w-[180px] py-1.5 glass rounded-md animate-fade-in"
|
||||
style={{ left: sidebarMenuPos.x, top: sidebarMenuPos.y }}
|
||||
>
|
||||
{canManageChannels && (
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white"
|
||||
style={{ width: 'calc(100% - 12px)' }}
|
||||
onClick={(e) => { e.stopPropagation(); openModal('createChannel'); setSidebarMenuOpen(false); }}
|
||||
>
|
||||
<span className="w-4 h-4">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
|
||||
<path d="M5.72 12.885l.18-.085V3.2L2.1 4.9v8.5l3.62-1.515zM7.1 3.2v9.6l3.8-1.6V2.7L7.1 3.2z" />
|
||||
</svg>
|
||||
</span>
|
||||
Create Channel
|
||||
</button>
|
||||
)}
|
||||
{canManageChannels && (
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white"
|
||||
style={{ width: 'calc(100% - 12px)' }}
|
||||
onClick={(e) => { e.stopPropagation(); openModal('createCategory'); setSidebarMenuOpen(false); }}
|
||||
>
|
||||
<span className="w-4 h-4">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
Create Category
|
||||
</button>
|
||||
)}
|
||||
{canCreateInvite && (
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white"
|
||||
style={{ width: 'calc(100% - 12px)' }}
|
||||
onClick={(e) => { e.stopPropagation(); openModal('invite'); setSidebarMenuOpen(false); }}
|
||||
>
|
||||
<span className="w-4 h-4">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
||||
</svg>
|
||||
</span>
|
||||
Invite People
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white"
|
||||
style={{ width: 'calc(100% - 12px)' }}
|
||||
onClick={(e) => { e.stopPropagation(); openModal('spaceSettings'); setSidebarMenuOpen(false); }}
|
||||
>
|
||||
<span className="w-4 h-4">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
|
||||
</svg>
|
||||
</span>
|
||||
Space Settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteCategoryId !== null}
|
||||
onClose={() => setDeleteCategoryId(null)}
|
||||
|
||||
Reference in New Issue
Block a user