This commit is contained in:
2026-03-09 14:36:19 -04:00
parent f37fe0086f
commit 42ad779750
40 changed files with 1928 additions and 593 deletions

View File

@@ -1,12 +1,94 @@
.chat-layout {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--background);
}
/* Global top bar */
.global-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
height: 72px;
min-height: 72px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 20;
flex-shrink: 0;
}
.global-bar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.global-bar-logo {
width: 40px;
height: 40px;
object-fit: contain;
border-radius: 6px;
flex-shrink: 0;
}
.global-bar-title {
font-size: 22px;
font-weight: 700;
color: var(--primary);
}
.global-bar-offline {
display: flex;
align-items: center;
gap: 6px;
color: #e53935;
font-size: 13px;
font-weight: 500;
}
.offline-label {
font-size: 13px;
}
/* Body below global bar */
.chat-body {
display: flex;
flex: 1;
min-height: 0; /* allows body to shrink when mobile keyboard resizes viewport */
overflow: hidden;
}
@media (max-width: 767px) {
.chat-layout {
position: relative;
}
.chat-body {
overflow: hidden;
min-height: 0;
}
.global-bar {
height: 56px;
min-height: 56px;
}
}
[data-theme="dark"] .global-bar {
background: var(--surface);
border-color: var(--border);
}
.no-chat-selected {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
font-size: 14px;
gap: 4px;
user-select: none;
}

View File

@@ -9,6 +9,8 @@ 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 './Chat.css';
function urlBase64ToUint8Array(base64String) {
@@ -99,7 +101,8 @@ export default function Chat() {
privateGroups: prev.privateGroups.map(updateGroup),
};
});
// Increment unread count for the group if not currently viewing it
// 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);
@@ -110,14 +113,8 @@ export default function Chat() {
const handleNotification = (notif) => {
if (notif.type === 'private_message') {
// Private message unread is already handled by handleNewMsg above
// (kept for push notification path when socket is not the source)
setUnreadGroups(prev => {
if (notif.groupId === activeGroupId) return prev;
const next = new Map(prev);
next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1);
return next;
});
// 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);
@@ -127,11 +124,51 @@ export default function Chat() {
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]);
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
const selectGroup = (id) => {
setActiveGroupId(id);
@@ -141,6 +178,13 @@ export default function Chat() {
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 || [])
@@ -148,33 +192,42 @@ export default function Chat() {
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}
/>
)}
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
{(!isMobile || !showSidebar) && (
<ChatWindow
group={activeGroup}
onBack={isMobile ? () => setShowSidebar(true) : null}
onGroupUpdated={loadGroups}
/>
)}
<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')}
/>
)}
{(!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)} />}
</div>
);
}