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