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:
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(recipients))
|
||||
for _, id := range recipients {
|
||||
// An invitee who has blocked the inviter never sees this invitation, so they must not
|
||||
// receive its updates either (an update would otherwise re-add it to their lobby). The
|
||||
// inviter always gets updates; on a lookup error suppress rather than risk a leak.
|
||||
if id != inv.InviterID {
|
||||
if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
|
||||
continue
|
||||
}
|
||||
}
|
||||
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
@@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||
// created and persisted for them, but they are never notified and never see it (their
|
||||
// friendship with the inviter survived the block, so they can still appear in the
|
||||
// inviter's picker — they must not learn of the block).
|
||||
suppressed := make(map[uuid.UUID]bool)
|
||||
for _, id := range inviteeIDs {
|
||||
if seen[id] {
|
||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
|
||||
@@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
}
|
||||
return Invitation{}, err
|
||||
}
|
||||
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
|
||||
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
|
||||
// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
|
||||
iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if blocked {
|
||||
if iBlock {
|
||||
return Invitation{}, ErrInvitationBlocked
|
||||
}
|
||||
theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if theyBlock {
|
||||
suppressed[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||
// Notify every invitee except those who have blocked the inviter (their copy stays
|
||||
// stored but unseen — the store-but-hide that keeps the block invisible).
|
||||
notifyIDs := inviteeIDs
|
||||
if len(suppressed) > 0 {
|
||||
notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
|
||||
for _, id := range inviteeIDs {
|
||||
if !suppressed[id] {
|
||||
notifyIDs = append(notifyIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, notifyIDs)
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
|
||||
// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
|
||||
if inv.InviterID != accountID {
|
||||
blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -38,11 +38,15 @@ type RobotProvider interface {
|
||||
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
||||
// invitations between blocked accounts.
|
||||
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
|
||||
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
|
||||
// block the invitee placed on the inviter is silently suppressed; auto-match consults
|
||||
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
|
||||
type Blocker interface {
|
||||
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
// Blocks reports whether blocker has blocked blocked (exact direction).
|
||||
Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error)
|
||||
// BlockedWith returns every account in a block with accountID in either direction.
|
||||
BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error)
|
||||
}
|
||||
|
||||
// Auto-match defaults: a casual two-player game on the longest move clock with one
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||
// it.
|
||||
type GameMatcher interface {
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
|
||||
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
|
||||
// one; exclude lists account IDs whose open game must never be joined (the caller's
|
||||
// per-user block set), so a block keeps the pair out of the same anonymous game.
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
|
||||
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
|
||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
@@ -35,8 +38,9 @@ type GameMatcher interface {
|
||||
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||
// concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
||||
// friends, chat and invitations between known players).
|
||||
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
|
||||
// paired into a game whose waiting opponent they have blocked, or who has blocked them
|
||||
// (either direction), so a block keeps two players apart everywhere.
|
||||
type Matchmaker struct {
|
||||
games GameMatcher
|
||||
robots RobotProvider
|
||||
@@ -44,6 +48,7 @@ type Matchmaker struct {
|
||||
jitter time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
blocker Blocker
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
|
||||
// player with someone they have a block with (either direction). It must be called
|
||||
// during startup wiring; the default is no blocker, in which case auto-match pairs
|
||||
// anonymously (no block exclusion).
|
||||
func (m *Matchmaker) SetBlocker(b Blocker) {
|
||||
if b != nil {
|
||||
m.blocker = b
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
|
||||
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||
// being freshly opened and still awaiting one.
|
||||
@@ -90,7 +105,17 @@ type EnqueueResult struct {
|
||||
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
|
||||
// that game's waiting starter.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
// Exclude every account the caller has a block with (either direction) from the
|
||||
// candidate open games, so auto-match never pairs a blocked pair into one game.
|
||||
var exclude []uuid.UUID
|
||||
if m.blocker != nil {
|
||||
var err error
|
||||
exclude, err = m.blocker.BlockedWith(ctx, accountID)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
}
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type stubMatcher struct {
|
||||
openErr error
|
||||
openCalls int
|
||||
lastDeadline time.Time
|
||||
lastExclude []uuid.UUID
|
||||
|
||||
expired []game.OpenGame
|
||||
|
||||
@@ -39,9 +40,10 @@ type stubMatcher struct {
|
||||
createParams game.CreateParams
|
||||
}
|
||||
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
|
||||
s.openCalls++
|
||||
s.lastDeadline = deadline
|
||||
s.lastExclude = exclude
|
||||
return s.openGame, s.openJoined, s.openErr
|
||||
}
|
||||
|
||||
@@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent }
|
||||
|
||||
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
|
||||
|
||||
// fakeBlocker is a Blocker returning a canned both-direction block set.
|
||||
type fakeBlocker struct {
|
||||
with []uuid.UUID
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
|
||||
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
|
||||
return f.with, f.err
|
||||
}
|
||||
|
||||
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
|
||||
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
|
||||
// whose waiting player has a block with the caller.
|
||||
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
|
||||
caller, blocked := uuid.New(), uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
|
||||
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if !slices.Contains(m.lastExclude, blocked) {
|
||||
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
||||
// (uuid.Nil for a still-empty opponent seat).
|
||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||
|
||||
Reference in New Issue
Block a user