Files
rosterchirp/frontend/src/pages/Chat.jsx

669 lines
33 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 UserManagerPage from './UserManagerPage.jsx';
import GroupManagerPage from './GroupManagerPage.jsx';
import HostPanel from '../components/HostPanel.jsx';
import SettingsModal from '../components/SettingsModal.jsx';
import BrandingModal from '../components/BrandingModal.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 NavDrawer from '../components/NavDrawer.jsx';
import SchedulePage from '../components/SchedulePage.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 [onlineUserIds, setOnlineUserIds] = useState(new Set());
const [activeGroupId, setActiveGroupId] = useState(null);
const [chatHasText, setChatHasText] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Map());
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
const [drawerOpen, setDrawerOpen] = useState(false);
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false });
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]);
// Load feature flags + current user's group memberships on mount
const loadFeatures = useCallback(() => {
api.getSettings().then(({ settings }) => {
setFeatures(prev => ({
...prev,
branding: settings.feature_branding === 'true',
groupManager: settings.feature_group_manager === 'true',
scheduleManager: settings.feature_schedule_manager === 'true',
appType: settings.app_type || 'RosterChirp-Chat',
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
isHostDomain: settings.is_host_domain === 'true',
}));
}).catch(() => {});
api.getMyUserGroups().then(({ userGroups }) => {
setFeatures(prev => ({ ...prev, userGroupMemberships: (userGroups || []).map(g => g.id) }));
}).catch(() => {});
}, []);
useEffect(() => {
loadFeatures();
window.addEventListener('rosterchirp:settings-changed', loadFeatures);
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]);
// Register / refresh FCM push subscription
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const registerPush = async () => {
try {
if (Notification.permission === 'denied') return;
// Fetch Firebase config from backend (returns 503 if FCM not configured)
const configRes = await fetch('/api/push/firebase-config');
if (!configRes.ok) return;
const { apiKey, projectId, messagingSenderId, appId, vapidKey } = await configRes.json();
// Dynamically import the Firebase SDK (tree-shaken, only loaded when needed)
const { initializeApp, getApps } = await import('firebase/app');
const { getMessaging, getToken } = await import('firebase/messaging');
const firebaseApp = getApps().length
? getApps()[0]
: initializeApp({ apiKey, projectId, messagingSenderId, appId });
const firebaseMessaging = getMessaging(firebaseApp);
const reg = await navigator.serviceWorker.ready;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Do NOT call deleteToken() here. Deleting the token on every page load (or
// every visibility-change) forces Chrome to create a new Web Push subscription
// each time. During the brief window between delete and re-register the server
// still holds the old (now invalid) token, so any in-flight message fails to
// deliver. Passing serviceWorkerRegistration directly to getToken() is enough
// for Firebase to return the existing valid token without needing a refresh.
console.log('[Push] Requesting FCM token...');
const fcmToken = await getToken(firebaseMessaging, {
vapidKey,
serviceWorkerRegistration: reg,
});
if (!fcmToken) {
console.warn('[Push] getToken() returned null — notification permission may not be granted at OS level, or VAPID key is wrong');
return;
}
console.log('[Push] FCM token obtained:', fcmToken.slice(0, 30) + '...');
// Skip the server round-trip if this token is already registered.
// Avoids a redundant DB write on every tab-focus / visibility change.
const cachedToken = localStorage.getItem('rc_fcm_token');
if (cachedToken === fcmToken) {
console.log('[Push] Token unchanged — skipping subscribe');
return;
}
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
const subRes = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ fcmToken }),
});
if (!subRes.ok) {
const err = await subRes.json().catch(() => ({}));
console.warn('[Push] Subscribe failed:', err.error || subRes.status);
} else {
localStorage.setItem('rc_fcm_token', fcmToken);
console.log('[Push] FCM subscription registered successfully');
}
} catch (e) {
console.warn('[Push] FCM subscription failed:', e.message);
}
};
registerPush();
const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush();
};
const handlePushInit = () => registerPush();
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('rosterchirp:push-init', handlePushInit);
return () => {
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('rosterchirp:push-init', handlePushInit);
};
}, []);
// When a message is deleted, update the sidebar preview immediately.
// ChatWindow passes back the full post-delete messages array so we can derive
// the new latest non-deleted message without an extra API call.
const handleMessageDeleted = useCallback(({ groupId, messages: updatedMessages }) => {
const latest = [...updatedMessages]
.reverse()
.find(m => !m.is_deleted);
setGroups(prev => {
const updateGroup = (g) => {
if (g.id !== groupId) return g;
return {
...g,
last_message: latest ? (latest.content || (latest.image_url ? '📷 Image' : '')) : null,
last_message_at: latest ? latest.created_at : null,
last_message_user_id: latest ? latest.user_id : null,
};
};
return {
publicGroups: prev.publicGroups.map(updateGroup),
privateGroups: prev.privateGroups.map(updateGroup),
};
});
}, []);
// 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 if (notif.type === 'support') {
// A support request was submitted — reload groups so Support group appears in sidebar
loadGroups();
} 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),
};
});
};
// Session displaced: another login on the same device type kicked us out
const handleSessionDisplaced = ({ device: displacedDevice }) => {
// Only act if it's our device slot that was taken over
// (The server emits to user room so all sockets of this user receive it;
// our socket's device is embedded in the socket but we can't read it here,
// so we force logout unconditionally — the new session will reconnect cleanly)
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
};
// Online presence
const handleUserOnline = ({ userId }) => setOnlineUserIds(prev => new Set([...prev, Number(userId)]));
const handleUserOffline = ({ userId }) => setOnlineUserIds(prev => { const n = new Set(prev); n.delete(Number(userId)); return n; });
const handleUsersOnline = ({ userIds }) => setOnlineUserIds(new Set((userIds || []).map(Number)));
socket.on('user:online', handleUserOnline);
socket.on('user:offline', handleUserOffline);
socket.on('users:online', handleUsersOnline);
// Request current online list on connect
socket.emit('users:online');
socket.on('group:new', handleGroupNew);
socket.on('group:deleted', handleGroupDeleted);
socket.on('group:updated', handleGroupUpdated);
socket.on('session:displaced', handleSessionDisplaced);
// 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('user:online', handleUserOnline);
socket.off('user:offline', handleUserOffline);
socket.off('users:online', handleUsersOnline);
socket.off('connect', handleReconnect);
socket.off('session:displaced', handleSessionDisplaced);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
const selectGroup = (id) => {
// Warn if there's unsaved text in the message input and the user is switching conversations
if (chatHasText && id !== activeGroupId) {
const ok = window.confirm('You have unsaved text in the message box.\n\nContinue to discard it and open the new conversation, or Cancel to stay.');
if (!ok) return;
setChatHasText(false);
}
setActiveGroupId(id);
if (isMobile) {
setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
window.history.pushState({ rosterchirpChatOpen: true }, '');
}
// 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; });
};
// Handle browser back gesture on mobile — return to sidebar instead of exiting
useEffect(() => {
const handlePopState = (e) => {
if (isMobile && activeGroupId) {
setShowSidebar(true);
setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted
window.history.pushState({ rosterchirpChatOpen: true }, '');
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile, activeGroupId]);
// 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);
const isToolManager = user?.role === 'admin' || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid));
// Unread indicators for burger icon and nav drawer
const allGroupsFlat = [...(groups.publicGroups || []), ...(groups.privateGroups || [])];
const hasUnreadChat = allGroupsFlat.some(g =>
(g.type === 'public' || !g.is_managed) && (unreadGroups.get(g.id) || 0) > 0
);
const hasUnreadGroupMessages = (groups.privateGroups || []).some(g =>
g.is_managed && (unreadGroups.get(g.id) || 0) > 0
);
const hasAnyUnread = hasUnreadChat || hasUnreadGroupMessages;
if (page === 'users') {
return (
<div className="chat-layout">
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} hasUnread={hasAnyUnread} />
<div className="chat-body" style={{ overflow: 'hidden' }}>
<UserManagerPage isMobile={isMobile} onProfile={() => setModal('profile')} onHelp={() => setModal('help')} onAbout={() => setModal('about')} />
</div>
<NavDrawer
open={drawerOpen} onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('chat'); }}
onGroupMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groupmessages'); }}
onSchedule={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div>
);
}
if (page === 'groups') {
return (
<div className="chat-layout">
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} hasUnread={hasAnyUnread} />
<div className="chat-body" style={{ overflow: 'hidden' }}>
<GroupManagerPage isMobile={isMobile} onProfile={() => setModal('profile')} onHelp={() => setModal('help')} onAbout={() => setModal('about')} />
</div>
<NavDrawer
open={drawerOpen} onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('chat'); }}
onGroupMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groupmessages'); }}
onSchedule={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div>
);
}
if (page === 'groupmessages') {
return (
<div className="chat-layout">
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} onBurger={() => setDrawerOpen(true)} hasUnread={hasAnyUnread} />
<div className="chat-body">
{(!isMobile || showSidebar) && (
<Sidebar
groups={groups}
activeGroupId={activeGroupId}
onSelectGroup={selectGroup}
notifications={notifications}
unreadGroups={unreadGroups}
onNewChat={() => setModal('newchat')}
onProfile={() => setModal('profile')}
onUsers={() => { setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')}
onGroupManager={() => { setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
features={features}
onGroupsUpdated={loadGroups}
isMobile={isMobile}
onAbout={() => setModal('about')}
onHelp={() => setModal('help')}
onlineUserIds={onlineUserIds}
groupMessagesMode={true} />
)}
{(!isMobile || !showSidebar) && (
<ChatWindow
group={activeGroup}
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
onGroupUpdated={loadGroups}
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
onMessageDeleted={handleMessageDeleted}
onHasTextChange={setChatHasText}
onlineUserIds={onlineUserIds} />
)}
</div>
<NavDrawer
open={drawerOpen} onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('chat'); }}
onGroupMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groupmessages'); }}
onSchedule={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
</div>
);
}
if (page === 'hostpanel') {
return (
<div className="chat-layout">
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} hasUnread={hasAnyUnread} />
<div className="chat-body" style={{ overflow: 'hidden' }}>
<HostPanel onProfile={() => setModal('profile')} onHelp={() => setModal('help')} onAbout={() => setModal('about')} />
</div>
<NavDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('chat'); }}
onGroupMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groupmessages'); }}
onSchedule={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
features={features}
currentPage={page}
isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div>
);
}
if (page === 'schedule') {
return (
<div className="chat-layout">
<GlobalBar isMobile={isMobile} showSidebar={true} onBurger={() => setDrawerOpen(true)} hasUnread={hasAnyUnread} />
<div className="chat-body" style={{ overflow: 'hidden' }}>
<SchedulePage
isToolManager={isToolManager}
isMobile={isMobile}
features={features}
onProfile={() => setModal('profile')}
onHelp={() => setModal('help')}
onAbout={() => setModal('about')} />
</div>
<NavDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('chat'); }}
onGroupMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groupmessages'); }}
onSchedule={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
features={features}
currentPage={page}
isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'mobilegroupmanager' && (
<div style={{ position:'fixed',inset:0,zIndex:200,background:'var(--background)' }}>
<MobileGroupManager onClose={() => setModal(null)}/>
</div>
)}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
</div>
);
}
return (
<div className="chat-layout">
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} onBurger={() => setDrawerOpen(true)} hasUnread={hasAnyUnread} />
<div className="chat-body">
{(!isMobile || showSidebar) && (
<Sidebar
groups={groups}
activeGroupId={activeGroupId}
onSelectGroup={selectGroup}
notifications={notifications}
unreadGroups={unreadGroups}
onNewChat={() => setModal('newchat')}
onProfile={() => setModal('profile')}
onUsers={() => { setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')}
onGroupManager={() => { setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
features={features}
onGroupsUpdated={loadGroups}
isMobile={isMobile}
onAbout={() => setModal('about')}
onHelp={() => setModal('help')}
onlineUserIds={onlineUserIds}
groupMessagesMode={false} />
)}
{(!isMobile || !showSidebar) && (
<ChatWindow
group={activeGroup}
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
onGroupUpdated={loadGroups}
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
onMessageDeleted={handleMessageDeleted}
onHasTextChange={setChatHasText}
onlineUserIds={onlineUserIds} />
)}
</div>
<NavDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('chat'); }}
onGroupMessages={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groupmessages'); }}
onSchedule={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onScheduleManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('schedule'); }}
onGroupManager={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('groups'); }}
onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
features={features}
currentPage={page}
isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal 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>
);
}