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:
@@ -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/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Block records that blockerID has blocked blockedID. Blocking severs any
|
||||
// friendship or pending request between the two and, through the mutual block
|
||||
// checks, suppresses chat visibility and new requests/invitations in both
|
||||
// directions. It is idempotent.
|
||||
// Block records that blockerID has blocked blockedID. The block is asymmetric and
|
||||
// non-destructive: it suppresses everything the blocked user sends toward the blocker
|
||||
// (chat, nudge, friend requests, invitations are persisted but never delivered or
|
||||
// surfaced) and hides the blocked user from the blocker's lists, while the blocked
|
||||
// user notices nothing. It does NOT delete the friendship — an unblock cleanly
|
||||
// restores it — and keeps every stored row. As a courtesy it marks any of the blocked
|
||||
// user's still-unread chat entries in their shared games read at once, so no stale
|
||||
// unread badge lingers for someone the blocker no longer sees. A user_blocked event is
|
||||
// delivered to the blocker (only) so their other sessions update in place. It is idempotent.
|
||||
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
if blockerID == blockedID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
return svc.store.insertBlock(ctx, blockerID, blockedID)
|
||||
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unblock removes blockerID's block on blockedID. It is idempotent.
|
||||
// Unblock removes blockerID's block on blockedID and confirms it to the (former)
|
||||
// blocker with a user_unblocked event so their open game screens restore the controls
|
||||
// and un-strike the name in place. Any friendship the block had been overriding becomes
|
||||
// effective again. It is idempotent.
|
||||
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
return svc.store.deleteBlock(ctx, blockerID, blockedID)
|
||||
if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, blockedID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBlocks returns the account IDs blockerID has blocked.
|
||||
@@ -35,11 +52,43 @@ func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid
|
||||
return svc.store.listBlocks(ctx, blockerID)
|
||||
}
|
||||
|
||||
// BlockedWith returns every account that has a block with accountID in either
|
||||
// direction (those accountID blocked, plus those who blocked accountID), de-duplicated.
|
||||
// The matchmaker excludes them so a block — either way — keeps the pair out of the same
|
||||
// anonymous auto-match game.
|
||||
func (svc *Service) BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
mine, err := svc.store.listBlocks(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
theirs, err := svc.store.listBlockedBy(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[uuid.UUID]struct{}, len(mine)+len(theirs))
|
||||
out := make([]uuid.UUID, 0, len(mine)+len(theirs))
|
||||
for _, id := range append(mine, theirs...) {
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsBlocked reports whether a block stands between a and b in either direction.
|
||||
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return svc.store.isBlocked(ctx, a, b)
|
||||
}
|
||||
|
||||
// Blocks reports whether blocker has blocked blocked — the exact direction, not the
|
||||
// symmetric IsBlocked. It backs the directional guards (chat, friend requests,
|
||||
// invitations) that must tell the blocker (refuse) from the blocked (silently suppress).
|
||||
func (svc *Service) Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
|
||||
return svc.store.blockExists(ctx, blocker, blocked)
|
||||
}
|
||||
|
||||
// isBlocked reports whether a block row exists between a and b in either direction.
|
||||
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
@@ -58,14 +107,33 @@ func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock severs any friendship between the pair and inserts the block, in one
|
||||
// transaction; a duplicate block is ignored.
|
||||
// blockExists reports whether blocker has blocked blocked — the exact direction, not
|
||||
// the symmetric isBlocked. The asymmetric block turns on this one-directional test:
|
||||
// the blocker filters out and refuses the blocked user, while the blocked user's own
|
||||
// actions are silently suppressed rather than refused, so they never notice.
|
||||
func (s *Store) blockExists(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
|
||||
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))).
|
||||
LIMIT(1)
|
||||
var row model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("social: block exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock inserts the block and, in the same transaction, marks read every chat
|
||||
// entry the blocked user authored that the blocker had still left unread across their
|
||||
// shared games — so no stale unread badge lingers for someone the blocker no longer
|
||||
// sees. It deliberately leaves the friendship intact (the block overrides it; an
|
||||
// unblock restores it). A duplicate block is ignored.
|
||||
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear friendship on block: %w", err)
|
||||
}
|
||||
ins := table.Blocks.
|
||||
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
||||
VALUES(blocker, blocked).
|
||||
@@ -73,6 +141,17 @@ func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) err
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert block: %w", err)
|
||||
}
|
||||
// Clear the blocker's unread bit on every entry the blocked user sent, in any game
|
||||
// they share, resolving the blocker's seat through game_players. The bitwise terms
|
||||
// are cast through int4 so the operators resolve unambiguously (as in markRead).
|
||||
const clearUnread = `UPDATE backend.chat_messages m
|
||||
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
||||
FROM backend.game_players p
|
||||
WHERE p.account_id = $1 AND p.game_id = m.game_id
|
||||
AND m.sender_id = $2 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
|
||||
if _, err := tx.ExecContext(ctx, clearUnread, blocker, blocked); err != nil {
|
||||
return fmt.Errorf("clear unread on block: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -104,3 +183,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listBlockedBy returns the accounts that have blocked blocked — the reverse of
|
||||
// listBlocks. The matchmaker unions the two so a block in either direction keeps a
|
||||
// pair out of the same auto-match game.
|
||||
func (s *Store) listBlockedBy(ctx context.Context, blocked uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))
|
||||
var rows []model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list blocked by: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.BlockerID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
+124
-13
@@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
if err := Clean(body); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
// A disguised robot opponent never reads chat, so its recipient bit is born clear.
|
||||
// Blocker-side guard: a sender whose only opponents are people they have blocked cannot
|
||||
// post (the composer is hidden client-side; this is the server-side counterpart). In a
|
||||
// multi-player game with a non-blocked opponent the post is allowed and reaches them.
|
||||
if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil {
|
||||
return Message{}, err
|
||||
} else if guard {
|
||||
return Message{}, ErrRecipientBlocked
|
||||
}
|
||||
// Recipients whose copy is born read so it never shows as unread: a disguised robot
|
||||
// opponent (never opens the chat) and any recipient who has blocked the sender — the
|
||||
// latter is also dropped from live delivery below, so the block stays invisible to the
|
||||
// blocked sender while the blocker sees nothing.
|
||||
autoRead, err := svc.robotRecipients(ctx, seats, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead))
|
||||
suppressed, err := svc.blockedRecipients(ctx, seats, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed)))
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
svc.metrics.recordChat(ctx, kindMessage)
|
||||
svc.emitChat(seats, senderID, msg)
|
||||
svc.emitChat(seats, senderID, msg, suppressed)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
if idx == toMove {
|
||||
return Message{}, ErrNudgeOnOwnTurn
|
||||
}
|
||||
// The awaited player is the nudge's sole recipient.
|
||||
var target uuid.UUID
|
||||
if toMove >= 0 && toMove < len(seats) {
|
||||
target = seats[toMove]
|
||||
}
|
||||
// Blocker-side guard: a sender who has blocked the awaited player cannot nudge them
|
||||
// (the control is hidden client-side; this is the server-side counterpart).
|
||||
if target != uuid.Nil {
|
||||
if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil {
|
||||
return Message{}, err
|
||||
} else if guard {
|
||||
return Message{}, ErrRecipientBlocked
|
||||
}
|
||||
}
|
||||
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
@@ -153,16 +182,30 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
return Message{}, ErrNudgeTooSoon
|
||||
}
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
|
||||
// Suppress when the awaited player has blocked the sender: the nudge still persists and
|
||||
// counts toward the once-per-hour cooldown (the sender notices nothing) but is born read
|
||||
// (mask 0) and never delivered, so the blocker sees no nudge.
|
||||
var suppressed bool
|
||||
if target != uuid.Nil {
|
||||
suppressed, err = svc.store.blockExists(ctx, target, senderID)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
}
|
||||
var mask int16
|
||||
if !suppressed && target != uuid.Nil {
|
||||
mask = int16(1) << uint(toMove)
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, mask)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
svc.metrics.recordChat(ctx, kindNudge)
|
||||
if toMove >= 0 && toMove < len(seats) {
|
||||
if !suppressed && target != uuid.Nil {
|
||||
// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
|
||||
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
|
||||
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
|
||||
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
|
||||
nudge := notify.Nudge(target, gameID, senderID, senderName)
|
||||
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
|
||||
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
|
||||
}
|
||||
@@ -187,12 +230,13 @@ func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// emitChat pushes a chat message to every seated player except the sender
|
||||
// (best-effort live delivery; the recipients still read it via Messages).
|
||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
|
||||
// emitChat pushes a chat message to every seated player except the sender and any
|
||||
// recipient in suppressed — a recipient who has blocked the sender, who must never see
|
||||
// it (best-effort live delivery; the other recipients still read it via Messages).
|
||||
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) {
|
||||
intents := make([]notify.Intent, 0, len(seats))
|
||||
for _, id := range seats {
|
||||
if id == senderID {
|
||||
if id == senderID || suppressed[id] {
|
||||
continue
|
||||
}
|
||||
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
|
||||
@@ -208,8 +252,9 @@ func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID)
|
||||
}
|
||||
|
||||
// Messages returns the per-game chat visible to viewerID: the viewer must be a
|
||||
// seated player. Messages from a sender the viewer has a block with (either
|
||||
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
|
||||
// seated player. Messages from a sender the viewer has blocked are dropped — one
|
||||
// direction only, so a blocked player still sees the blocker's messages and never
|
||||
// notices the block — and if the viewer has disabled chat only nudges remain.
|
||||
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
|
||||
seats, _, _, err := svc.games.Participants(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([
|
||||
if seat == viewerID {
|
||||
continue
|
||||
}
|
||||
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
|
||||
yes, err := svc.store.blockExists(ctx, viewerID, seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send
|
||||
return robots, nil
|
||||
}
|
||||
|
||||
// blockedRecipients returns the seated recipients (every non-empty seat but the sender)
|
||||
// that have blocked the sender. Their copy of a message or nudge is born read and is not
|
||||
// delivered — the store-but-hide that keeps a block invisible to the blocked sender while
|
||||
// denying the blocker any sight of what they send.
|
||||
func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
var out map[uuid.UUID]bool
|
||||
for _, id := range seats {
|
||||
if id == uuid.Nil || id == senderID {
|
||||
continue
|
||||
}
|
||||
yes, err := svc.store.blockExists(ctx, id, senderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if yes {
|
||||
if out == nil {
|
||||
out = make(map[uuid.UUID]bool)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// senderBlocksEveryOpponent reports whether senderID has blocked every other seated
|
||||
// player (and there is at least one). It is the blocker-side chat guard: a player whose
|
||||
// only opponents are people they have blocked cannot post. In a multi-player game that
|
||||
// still has a non-blocked opponent it is false, so the player can talk to the others.
|
||||
func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) {
|
||||
opponents := 0
|
||||
for _, id := range seats {
|
||||
if id == uuid.Nil || id == senderID {
|
||||
continue
|
||||
}
|
||||
opponents++
|
||||
blocked, err := svc.store.blockExists(ctx, senderID, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !blocked {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return opponents > 0, nil
|
||||
}
|
||||
|
||||
// mergeSeatSets returns the union of two seat sets; either may be nil. It folds the
|
||||
// born-read recipients (disguised robots and recipients who blocked the sender) into one
|
||||
// mask input.
|
||||
func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool {
|
||||
if len(b) == 0 {
|
||||
return a
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return b
|
||||
}
|
||||
out := make(map[uuid.UUID]bool, len(a)+len(b))
|
||||
for id := range a {
|
||||
out[id] = true
|
||||
}
|
||||
for id := range b {
|
||||
out[id] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
|
||||
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
|
||||
id, err := uuid.NewV7()
|
||||
|
||||
@@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if requesterID == addresseeID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
|
||||
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
if blocked || addressee.BlockFriendRequests {
|
||||
if iBlockThem {
|
||||
// The requester has blocked the addressee — they are the blocker and aware of it.
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
// When the addressee has blocked the requester, the request still proceeds and persists
|
||||
// by the normal logic below, but it is never delivered and the blocker never sees it
|
||||
// (ListIncomingRequests filters it out) — the blocked requester must not learn of the
|
||||
// block. The flag gates only the notification; it also bypasses the addressee's
|
||||
// block_friend_requests toggle so the suppressed request looks ordinary to the requester.
|
||||
suppressed := theyBlockMe
|
||||
if !suppressed && addressee.BlockFriendRequests {
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
|
||||
@@ -102,7 +116,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
if !suppressed {
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
if !suppressed {
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID)
|
||||
return svc.store.deleteFriendship(ctx, accountID, otherID)
|
||||
}
|
||||
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID.
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID, with any
|
||||
// the caller has blocked filtered out: a block overrides — but does not delete — the
|
||||
// friendship, so the blocker stops seeing the blocked friend while the blocked user's own
|
||||
// list still shows the blocker (they never notice).
|
||||
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listFriends(ctx, accountID)
|
||||
ids, err := svc.store.listFriends(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.dropBlocked(ctx, accountID, ids)
|
||||
}
|
||||
|
||||
// ListIncomingRequests returns the account IDs that have a live (not yet expired)
|
||||
// pending friend request awaiting accountID's response.
|
||||
// pending friend request awaiting accountID's response, with any from a requester the
|
||||
// caller has blocked filtered out (their suppressed request stays stored but unseen).
|
||||
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.dropBlocked(ctx, accountID, ids)
|
||||
}
|
||||
|
||||
// dropBlocked removes from ids every account the viewer has blocked. It keeps the
|
||||
// asymmetric block one-directional: the blocker stops seeing those they blocked in their
|
||||
// friend / incoming-request lists, while the blocked user's lists are never filtered.
|
||||
func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
|
||||
if len(ids) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
blockedIDs, err := svc.store.listBlocks(ctx, viewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blockedIDs) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
blocked := make(map[uuid.UUID]struct{}, len(blockedIDs))
|
||||
for _, id := range blockedIDs {
|
||||
blocked[id] = struct{}{}
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, b := blocked[id]; b {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||
|
||||
@@ -80,6 +80,10 @@ var (
|
||||
ErrMessageNotFound = errors.New("social: message not found")
|
||||
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
||||
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
||||
// ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone
|
||||
// they have blocked — the blocker-side guard behind the hidden composer (a blocked
|
||||
// player's own sends are never refused, only silently suppressed, so they never notice).
|
||||
ErrRecipientBlocked = errors.New("social: cannot message a blocked player")
|
||||
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
||||
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
||||
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
||||
|
||||
Reference in New Issue
Block a user