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
+1
View File
@@ -35,6 +35,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
+1
View File
@@ -189,6 +189,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
matchmaker.SetNotifier(hub)
matchmaker.SetBlocker(socialSvc)
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
invitations.SetNotifier(hub)
@@ -26,6 +26,11 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
}, `/_gm/users/c3`},
{"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
@@ -102,6 +102,36 @@
</tbody>
</table>
</section>
<section class="panel"><h2>Friends</h2>
<table class="list">
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
<tbody>
{{range .Friends}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocks</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .Blocks}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Blocked by</h2>
<table class="list">
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
<tbody>
{{range .BlockedBy}}
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}}
+15
View File
@@ -175,6 +175,21 @@ type UserDetailView struct {
// grant form offers. The first role is the feedback ban (see internal/account).
Roles []string
KnownRoles []string
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
// other account with the date it happened. They are the full truth; the asymmetric block
// suppression that hides relationships from players never applies to the console.
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
AccountID string
DisplayName string
Date string
}
// SuspensionView is an account's current manual-block state shown on the user card: whether it
+2 -2
View File
@@ -274,7 +274,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
@@ -319,7 +319,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
if seed&1 == 1 {
seats = []seatInsert{{}, caller}
}
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude)
if err != nil {
return Game{}, false, err
}
+7 -2
View File
@@ -200,22 +200,27 @@ func openMatchKey(variant string, multipleWords bool) int64 {
// order) used only when a game is created; callerName is the caller's display-name
// snapshot, stamped on their seat whether they open a fresh game or fill another
// player's open one.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
return fmt.Errorf("open match lock: %w", e)
}
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
// A game whose waiting player is in exclude (the caller's per-user block set, either
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
// an empty exclude (the "{}" literal) excludes nothing.
var other uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
WHERE p.game_id = g.game_id AND p.account_id = $3)
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
ORDER BY g.created_at
LIMIT 1 FOR UPDATE SKIP LOCKED`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
case e == nil:
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
return er
+270
View File
@@ -0,0 +1,270 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/social"
)
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
// user had left unread for the blocker in their shared games, so no stale unread badge
// lingers for someone the blocker no longer sees.
func TestBlockInstantReadsExistingUnread(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
t.Fatal("viewer should have the message unread before blocking")
}
if err := svc.Block(ctx, viewer, sender); err != nil {
t.Fatalf("block: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
t.Error("blocking should instant-read the blocked sender's prior unread message")
}
}
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
// message the blocked user sends is stored and visible to themselves, but is born read for
// the blocker, never delivered to them, and hidden from their view.
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's message must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindChatMessage) {
t.Error("the blocked sender's message must not be delivered to the blocker")
}
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
}
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
}
}
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
// only opponent is someone they have blocked (the composer is hidden client-side too).
func TestChatToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
// ordinary to them) but is born read and never delivered to the blocker.
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
t.Error("the blocked sender's nudge must be born read for the blocker")
}
if pub.delivered(blocker, notify.KindNudge) {
t.Error("the blocked sender's nudge must not be delivered to the blocker")
}
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
t.Error("the suppressed nudge should still be recorded")
}
}
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
// awaited opponent when they have blocked them.
func TestNudgeToBlockedIsRejected(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
}
}
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
// their other sessions update in place) and never reach the blocked user.
func TestBlockPublishesToBlockerOnly(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
blocker, blocked := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserBlocked) {
t.Error("the blocker should receive a user_blocked event")
}
if pub.notified(blocked, notify.NotifyUserBlocked) {
t.Error("the blocked user must never receive a user_blocked event")
}
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
t.Fatalf("unblock: %v", err)
}
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
t.Error("the blocker should receive a user_unblocked event")
}
if pub.notified(blocked, notify.NotifyUserUnblocked) {
t.Error("the blocked user must never receive a user_unblocked event")
}
}
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
// block between them (either direction), while an unblocked third player still joins.
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
soc := newSocialService()
mm.SetBlocker(soc)
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
if err := soc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
// The block is seen from both sides — the exclusion set unions both directions.
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
}
// b opens a game awaiting an opponent.
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must open a game, not match")
}
// a must not join b's game (a blocked b): it opens its own instead.
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r2.Matched || r2.Game.ID == r1.Game.ID {
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
}
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
// is specific to the blocked pair, not a blanket refusal.
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue c: %v", err)
}
if !r3.Matched || r3.Game.ID != r1.Game.ID {
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
}
}
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
// return the full truth in both directions, including a friendship that a block overrides but
// does not delete.
func TestAdminSocialLists(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
a, b := seats[0], seats[1]
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("request: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if err := svc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
blocks, err := svc.AdminBlocksBy(ctx, a)
if err != nil {
t.Fatalf("admin blocks by: %v", err)
}
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
}
blockedBy, err := svc.AdminBlockedBy(ctx, b)
if err != nil {
t.Fatalf("admin blocked by: %v", err)
}
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
}
// The friendship survives the block, so it still shows on both admin friend lists.
friendsA, err := svc.AdminFriends(ctx, a)
if err != nil {
t.Fatalf("admin friends: %v", err)
}
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
}
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
}
}
// blockedWith reads the both-direction block set for id.
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
t.Helper()
ids, err := soc.BlockedWith(context.Background(), id)
if err != nil {
t.Fatalf("blocked with: %v", err)
}
return ids
}
// contains reports whether ids includes want.
func contains(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
+1 -1
View File
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
// An open (awaiting-opponent) quick game counts.
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
}, time.Now().Add(time.Minute)); err != nil {
}, time.Now().Add(time.Minute), nil); err != nil {
t.Fatalf("open quick game: %v", err)
}
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
+27 -5
View File
@@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
func TestInvitationBlockedInvitee(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
social := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
soc := newSocialService()
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
inviter := provisionAccount(t)
invitee := provisionAccount(t)
if err := social.Block(ctx, invitee, inviter); err != nil {
blockedInvitee := provisionAccount(t)
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err)
}
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
// (no error — the inviter notices nothing), but that invitee is never notified and never
// sees it in their list.
inviter2 := provisionAccount(t)
suppressed := provisionAccount(t)
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
}
if pub.notified(suppressed, notify.NotifyInvitation) {
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
}
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
}
}
+3 -3
View File
@@ -49,7 +49,7 @@ func openParams(seed int64) game.CreateParams {
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
t.Helper()
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open game: %v", err)
}
@@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
clearOpenGames(t)
svc := newGameService()
starter := provisionAccount(t)
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open: %v", err)
}
@@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
}
joiner := provisionAccount(t)
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("join: %v", err)
}
+65 -6
View File
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
return false
}
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
// nudge) was published to user — used to assert that a blocked sender's message or nudge
// never reaches the blocker.
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
return true
}
}
return false
}
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request
// just sits unanswered and later expires — mirroring a human who ignores it.
@@ -128,17 +142,53 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
}
// Block: the addressee has blocked the requester.
c, d := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, d, c); err != nil {
// Block from the requester's own side: a requester who has blocked the addressee is
// refused outright (they are the blocker and aware of it). The reverse direction — the
// addressee having blocked the requester — is instead silently suppressed, covered by
// TestFriendRequestFromBlockedIsSuppressed.
_, seats := newGameWithSeats(t, 2)
c, d := seats[0], seats[1]
if err := svc.Block(ctx, c, d); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
}
}
func TestBlockSeversFriendship(t *testing.T) {
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
// addressee has blocked the requester, the requester's friend request succeeds silently
// (no error — they must not notice the block), is stored, but is never delivered to nor
// surfaced for the blocker.
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
_, seats := newGameWithSeats(t, 2)
blocker, blocked := seats[0], seats[1]
if err := svc.Block(ctx, blocker, blocked); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
t.Fatalf("suppressed request = %v, want nil", err)
}
if pub.notified(blocker, notify.NotifyFriendRequest) {
t.Error("blocker must not be notified of a suppressed request")
}
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
}
// The blocked user sees an ordinary outgoing request — no leak of the block.
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
t.Errorf("blocked outgoing = %v, want [blocker]", got)
}
}
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
_, seats := newGameWithSeats(t, 2)
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
t.Fatalf("block: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friendship must be severed by a block, got %v", friends)
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends)
}
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
}
if err := svc.Unblock(ctx, a, b); err != nil {
t.Fatalf("unblock: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
}
}
+47 -3
View File
@@ -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
+8 -4
View File
@@ -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
+29 -4
View File
@@ -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
}
+30 -1
View File
@@ -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 {
+10
View File
@@ -62,6 +62,16 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
// only to the blocker — never to the blocked user, who must not learn of the block —
// and is in-app only (no out-of-app push: blocking is the viewer's own action).
NotifyUserBlocked = "user_blocked"
// NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the
// unblocked account, so their open game screens restore the controls and un-strike
// the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only.
NotifyUserUnblocked = "user_unblocked"
)
// Intent is one live event destined for a single user. Payload is the
@@ -389,9 +389,30 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Roles = roles
}
view.KnownRoles = account.KnownRoles
if s.social != nil {
if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
view.Blocks = relationRows(rels)
}
if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
view.BlockedBy = relationRows(rels)
}
if rels, err := s.social.AdminFriends(ctx, id); err == nil {
view.Friends = relationRows(rels)
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
out := make([]adminconsole.RelationRow, 0, len(rels))
for _, r := range rels {
out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
}
return out
}
// consoleUserMessage sends an operator Telegram message to one user.
func (s *Server) consoleUserMessage(c *gin.Context) {
ctx := c.Request.Context()
+5 -4
View File
@@ -7,10 +7,11 @@ import (
"github.com/google/uuid"
)
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
// is mutual in effect (the social checks apply it both ways) and severs any
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
// account-ref resolution.
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block is asymmetric:
// the blocker stops seeing everything from the blocked user (chat, nudge, requests,
// invitations, matchmaking) while the blocked user notices nothing; it overrides — but does
// not delete — any friendship (an unblock restores it). They reuse the friend handlers'
// targetIDRequest and account-ref resolution.
// blockListDTO is the accounts the caller has blocked.
type blockListDTO struct {
+79
View File
@@ -0,0 +1,79 @@
package social
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
)
// AdminRelation is one entry in the admin user card's blocks / blocked-by / friends lists:
// the other account, its display name, and when the relationship was recorded. It is the
// unfiltered truth — the asymmetric block suppression that hides relationships from players
// never applies to the operator console.
type AdminRelation struct {
AccountID uuid.UUID
DisplayName string
At time.Time
}
// AdminBlocksBy returns the accounts blockerID has blocked, with display names and block
// times, newest first — the admin card's "blocks" list.
func (svc *Service) AdminBlocksBy(ctx context.Context, blockerID uuid.UUID) ([]AdminRelation, error) {
return svc.store.adminBlocks(ctx, blockerID, true)
}
// AdminBlockedBy returns the accounts that have blocked blockedID, with names and times,
// newest first — the admin card's "blocked by" list.
func (svc *Service) AdminBlockedBy(ctx context.Context, blockedID uuid.UUID) ([]AdminRelation, error) {
return svc.store.adminBlocks(ctx, blockedID, false)
}
// AdminFriends returns accountID's accepted friendships in either direction, with the other
// account's name and when the friendship was accepted, newest first — the admin card's
// "friends" list.
func (svc *Service) AdminFriends(ctx context.Context, accountID uuid.UUID) ([]AdminRelation, error) {
const q = `SELECT other_id, a.display_name, at FROM (
SELECT CASE WHEN f.requester_id = $1 THEN f.addressee_id ELSE f.requester_id END AS other_id,
COALESCE(f.responded_at, f.created_at) AS at
FROM backend.friendships f
WHERE f.status = 'accepted' AND (f.requester_id = $1 OR f.addressee_id = $1)
) rel
JOIN backend.accounts a ON a.account_id = rel.other_id
ORDER BY at DESC`
return svc.store.adminRelations(ctx, q, accountID)
}
// adminBlocks reads the blocks touching id: when asBlocker the rows where id is the blocker
// (the other side is who they blocked), otherwise the rows where id is the blocked (the
// other side is who blocked them).
func (s *Store) adminBlocks(ctx context.Context, id uuid.UUID, asBlocker bool) ([]AdminRelation, error) {
q := `SELECT b.blocked_id, a.display_name, b.created_at
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocked_id
WHERE b.blocker_id = $1 ORDER BY b.created_at DESC`
if !asBlocker {
q = `SELECT b.blocker_id, a.display_name, b.created_at
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocker_id
WHERE b.blocked_id = $1 ORDER BY b.created_at DESC`
}
return s.adminRelations(ctx, q, id)
}
// adminRelations runs an admin list query of the (account_id, display_name, at) shape.
func (s *Store) adminRelations(ctx context.Context, query string, id uuid.UUID) ([]AdminRelation, error) {
rows, err := s.db.QueryContext(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("social: admin relations: %w", err)
}
defer rows.Close()
var out []AdminRelation
for rows.Next() {
var r AdminRelation
if err := rows.Scan(&r.AccountID, &r.DisplayName, &r.At); err != nil {
return nil, fmt.Errorf("social: scan admin relation: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
+110 -13
View File
@@ -10,24 +10,41 @@ import (
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Block records that blockerID has blocked blockedID. Blocking severs any
// friendship or pending request between the two and, through the mutual block
// checks, suppresses chat visibility and new requests/invitations in both
// directions. It is idempotent.
// Block records that blockerID has blocked blockedID. The block is asymmetric and
// non-destructive: it suppresses everything the blocked user sends toward the blocker
// (chat, nudge, friend requests, invitations are persisted but never delivered or
// surfaced) and hides the blocked user from the blocker's lists, while the blocked
// user notices nothing. It does NOT delete the friendship — an unblock cleanly
// restores it — and keeps every stored row. As a courtesy it marks any of the blocked
// user's still-unread chat entries in their shared games read at once, so no stale
// unread badge lingers for someone the blocker no longer sees. A user_blocked event is
// delivered to the blocker (only) so their other sessions update in place. It is idempotent.
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
if blockerID == blockedID {
return ErrSelfRelation
}
return svc.store.insertBlock(ctx, blockerID, blockedID)
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID)))
return nil
}
// Unblock removes blockerID's block on blockedID. It is idempotent.
// Unblock removes blockerID's block on blockedID and confirms it to the (former)
// blocker with a user_unblocked event so their open game screens restore the controls
// and un-strike the name in place. Any friendship the block had been overriding becomes
// effective again. It is idempotent.
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
return svc.store.deleteBlock(ctx, blockerID, blockedID)
if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil {
return err
}
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, blockedID)))
return nil
}
// ListBlocks returns the account IDs blockerID has blocked.
@@ -35,11 +52,43 @@ func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid
return svc.store.listBlocks(ctx, blockerID)
}
// BlockedWith returns every account that has a block with accountID in either
// direction (those accountID blocked, plus those who blocked accountID), de-duplicated.
// The matchmaker excludes them so a block — either way — keeps the pair out of the same
// anonymous auto-match game.
func (svc *Service) BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
mine, err := svc.store.listBlocks(ctx, accountID)
if err != nil {
return nil, err
}
theirs, err := svc.store.listBlockedBy(ctx, accountID)
if err != nil {
return nil, err
}
seen := make(map[uuid.UUID]struct{}, len(mine)+len(theirs))
out := make([]uuid.UUID, 0, len(mine)+len(theirs))
for _, id := range append(mine, theirs...) {
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
// IsBlocked reports whether a block stands between a and b in either direction.
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
return svc.store.isBlocked(ctx, a, b)
}
// Blocks reports whether blocker has blocked blocked — the exact direction, not the
// symmetric IsBlocked. It backs the directional guards (chat, friend requests,
// invitations) that must tell the blocker (refuse) from the blocked (silently suppress).
func (svc *Service) Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
return svc.store.blockExists(ctx, blocker, blocked)
}
// isBlocked reports whether a block row exists between a and b in either direction.
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
@@ -58,14 +107,33 @@ func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
return true, nil
}
// insertBlock severs any friendship between the pair and inserts the block, in one
// transaction; a duplicate block is ignored.
// blockExists reports whether blocker has blocked blocked — the exact direction, not
// the symmetric isBlocked. The asymmetric block turns on this one-directional test:
// the blocker filters out and refuses the blocked user, while the blocked user's own
// actions are silently suppressed rather than refused, so they never notice.
func (s *Store) blockExists(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
FROM(table.Blocks).
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))).
LIMIT(1)
var row model.Blocks
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: block exists: %w", err)
}
return true, nil
}
// insertBlock inserts the block and, in the same transaction, marks read every chat
// entry the blocked user authored that the blocker had still left unread across their
// shared games — so no stale unread badge lingers for someone the blocker no longer
// sees. It deliberately leaves the friendship intact (the block overrides it; an
// unblock restores it). A duplicate block is ignored.
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear friendship on block: %w", err)
}
ins := table.Blocks.
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
VALUES(blocker, blocked).
@@ -73,6 +141,17 @@ func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) err
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert block: %w", err)
}
// Clear the blocker's unread bit on every entry the blocked user sent, in any game
// they share, resolving the blocker's seat through game_players. The bitwise terms
// are cast through int4 so the operators resolve unambiguously (as in markRead).
const clearUnread = `UPDATE backend.chat_messages m
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
FROM backend.game_players p
WHERE p.account_id = $1 AND p.game_id = m.game_id
AND m.sender_id = $2 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
if _, err := tx.ExecContext(ctx, clearUnread, blocker, blocked); err != nil {
return fmt.Errorf("clear unread on block: %w", err)
}
return nil
})
}
@@ -104,3 +183,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID,
}
return out, nil
}
// listBlockedBy returns the accounts that have blocked blocked — the reverse of
// listBlocks. The matchmaker unions the two so a block in either direction keeps a
// pair out of the same auto-match game.
func (s *Store) listBlockedBy(ctx context.Context, blocked uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
FROM(table.Blocks).
WHERE(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))
var rows []model.Blocks
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list blocked by: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.BlockerID)
}
return out, nil
}
+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()
+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
+4
View File
@@ -80,6 +80,10 @@ var (
ErrMessageNotFound = errors.New("social: message not found")
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
ErrChatBlocked = errors.New("social: chat is disabled for this account")
// ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone
// they have blocked — the blocker-side guard behind the hidden composer (a blocked
// player's own sends are never refused, only silently suppressed, so they never notice).
ErrRecipientBlocked = errors.New("social: cannot message a blocked player")
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
// ErrEmptyMessage is returned when a chat message is blank after trimming.
+21 -8
View File
@@ -470,7 +470,9 @@ disguised robot stays indistinguishable from a person.
(or joins a different player's) rather than returning the caller's own, so choosing
"random opponent" again always starts a new search (bounded by the simultaneous
quick-game cap, §9). Matchmaking state is therefore the **open games in the database** (not
an in-memory pool), so it survives a restart and stays anonymous (no block check);
an in-memory pool), so it survives a restart and stays anonymous beyond one filter — a
player is never paired into a game whose waiting opponent they have a per-user block with,
in either direction (the enqueue excludes the caller's `BlockedWith` set);
concurrent enqueues for one bucket are serialised by a transaction-scoped advisory
lock so two callers pair rather than each opening a game. A background **reaper**
seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus
@@ -506,12 +508,19 @@ disguised robot stays indistinguishable from a person.
expires after **30 days** and may be re-sent), or **decline** — a decline is
remembered (`status='declined'`) and blocks further requests from that sender,
unless they hand them a code, which overrides it. The requester's own cancel still
deletes the row; blocking someone severs an existing friendship. (Discovery by
friend list or platform deep-link is future work.)
deletes the row. (Discovery by friend list or platform deep-link is future work.)
- **Block**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
applied mutually: it hides the pair's chat from each other and refuses friend
requests and game invitations between them.
**asymmetric and non-destructive**: the blocker stops receiving everything **from** the
blocked user — chat, nudges, friend requests, game invitations, and auto-match (§6) never
pairs them — while the blocked user notices nothing. Their sends still persist by the normal
rules but are never delivered or surfaced to the blocker (a directional `blockExists` check
drives this: the blocker filters/refuses, the blocked is silently suppressed and born-read),
and applying a block also marks read any unread the blocked user had left for the blocker. It
**overrides but does not delete** a friendship (an unblock cleanly restores it), so a pair may
be friends **and** blocked at once; the admin user card shows the full truth (blocks,
blocked-by, friends) regardless of this suppression. Block/unblock emit a `user_blocked` /
`user_unblocked` notification to the blocker only (§10).
- **Friend games**: formed by **invitation → accept** (an `game_invitations`
record with one row per invitee). The 24 player game starts once **every**
invitee accepts; any decline cancels the invitation, and a pending invitation
@@ -693,7 +702,10 @@ opponent card and the resign/chat controls update **in place**; **game-over** ca
carry the changed account / invitation, so the client patches its lobby lists in place: **invitation**
and **invitation-update** carry the full invitation, and the client upserts a still-pending one and
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The move-commit **response** (`submit_play` / `pass` /
too, fresh from any screen without a refetch. The **user-blocked** / **user-unblocked** sub-kinds
confirm a per-user block change to the **blocker only** (never the blocked user), carrying the other
account, so the blocker's open game screens re-derive the block / add-friend controls and the struck
name in place across sessions. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby
**preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved
@@ -720,8 +732,9 @@ localized message with a Mini App deep-link button — only when the recipient h
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so the in-app-only **invitation-update** (a
response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts
the connector renders the message and skips the rest — so in-app-only sub-kinds like
**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a
block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
+20 -8
View File
@@ -179,14 +179,26 @@ digits, valid for twelve hours), or send a **request to someone you have played
with** — they accept, ignore it (a request lapses after thirty days and can then be
re-sent), or decline (a decline blocks further requests from you until they hand you
a code). Cancelling your own pending request withdraws it; unfriending removes the
friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the
move history is open) that mirrors the live relationship: it confirms with a tap on a fading
✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is
pending or was declined, and **disappears** once you are friends — updating in place the
moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat
and/or friend requests — and block individual players (a per-user block hides that
person's chat and stops requests and game invitations both ways; it also ends any
existing friendship). Per-game chat is for quick reactions: messages are short
friendship. In a game, each opponent's score card (while the move history is open) carries two controls:
an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap
on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while
confirming — and while one is confirming the other is hidden so they never overlap. The 🤝
goes **disabled** while a request is pending or was declined and **disappears** once you are
friends; both controls **disappear and the opponent's name is struck through** once you have
blocked them. They update in place the moment the relationship changes and stay correct across
reloads. Applying a block (or friend request) takes effect immediately and is confirmed by a
live event; a transport failure rolls the control back to its prior state.
Block globally — switch off incoming chat and/or friend requests — or block an **individual
player**. A per-user block is **one-directional and silent**: you stop receiving everything
**from** that person — chat, nudges, friend requests and game invitations — and the matchmaker
never pairs you with them, while they **notice nothing**. Their messages and nudges still send
by the normal rules but never reach you (anything that would show as unread for you is marked
read at once), and a friend request or invitation they send you is kept but never surfaces. A
block **overrides but does not delete** an existing friendship (so you may block a friend, and
they keep seeing you as one); active games are never interrupted — you can finish them, with
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only. Per-game chat is for quick reactions: messages are short
(up to 60 characters) and may not contain links, email addresses or phone numbers,
even disguised. You may send **one message per turn, on your own turn**; once it is sent
the field gives way to a short caption until your next turn. Nudge the player whose turn
+21 -8
View File
@@ -183,14 +183,27 @@ nudge) приходят от бота **этой партии** — по язы
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол
**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается
тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится
**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия —
обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника (при открытой
истории ходов) несёт два контрола: **в друзья 🤝** справа и **блокировка ✖️** слева. Каждый
подтверждается тапом по затухающей ✅ — на месте счёта карточка показывает *В друзья?* (или
*Блокируем?* красным) во время подтверждения, — и пока подтверждается один, второй скрыт, чтобы
они не пересекались. 🤝 становится **неактивным**, пока заявка висит или была отклонена, и
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
событием; при сбое транспорта контрол возвращается в прежнее состояние.
Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
всё — чат, nudge, заявки в друзья и приглашения в игру, — и матчмейкер никогда не сводит вас с
ним, а он **ничего не замечает**. Его сообщения и nudge по-прежнему отправляются по обычным
правилам, но не доходят до вас (всё, что было бы для вас непрочитанным, сразу помечается
прочитанным), а присланные им заявка или приглашение сохраняются, но никогда не всплывают. Блок
**перекрывает, но не удаляет** имеющуюся дружбу (поэтому можно заблокировать друга, а он
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там. Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
+10 -6
View File
@@ -131,10 +131,14 @@ except Login uses `Screen`.
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
game with "multiple words per turn" off) centres a **"One word per turn"** label between
those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains a
🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that
confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed
(see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge.
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
armed (see Controls); while one is armed the other hides so they never overlap. Once the
opponent is **blocked** both controls go and the **name is struck through** (and the comms-hub
chat composer is hidden — only the log remains). The name and score stay centred — the controls
are pinned to the card's edges.
Unread chat is also badged on the score bar itself, so it shows with the history closed.
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
@@ -180,8 +184,8 @@ except Login uses `Screen`.
tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade
under reduce-motion, but the window still holds); a tap on the ✅ within it confirms,
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
the old press-and-hold popover) and the in-game **add-friend 🤝**. The
**Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
button) beside the rack commits the move — no popover, and disabled while the pending word
+23
View File
@@ -193,6 +193,29 @@ test('game: the add-friend 🤝 confirms with a tap and then reads as sent', asy
await expect(add).toBeDisabled();
});
test('game: the block ✖️ confirms in red, then hides both controls and strikes the name', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
await page.locator('.scoreboard').click(); // open the history -> 🤝 and ✖️ appear on Ann's card
const block = page.getByRole('button', { name: 'Block player' });
await expect(block).toBeVisible();
await block.click(); // arm: ✖️ -> a fading ✅, the score caption turns to a red "Block?"
await expect(page.locator('.sc.blockprompt')).toHaveText('Block?');
// While confirming a block the add-friend control is hidden so the two never overlap.
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
await block.click(); // confirm within the window
// The block applied (optimistically): both controls disappear and the name is struck.
await expect(page.getByRole('button', { name: 'Block player' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
await expect(page.locator('.nm.struck')).toBeVisible();
// The chat composer is hidden for a blocked opponent — only the log remains, as in a
// finished game.
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
});
test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
+7
View File
@@ -13,6 +13,7 @@
waiting = false,
nudgeOnCooldown = false,
vsAi = false,
blocked = false,
onsend,
onnudge,
}: {
@@ -38,6 +39,10 @@
// vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
vsAi?: boolean;
// blocked hides the whole composer (message field, send and nudge), leaving only the chat
// log — as in a finished game — when the viewer has blocked the opponent: there is no one
// they will message (the backend rejects it too).
blocked?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
@@ -65,6 +70,7 @@
{/if}
{/each}
</div>
{#if !blocked}
<div class="input">
{#if canSend}
<input
@@ -85,6 +91,7 @@
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
{/if}
</div>
<style>
+27
View File
@@ -16,6 +16,9 @@
let messages = $state<ChatMessage[]>([]);
let busy = $state(false);
let tick = $state(0);
// The opponents the viewer has blocked: when every opponent is blocked the composer is hidden
// (only the chat log remains), matching the backend guard that rejects messaging a blocked peer.
let blockedIds = $state(new Set<string>());
const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived(
@@ -31,6 +34,14 @@
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat);
// While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open');
// peerBlocked is true when every seated opponent is one the viewer has blocked: the whole
// composer is then hidden (a blocked opponent cannot be messaged).
const peerBlocked = $derived.by(() => {
const v = view;
if (!v) return false;
const opponents = v.game.seats.filter((s) => s.seat !== v.seat && !!s.accountId);
return opponents.length > 0 && opponents.every((s) => blockedIds.has(s.accountId));
});
const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
// backend stays authoritative, so a move-based reset is left to it.
@@ -64,9 +75,20 @@
if (initial) handleError(e);
}
}
// loadBlocked refreshes the viewer's blocked set (best-effort); guests have none.
async function loadBlocked() {
if (app.profile?.isGuest) return;
try {
const bl = await gateway.blocksList();
blockedIds = new Set(bl.map((b) => b.accountId));
} catch {
/* best-effort */
}
}
onMount(async () => {
await loadState(true);
await refresh();
void loadBlocked();
});
// Live: refresh the message list on a chat / nudge for this game, and reload the state on
@@ -84,6 +106,10 @@
) {
void loadState();
}
// A block/unblock applied: re-derive whether the composer should be hidden.
if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
void loadBlocked();
}
});
// Re-evaluate the nudge cooldown on a timer so the control re-enables on time.
$effect(() => {
@@ -123,6 +149,7 @@
{waiting}
{nudgeOnCooldown}
vsAi={!!view && view.game.vsAi}
blocked={peerBlocked}
onsend={sendChat}
onnudge={nudge}
/>
+98 -10
View File
@@ -240,6 +240,7 @@
}
void load();
void loadFriends();
void loadBlocked();
});
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
@@ -294,6 +295,11 @@
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
} else if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
// A block/unblock applied (this or another of the viewer's sessions): reconcile the
// blocked set — and the friend set, since a block hides a blocked friend — in place.
void loadBlocked();
void loadFriends();
}
});
});
@@ -869,9 +875,12 @@
boardPointers.delete(e.pointerId);
boardSwipe = null;
}
// A closed history clears every per-seat add-friend confirmation.
// A closed history clears every per-seat add-friend and block confirmation.
$effect(() => {
if (!historyShown) addConfirm = {};
if (!historyShown) {
addConfirm = {};
blockConfirm = {};
}
});
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
@@ -881,9 +890,15 @@
// re-send and disable the 🤝).
let friends = $state(new Set<string>());
let requested = $state(new Set<string>());
// Per-seat "confirming" flag for the 🤝 → ✅ tap-to-confirm (TapConfirm writes it); while
// set, that seat's card shows "Add friend?" in place of the score. Reset when history closes.
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and
// their name is struck. Derived from the server so it is correct across reloads and live-updates
// on a user_blocked / user_unblocked event.
let blocked = $state(new Set<string>());
// Per-seat "confirming" flags for the 🤝 → ✅ and ✖️ → ✅ tap-to-confirm (TapConfirm writes them);
// while set, that seat's card shows "Add friend?" / "Block?" in place of the score, and the
// opposite control is hidden so the two never overlap. Reset when history closes.
let addConfirm = $state<Record<number, boolean>>({});
let blockConfirm = $state<Record<number, boolean>>({});
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
@@ -898,12 +913,40 @@
}
}
// loadBlocked refreshes the blocked set for a non-guest. Best-effort.
async function loadBlocked() {
if (app.profile?.isGuest) return;
try {
const bl = await gateway.blocksList();
blocked = new Set(bl.map((b) => b.accountId));
} catch {
/* best-effort */
}
}
// addFriend and blockUser apply the new relationship optimistically (so the controls and score
// caption update the instant the confirm fires) and roll back to the prior state if the command
// fails to reach the server. The confirming user_blocked / user_added event then just reconciles
// the same state in place (no flicker).
async function addFriend(accountId: string) {
const had = requested.has(accountId);
requested = new Set([...requested, accountId]);
try {
await gateway.friendRequest(accountId);
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
showToast(t('friends.requestSent'));
} catch (e) {
if (!had) requested = new Set([...requested].filter((id) => id !== accountId));
handleError(e);
}
}
async function blockUser(accountId: string) {
const had = blocked.has(accountId);
blocked = new Set([...blocked, accountId]);
try {
await gateway.block(accountId);
} catch (e) {
if (!had) blocked = new Set([...blocked].filter((id) => id !== accountId));
handleError(e);
}
}
@@ -933,9 +976,25 @@
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean {
// Never offer add-friend against an AI opponent.
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
if (view?.game.vsAi) return false;
return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
return (
!!accountId &&
!app.profile?.isGuest &&
accountId !== app.session?.userId &&
!friends.has(accountId) &&
!blocked.has(accountId)
);
}
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
// An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(accountId: string): boolean {
if (view?.game.vsAi) return false;
return (
!!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !blocked.has(accountId)
);
}
</script>
@@ -985,9 +1044,22 @@
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{seatName(s)}</div>
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyShown && canAddFriend(s.accountId)}
<div class="nm" class:struck={blocked.has(s.accountId)}>{seatName(s)}</div>
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div>
{#if historyShown && canBlock(s.accountId) && !addConfirm[s.seat]}
<span class="blockuser">
<TapConfirm
label={t('friends.blockFromGame')}
onConfirming={(v) => (blockConfirm[s.seat] = v)}
onconfirm={() => blockUser(s.accountId)}
>
<span class="fico">✖️</span>
</TapConfirm>
</span>
{/if}
{#if historyShown && canAddFriend(s.accountId) && !blockConfirm[s.seat]}
<span class="addfriend">
<TapConfirm
label={t('friends.addFromGame')}
@@ -1426,9 +1498,25 @@
transform: translateY(-50%);
font-size: 1.15rem;
}
/* The ✖️ block control mirrors add-friend on the seat's left edge. */
.blockuser {
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
font-size: 1.15rem;
}
.fico {
line-height: 1;
}
/* A blocked opponent's name is struck through. */
.nm.struck {
text-decoration: line-through;
}
/* The "Block?" confirm caption replaces the score in the danger colour (theme-aware). */
.sc.blockprompt {
color: var(--danger);
}
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
when the history is opened with unread present, rather than carrying a count badge. */
.unread-dot {
+2
View File
@@ -225,6 +225,7 @@ export const en = {
'friends.block': 'Block',
'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player',
'friends.requestSent': 'Friend request sent.',
'friends.getCode': 'Show my code',
'friends.codeHint': 'Give this code to a friend within 12 hours.',
@@ -284,6 +285,7 @@ export const en = {
'game.exportGcg': 'Export GCG',
'game.gcgActiveOnly': 'Available once the game is finished.',
'game.addFriendShort': 'Add friend?',
'game.blockShort': 'Block?',
'time.minutes': '{n} min',
'time.hours': '{n} h',
+2
View File
@@ -226,6 +226,7 @@ export const ru: Record<MessageKey, string> = {
'friends.block': 'Заблокировать',
'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья',
'friends.blockFromGame': 'Заблокировать',
'friends.requestSent': 'Заявка в друзья отправлена.',
'friends.getCode': 'Показать мой код',
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
@@ -285,6 +286,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.addFriendShort': 'В друзья?',
'game.blockShort': 'Блокируем?',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',