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],
)