From 85dc83f4e5db1fb83e6c6bc825aa29bbee92b7f1 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sun, 16 Aug 2026 16:37:40 -0600 Subject: [PATCH] Fix presence showing offline on non-chat pages, and a room-join bug it exposed The WebSocket connection lived entirely inside ChatShellPage, so navigating to /admin (which never opens its own connection) unmounted it -- the server correctly marked the user offline since the connection genuinely closed, even though they were still logged in and using the app. New ChatSocketContext.tsx hoists the connection to App.tsx, shared across every authenticated route via a single provider (keyed by user id, so a logout/login as a different account gets a clean reconnect rather than an old connection lingering under a new identity) instead of living inside whichever page happens to be mounted. Verifying that fix surfaced a second, independent bug: #31's visibility handling had gated the *explicit* joinRoom/leaveRoom calls (fired when a room actually mounts/unmounts in the UI) on the same isVisibleRef check meant for automatic background/foreground transitions. That's wrong -- a room can only be opened by a real user interaction, which can't happen on a genuinely backgrounded tab, so gating it too meant a stale or momentarily-wrong visibility reading at mount time could silently skip the join with nothing to ever retry it. joinRoom/leaveRoom now always send immediately; only the automatic hide/show transitions and the reconnect replay stay gated on visibility, which is what #31 actually needed. Verified both end-to-end in the browser: navigating to /admin via real in-app navigation (not a reload) keeps the presence dot online, confirmed via direct Redis inspection and the /api/users/online endpoint; opening a room and sending a message works immediately afterward. Co-Authored-By: Claude Sonnet 5 --- frontend/src/App.tsx | 82 +++++++++++++--------- frontend/src/context/ChatSocketContext.tsx | 25 +++++++ frontend/src/pages/ChatShellPage.tsx | 5 +- frontend/src/ws/useChatSocket.ts | 17 ++++- 4 files changed, 92 insertions(+), 37 deletions(-) create mode 100644 frontend/src/context/ChatSocketContext.tsx 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], )