296 lines
12 KiB
JavaScript
296 lines
12 KiB
JavaScript
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 / refresh push subscription
|
|
useEffect(() => {
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
|
|
|
const registerPush = async () => {
|
|
try {
|
|
const permission = Notification.permission;
|
|
if (permission === 'denied') return;
|
|
|
|
const reg = await navigator.serviceWorker.ready;
|
|
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
|
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
|
|
|
let sub = await reg.pushManager.getSubscription();
|
|
|
|
if (!sub) {
|
|
// First time or subscription was lost — request permission then subscribe
|
|
const granted = permission === 'granted'
|
|
? 'granted'
|
|
: await Notification.requestPermission();
|
|
if (granted !== 'granted') return;
|
|
sub = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
|
});
|
|
}
|
|
|
|
// Always re-register subscription with the server (keeps it fresh on mobile)
|
|
await fetch('/api/push/subscribe', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
body: JSON.stringify(sub.toJSON()),
|
|
});
|
|
console.log('[Push] Subscription registered');
|
|
} catch (e) {
|
|
console.warn('[Push] Subscription failed:', e.message);
|
|
}
|
|
};
|
|
|
|
registerPush();
|
|
|
|
// Bug A fix: re-register push subscription when app returns to foreground
|
|
// Mobile browsers can drop push subscriptions when the app is backgrounded
|
|
const handleVisibility = () => {
|
|
if (document.visibilityState === 'visible') registerPush();
|
|
};
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
|
}, []);
|
|
|
|
// 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, last_message_user_id: msg.user_id }
|
|
: g;
|
|
const updatedPrivate = prev.privateGroups.map(updateGroup)
|
|
.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);
|
|
});
|
|
return {
|
|
publicGroups: prev.publicGroups.map(updateGroup),
|
|
privateGroups: updatedPrivate,
|
|
};
|
|
});
|
|
// Don't badge own messages
|
|
if (msg.user_id === user?.id) return;
|
|
// Bug C fix: count unread even in the active group when window is hidden/minimized
|
|
const groupIsActive = msg.group_id === activeGroupId;
|
|
const windowHidden = document.visibilityState === 'hidden';
|
|
setUnreadGroups(prev => {
|
|
if (groupIsActive && !windowHidden) return prev; // visible & active: no badge
|
|
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);
|
|
|
|
// Bug B fix: on reconnect, reload groups to catch any messages missed while offline
|
|
const handleReconnect = () => { loadGroups(); };
|
|
socket.on('connect', handleReconnect);
|
|
|
|
// Bug B fix: also reload on visibility restore if socket is already connected
|
|
const handleVisibility = () => {
|
|
if (document.visibilityState === 'visible' && socket.connected) {
|
|
loadGroups();
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
|
|
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.off('connect', handleReconnect);
|
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
};
|
|
}, [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 AND PWA app badge with total unread count
|
|
useEffect(() => {
|
|
const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0);
|
|
// Strip any existing badge prefix to get the clean base title
|
|
const base = document.title.replace(/^\(\d+\)\s*/, '');
|
|
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
|
|
// PWA app icon badge (Chrome/Edge desktop + Android, Safari 16.4+)
|
|
if ('setAppBadge' in navigator) {
|
|
if (totalUnread > 0) {
|
|
navigator.setAppBadge(totalUnread).catch(() => {});
|
|
} else {
|
|
navigator.clearAppBadge().catch(() => {});
|
|
}
|
|
}
|
|
}, [unreadGroups]);
|
|
|
|
const activeGroup = [
|
|
...(groups.publicGroups || []),
|
|
...(groups.privateGroups || [])
|
|
].find(g => g.id === activeGroupId);
|
|
|
|
return (
|
|
<div className="chat-layout">
|
|
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
|
|
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
|
|
|
|
<div className="chat-body">
|
|
{(!isMobile || showSidebar) && (
|
|
<Sidebar
|
|
groups={groups}
|
|
activeGroupId={activeGroupId}
|
|
onSelectGroup={selectGroup}
|
|
notifications={notifications}
|
|
unreadGroups={unreadGroups}
|
|
onNewChat={() => setModal('newchat')}
|
|
onProfile={() => setModal('profile')}
|
|
onUsers={() => setModal('users')}
|
|
onSettings={() => setModal('settings')}
|
|
onGroupsUpdated={loadGroups}
|
|
isMobile={isMobile}
|
|
onAbout={() => setModal('about')}
|
|
onHelp={() => setModal('help')}
|
|
/>
|
|
)}
|
|
|
|
{(!isMobile || !showSidebar) && (
|
|
<ChatWindow
|
|
group={activeGroup}
|
|
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
|
|
onGroupUpdated={loadGroups}
|
|
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
|
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
|
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
|
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
|
</div>
|
|
);
|
|
}
|