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
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user