diff --git a/src/apps/lobby/components/LobbyGame.tsx b/src/apps/lobby/components/LobbyGame.tsx
index 821f7f1..7727404 100644
--- a/src/apps/lobby/components/LobbyGame.tsx
+++ b/src/apps/lobby/components/LobbyGame.tsx
@@ -77,7 +77,7 @@ const LobbyGame = (props: UseLobbyProps) => {
)}
- {chat.available && (
+ {room !== null && (
({
}))
vi.mock('@/apps/thoughts/components/ThoughtsGame', () => ({ default: () => world
}))
vi.mock('@/apps/castle/components/CastleTable', () => ({ default: () => table
}))
+vi.mock('../RoomChat', () => ({ default: () => chat
}))
vi.mock('@/apps/golf/components/GolfTable', () => ({
default: ({ shareUrl }: { shareUrl: string | null }) => golf table {shareUrl}
}))
@@ -70,6 +71,17 @@ describe('LobbyGame', () => {
expect(screen.getByRole('complementary', { name: 'lobby' })).toBeTruthy()
})
+ it('chat is up whenever the session is in a room, before anyone has spoken', () => {
+ const { rerender } = render()
+ expect(screen.queryByText('chat')).toBeNull()
+ state.room = { roomId: 'R1', players: [], games: [] }
+ rerender()
+ expect(screen.getByText('chat')).toBeTruthy()
+ state.room = null
+ rerender()
+ expect(screen.queryByText('chat')).toBeNull()
+ })
+
it('a lost hub is said, not hidden', () => {
state.lost = 'Lost connection to the games hub'
render()
diff --git a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
index fb2935d..ba08b30 100644
--- a/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
+++ b/src/apps/lobby/components/__tests__/LobbyPanel.test.tsx
@@ -29,7 +29,7 @@ const lobby = (over: Partial = {}): UseLobby =>
connected: true,
lost: null,
room: null,
- chat: { messages: [], available: false, replayUpTo: 0, rejection: null },
+ chat: { messages: [], replayUpTo: 0, rejection: null },
notice: '',
roomCode: '',
setRoomCode: vi.fn(),
diff --git a/src/hooks/__tests__/useLobby.test.tsx b/src/hooks/__tests__/useLobby.test.tsx
index 52b3363..707f30b 100644
--- a/src/hooks/__tests__/useLobby.test.tsx
+++ b/src/hooks/__tests__/useLobby.test.tsx
@@ -324,13 +324,11 @@ describe('useLobby', () => {
expect(result.current.castle.opening).toBe(false)
})
- it('chat appears only once the wire delivers it, merged by id, and is the room\'s', async () => {
+ it("chat is merged by id, and is the room's", async () => {
const { result, ws } = await open()
act(() => ws.receive('roomState', roomState('R1')))
- expect(result.current.chat.available).toBe(false)
const message = { messageId: 3, playerId: 'bob', text: 'hi', sentAtUnixMillis: 1 }
act(() => ws.receive('roomChatHistory', { messages: [message] }))
- expect(result.current.chat.available).toBe(true)
expect(result.current.chat.replayUpTo).toBe(3)
act(() => ws.receive('roomChat', message))
expect(result.current.chat.messages).toHaveLength(1)
@@ -339,7 +337,7 @@ describe('useLobby', () => {
act(() => ws.receive('commandRejected', { reason: 'slow down' }))
expect(result.current.chat.rejection).toEqual({ seq: 1, reason: 'slow down' })
act(() => ws.receive('roomState', roomState('R2')))
- expect(result.current.chat).toEqual({ messages: [], available: false, replayUpTo: 0, rejection: null })
+ expect(result.current.chat).toEqual({ messages: [], replayUpTo: 0, rejection: null })
act(() => ws.receive('roomChat', message))
act(() => ws.receive('roomLeft', { roomId: 'R2' }))
expect(result.current.chat.messages).toEqual([])
@@ -379,6 +377,70 @@ describe('useLobby', () => {
}
})
+ it('a reconnect that lands outside the room drops the room it was showing', async () => {
+ vi.useFakeTimers()
+ try {
+ const { result, pathname } = mount({ permalinkRoomId: 'R1' }, '/games/room/R1')
+ let ws!: FakeWebSocket
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ ws = FakeWebSocket.instances[0]
+ ws.open()
+ ws.receive('sessionReady', { playerId: 'alice', resumed: true, roomId: 'R1' })
+ })
+ act(() => ws.receive('roomState', roomState('R1')))
+ act(() => ws.receive('roomChat', { messageId: 1, playerId: 'bob', text: 'hi', sentAtUnixMillis: 1 }))
+ expect(result.current.room?.roomId).toBe('R1')
+ act(() => ws.close())
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ // The room was reaped while the seat was away: the hub admits the
+ // session into the plaza, and the link's room is refused.
+ const next = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
+ act(() => {
+ next.open()
+ next.receive('sessionReady', { playerId: 'alice', resumed: true })
+ })
+ expect(result.current.room).toBeNull()
+ expect(result.current.chat.messages).toEqual([])
+ expect(next.lastSent()).toEqual({ event: 'joinRoom', payload: { roomId: 'R1' } })
+ act(() => next.receive('commandRejected', { reason: 'room not found' }))
+ expect(result.current.room).toBeNull()
+ expect(result.current.notice).toBe('Room R1 is gone')
+ expect(pathname()).toBe('/games')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('a reconnect back into the same room keeps the room on screen', async () => {
+ vi.useFakeTimers()
+ try {
+ const { result } = mount()
+ let ws!: FakeWebSocket
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ ws = FakeWebSocket.instances[0]
+ ws.open()
+ ws.receive('sessionReady', { playerId: 'alice', resumed: false })
+ })
+ act(() => ws.receive('roomState', roomState('R1')))
+ act(() => ws.close())
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ const next = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
+ act(() => {
+ next.open()
+ next.receive('sessionReady', { playerId: 'alice', resumed: true, roomId: 'R1' })
+ })
+ expect(result.current.room?.roomId).toBe('R1')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('a link refused while leaving the resumed room gives up rather than looping', async () => {
const { result, ws, pathname } = await open({ permalinkRoomId: 'R2' }, '/games/room/R2', 'R1')
expect(ws.lastSent()).toEqual({ event: 'leaveRoom', payload: {} })
diff --git a/src/hooks/useLobby.ts b/src/hooks/useLobby.ts
index 4227228..2ef597b 100644
--- a/src/hooks/useLobby.ts
+++ b/src/hooks/useLobby.ts
@@ -32,11 +32,11 @@ export const lobbyTablePath = (roomId: string, gameId: string) =>
const NOTICE_MS = 3000
+// The room's chat; it is open the whole time the session is in a room.
+// A fresh room has no history to replay, so the wire says nothing
+// until someone speaks.
export interface LobbyChat {
messages: ChatMessage[]
- // True once the room's wire has delivered chat (the join replay or a
- // live message); a UI ahead of its server renders no composer.
- available: boolean
replayUpTo: number
rejection: { seq: number; reason: string } | null
}
@@ -87,7 +87,7 @@ export const useLobby = ({
const [room, setRoom] = useState(null)
const [notice, setNotice] = useState('')
const [roomCode, setRoomCode] = useState('')
- const [chat, setChat] = useState({ messages: [], available: false, replayUpTo: 0, rejection: null })
+ const [chat, setChat] = useState({ messages: [], replayUpTo: 0, rejection: null })
const streamRef = useRef(null)
const noticeTimeoutRef = useRef(null)
@@ -118,7 +118,7 @@ export const useLobby = ({
}, [])
const resetChat = useCallback(() => {
- setChat({ messages: [], available: false, replayUpTo: 0, rejection: null })
+ setChat({ messages: [], replayUpTo: 0, rejection: null })
}, [])
// One link for the life of the hook; the renderer attaches to it when
@@ -209,6 +209,14 @@ export const useLobby = ({
switchRef.current = null
tablePendingRef.current = null
const here = ready.roomId ?? null
+ // A reconnect that lands somewhere other than the room this
+ // session was showing — the room reaped while it was away, or a
+ // fresh seat — leaves nothing of that room behind: a panel still
+ // showing it offers tables the hub refuses as "not in a room".
+ if (here !== roomIdRef.current) {
+ setRoom(null)
+ resetChat()
+ }
roomIdRef.current = here
const wanted = permalinkRef.current
if (wanted.roomId && wanted.roomId !== here) {
@@ -224,7 +232,7 @@ export const useLobby = ({
if (here !== null && !wanted.roomId) navigate(lobbyRoomPath(here), { replace: true })
enterWorld(here)
},
- [clearTables, enterWorld, navigate, onPlayerIdChange, world]
+ [clearTables, enterWorld, navigate, onPlayerIdChange, resetChat, world]
)
const handleRoom = useCallback(
@@ -326,11 +334,10 @@ export const useLobby = ({
onRoom: handleRoom,
onRoomLeft: handleRoomLeft,
onChat: message =>
- setChat(prev => ({ ...prev, available: true, messages: mergeChatMessages(prev.messages, [message]) })),
+ setChat(prev => ({ ...prev, messages: mergeChatMessages(prev.messages, [message]) })),
onChatHistory: messages =>
setChat(prev => ({
...prev,
- available: true,
messages: mergeChatMessages(prev.messages, messages),
replayUpTo: Math.max(prev.replayUpTo, ...messages.map(m => m.messageId))
})),