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'; import UserFooter from './UserFooter.jsx'; // Must match Avatar.jsx exactly so sidebar colours are consistent with message avatars const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7']; function nameToColor(name) { return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length]; } function useAppSettings() { const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' }); const fetchSettings = () => { api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); }; useEffect(() => { fetchSettings(); window.addEventListener('rosterchirp:settings-changed', fetchSettings); return () => window.removeEventListener('rosterchirp:settings-changed', fetchSettings); }, []); useEffect(() => { const name = settings.app_name || 'rosterchirp'; const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || ''; document.title = prefix + name; const faviconUrl = settings.logo_url || '/icons/rosterchirp.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; } 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' }); } export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupManager, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set(), features = {}, groupMessagesMode = false }) { const { user } = useAuth(); const { connected } = useSocket(); const toast = useToast(); const settings = useAppSettings(); const allGroups = [ ...(groups.publicGroups || []), ...(groups.privateGroups || []) ]; const publicFiltered = allGroups.filter(g => g.type === 'public'); // In groupMessagesMode show only managed groups; on main Messages hide managed groups const privateFiltered = [...allGroups.filter(g => g.type === 'private' && (groupMessagesMode ? g.is_managed : !g.is_managed))].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 getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length; 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 isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false); // Peer avatar colour: use the same algorithm as Avatar.jsx so it matches message bubbles const peerColor = group.is_direct && !group.is_managed && group.peer_real_name ? nameToColor(group.peer_real_name) : null; return (