feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.

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