feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
This commit is contained in:
@@ -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) |
|
| 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** |
|
| 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** |
|
| 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) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|||||||
@@ -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 := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
|
||||||
matchmaker.SetNotifier(hub)
|
matchmaker.SetNotifier(hub)
|
||||||
|
matchmaker.SetBlocker(socialSvc)
|
||||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||||
invitations.SetNotifier(hub)
|
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", 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", 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", 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{
|
{"throttled", ThrottledView{
|
||||||
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
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"}},
|
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||||
|
|||||||
@@ -102,6 +102,36 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</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}}
|
{{if .TelegramID}}
|
||||||
<section class="panel"><h2>Send Telegram message</h2>
|
<section class="panel"><h2>Send Telegram message</h2>
|
||||||
{{if .ConnectorEnabled}}
|
{{if .ConnectorEnabled}}
|
||||||
|
|||||||
@@ -175,6 +175,21 @@ type UserDetailView struct {
|
|||||||
// grant form offers. The first role is the feedback ban (see internal/account).
|
// grant form offers. The first role is the feedback ban (see internal/account).
|
||||||
Roles []string
|
Roles []string
|
||||||
KnownRoles []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
|
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||||
|
|||||||
@@ -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
|
// 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
|
// (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.
|
// 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)
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, account.ErrNotFound) {
|
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 {
|
if seed&1 == 1 {
|
||||||
seats = []seatInsert{{}, caller}
|
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 {
|
if err != nil {
|
||||||
return Game{}, false, err
|
return Game{}, false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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
|
// snapshot, stamped on their seat whether they open a fresh game or fill another
|
||||||
// player's open one.
|
// 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 {
|
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||||
return fmt.Errorf("open match lock: %w", e)
|
return fmt.Errorf("open match lock: %w", e)
|
||||||
}
|
}
|
||||||
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
|
// 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
|
var other uuid.UUID
|
||||||
switch e := tx.QueryRowContext(ctx,
|
switch e := tx.QueryRowContext(ctx,
|
||||||
`SELECT g.game_id FROM backend.games g
|
`SELECT g.game_id FROM backend.games g
|
||||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
|
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
|
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
|
||||||
WHERE p.game_id = g.game_id AND p.account_id = $3)
|
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
|
ORDER BY g.created_at
|
||||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
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:
|
case e == nil:
|
||||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||||
return er
|
return er
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
|
|||||||
// An open (awaiting-opponent) quick game counts.
|
// An open (awaiting-opponent) quick game counts.
|
||||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
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)
|
t.Fatalf("open quick game: %v", err)
|
||||||
}
|
}
|
||||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||||
|
|||||||
@@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
|
|||||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newInvitationService()
|
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)
|
inviter := provisionAccount(t)
|
||||||
invitee := provisionAccount(t)
|
blockedInvitee := provisionAccount(t)
|
||||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
|
||||||
t.Fatalf("block: %v", err)
|
t.Fatalf("block: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("open game: %v", err)
|
t.Fatalf("open game: %v", err)
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
|||||||
clearOpenGames(t)
|
clearOpenGames(t)
|
||||||
svc := newGameService()
|
svc := newGameService()
|
||||||
starter := provisionAccount(t)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
joiner := provisionAccount(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 {
|
if err != nil {
|
||||||
t.Fatalf("join: %v", err)
|
t.Fatalf("join: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
|||||||
return false
|
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
|
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
// 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.
|
// 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)
|
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block: the addressee has blocked the requester.
|
// Block from the requester's own side: a requester who has blocked the addressee is
|
||||||
c, d := provisionAccount(t), provisionAccount(t)
|
// refused outright (they are the blocker and aware of it). The reverse direction — the
|
||||||
if err := svc.Block(ctx, d, c); err != nil {
|
// 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)
|
t.Fatalf("block: %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
|
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()
|
ctx := context.Background()
|
||||||
svc := newSocialService()
|
svc := newSocialService()
|
||||||
_, seats := newGameWithSeats(t, 2)
|
_, seats := newGameWithSeats(t, 2)
|
||||||
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
|
|||||||
t.Fatalf("block: %v", err)
|
t.Fatalf("block: %v", err)
|
||||||
}
|
}
|
||||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
|
|||||||
}
|
}
|
||||||
intents := make([]notify.Intent, 0, len(recipients))
|
intents := make([]notify.Intent, 0, len(recipients))
|
||||||
for _, id := range 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))
|
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
|
||||||
}
|
}
|
||||||
svc.pub.Publish(intents...)
|
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)
|
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||||
}
|
}
|
||||||
seen := map[uuid.UUID]bool{inviterID: true}
|
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 {
|
for _, id := range inviteeIDs {
|
||||||
if seen[id] {
|
if seen[id] {
|
||||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, 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
|
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 {
|
if err != nil {
|
||||||
return Invitation{}, err
|
return Invitation{}, err
|
||||||
}
|
}
|
||||||
if blocked {
|
if iBlock {
|
||||||
return Invitation{}, ErrInvitationBlocked
|
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()
|
id, err := uuid.NewV7()
|
||||||
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Invitation{}, err
|
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
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
out = append(out, inv)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -38,11 +38,15 @@ type RobotProvider interface {
|
|||||||
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
|
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocker reports whether two accounts have a block between them (either
|
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
|
||||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
|
||||||
// invitations between blocked accounts.
|
// 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 {
|
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
|
// Auto-match defaults: a casual two-player game on the longest move clock with one
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import (
|
|||||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||||
// it.
|
// it.
|
||||||
type GameMatcher interface {
|
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)
|
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
|
||||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, 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
|
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||||
// concurrent use.
|
// concurrent use.
|
||||||
//
|
//
|
||||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
|
||||||
// friends, chat and invitations between known players).
|
// 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 {
|
type Matchmaker struct {
|
||||||
games GameMatcher
|
games GameMatcher
|
||||||
robots RobotProvider
|
robots RobotProvider
|
||||||
@@ -44,6 +48,7 @@ type Matchmaker struct {
|
|||||||
jitter time.Duration
|
jitter time.Duration
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
pub notify.Publisher
|
pub notify.Publisher
|
||||||
|
blocker Blocker
|
||||||
log *zap.Logger
|
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
|
// 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
|
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||||
// being freshly opened and still awaiting one.
|
// 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
|
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
|
||||||
// that game's waiting starter.
|
// that game's waiting starter.
|
||||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
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 {
|
if err != nil {
|
||||||
return EnqueueResult{}, err
|
return EnqueueResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type stubMatcher struct {
|
|||||||
openErr error
|
openErr error
|
||||||
openCalls int
|
openCalls int
|
||||||
lastDeadline time.Time
|
lastDeadline time.Time
|
||||||
|
lastExclude []uuid.UUID
|
||||||
|
|
||||||
expired []game.OpenGame
|
expired []game.OpenGame
|
||||||
|
|
||||||
@@ -39,9 +40,10 @@ type stubMatcher struct {
|
|||||||
createParams game.CreateParams
|
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.openCalls++
|
||||||
s.lastDeadline = deadline
|
s.lastDeadline = deadline
|
||||||
|
s.lastExclude = exclude
|
||||||
return s.openGame, s.openJoined, s.openErr
|
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...) }
|
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
|
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
||||||
// (uuid.Nil for a still-empty opponent seat).
|
// (uuid.Nil for a still-empty opponent seat).
|
||||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ const (
|
|||||||
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
// 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.
|
// banner set rides the profile response). In-app only.
|
||||||
NotifyBanner = "banner"
|
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
|
// 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.Roles = roles
|
||||||
}
|
}
|
||||||
view.KnownRoles = account.KnownRoles
|
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)
|
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.
|
// consoleUserMessage sends an operator Telegram message to one user.
|
||||||
func (s *Server) consoleUserMessage(c *gin.Context) {
|
func (s *Server) consoleUserMessage(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
|
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block is asymmetric:
|
||||||
// is mutual in effect (the social checks apply it both ways) and severs any
|
// the blocker stops seeing everything from the blocked user (chat, nudge, requests,
|
||||||
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
// invitations, matchmaking) while the blocked user notices nothing; it overrides — but does
|
||||||
// account-ref resolution.
|
// 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.
|
// blockListDTO is the accounts the caller has blocked.
|
||||||
type blockListDTO struct {
|
type blockListDTO struct {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -10,24 +10,41 @@ import (
|
|||||||
"github.com/go-jet/jet/v2/qrm"
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Block records that blockerID has blocked blockedID. Blocking severs any
|
// Block records that blockerID has blocked blockedID. The block is asymmetric and
|
||||||
// friendship or pending request between the two and, through the mutual block
|
// non-destructive: it suppresses everything the blocked user sends toward the blocker
|
||||||
// checks, suppresses chat visibility and new requests/invitations in both
|
// (chat, nudge, friend requests, invitations are persisted but never delivered or
|
||||||
// directions. It is idempotent.
|
// 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 {
|
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||||
if blockerID == blockedID {
|
if blockerID == blockedID {
|
||||||
return ErrSelfRelation
|
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 {
|
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.
|
// 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)
|
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.
|
// 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) {
|
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||||
return svc.store.isBlocked(ctx, a, b)
|
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.
|
// 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) {
|
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
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
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// insertBlock severs any friendship between the pair and inserts the block, in one
|
// blockExists reports whether blocker has blocked blocked — the exact direction, not
|
||||||
// transaction; a duplicate block is ignored.
|
// 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 {
|
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
||||||
return withTx(ctx, s.db, func(tx *sql.Tx) 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.
|
ins := table.Blocks.
|
||||||
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
||||||
VALUES(blocker, blocked).
|
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 {
|
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("insert block: %w", err)
|
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
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -104,3 +183,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID,
|
|||||||
}
|
}
|
||||||
return out, nil
|
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
@@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
|||||||
if err := Clean(body); err != nil {
|
if err := Clean(body); err != nil {
|
||||||
return Message{}, err
|
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)
|
autoRead, err := svc.robotRecipients(ctx, seats, senderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
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 {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
svc.metrics.recordChat(ctx, kindMessage)
|
svc.metrics.recordChat(ctx, kindMessage)
|
||||||
svc.emitChat(seats, senderID, msg)
|
svc.emitChat(seats, senderID, msg, suppressed)
|
||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
if idx == toMove {
|
if idx == toMove {
|
||||||
return Message{}, ErrNudgeOnOwnTurn
|
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)
|
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
@@ -153,16 +182,30 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
return Message{}, ErrNudgeTooSoon
|
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 {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
svc.metrics.recordChat(ctx, kindNudge)
|
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
|
// 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.
|
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
|
||||||
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
|
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 {
|
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
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// emitChat pushes a chat message to every seated player except the sender
|
// emitChat pushes a chat message to every seated player except the sender and any
|
||||||
// (best-effort live delivery; the recipients still read it via Messages).
|
// recipient in suppressed — a recipient who has blocked the sender, who must never see
|
||||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
|
// 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))
|
intents := make([]notify.Intent, 0, len(seats))
|
||||||
for _, id := range seats {
|
for _, id := range seats {
|
||||||
if id == senderID {
|
if id == senderID || suppressed[id] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
|
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
|
// 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
|
// seated player. Messages from a sender the viewer has blocked are dropped — one
|
||||||
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
|
// 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) {
|
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
|
||||||
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([
|
|||||||
if seat == viewerID {
|
if seat == viewerID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
|
yes, err := svc.store.blockExists(ctx, viewerID, seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send
|
|||||||
return robots, nil
|
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.
|
// 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) {
|
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
|||||||
if requesterID == addresseeID {
|
if requesterID == addresseeID {
|
||||||
return ErrSelfRelation
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
|||||||
}
|
}
|
||||||
return err
|
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
|
return ErrRequestBlocked
|
||||||
}
|
}
|
||||||
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
|
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 {
|
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
|||||||
}
|
}
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID)
|
|||||||
return svc.store.deleteFriendship(ctx, accountID, otherID)
|
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) {
|
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)
|
// 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) {
|
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
|
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ var (
|
|||||||
ErrMessageNotFound = errors.New("social: message not found")
|
ErrMessageNotFound = errors.New("social: message not found")
|
||||||
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
||||||
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
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 is returned when a chat message exceeds the rune limit.
|
||||||
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
||||||
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
||||||
|
|||||||
+21
-8
@@ -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
|
(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
|
"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
|
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
|
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**
|
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
|
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
|
expires after **30 days** and may be re-sent), or **decline** — a decline is
|
||||||
remembered (`status='declined'`) and blocks further requests from that sender,
|
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
|
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
|
deletes the row. (Discovery by friend list or platform deep-link is future work.)
|
||||||
friend list or platform deep-link is future work.)
|
|
||||||
- **Block**: two independent **global** account toggles (`block_chat`,
|
- **Block**: two independent **global** account toggles (`block_chat`,
|
||||||
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
|
`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
|
**asymmetric and non-destructive**: the blocker stops receiving everything **from** the
|
||||||
requests and game invitations between them.
|
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`
|
- **Friend games**: formed by **invitation → accept** (an `game_invitations`
|
||||||
record with one row per invitee). The 2–4 player game starts once **every**
|
record with one row per invitee). The 2–4 player game starts once **every**
|
||||||
invitee accepts; any decline cancels the invitation, and a pending invitation
|
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**
|
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
|
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
|
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
|
`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
|
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
|
**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
|
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
|
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;
|
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
|
the connector renders the message and skips the rest — so in-app-only sub-kinds like
|
||||||
response/withdrawal lobby sync) never becomes a platform push. Operator broadcasts
|
**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
|
(`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
|
**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).
|
cursor-based stream resume stay deferred (single-instance MVP).
|
||||||
|
|||||||
+20
-8
@@ -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
|
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
|
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
|
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
|
friendship. In a game, each opponent's score card (while the move history is open) carries two controls:
|
||||||
move history is open) that mirrors the live relationship: it confirms with a tap on a fading
|
an **add-to-friends 🤝** on the right and a **block ✖️** on the left. Each confirms with a tap
|
||||||
✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is
|
on a fading ✅ — the card reads *Add friend?* (or *Block?*, in red) in place of the score while
|
||||||
pending or was declined, and **disappears** once you are friends — updating in place the
|
confirming — and while one is confirming the other is hidden so they never overlap. The 🤝
|
||||||
moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat
|
goes **disabled** while a request is pending or was declined and **disappears** once you are
|
||||||
and/or friend requests — and block individual players (a per-user block hides that
|
friends; both controls **disappear and the opponent's name is struck through** once you have
|
||||||
person's chat and stops requests and game invitations both ways; it also ends any
|
blocked them. They update in place the moment the relationship changes and stay correct across
|
||||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
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,
|
(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
|
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
|
the field gives way to a short caption until your next turn. Nudge the player whose turn
|
||||||
|
|||||||
+21
-8
@@ -183,14 +183,27 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||||
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол
|
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника (при открытой
|
||||||
**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается
|
истории ходов) несёт два контрола: **в друзья 🤝** справа и **блокировка ✖️** слева. Каждый
|
||||||
тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится
|
подтверждается тапом по затухающей ✅ — на месте счёта карточка показывает *В друзья?* (или
|
||||||
**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия —
|
*Блокируем?* красным) во время подтверждения, — и пока подтверждается один, второй скрыт, чтобы
|
||||||
обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
|
они не пересекались. 🤝 становится **неактивным**, пока заявка висит или была отклонена, и
|
||||||
чат и/или заявки —
|
**исчезает** после принятия; **оба контрола исчезают, а имя соперника становится зачёркнутым**
|
||||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
после блокировки. Они обновляются на месте в момент изменения отношения и остаются верны после
|
||||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
перезагрузки. Блокировка (как и заявка в друзья) применяется немедленно и подтверждается живым
|
||||||
|
событием; при сбое транспорта контрол возвращается в прежнее состояние.
|
||||||
|
|
||||||
|
Глобальная блокировка — отключить входящие чат и/или заявки — либо блокировка **конкретного
|
||||||
|
игрока**. Пер-юзер блок **односторонний и незаметный**: вы перестаёте получать от этого человека
|
||||||
|
всё — чат, nudge, заявки в друзья и приглашения в игру, — и матчмейкер никогда не сводит вас с
|
||||||
|
ним, а он **ничего не замечает**. Его сообщения и nudge по-прежнему отправляются по обычным
|
||||||
|
правилам, но не доходят до вас (всё, что было бы для вас непрочитанным, сразу помечается
|
||||||
|
прочитанным), а присланные им заявка или приглашение сохраняются, но никогда не всплывают. Блок
|
||||||
|
**перекрывает, но не удаляет** имеющуюся дружбу (поэтому можно заблокировать друга, а он
|
||||||
|
продолжает видеть вас в друзьях); активные игры не прерываются — их можно доигрывать, при этом у
|
||||||
|
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
|
||||||
|
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
|
||||||
|
есть только там. Чат
|
||||||
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
|
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
|
||||||
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
||||||
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
||||||
|
|||||||
+10
-6
@@ -131,10 +131,14 @@ except Login uses `Screen`.
|
|||||||
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
|
(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
|
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
|
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
|
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two
|
||||||
🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that
|
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
|
||||||
confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed
|
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
|
||||||
(see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge.
|
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.
|
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
|
- **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
|
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
|
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,
|
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
|
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
|
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
|
||||||
**Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
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
|
- **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
|
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
|
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||||
|
|||||||
@@ -193,6 +193,29 @@ test('game: the add-friend 🤝 confirms with a tap and then reads as sent', asy
|
|||||||
await expect(add).toBeDisabled();
|
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 }) => {
|
test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
|
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
waiting = false,
|
waiting = false,
|
||||||
nudgeOnCooldown = false,
|
nudgeOnCooldown = false,
|
||||||
vsAi = false,
|
vsAi = false,
|
||||||
|
blocked = false,
|
||||||
onsend,
|
onsend,
|
||||||
onnudge,
|
onnudge,
|
||||||
}: {
|
}: {
|
||||||
@@ -38,6 +39,10 @@
|
|||||||
// vsAi disables both controls in an honest-AI game: the opponent is a robot, so there is
|
// 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.
|
// nobody to message or hurry. The fields still show (turn-driven), but disabled.
|
||||||
vsAi?: boolean;
|
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;
|
onsend: (text: string) => void;
|
||||||
onnudge: () => void;
|
onnudge: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !blocked}
|
||||||
<div class="input">
|
<div class="input">
|
||||||
{#if canSend}
|
{#if canSend}
|
||||||
<input
|
<input
|
||||||
@@ -85,6 +91,7 @@
|
|||||||
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || vsAi || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
let messages = $state<ChatMessage[]>([]);
|
let messages = $state<ChatMessage[]>([]);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let tick = $state(0);
|
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 myId = $derived(app.session?.userId ?? '');
|
||||||
const isMyTurn = $derived(
|
const isMyTurn = $derived(
|
||||||
@@ -31,6 +34,14 @@
|
|||||||
const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat);
|
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.
|
// While the auto-match game still has no opponent, chat and nudge are both disabled.
|
||||||
const waiting = $derived(!!view && view.game.status === 'open');
|
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;
|
const nudgeCooldownSecs = 3600;
|
||||||
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
|
// 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.
|
// backend stays authoritative, so a move-based reset is left to it.
|
||||||
@@ -64,9 +75,20 @@
|
|||||||
if (initial) handleError(e);
|
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 () => {
|
onMount(async () => {
|
||||||
await loadState(true);
|
await loadState(true);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
void loadBlocked();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live: refresh the message list on a chat / nudge for this game, and reload the state on
|
// Live: refresh the message list on a chat / nudge for this game, and reload the state on
|
||||||
@@ -84,6 +106,10 @@
|
|||||||
) {
|
) {
|
||||||
void loadState();
|
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.
|
// Re-evaluate the nudge cooldown on a timer so the control re-enables on time.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -123,6 +149,7 @@
|
|||||||
{waiting}
|
{waiting}
|
||||||
{nudgeOnCooldown}
|
{nudgeOnCooldown}
|
||||||
vsAi={!!view && view.game.vsAi}
|
vsAi={!!view && view.game.vsAi}
|
||||||
|
blocked={peerBlocked}
|
||||||
onsend={sendChat}
|
onsend={sendChat}
|
||||||
onnudge={nudge}
|
onnudge={nudge}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+98
-10
@@ -240,6 +240,7 @@
|
|||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
void loadFriends();
|
void loadFriends();
|
||||||
|
void loadBlocked();
|
||||||
});
|
});
|
||||||
|
|
||||||
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
|
// 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')) {
|
} 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.
|
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
||||||
void loadFriends();
|
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);
|
boardPointers.delete(e.pointerId);
|
||||||
boardSwipe = null;
|
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(() => {
|
$effect(() => {
|
||||||
if (!historyShown) addConfirm = {};
|
if (!historyShown) {
|
||||||
|
addConfirm = {};
|
||||||
|
blockConfirm = {};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
||||||
@@ -881,9 +890,15 @@
|
|||||||
// re-send and disable the 🤝).
|
// re-send and disable the 🤝).
|
||||||
let friends = $state(new Set<string>());
|
let friends = $state(new Set<string>());
|
||||||
let requested = $state(new Set<string>());
|
let requested = $state(new Set<string>());
|
||||||
// Per-seat "confirming" flag for the 🤝 → ✅ tap-to-confirm (TapConfirm writes it); while
|
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and
|
||||||
// set, that seat's card shows "Add friend?" in place of the score. Reset when history closes.
|
// 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 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
|
// 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.
|
// 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) {
|
async function addFriend(accountId: string) {
|
||||||
|
const had = requested.has(accountId);
|
||||||
|
requested = new Set([...requested, accountId]);
|
||||||
try {
|
try {
|
||||||
await gateway.friendRequest(accountId);
|
await gateway.friendRequest(accountId);
|
||||||
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
|
||||||
showToast(t('friends.requestSent'));
|
showToast(t('friends.requestSent'));
|
||||||
} catch (e) {
|
} 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);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,9 +976,25 @@
|
|||||||
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
||||||
// opponent still shows it, but disabled).
|
// opponent still shows it, but disabled).
|
||||||
function canAddFriend(accountId: string): boolean {
|
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;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -985,9 +1044,22 @@
|
|||||||
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
|
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#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="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
<div class="nm">{seatName(s)}</div>
|
<div class="nm" class:struck={blocked.has(s.accountId)}>{seatName(s)}</div>
|
||||||
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
|
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
||||||
{#if historyShown && canAddFriend(s.accountId)}
|
{#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">
|
<span class="addfriend">
|
||||||
<TapConfirm
|
<TapConfirm
|
||||||
label={t('friends.addFromGame')}
|
label={t('friends.addFromGame')}
|
||||||
@@ -1426,9 +1498,25 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 1.15rem;
|
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 {
|
.fico {
|
||||||
line-height: 1;
|
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)
|
/* 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. */
|
when the history is opened with unread present, rather than carrying a count badge. */
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export const en = {
|
|||||||
'friends.block': 'Block',
|
'friends.block': 'Block',
|
||||||
'friends.add': 'Add a friend',
|
'friends.add': 'Add a friend',
|
||||||
'friends.addFromGame': 'Add to friends',
|
'friends.addFromGame': 'Add to friends',
|
||||||
|
'friends.blockFromGame': 'Block player',
|
||||||
'friends.requestSent': 'Friend request sent.',
|
'friends.requestSent': 'Friend request sent.',
|
||||||
'friends.getCode': 'Show my code',
|
'friends.getCode': 'Show my code',
|
||||||
'friends.codeHint': 'Give this code to a friend within 12 hours.',
|
'friends.codeHint': 'Give this code to a friend within 12 hours.',
|
||||||
@@ -284,6 +285,7 @@ export const en = {
|
|||||||
'game.exportGcg': 'Export GCG',
|
'game.exportGcg': 'Export GCG',
|
||||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||||
'game.addFriendShort': 'Add friend?',
|
'game.addFriendShort': 'Add friend?',
|
||||||
|
'game.blockShort': 'Block?',
|
||||||
|
|
||||||
'time.minutes': '{n} min',
|
'time.minutes': '{n} min',
|
||||||
'time.hours': '{n} h',
|
'time.hours': '{n} h',
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'friends.block': 'Заблокировать',
|
'friends.block': 'Заблокировать',
|
||||||
'friends.add': 'Добавить друга',
|
'friends.add': 'Добавить друга',
|
||||||
'friends.addFromGame': 'В друзья',
|
'friends.addFromGame': 'В друзья',
|
||||||
|
'friends.blockFromGame': 'Заблокировать',
|
||||||
'friends.requestSent': 'Заявка в друзья отправлена.',
|
'friends.requestSent': 'Заявка в друзья отправлена.',
|
||||||
'friends.getCode': 'Показать мой код',
|
'friends.getCode': 'Показать мой код',
|
||||||
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
|
'friends.codeHint': 'Передайте этот код другу в течение 12 часов.',
|
||||||
@@ -285,6 +286,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.exportGcg': 'Экспорт GCG',
|
'game.exportGcg': 'Экспорт GCG',
|
||||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||||
'game.addFriendShort': 'В друзья?',
|
'game.addFriendShort': 'В друзья?',
|
||||||
|
'game.blockShort': 'Блокируем?',
|
||||||
|
|
||||||
'time.minutes': '{n} мин',
|
'time.minutes': '{n} мин',
|
||||||
'time.hours': '{n} ч',
|
'time.hours': '{n} ч',
|
||||||
|
|||||||
Reference in New Issue
Block a user