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
+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