diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 607e35a..83fd2c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' -import { AuthProvider } from './context/AuthContext' +import { AuthProvider, useAuth } from './context/AuthContext' +import { ChatSocketProvider } from './context/ChatSocketContext' import { AdminRoute } from './components/AdminRoute' import { ProtectedRoute } from './components/ProtectedRoute' import { UpdateBanner } from './components/UpdateBanner' @@ -10,41 +11,58 @@ import { ResetPasswordPage } from './pages/ResetPasswordPage' import { ChatShellPage } from './pages/ChatShellPage' import { AdminPage } from './pages/AdminPage' +function AppRoutes() { + const routes = ( + + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ) + + // Only while actually logged in -- and keyed by user id so switching + // which account is logged in (same tab) tears down and re-establishes a + // fresh connection rather than an old one lingering under a new + // identity. Wraps every authenticated route (not just ChatShellPage), + // since the connection backs the presence indicator and cross-page + // signals (e.g. "added to a room") that matter regardless of which page + // is currently open. + const { user } = useAuth() + if (!user) return routes + return {routes} +} + function App() { return ( - - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - + ) } diff --git a/frontend/src/context/ChatSocketContext.tsx b/frontend/src/context/ChatSocketContext.tsx new file mode 100644 index 0000000..8bef68b --- /dev/null +++ b/frontend/src/context/ChatSocketContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useCallback, useContext, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { useChatSocket, type ChatSocketHandle } from '../ws/useChatSocket' + +const ChatSocketContext = createContext(undefined) + +// One connection for the whole authenticated session, not just whichever +// page happens to be mounted -- previously this lived inside +// ChatShellPage, so navigating to a page that isn't ChatShellPage (e.g. +// /admin) unmounted it, closing the connection. The server correctly +// read that as "this user is no longer connected," which made a logged-in +// admin looking at the admin page show up as offline everywhere else +// (the presence dot reads this same connection). +export function ChatSocketProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate() + const onUnauthenticated = useCallback(() => navigate('/login'), [navigate]) + const socket = useChatSocket({ onUnauthenticated }) + return {children} +} + +export function useChatSocketContext(): ChatSocketHandle { + const ctx = useContext(ChatSocketContext) + if (!ctx) throw new Error('useChatSocketContext must be used within a ChatSocketProvider') + return ctx +} diff --git a/frontend/src/pages/ChatShellPage.tsx b/frontend/src/pages/ChatShellPage.tsx index bfe228d..7eb7a51 100644 --- a/frontend/src/pages/ChatShellPage.tsx +++ b/frontend/src/pages/ChatShellPage.tsx @@ -10,9 +10,9 @@ import { RoomInfoPanel } from '../components/RoomInfoPanel' import { Sidebar } from '../components/Sidebar' import { TopBar } from '../components/TopBar' import { useAuth } from '../context/AuthContext' +import { useChatSocketContext } from '../context/ChatSocketContext' import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth' import type { MyRoomItem, RoomMember } from '../types' -import { useChatSocket } from '../ws/useChatSocket' import './ChatShellPage.css' type ModalKind = 'new' | 'browse' | null @@ -57,8 +57,7 @@ export function ChatShellPage() { refreshRooms().catch(() => {}) }, [refreshRooms]) - const onSocketUnauthenticated = useCallback(() => navigate('/login'), [navigate]) - const socket = useChatSocket({ onUnauthenticated: onSocketUnauthenticated }) + const socket = useChatSocketContext() useEffect( () => diff --git a/frontend/src/ws/useChatSocket.ts b/frontend/src/ws/useChatSocket.ts index d3d608e..ffb4736 100644 --- a/frontend/src/ws/useChatSocket.ts +++ b/frontend/src/ws/useChatSocket.ts @@ -135,7 +135,20 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { const joinRoom = useCallback( (roomId: string) => { desiredRoomsRef.current.add(roomId) - if (isVisibleRef.current) sendRoomFrame('join', roomId) + // Unconditional, not gated on isVisibleRef: this fires from a + // component actually mounting (opening a room in the UI), which by + // definition only happens while the user is interacting with the + // page -- a genuinely backgrounded tab can't run the click handler + // that leads here in the first place. Gating this too (rather than + // only the automatic hide/show transitions below) meant a stale or + // momentarily-wrong visibilityState at mount time could silently + // skip the join entirely, with nothing to ever retry it. Also + // self-corrects isVisibleRef -- opening a room this way is itself + // stronger evidence of visibility than whatever the ref currently + // holds, so a wrong/stale `false` doesn't also skip replaying this + // join on a later reconnect (which does still check the ref). + isVisibleRef.current = true + sendRoomFrame('join', roomId) }, [sendRoomFrame], ) @@ -143,7 +156,7 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { const leaveRoom = useCallback( (roomId: string) => { desiredRoomsRef.current.delete(roomId) - if (isVisibleRef.current) sendRoomFrame('leave', roomId) + sendRoomFrame('leave', roomId) }, [sendRoomFrame], )