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];
}
// Layouts for composite avatars inside a 44×44 circle (all values in px)
const COMPOSITE_LAYOUTS = {
1: [{ top: 4, left: 4, size: 36 }],
2: [
{ top: 11, left: 1, size: 21 },
{ top: 11, right: 1, size: 21 },
],
3: [
{ top: 2, left: 3, size: 19 },
{ top: 2, right: 3, size: 19 },
{ bottom: 2, left: 12, size: 19 },
],
4: [
{ top: 1, left: 1, size: 20 },
{ top: 1, right: 1, size: 20 },
{ bottom: 1, left: 1, size: 20 },
{ bottom: 1, right: 1, size: 20 },
],
};
function GroupAvatarComposite({ memberPreviews }) {
const members = (memberPreviews || []).slice(0, 4);
const n = members.length;
const positions = COMPOSITE_LAYOUTS[n];
if (!positions) {
return (
?
);
}
return (
{members.map((m, i) => {
const pos = positions[i];
const base = {
position: 'absolute',
width: pos.size,
height: pos.size,
borderRadius: '50%',
boxSizing: 'border-box',
border: '2px solid var(--surface)',
...(pos.top !== undefined ? { top: pos.top } : {}),
...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}),
...(pos.left !== undefined ? { left: pos.left } : {}),
...(pos.right !== undefined ? { right: pos.right } : {}),
overflow: 'hidden',
flexShrink: 0,
};
if (m.avatar) {
return

;
}
return (
{(m.name || '')[0]?.toUpperCase()}
);
})}
);
}
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 (
onSelectGroup(group.id)}
>
{group.is_direct && group.peer_avatar && !group.is_managed ? (

) : group.is_direct && !group.is_managed ? (
// No custom avatar — use the per-user colour matching Avatar.jsx
{(group.peer_real_name || group.name)[0]?.toUpperCase()}
) : group.is_managed && group.is_multi_group ? (
MG
) : group.is_managed ? (
UG
) : group.composite_members?.length > 0 ? (
) : (
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
)}
{isOnline &&
}
{group.is_direct && group.peer_display_name
? <>{group.peer_display_name} ({group.peer_real_name})>
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
{group.last_message_at && (
{formatTime(group.last_message_at)}
)}
{(() => {
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 ? <>You: {preview}> : preview;
})()}
{notifs > 0 && {notifs}}
{hasUnread && notifs === 0 && {unreadCount}}
);
};
return (
{!isMobile && (
)}
{!groupMessagesMode && publicFiltered.length > 0 && (
PUBLIC MESSAGES
{publicFiltered.map(g =>
)}
)}
{!groupMessagesMode && privateFiltered.length > 0 && (
PRIVATE MESSAGES
{privateFiltered.map(g =>
)}
)}
{groupMessagesMode && privateFiltered.length > 0 && (
USER GROUP MESSAGES
{privateFiltered.map(g =>
)}
)}
{groupMessagesMode && privateFiltered.length === 0 && (
No group messages yet
)}
{!groupMessagesMode && allGroups.filter(g => !g.is_managed || g.type === 'public').length === 0 && (
No chats yet
)}
{isMobile && (
)}
);
}