import { useState, useEffect, useCallback } from 'react'; import { useSocket } from '../contexts/SocketContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx'; import { api } from '../utils/api.js'; import Sidebar from '../components/Sidebar.jsx'; import ChatWindow from '../components/ChatWindow.jsx'; import ProfileModal from '../components/ProfileModal.jsx'; import UserManagerModal from '../components/UserManagerModal.jsx'; import SettingsModal from '../components/SettingsModal.jsx'; import NewChatModal from '../components/NewChatModal.jsx'; import GlobalBar from '../components/GlobalBar.jsx'; import AboutModal from '../components/AboutModal.jsx'; import HelpModal from '../components/HelpModal.jsx'; import './Chat.css'; function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); return outputArray; } export default function Chat() { const { socket } = useSocket(); const { user } = useAuth(); const toast = useToast(); const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] }); const [activeGroupId, setActiveGroupId] = useState(null); const [notifications, setNotifications] = useState([]); const [unreadGroups, setUnreadGroups] = useState(new Map()); const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [showSidebar, setShowSidebar] = useState(true); // Check if help should be shown on login useEffect(() => { api.getHelpStatus() .then(({ dismissed }) => { setHelpDismissed(dismissed); if (!dismissed) setModal('help'); }) .catch(() => {}); }, []); useEffect(() => { const handle = () => { const mobile = window.innerWidth < 768; setIsMobile(mobile); if (!mobile) setShowSidebar(true); }; window.addEventListener('resize', handle); return () => window.removeEventListener('resize', handle); }, []); const loadGroups = useCallback(() => { api.getGroups().then(setGroups).catch(() => {}); }, []); useEffect(() => { loadGroups(); }, [loadGroups]); // Register push subscription useEffect(() => { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; (async () => { try { const reg = await navigator.serviceWorker.ready; const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json()); const existing = await reg.pushManager.getSubscription(); if (existing) { // Re-register to keep subscription fresh await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` }, body: JSON.stringify(existing.toJSON()) }); return; } const permission = await Notification.requestPermission(); if (permission !== 'granted') return; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) }); await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` }, body: JSON.stringify(sub.toJSON()) }); console.log('[Push] Subscribed'); } catch (e) { console.warn('[Push] Subscription failed:', e.message); } })(); }, []); // Socket message events to update group previews useEffect(() => { if (!socket) return; const handleNewMsg = (msg) => { // Update group preview text setGroups(prev => { const updateGroup = (g) => g.id === msg.group_id ? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at } : g; return { publicGroups: prev.publicGroups.map(updateGroup), privateGroups: prev.privateGroups.map(updateGroup), }; }); // Don't badge: message is from this user, or group is currently open if (msg.user_id === user?.id) return; setUnreadGroups(prev => { if (msg.group_id === activeGroupId) return prev; const next = new Map(prev); next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1); return next; }); }; const handleNotification = (notif) => { if (notif.type === 'private_message') { // Badge is already handled by handleNewMsg via message:new socket event. // Nothing to do here for the socket path. } else { setNotifications(prev => [notif, ...prev]); toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000); } }; socket.on('message:new', handleNewMsg); socket.on('notification:new', handleNotification); // Group list real-time updates const handleGroupNew = ({ group }) => { // Join the socket room for this new group socket.emit('group:join-room', { groupId: group.id }); // Reload the full group list so name/metadata is correct loadGroups(); }; const handleGroupDeleted = ({ groupId }) => { // Leave the socket room so we stop receiving events for this group socket.emit('group:leave-room', { groupId }); setGroups(prev => ({ publicGroups: prev.publicGroups.filter(g => g.id !== groupId), privateGroups: prev.privateGroups.filter(g => g.id !== groupId), })); setActiveGroupId(prev => { if (prev === groupId) { if (isMobile) setShowSidebar(true); return null; } return prev; }); setUnreadGroups(prev => { const next = new Map(prev); next.delete(groupId); return next; }); }; const handleGroupUpdated = ({ group }) => { setGroups(prev => { const update = g => g.id === group.id ? { ...g, ...group } : g; return { publicGroups: prev.publicGroups.map(update), privateGroups: prev.privateGroups.map(update), }; }); }; socket.on('group:new', handleGroupNew); socket.on('group:deleted', handleGroupDeleted); socket.on('group:updated', handleGroupUpdated); return () => { socket.off('message:new', handleNewMsg); socket.off('notification:new', handleNotification); socket.off('group:new', handleGroupNew); socket.off('group:deleted', handleGroupDeleted); socket.off('group:updated', handleGroupUpdated); }; }, [socket, toast, activeGroupId, user, isMobile, loadGroups]); const selectGroup = (id) => { setActiveGroupId(id); if (isMobile) setShowSidebar(false); // Clear notifications and unread count for this group setNotifications(prev => prev.filter(n => n.groupId !== id)); setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; }); }; // Update page title with total unread badge count useEffect(() => { const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0); const base = document.title.replace(/^\(\d+\)\s*/, ''); document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base; }, [unreadGroups]); const activeGroup = [ ...(groups.publicGroups || []), ...(groups.privateGroups || []) ].find(g => g.id === activeGroupId); return (
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
{(!isMobile || showSidebar) && ( setModal('newchat')} onProfile={() => setModal('profile')} onUsers={() => setModal('users')} onSettings={() => setModal('settings')} onGroupsUpdated={loadGroups} isMobile={isMobile} onAbout={() => setModal('about')} onHelp={() => setModal('help')} /> )} {(!isMobile || !showSidebar) && ( { setShowSidebar(true); setActiveGroupId(null); } : null} onGroupUpdated={loadGroups} onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }} /> )}
{modal === 'profile' && setModal(null)} />} {modal === 'users' && setModal(null)} />} {modal === 'settings' && setModal(null)} />} {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'about' && setModal(null)} />} {modal === 'help' && setModal(null)} dismissed={helpDismissed} />}
); }