Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
362 lines
17 KiB
JavaScript
362 lines
17 KiB
JavaScript
// ==========================================
|
|
// 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`
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>`;
|
|
|
|
const HASH_SVG = html`
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="4" y1="9" x2="20" y2="9"/>
|
|
<line x1="4" y1="15" x2="20" y2="15"/>
|
|
<line x1="10" y1="3" x2="8" y2="21"/>
|
|
<line x1="16" y1="3" x2="14" y2="21"/>
|
|
</svg>`;
|
|
|
|
const FOLDER_SVG = html`
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
</svg>`;
|
|
|
|
const DOTS_SVG = html`
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
|
|
</svg>`;
|
|
|
|
const CHEVRON_SVG = html`
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>`;
|
|
|
|
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`
|
|
<div class="sw-chat-surface__section-body"
|
|
role="listbox"
|
|
onContextMenu=${_bodyContextMenu}
|
|
onDragOver=${(e) => { 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`
|
|
<div class="sw-chat-surface__empty">No conversations yet</div>`}
|
|
${/* Visible root drop zone — shown during drag */''}
|
|
${dragging && tree.length > 0 && html`
|
|
<div class=${'sw-chat-surface__root-drop' + (rootDropActive ? ' sw-chat-surface__root-drop--active' : '')}
|
|
onDragOver=${(e) => _onDragOver(e, '__root', 'root')}
|
|
onDragLeave=${_onDragLeave}
|
|
onDrop=${_onDropOnRoot}>
|
|
Drop here to remove from folder
|
|
</div>`}
|
|
</div>
|
|
|
|
${/* Context Menu */''}
|
|
${contextMenu && html`
|
|
<div class="sw-chat-surface__context-menu" ref=${menuRef}
|
|
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
|
|
${contextMenu.type === 'chat' && html`
|
|
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
|
${folders.length > 0 && folders.map(f => html`
|
|
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
|
|
Move to ${f.name}
|
|
</button>`)}
|
|
${contextMenu.item.folder_id && html`
|
|
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
|
|
<button class="sw-chat-surface__ctx-danger"
|
|
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
|
`}
|
|
${contextMenu.type === 'folder' && html`
|
|
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
|
<button onClick=${() => _action('new-subfolder', contextMenu.item)}>New subfolder</button>
|
|
${contextMenu.item.parent_id && html`
|
|
<button onClick=${() => _action('unnest-folder', contextMenu.item)}>Move to root</button>`}
|
|
<button class="sw-chat-surface__ctx-danger"
|
|
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
|
`}
|
|
${contextMenu.type === 'body' && html`
|
|
<button onClick=${() => _action('new-folder', null)}>New Folder</button>
|
|
`}
|
|
</div>`}
|
|
`;
|
|
}
|
|
|
|
// ── 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`
|
|
<div key=${'f-' + node.id}
|
|
class=${'sw-chat-surface__folder' + (isDragTarget ? ' sw-chat-surface__folder--drag-over' : '')}
|
|
style=${indent ? 'padding-left:' + indent + 'px' : ''}
|
|
draggable=${depth < MAX_DEPTH}
|
|
onDragStart=${(e) => { 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); }}>
|
|
<div class="sw-chat-surface__folder-header"
|
|
onClick=${() => onToggleFolder(node.id)}>
|
|
<span class=${'sw-chat-surface__chevron' + (isCollapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
|
${CHEVRON_SVG}
|
|
</span>
|
|
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
|
|
<span class="sw-chat-surface__folder-name">${node.name}</span>
|
|
<span class="sw-chat-surface__folder-count">${totalCount}</span>
|
|
<button class="sw-chat-surface__item-menu"
|
|
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'folder', node); }}
|
|
title="Folder actions"
|
|
aria-label="Folder actions">
|
|
${DOTS_SVG}
|
|
</button>
|
|
</div>
|
|
${!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} />`)}
|
|
`}
|
|
</div>`;
|
|
}
|
|
|
|
// ── ChannelItem ──────────────────────────
|
|
|
|
function ChannelItem({ channel, activeId, onSelect, onOpenMenu, onDragStart, indent }) {
|
|
const icon = _iconForType(channel.type);
|
|
return html`
|
|
<button class=${'sw-chat-surface__item' + (channel.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
|
|
onClick=${() => onSelect(channel.id, channel.type)}
|
|
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', channel); }}
|
|
draggable=${true}
|
|
onDragStart=${(e) => onDragStart(e, 'channel', channel)}
|
|
role="option"
|
|
aria-selected=${channel.id === activeId}
|
|
title=${channel.title || 'Untitled'}>
|
|
<span class="sw-chat-surface__item-icon">${icon}</span>
|
|
<span class="sw-chat-surface__item-title">${channel.title || 'Untitled'}</span>
|
|
${(channel.unread_count || 0) > 0 && html`
|
|
<span class="sw-chat-surface__unread">${channel.unread_count}</span>`}
|
|
<button class="sw-chat-surface__item-menu"
|
|
onClick=${(e) => { e.stopPropagation(); onOpenMenu(e, 'chat', channel); }}
|
|
title="Actions"
|
|
aria-label="Actions">
|
|
${DOTS_SVG}
|
|
</button>
|
|
</button>`;
|
|
}
|
|
|
|
// Scale detection moved to sw.shell.getScale() in v0.37.19 (CR P3-3).
|
|
|
|
// Folder count = direct children only (subfolders + channels in this folder).
|