// ========================================== // 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`
{ e.preventDefault(); }} onDrop=${_onDropOnRoot} onDragEnd=${_onDragEnd}> ${/* Render folder tree recursively */''} ${tree.map(node => html` <${FolderNode} key=${'f-' + node.id} node=${node} depth=${0} itemsByFolder=${itemsByFolder} activeId=${activeId} collapsedFolders=${collapsedFolders} dragOver=${dragOver} onSelect=${onSelect} onOpenMenu=${_openMenu} onToggleFolder=${_toggleFolder} onDragStart=${_onDragStart} onDragOver=${_onDragOver} onDragLeave=${_onDragLeave} onDropOnFolder=${_onDropOnFolder} />`)} ${/* Unfoldered items */''} ${unfolderedItems.map(ch => html` <${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId} onSelect=${onSelect} onOpenMenu=${_openMenu} onDragStart=${_onDragStart} />`)} ${totalItems === 0 && tree.length === 0 && html`
No conversations yet
`} ${/* Visible root drop zone — shown during drag */''} ${dragging && tree.length > 0 && html`
_onDragOver(e, '__root', 'root')} onDragLeave=${_onDragLeave} onDrop=${_onDropOnRoot}> Drop here to remove from folder
`}
${/* Context Menu */''} ${contextMenu && html`
${contextMenu.type === 'chat' && html` ${folders.length > 0 && folders.map(f => html` `)} ${contextMenu.item.folder_id && html` `} `} ${contextMenu.type === 'folder' && html` ${contextMenu.item.parent_id && html` `} `} ${contextMenu.type === 'body' && html` `}
`} `; } // ── FolderNode (recursive) ─────────────── function FolderNode({ node, depth, itemsByFolder, activeId, collapsedFolders, dragOver, onSelect, onOpenMenu, onToggleFolder, onDragStart, onDragOver, onDragLeave, onDropOnFolder, }) { const folderItems = itemsByFolder[node.id] || []; const totalCount = folderItems.length + (node.children || []).length; const isCollapsed = !!collapsedFolders[node.id]; const isDragTarget = dragOver?.id === node.id && dragOver?.type === 'folder'; const hasChildren = (node.children?.length > 0) || folderItems.length > 0; const indent = depth * 12; return html`
{ e.stopPropagation(); onDragStart(e, 'folder', node); }} onDragOver=${(e) => { if (depth < MAX_DEPTH - 1) onDragOver(e, node.id, 'folder'); }} onDragLeave=${onDragLeave} onDrop=${(e) => { e.stopPropagation(); onDropOnFolder(e, node.id); }}>
onToggleFolder(node.id)}> ${CHEVRON_SVG} ${FOLDER_SVG} ${node.name} ${totalCount}
${!isCollapsed && html` ${/* Child folders */''} ${(node.children || []).map(child => html` <${FolderNode} key=${'f-' + child.id} node=${child} depth=${depth + 1} itemsByFolder=${itemsByFolder} activeId=${activeId} collapsedFolders=${collapsedFolders} dragOver=${dragOver} onSelect=${onSelect} onOpenMenu=${onOpenMenu} onToggleFolder=${onToggleFolder} onDragStart=${onDragStart} onDragOver=${onDragOver} onDragLeave=${onDragLeave} onDropOnFolder=${onDropOnFolder} />`)} ${/* Channel items in this folder */''} ${folderItems.map(ch => html` <${ChannelItem} key=${ch.id} channel=${ch} activeId=${activeId} onSelect=${onSelect} onOpenMenu=${onOpenMenu} onDragStart=${onDragStart} indent=${true} />`)} `}
`; } // ── ChannelItem ────────────────────────── function ChannelItem({ channel, activeId, onSelect, onOpenMenu, onDragStart, indent }) { const icon = _iconForType(channel.type); return html` `; } // Scale detection moved to sw.shell.getScale() in v0.37.19 (CR P3-3). // Folder count = direct children only (subfolders + channels in this folder).