Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state #24
@@ -1355,11 +1355,29 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
||||
and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
|
||||
The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
|
||||
- **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle,
|
||||
scaled down ×1.5 below the star); touch drag enlarges the drag ghost ×1.5 (touch only — the
|
||||
finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding
|
||||
into a dragged tile's slot; and **Telegram fullscreen** no longer hides our header under its
|
||||
native nav — the header drops below the content-safe-area top inset and the menu (hamburger)
|
||||
lifts into the nav band, centred (`--tg-content-top` from the SDK + a `tg-fullscreen` class).
|
||||
scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
|
||||
(touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
|
||||
rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
|
||||
cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
|
||||
board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram
|
||||
fullscreen** no longer hides our header under its native nav — the whole header drops below the
|
||||
content-safe-area top inset (title and the right-aligned menu both clear the nav), via
|
||||
`--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way
|
||||
to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our
|
||||
own header and simply push it clear.)
|
||||
- **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games
|
||||
into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last
|
||||
activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a
|
||||
compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB
|
||||
`GameView` gained `last_activity_unix` (the turn start while active, the finish time once
|
||||
finished). The in-game **"add to friends"** item is now **server-derived** (new `GET
|
||||
/user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested —
|
||||
pending **or** declined, which both read as "request sent") so it is correct across reloads, shows
|
||||
a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers:
|
||||
`RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify
|
||||
sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
|
||||
Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
|
||||
reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +15,36 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/social"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// capturePublisher records every published intent for assertions on live events.
|
||||
type capturePublisher struct {
|
||||
mu sync.Mutex
|
||||
intents []notify.Intent
|
||||
}
|
||||
|
||||
func (c *capturePublisher) Publish(in ...notify.Intent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.intents = append(c.intents, in...)
|
||||
}
|
||||
|
||||
// notified reports whether a Notification with the given sub-kind was published to user.
|
||||
func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, in := range c.intents {
|
||||
if in.UserID == user && in.Kind == notify.KindNotification &&
|
||||
string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newSocialService builds a social service over the shared pool, reading game
|
||||
// state through a real game service.
|
||||
func newSocialService() *social.Service {
|
||||
@@ -383,3 +411,93 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
|
||||
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
|
||||
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
|
||||
// still "sent"); a lazily expired pending one drops (it may be re-sent).
|
||||
func TestListOutgoingRequests(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
|
||||
// Pending: outgoing for the requester, not the addressee.
|
||||
_, s1 := newGameWithSeats(t, 2)
|
||||
a, b := s1[0], s1[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b {
|
||||
t.Fatalf("outgoing pending = %v, want [b]", got)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 {
|
||||
t.Fatalf("addressee outgoing = %v, want none", got)
|
||||
}
|
||||
// Accepted: a friendship, no longer an outgoing request.
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 {
|
||||
t.Fatalf("outgoing after accept = %v, want none", got)
|
||||
}
|
||||
|
||||
// Declined: stays outgoing (reads as sent; cannot re-send).
|
||||
_, s2 := newGameWithSeats(t, 2)
|
||||
c, d := s2[0], s2[1]
|
||||
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||
t.Fatalf("send2: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d {
|
||||
t.Fatalf("outgoing after decline = %v, want [d]", got)
|
||||
}
|
||||
|
||||
// Lazily expired pending: omitted (may be re-sent).
|
||||
_, s3 := newGameWithSeats(t, 2)
|
||||
e, f := s3[0], s3[1]
|
||||
if err := svc.SendFriendRequest(ctx, e, f); err != nil {
|
||||
t.Fatalf("send3: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 {
|
||||
t.Fatalf("expired outgoing = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRespondPublishesToRequester checks that answering a request notifies the original
|
||||
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
|
||||
// friend_declined, so a game screen watching that opponent re-derives its friend state.
|
||||
func TestRespondPublishesToRequester(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
|
||||
_, s1 := newGameWithSeats(t, 2)
|
||||
a, b := s1[0], s1[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if !pub.notified(a, notify.NotifyFriendAdded) {
|
||||
t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded)
|
||||
}
|
||||
|
||||
_, s2 := newGameWithSeats(t, 2)
|
||||
c, d := s2[0], s2[1]
|
||||
if err := svc.SendFriendRequest(ctx, c, d); err != nil {
|
||||
t.Fatalf("send2: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil {
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
if !pub.notified(c, notify.NotifyFriendDeclined) {
|
||||
t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ func MatchFound(userID, gameID uuid.UUID) Intent {
|
||||
|
||||
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
||||
// scope its refresh.
|
||||
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
||||
// client may use to scope its refresh.
|
||||
func Notification(userID uuid.UUID, kind string) Intent {
|
||||
b := flatbuffers.NewBuilder(32)
|
||||
k := b.CreateString(kind)
|
||||
|
||||
@@ -34,8 +34,11 @@ const (
|
||||
const (
|
||||
NotifyFriendRequest = "friend_request"
|
||||
NotifyFriendAdded = "friend_added"
|
||||
NotifyInvitation = "invitation"
|
||||
NotifyGameStarted = "game_started"
|
||||
// NotifyFriendDeclined tells the original requester their request was declined, so a
|
||||
// game screen watching that opponent re-derives its "add to friends" state.
|
||||
NotifyFriendDeclined = "friend_declined"
|
||||
NotifyInvitation = "invitation"
|
||||
NotifyGameStarted = "game_started"
|
||||
)
|
||||
|
||||
// Intent is one live event destined for a single user. Payload is the
|
||||
|
||||
@@ -83,16 +83,19 @@ type seatDTO struct {
|
||||
|
||||
// gameDTO is the shared game summary.
|
||||
type gameDTO struct {
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
DictVersion string `json:"dict_version"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
ToMove int `json:"to_move"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
Seats []seatDTO `json:"seats"`
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
DictVersion string `json:"dict_version"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
ToMove int `json:"to_move"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||
// game, the finish time once finished (Stage 17).
|
||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||
Seats []seatDTO `json:"seats"`
|
||||
}
|
||||
|
||||
// moveResultDTO is the outcome of a committed move.
|
||||
@@ -189,17 +192,22 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
||||
IsWinner: s.IsWinner,
|
||||
})
|
||||
}
|
||||
last := g.TurnStartedAt
|
||||
if g.FinishedAt != nil {
|
||||
last = *g.FinishedAt
|
||||
}
|
||||
return gameDTO{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
LastActivityUnix: last.Unix(),
|
||||
Seats: seats,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/games/:id/nudge", s.handleNudge)
|
||||
u.GET("/friends", s.handleListFriends)
|
||||
u.GET("/friends/incoming", s.handleIncomingRequests)
|
||||
u.GET("/friends/outgoing", s.handleOutgoingRequests)
|
||||
u.POST("/friends/request", s.handleFriendRequest)
|
||||
u.POST("/friends/respond", s.handleFriendRespond)
|
||||
u.POST("/friends/cancel", s.handleFriendCancel)
|
||||
|
||||
@@ -31,6 +31,12 @@ type incomingListDTO struct {
|
||||
Requests []accountRefDTO `json:"requests"`
|
||||
}
|
||||
|
||||
// outgoingListDTO is the addressees the caller has already requested (a live pending
|
||||
// request or one the addressee declined) and therefore cannot re-request.
|
||||
type outgoingListDTO struct {
|
||||
Requests []accountRefDTO `json:"requests"`
|
||||
}
|
||||
|
||||
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
||||
type friendCodeDTO struct {
|
||||
Code string `json:"code"`
|
||||
@@ -218,6 +224,22 @@ func (s *Server) handleIncomingRequests(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||
}
|
||||
|
||||
// handleOutgoingRequests returns the addressees the caller has already requested
|
||||
// (pending or declined) and cannot re-request.
|
||||
func (s *Server) handleOutgoingRequests(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
abortBadRequest(c, "missing identity")
|
||||
return
|
||||
}
|
||||
ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)})
|
||||
}
|
||||
|
||||
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
||||
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
|
||||
@@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
|
||||
if !ok {
|
||||
return ErrRequestNotFound
|
||||
}
|
||||
// Tell the original requester their request was answered, so a game screen watching
|
||||
// this opponent re-derives its "add to friends" state (accepted -> friends, declined
|
||||
// -> stays "request sent").
|
||||
if accept {
|
||||
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded))
|
||||
} else {
|
||||
svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI
|
||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
}
|
||||
|
||||
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||
// cannot (re-)request: a live (not yet expired) pending request, or one the addressee
|
||||
// permanently declined. The game's "add to friends" item reads it to stay disabled
|
||||
// across reloads (a declined request reads identically to a still-pending one).
|
||||
func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
}
|
||||
|
||||
// loadEdges returns every friendship row between a and b in either direction (at
|
||||
// most one per direction). It feeds SendFriendRequest's re-send classification.
|
||||
func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) {
|
||||
@@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listOutgoingRequests returns the addressees of the caller's requests that block a
|
||||
// re-send: a live (created after cutoff) pending request, or a permanently declined
|
||||
// one. An ignored pending request that has lazily expired is omitted (it may be re-sent).
|
||||
func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Friendships.AddresseeID).
|
||||
FROM(table.Friendships).
|
||||
WHERE(
|
||||
table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)).
|
||||
OR(table.Friendships.Status.EQ(postgres.String(friendPending)).
|
||||
AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))))),
|
||||
)
|
||||
var rows []model.Friendships
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list outgoing requests: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.AddresseeID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// edgeEither matches a friendship row between a and b in either direction.
|
||||
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
||||
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
||||
|
||||
@@ -473,8 +473,10 @@ including the mover**, so the mover's own other devices and their lobby refresh
|
||||
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
|
||||
(from the social service), **match-found** (from the matchmaker, §8), and **notify**
|
||||
(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request,
|
||||
friend-added, invitation or game-started; emitted on a friend-request and invitation
|
||||
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
|
||||
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||
on answering one (accept → friend-added, decline → friend-declined — to the original
|
||||
requester, so a game screen watching that opponent re-derives its "add to friends" state,
|
||||
Stage 17), and on an invitation create or its game start). Event payloads are FlatBuffers-encoded by
|
||||
the backend and forwarded verbatim. A client that is not currently streaming falls
|
||||
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
||||
badge** (incoming friend requests + open invitations), the client polls on lobby
|
||||
|
||||
+9
-2
@@ -58,7 +58,11 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
||||
two accounts share a game still in progress.
|
||||
|
||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
||||
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
|
||||
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
|
||||
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||||
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||||
opponent-turn and finished games are most-recent first; it renders as a compact,
|
||||
line-separated list (Stage 17). The game types offered on **New Game** are
|
||||
limited to the languages the player's sign-in service supports (English → Scrabble;
|
||||
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
|
||||
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
|
||||
@@ -111,7 +115,10 @@ digits, valid for twelve hours), or send a **request to someone you have played
|
||||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||||
friendship. Block globally — switch off incoming chat
|
||||
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||
answers, and staying correct across reloads (Stage 17). Block globally — switch off incoming chat
|
||||
and/or friend requests — and block individual players (a per-user block hides that
|
||||
person's chat and stops requests and game invitations both ways; it also ends any
|
||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||
|
||||
@@ -59,7 +59,11 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
### Лобби и подбор *(Stage 4 / 15)*
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
|
||||
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
||||
@@ -113,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
|
||||
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||
чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
|
||||
@@ -93,16 +93,17 @@ type SeatResp struct {
|
||||
|
||||
// GameResp is the shared game summary.
|
||||
type GameResp struct {
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
DictVersion string `json:"dict_version"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
ToMove int `json:"to_move"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
Seats []SeatResp `json:"seats"`
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
DictVersion string `json:"dict_version"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
ToMove int `json:"to_move"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||
Seats []SeatResp `json:"seats"`
|
||||
}
|
||||
|
||||
// MoveResultResp is the outcome of a committed move.
|
||||
|
||||
@@ -25,6 +25,12 @@ type IncomingListResp struct {
|
||||
Requests []AccountRefResp `json:"requests"`
|
||||
}
|
||||
|
||||
// OutgoingListResp is the addressees the caller has already requested (a live pending
|
||||
// request or one the addressee declined) and cannot re-request.
|
||||
type OutgoingListResp struct {
|
||||
Requests []AccountRefResp `json:"requests"`
|
||||
}
|
||||
|
||||
// FriendCodeResp is a freshly issued one-time friend code.
|
||||
type FriendCodeResp struct {
|
||||
Code string `json:"code"`
|
||||
@@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListOutgoing returns the addressees the caller has already requested (pending or
|
||||
// declined) and cannot re-request.
|
||||
func (c *Client) ListOutgoing(ctx context.Context, userID string) (OutgoingListResp, error) {
|
||||
var out OutgoingListResp
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/outgoing", userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// IssueFriendCode issues a one-time friend code for the caller.
|
||||
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
||||
var out FriendCodeResp
|
||||
|
||||
@@ -357,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
|
||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||
fb.GameViewAddEndReason(b, endReason)
|
||||
fb.GameViewAddSeats(b, seats)
|
||||
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||
return fb.GameViewEnd(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeOutgoingList builds an OutgoingRequestList payload.
|
||||
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
|
||||
fb.OutgoingRequestListStart(b)
|
||||
fb.OutgoingRequestListAddRequests(b, v)
|
||||
b.Finish(fb.OutgoingRequestListEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeBlockList builds a BlockList payload.
|
||||
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
const (
|
||||
MsgFriendsList = "friends.list"
|
||||
MsgFriendsIncoming = "friends.incoming"
|
||||
MsgFriendsOutgoing = "friends.outgoing"
|
||||
MsgFriendRequest = "friends.request"
|
||||
MsgFriendRespond = "friends.respond"
|
||||
MsgFriendCancel = "friends.cancel"
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
||||
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
|
||||
@@ -78,6 +80,16 @@ func friendsIncomingHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func friendsOutgoingHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
res, err := backend.ListOutgoing(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeOutgoingList(res), nil
|
||||
}
|
||||
}
|
||||
|
||||
func friendRequestHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
||||
|
||||
@@ -54,6 +54,35 @@ func TestFriendsListRoundTripDecodesNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendsOutgoingRoundTrip(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/user/friends/outgoing" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, ok := reg.Lookup(transcode.MsgFriendsOutgoing)
|
||||
if !ok {
|
||||
t.Fatal("friends.outgoing not registered")
|
||||
}
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
ol := fb.GetRootAsOutgoingRequestList(payload, 0)
|
||||
if ol.RequestsLength() != 1 {
|
||||
t.Fatalf("outgoing length = %d, want 1", ol.RequestsLength())
|
||||
}
|
||||
var ref fb.AccountRef
|
||||
ol.Requests(&ref, 0)
|
||||
if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" {
|
||||
t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendRequestForwardsTarget(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
||||
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
||||
if string(g.Id()) != "g-1" {
|
||||
t.Errorf("game id = %q, want g-1", g.Id())
|
||||
}
|
||||
if g.LastActivityUnix() != 1717000000 {
|
||||
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
|
||||
}
|
||||
var seat fb.SeatView
|
||||
g.Seats(&seat, 1)
|
||||
if string(seat.DisplayName()) != "Ann" {
|
||||
|
||||
+13
-2
@@ -66,6 +66,9 @@ table GameView {
|
||||
move_count:int;
|
||||
end_reason:string;
|
||||
seats:[SeatView];
|
||||
// last_activity_unix is the lobby sort key: the current turn's start for an active
|
||||
// game, the finish time for a finished one (Stage 17).
|
||||
last_activity_unix:long;
|
||||
}
|
||||
|
||||
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||
@@ -389,6 +392,13 @@ table IncomingRequestList {
|
||||
requests:[AccountRef];
|
||||
}
|
||||
|
||||
// OutgoingRequestList is the accounts the caller has already requested and cannot
|
||||
// (re-)request: a live pending request or one the addressee declined. The game's
|
||||
// "add to friends" item reads it to stay disabled across reloads (Stage 17).
|
||||
table OutgoingRequestList {
|
||||
requests:[AccountRef];
|
||||
}
|
||||
|
||||
// FriendCode is a freshly issued one-time add-a-friend code (returned once).
|
||||
table FriendCode {
|
||||
code:string;
|
||||
@@ -492,8 +502,9 @@ table MatchFoundEvent {
|
||||
|
||||
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
||||
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
||||
// discriminator ("friend_request", "friend_added", "invitation", "game_started");
|
||||
// the client re-fetches its lobby counters on any of them.
|
||||
// discriminator ("friend_request", "friend_added", "friend_declined", "invitation",
|
||||
// "game_started"); the client re-fetches its lobby counters (and, for a requester
|
||||
// watching a game, its friend state) on any of them.
|
||||
table NotificationEvent {
|
||||
kind:string;
|
||||
}
|
||||
|
||||
@@ -149,8 +149,20 @@ func (rcv *GameView) SeatsLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *GameView) LastActivityUnix() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *GameView) MutateLastActivityUnix(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(24, n)
|
||||
}
|
||||
|
||||
func GameViewStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(10)
|
||||
builder.StartObject(11)
|
||||
}
|
||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
||||
@@ -185,6 +197,9 @@ func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT)
|
||||
func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) {
|
||||
builder.PrependInt64Slot(10, lastActivityUnix, 0)
|
||||
}
|
||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type OutgoingRequestList struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &OutgoingRequestList{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &OutgoingRequestList{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *OutgoingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *OutgoingRequestList) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *OutgoingRequestList) Requests(obj *AccountRef, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
x = rcv._tab.Indirect(x)
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *OutgoingRequestList) RequestsLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func OutgoingRequestListStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
}
|
||||
func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0)
|
||||
}
|
||||
func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -8,7 +8,7 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
|
||||
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
|
||||
await expect(page.getByText('Active games')).toBeVisible();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
const activeRow = page.getByRole('button', { name: /Ann/ });
|
||||
await expect(activeRow).toBeVisible();
|
||||
await activeRow.click();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures';
|
||||
async function loginLobby(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await expect(page.getByText('Active games')).toBeVisible();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openFriends(page: Page): Promise<void> {
|
||||
@@ -107,7 +107,7 @@ test('play with friends: a game type is required to send an invitation', async (
|
||||
await expect(send).toBeEnabled();
|
||||
|
||||
await send.click(); // the mock creates it and returns to the lobby
|
||||
await expect(page.getByText('Active games')).toBeVisible();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
|
||||
await page.goto('/');
|
||||
|
||||
// No guest-login click: the Mini App authenticates from initData and lands on the lobby.
|
||||
await expect(page.getByText('Active games')).toBeVisible();
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
|
||||
// The Telegram themeParams override the background token at runtime.
|
||||
await expect
|
||||
|
||||
+28
-4
@@ -189,6 +189,7 @@
|
||||
rackIds = cached.view.rack.map((_, i) => i);
|
||||
}
|
||||
void load();
|
||||
void loadFriends();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -201,6 +202,9 @@
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
|
||||
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
|
||||
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
|
||||
// A request the player sent was answered (accepted -> now friends; declined -> stays
|
||||
// "request sent"): re-derive the in-game friend state.
|
||||
else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends();
|
||||
});
|
||||
|
||||
// Tick the nudge cooldown while the chat is open so the control re-enables on time.
|
||||
@@ -681,13 +685,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Friend state for the in-game "add to friends" item, derived from the server so it is
|
||||
// correct across reloads and live-updates when a request is answered (Stage 17):
|
||||
// `friends` are the caller's accepted friends; `requested` are the addressees already
|
||||
// requested (pending or declined — both block a re-send and read as "request sent").
|
||||
let friends = $state(new Set<string>());
|
||||
let requested = $state(new Set<string>());
|
||||
const noop = () => {};
|
||||
|
||||
// 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.
|
||||
async function loadFriends() {
|
||||
if (app.profile?.isGuest) return;
|
||||
try {
|
||||
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
|
||||
friends = new Set(fl.map((f) => f.accountId));
|
||||
requested = new Set(out.map((f) => f.accountId));
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
async function addFriend(accountId: string) {
|
||||
try {
|
||||
await gateway.friendRequest(accountId);
|
||||
requested = new Set([...requested, accountId]);
|
||||
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
||||
showToast(t('friends.requestSent'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@@ -707,9 +729,11 @@
|
||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||
...(!app.profile?.isGuest
|
||||
? opponents.map((s) =>
|
||||
requested.has(s.accountId)
|
||||
? { label: t('game.requestSent'), onclick: noop, disabled: true }
|
||||
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
|
||||
friends.has(s.accountId)
|
||||
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
|
||||
: requested.has(s.accountId)
|
||||
? { label: t('game.requestSent'), onclick: noop, disabled: true }
|
||||
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
|
||||
)
|
||||
: []),
|
||||
...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
|
||||
|
||||
@@ -44,6 +44,7 @@ export { MoveResult } from './scrabblefb/move-result.js';
|
||||
export { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
||||
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
|
||||
export { OutgoingRequestList } from './scrabblefb/outgoing-request-list.js';
|
||||
export { PlayTile } from './scrabblefb/play-tile.js';
|
||||
export { Profile } from './scrabblefb/profile.js';
|
||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
|
||||
|
||||
@@ -88,8 +88,13 @@ seatsLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
lastActivityUnix():bigint {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 24);
|
||||
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
|
||||
}
|
||||
|
||||
static startGameView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(10);
|
||||
builder.startObject(11);
|
||||
}
|
||||
|
||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||
@@ -144,12 +149,16 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) {
|
||||
builder.addFieldInt64(10, lastActivityUnix, BigInt('0'));
|
||||
}
|
||||
|
||||
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset {
|
||||
GameView.startGameView(builder);
|
||||
GameView.addId(builder, idOffset);
|
||||
GameView.addVariant(builder, variantOffset);
|
||||
@@ -161,6 +170,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
||||
GameView.addMoveCount(builder, moveCount);
|
||||
GameView.addEndReason(builder, endReasonOffset);
|
||||
GameView.addSeats(builder, seatsOffset);
|
||||
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
||||
return GameView.endGameView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AccountRef } from '../scrabblefb/account-ref.js';
|
||||
|
||||
|
||||
export class OutgoingRequestList {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):OutgoingRequestList {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList {
|
||||
return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
requests(index: number, obj?:AccountRef):AccountRef|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
requestsLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startOutgoingRequestList(builder:flatbuffers.Builder) {
|
||||
builder.startObject(1);
|
||||
}
|
||||
|
||||
static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, requestsOffset, 0);
|
||||
}
|
||||
|
||||
static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startRequestsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
OutgoingRequestList.startOutgoingRequestList(builder);
|
||||
OutgoingRequestList.addRequests(builder, requestsOffset);
|
||||
return OutgoingRequestList.endOutgoingRequestList(builder);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,8 @@ export interface GatewayClient {
|
||||
// --- friends (Stage 8) ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
/** Addressees the caller has already requested (pending or declined); cannot re-request. */
|
||||
friendsOutgoing(): Promise<AccountRef[]>;
|
||||
friendRequest(accountId: string): Promise<void>;
|
||||
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
||||
friendCancel(accountId: string): Promise<void>;
|
||||
|
||||
@@ -249,6 +249,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
||||
turnTimeoutSecs: g.turnTimeoutSecs(),
|
||||
moveCount: g.moveCount(),
|
||||
endReason: s(g.endReason()),
|
||||
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||
seats,
|
||||
};
|
||||
}
|
||||
@@ -587,6 +588,16 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeOutgoingList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
for (let i = 0; i < l.requestsLength(); i++) {
|
||||
const r = l.requests(i);
|
||||
if (r) out.push(decodeAccountRef(r));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
|
||||
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
||||
const out: AccountRef[] = [];
|
||||
@@ -678,6 +689,7 @@ function emptyGame(): GameView {
|
||||
turnTimeoutSecs: 0,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: 0,
|
||||
seats: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ export const en = {
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
'game.requestSent': 'Request sent',
|
||||
'game.alreadyFriends': '✓ In friends',
|
||||
|
||||
'time.minutes': '{n} min',
|
||||
'time.hours': '{n} h',
|
||||
|
||||
@@ -242,6 +242,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
'game.requestSent': 'Запрос отправлен',
|
||||
'game.alreadyFriends': '✓ В друзьях',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { groupGames, isMyTurn } from './lobbysort';
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
const ME = 'me';
|
||||
const seat = (s: number, accountId: string): Seat => ({
|
||||
seat: s,
|
||||
accountId,
|
||||
displayName: accountId,
|
||||
score: 0,
|
||||
hintsUsed: 0,
|
||||
isWinner: false,
|
||||
});
|
||||
|
||||
function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView {
|
||||
return {
|
||||
id,
|
||||
variant: 'english',
|
||||
dictVersion: 'v1',
|
||||
status,
|
||||
players: 2,
|
||||
toMove,
|
||||
turnTimeoutSecs: 0,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix,
|
||||
seats: [seat(0, ME), seat(1, 'opp')],
|
||||
};
|
||||
}
|
||||
|
||||
describe('groupGames', () => {
|
||||
it('partitions into your-turn, their-turn and finished', () => {
|
||||
const g = groupGames(
|
||||
[
|
||||
game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn
|
||||
game('b', 'active', 1, 100), // their turn
|
||||
game('c', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['b']);
|
||||
expect(g.finished.map((x) => x.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('orders your-turn oldest-first, the other two newest-first', () => {
|
||||
const g = groupGames(
|
||||
[
|
||||
game('y_new', 'active', 0, 200),
|
||||
game('y_old', 'active', 0, 100),
|
||||
game('t_new', 'active', 1, 200),
|
||||
game('t_old', 'active', 1, 100),
|
||||
game('f_new', 'finished', 0, 200),
|
||||
game('f_old', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']);
|
||||
expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']);
|
||||
});
|
||||
|
||||
it('isMyTurn is false for a finished game even at my seat', () => {
|
||||
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
|
||||
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three
|
||||
// sections — games awaiting the caller's move, games awaiting the opponent, and finished
|
||||
// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on
|
||||
// top), the other two newest-first.
|
||||
|
||||
import type { GameView } from './model';
|
||||
|
||||
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
|
||||
export function isMyTurn(game: GameView, myId: string): boolean {
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
return game.status === 'active' && !!me && game.toMove === me.seat;
|
||||
}
|
||||
|
||||
/** LobbyGroups holds the three ordered lobby sections. */
|
||||
export interface LobbyGroups {
|
||||
yourTurn: GameView[];
|
||||
theirTurn: GameView[];
|
||||
finished: GameView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* groupGames partitions games for myId into the three lobby sections and orders each: the
|
||||
* your-turn games by ascending last activity (the longest-waiting first), the opponent-turn
|
||||
* and finished games by descending last activity (the most recent first).
|
||||
*/
|
||||
export function groupGames(games: GameView[], myId: string): LobbyGroups {
|
||||
const yourTurn: GameView[] = [];
|
||||
const theirTurn: GameView[] = [];
|
||||
const finished: GameView[] = [];
|
||||
for (const g of games) {
|
||||
if (g.status !== 'active') finished.push(g);
|
||||
else if (isMyTurn(g, myId)) yourTurn.push(g);
|
||||
else theirTurn.push(g);
|
||||
}
|
||||
yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix);
|
||||
theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
|
||||
finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix);
|
||||
return { yourTurn, theirTurn, finished };
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export class MockGateway implements GatewayClient {
|
||||
private pendingMatch: string | null = null;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||
private outgoing: AccountRef[] = [];
|
||||
private blocks: AccountRef[] = [];
|
||||
private invitations: Invitation[] = mockInvitations();
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
@@ -155,6 +156,7 @@ export class MockGateway implements GatewayClient {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||
seats: [
|
||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
|
||||
@@ -372,8 +374,15 @@ export class MockGateway implements GatewayClient {
|
||||
async friendsIncoming(): Promise<AccountRef[]> {
|
||||
return this.incoming.map((f) => ({ ...f }));
|
||||
}
|
||||
async friendRequest(_accountId: string): Promise<void> {
|
||||
// The real backend requires a shared game; the mock simply acknowledges.
|
||||
async friendsOutgoing(): Promise<AccountRef[]> {
|
||||
return this.outgoing.map((f) => ({ ...f }));
|
||||
}
|
||||
async friendRequest(accountId: string): Promise<void> {
|
||||
// The real backend requires a shared game; the mock records the outgoing request so
|
||||
// the game's "add to friends" item reads as sent across reloads.
|
||||
if (!this.outgoing.some((o) => o.accountId === accountId)) {
|
||||
this.outgoing.push({ accountId, displayName: this.nameFor(accountId) });
|
||||
}
|
||||
}
|
||||
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
|
||||
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
|
||||
|
||||
@@ -43,10 +43,9 @@ export const PROFILE: Profile = {
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
|
||||
export const MOCK_FRIENDS: AccountRef[] = [
|
||||
{ accountId: 'ann', displayName: 'Ann' },
|
||||
{ accountId: 'kaya', displayName: 'Kaya' },
|
||||
];
|
||||
// Ann is the active game's opponent but deliberately not a friend, so the in-game
|
||||
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
|
||||
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
|
||||
|
||||
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
||||
|
||||
@@ -144,6 +143,7 @@ function activeGame(): MockGame {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: G1_MOVES.length,
|
||||
endReason: '',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
|
||||
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
||||
},
|
||||
moves: G1_MOVES,
|
||||
@@ -177,6 +177,7 @@ function finishedG2(): MockGame {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 2,
|
||||
endReason: 'normal',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
|
||||
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
||||
},
|
||||
moves: [
|
||||
@@ -211,6 +212,7 @@ function finishedG3(): MockGame {
|
||||
turnTimeoutSecs: 86400,
|
||||
moveCount: 1,
|
||||
endReason: 'resignation',
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
|
||||
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
||||
},
|
||||
moves: [
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface GameView {
|
||||
turnTimeoutSecs: number;
|
||||
moveCount: number;
|
||||
endReason: string;
|
||||
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
|
||||
lastActivityUnix: number;
|
||||
seats: Seat[];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
turnTimeoutSecs: 0,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
lastActivityUnix: 0,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async friendsIncoming() {
|
||||
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
||||
},
|
||||
async friendsOutgoing() {
|
||||
return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty()));
|
||||
},
|
||||
async friendRequest(accountId) {
|
||||
await exec('friends.request', codec.encodeTarget(accountId));
|
||||
},
|
||||
|
||||
+41
-16
@@ -9,6 +9,7 @@
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { resultBadge } from '../lib/result';
|
||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||
import { groupGames } from '../lib/lobbysort';
|
||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||
|
||||
let games = $state<GameView[]>([]);
|
||||
@@ -46,8 +47,7 @@
|
||||
});
|
||||
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
const active = $derived(games.filter((g) => g.status === 'active'));
|
||||
const finished = $derived(games.filter((g) => g.status !== 'active'));
|
||||
const groups = $derived(groupGames(games, myId));
|
||||
|
||||
function opponents(g: GameView): string {
|
||||
return g.seats
|
||||
@@ -129,25 +129,26 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
|
||||
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)}
|
||||
{#if group.list.length}
|
||||
<section>
|
||||
<h2>{t(group.h as 'lobby.activeGames')}</h2>
|
||||
{#each group.list as g (g.id)}
|
||||
{@const b = resultBadge(g, myId)}
|
||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
||||
<span class="info">
|
||||
<span class="who">{opponents(g) || '—'}</span>
|
||||
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
|
||||
</span>
|
||||
<span class="emoji">{b.emoji}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||||
<div class="list">
|
||||
{#each group.list as g (g.id)}
|
||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
||||
<span class="info">
|
||||
<span class="who">{opponents(g) || '—'}</span>
|
||||
<span class="sub">{scoreline(g)}</span>
|
||||
</span>
|
||||
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !active.length && !finished.length && !invitations.length}
|
||||
{#if !games.length && !invitations.length}
|
||||
<p class="empty">{t('lobby.noActive')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -186,7 +187,6 @@
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
.row,
|
||||
.invite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -202,6 +202,31 @@
|
||||
border-radius: var(--radius);
|
||||
user-select: none;
|
||||
}
|
||||
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||||
consecutive rows (Stage 17). */
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
.row + .row {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.row:active {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user