// ========================================== // Chat Surface — SidebarItems Component // ========================================== // Unified channel list with folder grouping, context menus, // nested folders, and HTML5 drag-and-drop. const html = window.html; const { useState, useCallback, useRef, useEffect } = window.hooks; // ── Icons ──────────────────────────────── const CHAT_SVG = html` `; const HASH_SVG = html` `; const FOLDER_SVG = html` `; const DOTS_SVG = html` `; const CHEVRON_SVG = html` `; function _iconForType(type) { if (type === 'channel' || type === 'group') return HASH_SVG; return CHAT_SVG; } // Max nesting depth for folders const MAX_DEPTH = 3; // ── Main Component ─────────────────────── export function SidebarItems({ itemsByFolder, folders, folderTree, activeId, onSelect, onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder, onRenameFolder, onDeleteFolder, }) { const [contextMenu, setContextMenu] = useState(null); const [dragOver, setDragOver] = useState(null); // { id, type } of drop target const [dragging, setDragging] = useState(false); // true while any drag in progress const [collapsedFolders, setCollapsedFolders] = useState({}); const menuRef = useRef(null); // ── Context menu close ────────────── useEffect(() => { if (!contextMenu) return; function _close(e) { if (e.type === 'keydown' && e.key !== 'Escape') return; if (e.type === 'click' && menuRef.current?.contains(e.target)) return; setContextMenu(null); } document.addEventListener('click', _close); document.addEventListener('keydown', _close); return () => { document.removeEventListener('click', _close); document.removeEventListener('keydown', _close); }; }, [contextMenu]); const _openMenu = useCallback((e, type, item) => { e.preventDefault(); e.stopPropagation(); const scale = sw.shell.getScale(); // The dots button may be display:none (zero rect) — fall back to parent let rect = e.currentTarget.getBoundingClientRect(); if (!rect.width && !rect.height) { const parent = e.currentTarget.closest('.sw-chat-surface__item, .sw-chat-surface__folder-header'); if (parent) rect = parent.getBoundingClientRect(); } const x = rect.right / scale; const y = rect.bottom / scale; setContextMenu({ type, item, x, y }); }, []); const _bodyContextMenu = useCallback((e) => { if (e.target.closest('.sw-chat-surface__item') || e.target.closest('.sw-chat-surface__folder-header')) return; e.preventDefault(); const scale = sw.shell.getScale(); setContextMenu({ type: 'body', item: null, x: e.clientX / scale, y: e.clientY / scale }); }, []); const _action = useCallback(async (action, item) => { setContextMenu(null); if (action === 'rename') { const newTitle = await window.sw.prompt('Rename:', item.title || item.name || ''); if (newTitle && newTitle.trim()) { if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim()); else await onRename(item.id, newTitle.trim()); } } else if (action === 'delete') { const label = item.name !== undefined ? 'folder' : 'conversation'; const ok = await window.sw.confirm('Delete this ' + label + '?', true); if (ok) { if (item.name !== undefined) await onDeleteFolder(item.id); else await onDelete(item.id); } } else if (action === 'move-out') { await onMoveToFolder(item.id, null); } else if (action === 'new-folder') { const name = await window.sw.prompt('Folder name:', ''); if (name && name.trim()) await onCreateFolder(name.trim()); } else if (action === 'new-subfolder') { const name = await window.sw.prompt('Subfolder name:', ''); if (name && name.trim()) await onCreateFolder(name.trim(), item.id); } else if (action === 'unnest-folder') { await onMoveFolderTo(item.id, null); } else if (action.startsWith('move-to:')) { const fid = action.slice(8); await onMoveToFolder(item.id, fid); } }, [onRename, onDelete, onMoveToFolder, onMoveFolderTo, onCreateFolder, onRenameFolder, onDeleteFolder]); // ── Folder collapse toggle ────────── const _toggleFolder = useCallback((folderId) => { setCollapsedFolders(prev => ({ ...prev, [folderId]: !prev[folderId] })); }, []); // ── Drag & Drop ───────────────────── const _onDragStart = useCallback((e, dragType, item) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ dragType, id: item.id })); setDragging(true); }, []); const _onDragOver = useCallback((e, targetId, targetType) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOver({ id: targetId, type: targetType }); }, []); const _onDragLeave = useCallback(() => { setDragOver(null); }, []); const _onDropOnFolder = useCallback((e, folderId) => { e.preventDefault(); setDragOver(null); try { const data = JSON.parse(e.dataTransfer.getData('text/plain')); if (data.dragType === 'channel') { onMoveToFolder(data.id, folderId); } else if (data.dragType === 'folder' && data.id !== folderId) { onMoveFolderTo(data.id, folderId); } } catch (_) {} }, [onMoveToFolder, onMoveFolderTo]); const _onDropOnRoot = useCallback((e) => { e.preventDefault(); setDragOver(null); setDragging(false); try { const data = JSON.parse(e.dataTransfer.getData('text/plain')); if (data.dragType === 'channel') { onMoveToFolder(data.id, null); } else if (data.dragType === 'folder') { onMoveFolderTo(data.id, null); } } catch (_) {} }, [onMoveToFolder, onMoveFolderTo]); const _onDragEnd = useCallback(() => { setDragging(false); setDragOver(null); }, []); // ── Render ─────────────────────────── const unfolderedItems = itemsByFolder[''] || []; const totalItems = Object.values(itemsByFolder).reduce((sum, arr) => sum + arr.length, 0); const tree = folderTree || []; const rootDropActive = dragging && dragOver?.id === '__root' && dragOver?.type === 'root'; return html`