feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s

Builds on the cross-screen cache work: the global stream handler now keeps both
caches current no matter which screen is mounted, and invitations become a live
delta channel so the lobby's invitations list is fresh from any screen too.

Client (boundary already started):
- advanceCached now also folds opponent_joined into a not-currently-viewed game's
  cache via a new pure reducer applyOpponentJoined (extracted and reused by the
  mounted game board), so opening an open game that filled while you were elsewhere
  is flash-free.
- patchLobbyInvitation upserts a still-pending invitation and removes a terminal
  one (started/declined/cancelled/expired); the global notify handler calls it on
  the invitation / invitation_update sub-kinds.

Invitations delta channel (no wire/gateway/connector change — the notification
already carries the full invitation with id/status/game_id end to end):
- notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate
  constructor (shares encoding with NotificationInvitation). The Telegram connector
  renders no message for it, so a decline/cancel never becomes an out-of-app push.
- lobby: emit the changed invitation to every participant on respond (accept/decline),
  on the final accept's game start, and on cancel — so each participant's lobby patches
  its list in place. The authoritative list holds only pending invitations, so the
  client's pending-vs-terminal rule matches it exactly.

Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the
NotificationInvitationUpdate encoding unit test, and integration assertions that
decline/cancel/accept publish invitation_update to every participant. Full local
suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs:
ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
This commit is contained in:
Ilia Denisov
2026-06-14 11:22:47 +02:00
parent 5312b11f0e
commit 56dbf86472
13 changed files with 269 additions and 22 deletions
+29
View File
@@ -13,6 +13,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
)
// newInvitationService builds an invitation service over the shared pool, starting
@@ -97,6 +98,8 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
func TestInvitationAllAcceptStartsGame(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
@@ -118,6 +121,16 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
if final.Status != "started" || final.GameID == nil {
t.Fatalf("invitation not started: %+v", final)
}
// Every participant is told the invitation changed so their lobby drops the (now started) card
// from any screen, and the started-game notification lands too.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update", u)
}
}
if !pub.notified(invitees[0], notify.NotifyGameStarted) {
t.Errorf("invitee missing game_started")
}
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
if err != nil {
t.Fatalf("participants: %v", err)
@@ -130,6 +143,8 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
func TestInvitationDeclineCancels(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -143,6 +158,12 @@ func TestInvitationDeclineCancels(t *testing.T) {
if got.Status != "declined" || got.GameID != nil {
t.Fatalf("after decline: %+v", got)
}
// A decline kills the whole invitation: every participant is notified to drop it.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update after decline", u)
}
}
// A further response is refused.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
@@ -184,6 +205,8 @@ func TestInvitationBlockedInvitee(t *testing.T) {
func TestInvitationCancelByInviter(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
@@ -197,6 +220,12 @@ func TestInvitationCancelByInviter(t *testing.T) {
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
t.Fatalf("inviter cancel: %v", err)
}
// The cancellation reaches every participant so an invitee's lobby drops the card from any screen.
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
if !pub.notified(u, notify.NotifyInvitationUpdate) {
t.Errorf("participant %s missing invitation_update after cancel", u)
}
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
}
+37 -2
View File
@@ -136,6 +136,25 @@ func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game,
svc.pub.Publish(intents...)
}
// emitInvitationUpdate publishes a changed invitation to every participant (the inviter and all
// invitees), so each one's lobby patches its invitations list without a refetch: a still-pending
// invitation is upserted (an updated response) and a terminal one (started, declined, cancelled,
// expired) removed. It is in-app only — notify.NotifyInvitationUpdate carries no out-of-app push —
// so a withdrawal or decline never becomes a platform notification.
func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invitation) {
summary := svc.invitationSummary(ctx, inv)
recipients := make([]uuid.UUID, 0, len(inv.Invitees)+1)
recipients = append(recipients, inv.InviterID)
for _, iv := range inv.Invitees {
recipients = append(recipients, iv.AccountID)
}
intents := make([]notify.Intent, 0, len(recipients))
for _, id := range recipients {
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
}
svc.pub.Publish(intents...)
}
// invitationSummary projects an Invitation into the notify.InvitationSummary the event carries,
// resolving the inviter's and invitees' display names from the account store.
func (svc *InvitationService) invitationSummary(ctx context.Context, inv Invitation) notify.InvitationSummary {
@@ -251,7 +270,15 @@ func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationI
return Invitation{}, err
}
}
return svc.store.loadInvitation(ctx, invitationID)
inv, err := svc.store.loadInvitation(ctx, invitationID)
if err != nil {
return Invitation{}, err
}
// Tell every participant the invitation changed, so their lobby patches its list from any
// screen: an updated response is upserted, a terminal one (declined, or started after the final
// accept) removed. startGame already emitted the started-game notification separately.
svc.emitInvitationUpdate(ctx, inv)
return inv, nil
}
// startGame creates the game for a fully-accepted invitation and marks it started.
@@ -289,7 +316,15 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
// CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
if err := svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now()); err != nil {
return err
}
// The invitation is now cancelled: tell every participant so an invitee's lobby drops the card
// from any screen. Best-effort — a failed reload never fails the cancel.
if inv, err := svc.store.loadInvitation(ctx, invitationID); err == nil {
svc.emitInvitationUpdate(ctx, inv)
}
return nil
}
// GetInvitation loads an invitation with its invitees.
+14 -1
View File
@@ -182,8 +182,21 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
// so the client adds it to its lobby invitations list without a refetch.
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
return notificationInvitation(userID, NotifyInvitation, inv)
}
// NotificationInvitationUpdate builds the NotifyInvitationUpdate notification carrying a changed
// invitation, so the client patches its lobby invitations list without a refetch — upserting a
// still-pending one (an updated response) and removing one that reached a terminal status.
func NotificationInvitationUpdate(userID uuid.UUID, inv InvitationSummary) Intent {
return notificationInvitation(userID, NotifyInvitationUpdate, inv)
}
// notificationInvitation builds a KindNotification intent of the given sub-kind carrying the
// invitation summary; it backs both the new-invitation and invitation-update notifications.
func notificationInvitation(userID uuid.UUID, sub string, inv InvitationSummary) Intent {
b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyInvitation)
k := b.CreateString(sub)
invOff := buildInvitation(b, inv)
fb.NotificationEventStart(b)
fb.NotificationEventAddKind(b, k)
+7 -1
View File
@@ -46,7 +46,13 @@ const (
// game screen watching that opponent re-derives its "add to friends" state.
NotifyFriendDeclined = "friend_declined"
NotifyInvitation = "invitation"
NotifyGameStarted = "game_started"
// NotifyInvitationUpdate carries a changed invitation — an updated invitee response, or a
// terminal status (started, declined, cancelled, expired) — so the client patches its lobby
// invitations list without a refetch. Unlike NotifyInvitation (a brand-new invitation), it is
// in-app only: the Telegram connector renders no message for it, so a withdrawal or decline never
// becomes an out-of-app push.
NotifyInvitationUpdate = "invitation_update"
NotifyGameStarted = "game_started"
)
// Intent is one live event destined for a single user. Payload is the
+17
View File
@@ -181,6 +181,23 @@ func TestNotificationInvitationCarriesInvitation(t *testing.T) {
}
}
func TestNotificationInvitationUpdateCarriesTerminalInvitation(t *testing.T) {
uid := uuid.New()
inv := notify.InvitationSummary{ID: "inv-9", Variant: "scrabble_en", Status: "declined"}
in := notify.NotificationInvitationUpdate(uid, inv)
if in.Kind != notify.KindNotification {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsNotificationEvent(in.Payload, 0)
if string(ev.Kind()) != notify.NotifyInvitationUpdate {
t.Fatalf("sub-kind = %q, want %q", ev.Kind(), notify.NotifyInvitationUpdate)
}
got := ev.Invitation(nil)
if got == nil || string(got.Id()) != "inv-9" || string(got.Status()) != "declined" {
t.Fatalf("invitation wrong: %+v", got)
}
}
func TestNotificationAccountCarriesAccount(t *testing.T) {
uid := uuid.New()
in := notify.NotificationAccount(uid, notify.NotifyFriendRequest, notify.AccountRef{AccountID: "a-1", DisplayName: "Ann"})
+10 -5
View File
@@ -549,10 +549,11 @@ including the mover**, so the mover's own other devices and their lobby refresh
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
friend-added, friend-declined, invitation, invitation-update or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state),
and on an invitation create or its game start). **game-over** is emitted to every
and on an invitation create (**invitation**) or any later change to it (**invitation-update**: an
updated response, a decline, a cancel, or its game start — to every participant)). **game-over** is emitted to every
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it
also carries the mover's display name, their last action and the main word of a scoring play,
@@ -566,7 +567,10 @@ refetch); **your-turn** carries that move count as a consistency check; the **ga
notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation. The move-commit **response** (`submit_play` / `pass` /
carry the changed account / invitation, so the client patches its lobby lists in place: **invitation**
and **invitation-update** carry the full invitation, and the client upserts a still-pending one and
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
@@ -589,8 +593,9 @@ not the recipient's latest-login bot. It then asks the **Telegram connector** to
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a
response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
+4 -2
View File
@@ -53,8 +53,10 @@ Login uses `Screen`.
stream handler (`lib/app.svelte.ts`) is the one place that runs for every live event regardless of
the mounted screen, so it owns cross-screen freshness: it upserts the affected game's `GameView`
into the lobby snapshot (`patchLobbyGame`) on every game event — opponent move, game-over,
opponent-joined, and a match/started game (which it also adds) — and advances a not-currently-viewed
game's own cache from the move/over delta (`advanceCached`, skipping the game in view, whose mounted
opponent-joined, and a match/started game (which it also adds) — patches the lobby's invitations
list (`patchLobbyInvitation`: a still-pending invitation upserted, a terminal one removed) on the
`invitation` / `invitation_update` notifications, and advances a not-currently-viewed game's own
cache from the move/over/joined delta (`advanceCached`, skipping the game in view, whose mounted
board owns its cache). The game board additionally mirrors the player's **own** move and its own
`load()` into the lobby snapshot — the two updates no live event carries.
- **Telegram integration** (`lib/telegram.ts`): inside the Mini App the colour
+5 -4
View File
@@ -21,7 +21,7 @@
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, type DeltaResult } from '../lib/gamedelta';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
@@ -232,9 +232,10 @@
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
// The opponent took the empty seat: adopt the new participants and status in place,
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
if (view) {
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
setCachedGame(id, view, moves);
const r = applyOpponentJoined(cacheSnapshot(), e.state);
if (r) {
view = r.view;
setCachedGame(id, r.view, r.moves);
}
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
+17 -4
View File
@@ -27,7 +27,7 @@ import { connection, reportOffline, reportOnline, resetConnection } from './conn
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame } from './lobbycache';
import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -191,9 +191,16 @@ function openStream(): void {
if (c) setCachedGame(e.gameId, c.view, c.moves);
}
} else if (e.kind === 'opponent_joined') {
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot
// from any screen. A mounted game board adopts the change in place itself (game/Game.svelte).
if (e.state) patchLobbyGame(e.state.game);
// The opponent took an open game's empty seat: mirror the new status into the lobby snapshot,
// and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free.
// The mounted game board owns its cache (game/Game.svelte), so skip the game in view.
if (e.state) {
patchLobbyGame(e.state.game);
if (!viewingGame(e.gameId)) {
const c = advanceCached(getCachedGame(e.gameId), e);
if (c) setCachedGame(e.gameId, c.view, c.moves);
}
}
} else if (e.kind === 'match_found') {
// Seed both caches from the event's initial state so the game renders instantly on arrival
// and the new game is already in the lobby on a later return, then navigate.
@@ -209,6 +216,12 @@ function openStream(): void {
setCachedGame(e.state.game.id, e.state, []);
patchLobbyGame(e.state.game);
}
// An invitation created / updated / withdrawn elsewhere: keep the lobby's invitations list
// fresh from any screen — a new pending one is added, a consumed (started), declined,
// cancelled or expired one is removed (see patchLobbyInvitation).
if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
patchLobbyInvitation(e.invitation);
}
void refreshNotifications();
}
},
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { advanceCached, applyGameOver, applyMoveDelta, seedInitialState, type MoveDelta } from './gamedelta';
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';
@@ -141,3 +141,30 @@ describe('advanceCached', () => {
expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
});
});
describe('applyOpponentJoined', () => {
// joinedState is the refreshed StateView an opponent_joined event carries: the open game is now
// active and both seats are filled.
function joinedState(): StateView {
const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
] };
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
}
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
const res = applyOpponentJoined(cached, joinedState());
expect(res?.view.game.status).toBe('active');
expect(res?.view.game.seats).toHaveLength(2);
expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join
expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved
expect(res?.moves).toHaveLength(1);
});
it('returns undefined when nothing is cached for the game', () => {
expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined();
});
});
+14
View File
@@ -82,5 +82,19 @@ export function advanceCached(cached: CachedGame | undefined, e: PushEvent): Cac
return applyMoveDelta(cached, { move: e.move, game: e.game, bagLen: e.bagLen }).cache;
}
if (e.kind === 'game_over') return applyGameOver(cached, e.game).cache;
if (e.kind === 'opponent_joined' && e.state) return applyOpponentJoined(cached, e.state);
return undefined;
}
/**
* applyOpponentJoined adopts an opponent_joined event into a cached open game: it overlays the
* refreshed seats, status and player count from the event's StateView onto the cached game, leaving
* the board, the starter's rack and everything else intact (the join changes who is seated, not the
* position). It returns undefined when the game is not cached (the next open cold-loads it). It backs
* both the mounted game board's in-place update and the global handler's off-screen cache warming.
*/
export function applyOpponentJoined(cached: CachedGame | undefined, state: StateView): CachedGame | undefined {
if (!cached) return undefined;
const game: GameView = { ...cached.view.game, seats: state.game.seats, status: state.game.status, players: state.game.players };
return { view: { ...cached.view, game }, moves: cached.moves };
}
+62 -2
View File
@@ -1,6 +1,23 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { clearLobby, getLobby, patchLobbyGame, setLobby } from './lobbycache';
import type { AccountRef, GameView } from './model';
import { clearLobby, getLobby, patchLobbyGame, patchLobbyInvitation, setLobby } from './lobbycache';
import type { AccountRef, GameView, Invitation } from './model';
function invitation(id: string, status: string): Invitation {
return {
id,
inviter: { accountId: 'inv', displayName: 'Inv' },
invitees: [],
variant: 'scrabble_en',
turnTimeoutSecs: 300,
hintsAllowed: true,
hintsPerPlayer: 0,
multipleWordsPerTurn: true,
dropoutTiles: 'remove',
status,
gameId: '',
expiresAtUnix: 0,
};
}
function gameView(id: string, status: GameView['status'] = 'active', toMove = 0): GameView {
return {
@@ -59,3 +76,46 @@ describe('patchLobbyGame', () => {
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
});
describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [] });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
});
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
});
it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}
});
it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()).toBeNull();
});
it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming);
});
});
+25
View File
@@ -42,6 +42,31 @@ export function patchLobbyGame(g: GameView): void {
snapshot = { ...snapshot, games };
}
/**
* patchLobbyInvitation applies a live invitation change to the cached lobby's invitations list, so a
* change seen while the lobby is unmounted is reflected on its next render instead of a stale card
* lingering until the background refresh. A still-pending invitation is upserted (a new one
* prepended newest-first, an updated one replaced in place); an invitation that reached any terminal
* status (started, declined, cancelled, expired) is removed — the authoritative list holds only
* pending invitations. It is a no-op when there is no snapshot yet, or a terminal invitation is not
* in the list. The lobby re-renders from the snapshot, so the in-place upsert needs no re-sort.
*/
export function patchLobbyInvitation(inv: Invitation): void {
if (!snapshot) return;
const i = snapshot.invitations.findIndex((x) => x.id === inv.id);
let invitations: Invitation[];
if (inv.status !== 'pending') {
if (i === -1) return; // a terminal invitation already absent — nothing to remove
invitations = snapshot.invitations.filter((x) => x.id !== inv.id);
} else if (i === -1) {
invitations = [inv, ...snapshot.invitations];
} else {
invitations = snapshot.invitations.slice();
invitations[i] = inv;
}
snapshot = { ...snapshot, invitations };
}
/** clearLobby drops the cached lobby (called on logout). */
export function clearLobby(): void {
snapshot = null;