Files
rosterchirp-dev/frontend/src/components/Sidebar.jsx

360 lines
17 KiB
JavaScript

import { useState, useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useSocket } from '../contexts/SocketContext.jsx';
import { api, parseTS } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx';
import './Sidebar.css';
function useTheme() {
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
}, [dark]);
return [dark, setDark];
}
function useAppSettings() {
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
};
useEffect(() => {
fetchSettings();
window.addEventListener('jama:settings-changed', fetchSettings);
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
}, []);
useEffect(() => {
const name = settings.app_name || 'jama';
// Preserve any unread badge prefix already set by Chat.jsx
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
document.title = prefix + name;
const logoUrl = settings.logo_url;
const faviconUrl = logoUrl || '/icons/jama.png';
let link = document.querySelector("link[rel~='icon']");
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = faviconUrl;
}, [settings]);
return settings;
}
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
const { user, logout } = useAuth();
const { connected } = useSocket();
const toast = useToast();
const [showMenu, setShowMenu] = useState(false);
const [contextMenu, setContextMenu] = useState(null); // { groupId, x, y, isPinned }
const settings = useAppSettings();
const [dark, setDark] = useTheme();
const menuRef = useRef(null);
const footerBtnRef = useRef(null);
const handlePin = async (groupId) => {
try {
await api.pinDM(groupId);
onGroupsUpdated();
} catch (e) { toast(e.message, 'error'); }
setContextMenu(null);
};
const handleUnpin = async (groupId) => {
try {
await api.unpinDM(groupId);
onGroupsUpdated();
} catch (e) { toast(e.message, 'error'); }
setContextMenu(null);
};
// Close context menu on outside click
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
document.addEventListener('mousedown', close);
document.addEventListener('touchstart', close);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('touchstart', close);
};
}, [contextMenu]);
// Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit
// Close menu on click outside
useEffect(() => {
if (!showMenu) return;
const handler = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target) &&
footerBtnRef.current && !footerBtnRef.current.contains(e.target)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('touchstart', handler);
};
}, [showMenu]);
const appName = settings.app_name || 'jama';
const logoUrl = settings.logo_url;
const allGroups = [
...(groups.publicGroups || []),
...(groups.privateGroups || [])
];
const publicFiltered = allGroups.filter(g => g.type === 'public');
const pinnedDMs = allGroups
.filter(g => g.type === 'private' && g.is_direct && g.pin_order != null)
.sort((a, b) => a.pin_order - b.pin_order);
const unpinnedDMs = allGroups
.filter(g => g.type === 'private' && g.is_direct && g.pin_order == null)
.sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;
if (!a.last_message_at) return 1;
if (!b.last_message_at) return -1;
return new Date(b.last_message_at) - new Date(a.last_message_at);
});
const privateNonDM = allGroups.filter(g => g.type === 'private' && !g.is_direct);
const privateFiltered = [...privateNonDM, ...pinnedDMs, ...unpinnedDMs];
const pinnedGroupIds = new Set(pinnedDMs.map(g => g.id));
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
const handleLogout = async () => { await logout(); };
const GroupItem = ({ group }) => {
const notifs = getNotifCount(group.id);
const unreadCount = unreadGroups.get(group.id) || 0;
const hasUnread = unreadCount > 0;
const isActive = group.id === activeGroupId;
const isPinned = pinnedGroupIds.has(group.id);
const isOnline = group.is_direct && group.peer_id && onlineUserIds.has(group.peer_id);
const handleContextMenu = (e) => {
if (!group.is_direct) return;
e.preventDefault();
e.stopPropagation();
setContextMenu({ groupId: group.id, x: e.clientX, y: e.clientY, isPinned });
};
return (
<div
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
onClick={() => onSelectGroup(group.id)}
onContextMenu={handleContextMenu}
>
<div className="group-icon-wrap">
{group.is_direct && group.peer_avatar ? (
<img
src={group.peer_avatar}
alt={group.name}
className="group-icon"
style={{ objectFit: 'cover', padding: 0 }}
/>
) : (
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
</div>
)}
{isOnline && <span className="online-dot" />}
</div>
<div className="group-info flex-1 overflow-hidden">
<div className="flex items-center justify-between">
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
{group.is_direct && group.peer_display_name
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
</span>
{group.last_message_at && (
<span className="group-time">{formatTime(group.last_message_at)}</span>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="group-last-msg truncate">
{(() => {
const preview = (group.last_message || '').replace(/@\[([^\]]+)\]/g, '@$1');
if (!preview) return group.is_readonly ? '📢 Read-only' : 'No messages yet';
const isOwn = group.last_message_user_id && user && group.last_message_user_id === user.id;
return isOwn ? <><strong style={{fontWeight:600}}>You:</strong> {preview}</> : preview;
})()}
</span>
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
{hasUnread && notifs === 0 && <span className="badge badge-unread shrink-0">{unreadCount}</span>}
</div>
</div>
</div>
);
};
return (
<div className="sidebar">
{/* New Chat button replacing search bar */}
<div className="sidebar-newchat-bar">
{!isMobile && (
<button className="newchat-btn" onClick={onNewChat}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
New Chat
</button>
)}
</div>
{/* Groups list */}
<div className="groups-list">
{publicFiltered.length > 0 && (
<div className="group-section">
<div className="section-label">PUBLIC MESSAGES</div>
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div>
)}
{privateFiltered.length > 0 && (
<div className="group-section">
<div className="section-label">DIRECT MESSAGES</div>
{pinnedDMs.length > 0 && (
<>
<div className="section-sublabel">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: 3 }}><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
PINNED
</div>
{pinnedDMs.map(g => <GroupItem key={g.id} group={g} />)}
{unpinnedDMs.length > 0 && <div className="section-divider" />}
</>
)}
{privateNonDM.map(g => <GroupItem key={g.id} group={g} />)}
{unpinnedDMs.map(g => <GroupItem key={g.id} group={g} />)}
</div>
)}
{allGroups.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
No chats yet
</div>
)}
</div>
{/* Mobile FAB: New Chat button floats above user footer */}
{isMobile && (
<button className="newchat-fab" onClick={onNewChat} title="New Chat">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
</button>
)}
{/* User footer */}
<div className="sidebar-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
<Avatar user={user} size="sm" />
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</button>
<button
className="btn-icon"
onClick={() => setDark(d => !d)}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
style={{ flexShrink: 0, padding: 8 }}
>
{dark ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
</div>
{showMenu && (
<div ref={menuRef} className="footer-menu">
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onProfile(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</button>
{user?.role === 'admin' && (
<>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onUsers(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
User Manager
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onOpenSettings(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
Settings
</button>
</>
)}
<hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Help
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
About
</button>
<hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item danger" onClick={handleLogout}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Sign out
</button>
</div>
)}
</div>
{/* DM pin context menu */}
{contextMenu && (
<div
className="dm-context-menu"
style={{ top: contextMenu.y, left: Math.min(contextMenu.x, window.innerWidth - 160) }}
onMouseDown={e => e.stopPropagation()}
>
{contextMenu.isPinned ? (
<button onClick={() => handleUnpin(contextMenu.groupId)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
Unpin conversation
</button>
) : (
<button onClick={() => handlePin(contextMenu.groupId)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 2v4l-3 3v7l-4-4-4 4V9L2 6V2h14zm2 0h2v2h-2V2z"/></svg>
Pin conversation
</button>
)}
</div>
)}
</div>
);
}
function formatTime(dateStr) {
if (!dateStr) return '';
const date = parseTS(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 86400000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diff < 604800000) {
return date.toLocaleDateString([], { weekday: 'short' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}