P12: persistent live session across navigation
- Player room connection lifted out of the /play route to an app-level hook (usePlayerConnection in RootLayout), keyed by a persisted joinIntent — so a player stays connected while browsing their sheet, the compendium, anywhere, and a reload reconnects automatically. - Header "● Live: CODE · Leave" badge (PlayerSessionBadge); leaving is explicit (leaveRoom clears intent + disconnects). /play renders the table from the store. - Realtime e2e: player navigates away, badge persists, returns to a synced table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import type { Campaign, Character } from '@/lib/schemas';
|
||||
import { localSync } from '@/lib/sync';
|
||||
import { buildSnapshot } from '@/lib/sync/snapshot';
|
||||
import { joinSession, stopSession, claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
|
||||
import { claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
@@ -23,8 +23,16 @@ function roomParam(): string | null {
|
||||
}
|
||||
|
||||
export function PlayerViewPage() {
|
||||
// A player joining via link (?room=CODE) has no local campaign — networked mode.
|
||||
if (roomParam()) return <NetworkedPlayerView room={roomParam()!} />;
|
||||
// Joining via link (?room=CODE) records a persistent join intent; the app-level
|
||||
// connection (RootLayout) keeps it alive across navigation. We render the
|
||||
// networked table whenever an intent exists, even without ?room in the URL.
|
||||
const joinIntent = useSessionStore((s) => s.joinIntent);
|
||||
const room = roomParam();
|
||||
useEffect(() => {
|
||||
if (room && room !== joinIntent?.joinCode) useSessionStore.getState().setJoinIntent({ joinCode: room });
|
||||
}, [room, joinIntent?.joinCode]);
|
||||
|
||||
if (joinIntent || room) return <NetworkedPlayerView />;
|
||||
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
@@ -64,8 +72,10 @@ function LocalPlayerView({ campaign }: { campaign: Campaign }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Networked player view — joins a GM's session over the network. */
|
||||
function NetworkedPlayerView({ room }: { room: string }) {
|
||||
/** Networked player view — renders the live table. The connection itself is kept
|
||||
* alive app-wide by usePlayerConnection (RootLayout), so this view is just UI. */
|
||||
function NetworkedPlayerView() {
|
||||
const room = useSessionStore((s) => s.joinIntent?.joinCode) ?? '';
|
||||
const status = useSessionStore((s) => s.status);
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const snapshot = usePlayerSessionStore((s) => s.snapshot);
|
||||
@@ -79,16 +89,11 @@ function NetworkedPlayerView({ room }: { room: string }) {
|
||||
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
||||
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
|
||||
|
||||
useEffect(() => {
|
||||
joinSession(room);
|
||||
return () => stopSession();
|
||||
}, [room]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error?.toLowerCase().includes('password') && !needPw) {
|
||||
setNeedPw(true);
|
||||
const pw = window.prompt('This session needs a password:')?.trim();
|
||||
if (pw) { joinSession(room, pw); setNeedPw(false); }
|
||||
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); }
|
||||
}
|
||||
}, [error, needPw, room]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user