feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.

- backend: a directional blockExists guard across chat/nudge/friends/invitations
  (store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
  the matchmaker excludes a block-related pair (both directions) from auto-match;
  user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
  (red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
  composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
This commit is contained in:
Ilia Denisov
2026-06-18 11:50:34 +02:00
parent 9074417762
commit 81b9e1529e
34 changed files with 1191 additions and 109 deletions
+66 -8
View File
@@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if requesterID == addresseeID {
return ErrSelfRelation
}
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
if err != nil {
return err
}
theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID)
if err != nil {
return err
}
@@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
if blocked || addressee.BlockFriendRequests {
if iBlockThem {
// The requester has blocked the addressee — they are the blocker and aware of it.
return ErrRequestBlocked
}
// When the addressee has blocked the requester, the request still proceeds and persists
// by the normal logic below, but it is never delivered and the blocker never sees it
// (ListIncomingRequests filters it out) — the blocked requester must not learn of the
// block. The flag gates only the notification; it also bypasses the addressee's
// block_friend_requests toggle so the suppressed request looks ordinary to the requester.
suppressed := theyBlockMe
if !suppressed && addressee.BlockFriendRequests {
return ErrRequestBlocked
}
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
@@ -102,7 +116,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
if !suppressed {
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
}
return nil
}
}
@@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
}
return err
}
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
if !suppressed {
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
}
return nil
}
@@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID)
return svc.store.deleteFriendship(ctx, accountID, otherID)
}
// ListFriends returns the account IDs that are accepted friends of accountID.
// ListFriends returns the account IDs that are accepted friends of accountID, with any
// the caller has blocked filtered out: a block overrides — but does not delete — the
// friendship, so the blocker stops seeing the blocked friend while the blocked user's own
// list still shows the blocker (they never notice).
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listFriends(ctx, accountID)
ids, err := svc.store.listFriends(ctx, accountID)
if err != nil {
return nil, err
}
return svc.dropBlocked(ctx, accountID, ids)
}
// ListIncomingRequests returns the account IDs that have a live (not yet expired)
// pending friend request awaiting accountID's response.
// pending friend request awaiting accountID's response, with any from a requester the
// caller has blocked filtered out (their suppressed request stays stored but unseen).
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
if err != nil {
return nil, err
}
return svc.dropBlocked(ctx, accountID, ids)
}
// dropBlocked removes from ids every account the viewer has blocked. It keeps the
// asymmetric block one-directional: the blocker stops seeing those they blocked in their
// friend / incoming-request lists, while the blocked user's lists are never filtered.
func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
if len(ids) == 0 {
return ids, nil
}
blockedIDs, err := svc.store.listBlocks(ctx, viewer)
if err != nil {
return nil, err
}
if len(blockedIDs) == 0 {
return ids, nil
}
blocked := make(map[uuid.UUID]struct{}, len(blockedIDs))
for _, id := range blockedIDs {
blocked[id] = struct{}{}
}
out := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
if _, b := blocked[id]; b {
continue
}
out = append(out, id)
}
return out, nil
}
// ListOutgoingRequests returns the account IDs the caller has already requested and