Initial Commit
This commit is contained in:
171
frontend/src/pages/Chat.jsx
Normal file
171
frontend/src/pages/Chat.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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 './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 Set());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat'
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
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) => {
|
||||
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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotification = (notif) => {
|
||||
if (notif.type === 'private_message') {
|
||||
// Show unread dot on private group in sidebar (if not currently viewing it)
|
||||
setUnreadGroups(prev => {
|
||||
if (notif.groupId === activeGroupId) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(notif.groupId);
|
||||
return next;
|
||||
});
|
||||
} 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);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMsg);
|
||||
socket.off('notification:new', handleNotification);
|
||||
};
|
||||
}, [socket, toast]);
|
||||
|
||||
const selectGroup = (id) => {
|
||||
setActiveGroupId(id);
|
||||
if (isMobile) setShowSidebar(false);
|
||||
// Clear notifications for this group
|
||||
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||
setUnreadGroups(prev => { const next = new Set(prev); next.delete(id); return next; });
|
||||
};
|
||||
|
||||
const activeGroup = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
].find(g => g.id === activeGroupId);
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
{(!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 || !showSidebar) && (
|
||||
<ChatWindow
|
||||
group={activeGroup}
|
||||
onBack={isMobile ? () => setShowSidebar(true) : null}
|
||||
onGroupUpdated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user