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
+124 -13
View File
@@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err := Clean(body); err != nil {
return Message{}, err
}
// A disguised robot opponent never reads chat, so its recipient bit is born clear.
// Blocker-side guard: a sender whose only opponents are people they have blocked cannot
// post (the composer is hidden client-side; this is the server-side counterpart). In a
// multi-player game with a non-blocked opponent the post is allowed and reaches them.
if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil {
return Message{}, err
} else if guard {
return Message{}, ErrRecipientBlocked
}
// Recipients whose copy is born read so it never shows as unread: a disguised robot
// opponent (never opens the chat) and any recipient who has blocked the sender — the
// latter is also dropped from live delivery below, so the block stays invisible to the
// blocked sender while the blocker sees nothing.
autoRead, err := svc.robotRecipients(ctx, seats, senderID)
if err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead))
suppressed, err := svc.blockedRecipients(ctx, seats, senderID)
if err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed)))
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindMessage)
svc.emitChat(seats, senderID, msg)
svc.emitChat(seats, senderID, msg, suppressed)
return msg, nil
}
@@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn
}
// The awaited player is the nudge's sole recipient.
var target uuid.UUID
if toMove >= 0 && toMove < len(seats) {
target = seats[toMove]
}
// Blocker-side guard: a sender who has blocked the awaited player cannot nudge them
// (the control is hidden client-side; this is the server-side counterpart).
if target != uuid.Nil {
if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil {
return Message{}, err
} else if guard {
return Message{}, ErrRecipientBlocked
}
}
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
if err != nil {
return Message{}, err
@@ -153,16 +182,30 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return Message{}, ErrNudgeTooSoon
}
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
// Suppress when the awaited player has blocked the sender: the nudge still persists and
// counts toward the once-per-hour cooldown (the sender notices nothing) but is born read
// (mask 0) and never delivered, so the blocker sees no nudge.
var suppressed bool
if target != uuid.Nil {
suppressed, err = svc.store.blockExists(ctx, target, senderID)
if err != nil {
return Message{}, err
}
}
var mask int16
if !suppressed && target != uuid.Nil {
mask = int16(1) << uint(toMove)
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, mask)
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindNudge)
if toMove >= 0 && toMove < len(seats) {
if !suppressed && target != uuid.Nil {
// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
nudge := notify.Nudge(target, gameID, senderID, senderName)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
}
@@ -187,12 +230,13 @@ func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID,
return false, nil
}
// emitChat pushes a chat message to every seated player except the sender
// (best-effort live delivery; the recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
// emitChat pushes a chat message to every seated player except the sender and any
// recipient in suppressed — a recipient who has blocked the sender, who must never see
// it (best-effort live delivery; the other recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) {
intents := make([]notify.Intent, 0, len(seats))
for _, id := range seats {
if id == senderID {
if id == senderID || suppressed[id] {
continue
}
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
@@ -208,8 +252,9 @@ func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID)
}
// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has a block with (either
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
// seated player. Messages from a sender the viewer has blocked are dropped — one
// direction only, so a blocked player still sees the blocker's messages and never
// notices the block — and if the viewer has disabled chat only nudges remain.
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
@@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([
if seat == viewerID {
continue
}
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
yes, err := svc.store.blockExists(ctx, viewerID, seat)
if err != nil {
return nil, err
}
@@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send
return robots, nil
}
// blockedRecipients returns the seated recipients (every non-empty seat but the sender)
// that have blocked the sender. Their copy of a message or nudge is born read and is not
// delivered — the store-but-hide that keeps a block invisible to the blocked sender while
// denying the blocker any sight of what they send.
func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
var out map[uuid.UUID]bool
for _, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
yes, err := svc.store.blockExists(ctx, id, senderID)
if err != nil {
return nil, err
}
if yes {
if out == nil {
out = make(map[uuid.UUID]bool)
}
out[id] = true
}
}
return out, nil
}
// senderBlocksEveryOpponent reports whether senderID has blocked every other seated
// player (and there is at least one). It is the blocker-side chat guard: a player whose
// only opponents are people they have blocked cannot post. In a multi-player game that
// still has a non-blocked opponent it is false, so the player can talk to the others.
func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) {
opponents := 0
for _, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
opponents++
blocked, err := svc.store.blockExists(ctx, senderID, id)
if err != nil {
return false, err
}
if !blocked {
return false, nil
}
}
return opponents > 0, nil
}
// mergeSeatSets returns the union of two seat sets; either may be nil. It folds the
// born-read recipients (disguised robots and recipients who blocked the sender) into one
// mask input.
func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool {
if len(b) == 0 {
return a
}
if len(a) == 0 {
return b
}
out := make(map[uuid.UUID]bool, len(a)+len(b))
for id := range a {
out[id] = true
}
for id := range b {
out[id] = true
}
return out
}
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
id, err := uuid.NewV7()