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:
+124
-13
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user