166 lines
8.1 KiB
JavaScript
166 lines
8.1 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';
|
|
import UserFooter from './UserFooter.jsx';
|
|
|
|
function useAppSettings() {
|
|
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
|
|
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';
|
|
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
|
|
document.title = prefix + name;
|
|
const faviconUrl = settings.logo_url || '/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;
|
|
}
|
|
|
|
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 = {} }) {
|
|
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');
|
|
|
|
const privateFiltered = [...allGroups.filter(g => g.type === 'private')].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);
|
|
|
|
return (
|
|
<div
|
|
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
|
onClick={() => onSelectGroup(group.id)}
|
|
>
|
|
<div className="group-icon-wrap">
|
|
{group.is_direct && group.peer_avatar && !group.is_managed ? (
|
|
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
|
|
) : group.is_managed && group.is_multi_group ? (
|
|
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>MG</div>
|
|
) : group.is_managed ? (
|
|
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>UG</div>
|
|
) : (
|
|
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#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">
|
|
<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>
|
|
|
|
<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">MESSAGES</div>
|
|
{privateFiltered.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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
|
</div>
|
|
);
|
|
} |