feat(social): hide social controls on a deleted opponent's seat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s

A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
This commit is contained in:
Ilia Denisov
2026-07-27 17:06:10 +02:00
parent 9506f89d9e
commit 9471341a0e
30 changed files with 375 additions and 23 deletions
+10
View File
@@ -28,6 +28,16 @@ func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) e
if blockerID == blockedID {
return ErrSelfRelation
}
// A deleted account sends nothing there is anything left to suppress, and the block would
// outlive the retention purge as a dangling row: refuse it. The client already hides the
// control on a deleted seat; this guard catches an older one.
blocked, err := svc.accounts.GetByID(ctx, blockedID)
if err != nil {
return err
}
if blocked.Deleted {
return ErrAccountDeleted
}
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err
}
+5
View File
@@ -73,6 +73,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
if addressee.Deleted {
// The addressee deleted their account: there is nobody to answer the request, so it is
// refused rather than left pending forever.
return ErrAccountDeleted
}
if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked
+5
View File
@@ -62,6 +62,11 @@ var (
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
// request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends")
// ErrAccountDeleted is returned when a social action targets a deleted (tombstoned)
// account: nobody is behind it any more, so it can be neither befriended nor blocked.
// The client already hides those controls on a deleted seat; this is the server source
// of truth, and the guard an older client hits.
ErrAccountDeleted = errors.New("social: this account has been deleted")
// ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the