Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
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 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Lobby: group the my-games list into your-turn / opponent-turn / finished (empty sections hidden), ordered by last activity (your-turn oldest-first, the other two newest-first), as a compact line-separated list. gameDTO and FB GameView gain last_activity_unix (turn start while active, finish time once finished); a pure lib/lobbysort.ts holds the grouping/ordering. Friends: the in-game 'add to friends' item is now server-derived via a new GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with a pending OR declined request (both read as 'request sent'), so it is correct across reloads; it shows a disabled '✓ in friends' once accepted. It live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests; backend integration ListOutgoingRequests + respond-publishes-to-requester; e2e updated for the new lobby section labels + a non-friend active opponent. Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN.
This commit is contained in:
@@ -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").
|
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).
|
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,
|
- **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
|
scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
|
||||||
finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding
|
(touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
|
||||||
into a dragged tile's slot; and **Telegram fullscreen** no longer hides our header under its
|
rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
|
||||||
native nav — the header drops below the content-safe-area top inset and the menu (hamburger)
|
cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
|
||||||
lifts into the nav band, centred (`--tg-content-top` from the SDK + a `tg-fullscreen` class).
|
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)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +15,36 @@ import (
|
|||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/social"
|
"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
|
// newSocialService builds a social service over the shared pool, reading game
|
||||||
// state through a real game service.
|
// state through a real game service.
|
||||||
func newSocialService() *social.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)
|
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
|
// Notification is a lightweight "re-poll" signal to userID that a friend request or
|
||||||
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
// invitation changed. kind is a sub-discriminator (NotifyFriendRequest,
|
||||||
// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to
|
// NotifyFriendAdded, NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted) the
|
||||||
// scope its refresh.
|
// client may use to scope its refresh.
|
||||||
func Notification(userID uuid.UUID, kind string) Intent {
|
func Notification(userID uuid.UUID, kind string) Intent {
|
||||||
b := flatbuffers.NewBuilder(32)
|
b := flatbuffers.NewBuilder(32)
|
||||||
k := b.CreateString(kind)
|
k := b.CreateString(kind)
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ const (
|
|||||||
const (
|
const (
|
||||||
NotifyFriendRequest = "friend_request"
|
NotifyFriendRequest = "friend_request"
|
||||||
NotifyFriendAdded = "friend_added"
|
NotifyFriendAdded = "friend_added"
|
||||||
NotifyInvitation = "invitation"
|
// NotifyFriendDeclined tells the original requester their request was declined, so a
|
||||||
NotifyGameStarted = "game_started"
|
// 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
|
// 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.
|
// gameDTO is the shared game summary.
|
||||||
type gameDTO struct {
|
type gameDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
DictVersion string `json:"dict_version"`
|
DictVersion string `json:"dict_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Players int `json:"players"`
|
Players int `json:"players"`
|
||||||
ToMove int `json:"to_move"`
|
ToMove int `json:"to_move"`
|
||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
Seats []seatDTO `json:"seats"`
|
// 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.
|
// moveResultDTO is the outcome of a committed move.
|
||||||
@@ -189,17 +192,22 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
|||||||
IsWinner: s.IsWinner,
|
IsWinner: s.IsWinner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
last := g.TurnStartedAt
|
||||||
|
if g.FinishedAt != nil {
|
||||||
|
last = *g.FinishedAt
|
||||||
|
}
|
||||||
return gameDTO{
|
return gameDTO{
|
||||||
ID: g.ID.String(),
|
ID: g.ID.String(),
|
||||||
Variant: g.Variant.String(),
|
Variant: g.Variant.String(),
|
||||||
DictVersion: g.DictVersion,
|
DictVersion: g.DictVersion,
|
||||||
Status: g.Status,
|
Status: g.Status,
|
||||||
Players: g.Players,
|
Players: g.Players,
|
||||||
ToMove: g.ToMove,
|
ToMove: g.ToMove,
|
||||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||||
MoveCount: g.MoveCount,
|
MoveCount: g.MoveCount,
|
||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
LastActivityUnix: last.Unix(),
|
||||||
|
Seats: seats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/games/:id/nudge", s.handleNudge)
|
u.POST("/games/:id/nudge", s.handleNudge)
|
||||||
u.GET("/friends", s.handleListFriends)
|
u.GET("/friends", s.handleListFriends)
|
||||||
u.GET("/friends/incoming", s.handleIncomingRequests)
|
u.GET("/friends/incoming", s.handleIncomingRequests)
|
||||||
|
u.GET("/friends/outgoing", s.handleOutgoingRequests)
|
||||||
u.POST("/friends/request", s.handleFriendRequest)
|
u.POST("/friends/request", s.handleFriendRequest)
|
||||||
u.POST("/friends/respond", s.handleFriendRespond)
|
u.POST("/friends/respond", s.handleFriendRespond)
|
||||||
u.POST("/friends/cancel", s.handleFriendCancel)
|
u.POST("/friends/cancel", s.handleFriendCancel)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type incomingListDTO struct {
|
|||||||
Requests []accountRefDTO `json:"requests"`
|
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).
|
// friendCodeDTO is a freshly issued one-time friend code (returned once).
|
||||||
type friendCodeDTO struct {
|
type friendCodeDTO struct {
|
||||||
Code string `json:"code"`
|
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)})
|
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.
|
// handleIssueFriendCode issues a one-time add-a-friend code for the caller.
|
||||||
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
func (s *Server) handleIssueFriendCode(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ErrRequestNotFound
|
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
|
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))
|
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
|
// loadEdges returns every friendship row between a and b in either direction (at
|
||||||
// most one per direction). It feeds SendFriendRequest's re-send classification.
|
// 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) {
|
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
|
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.
|
// edgeEither matches a friendship row between a and b in either direction.
|
||||||
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
|
||||||
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
|
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**
|
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**
|
(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,
|
(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
|
friend-added, friend-declined, invitation or game-started; emitted on a friend-request,
|
||||||
create and on an invitation's game start). Event payloads are FlatBuffers-encoded by
|
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
|
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
|
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
|
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.
|
two accounts share a game still in progress.
|
||||||
|
|
||||||
### Lobby & matchmaking *(Stage 4 / 15)*
|
### 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;
|
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
|
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
|
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
|
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||||||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||||||
friendship. 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
|
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
|
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
|
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||||
|
|
||||||
### Лобби и подбор *(Stage 4 / 15)*
|
### Лобби и подбор *(Stage 4 / 15)*
|
||||||
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
|
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||||
|
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||||
|
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||||
|
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||||
|
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
|
||||||
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
|
||||||
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
|
||||||
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
|
||||||
@@ -113,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат
|
|||||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||||
снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие
|
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||||
|
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||||
|
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||||
|
в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие
|
||||||
чат и/или заявки —
|
чат и/или заявки —
|
||||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||||
|
|||||||
@@ -93,16 +93,17 @@ type SeatResp struct {
|
|||||||
|
|
||||||
// GameResp is the shared game summary.
|
// GameResp is the shared game summary.
|
||||||
type GameResp struct {
|
type GameResp struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
DictVersion string `json:"dict_version"`
|
DictVersion string `json:"dict_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Players int `json:"players"`
|
Players int `json:"players"`
|
||||||
ToMove int `json:"to_move"`
|
ToMove int `json:"to_move"`
|
||||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||||
MoveCount int `json:"move_count"`
|
MoveCount int `json:"move_count"`
|
||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
Seats []SeatResp `json:"seats"`
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
|
Seats []SeatResp `json:"seats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResultResp is the outcome of a committed move.
|
// MoveResultResp is the outcome of a committed move.
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type IncomingListResp struct {
|
|||||||
Requests []AccountRefResp `json:"requests"`
|
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.
|
// FriendCodeResp is a freshly issued one-time friend code.
|
||||||
type FriendCodeResp struct {
|
type FriendCodeResp struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
@@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR
|
|||||||
return out, err
|
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.
|
// IssueFriendCode issues a one-time friend code for the caller.
|
||||||
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) {
|
||||||
var out FriendCodeResp
|
var out FriendCodeResp
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
|
|||||||
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
fb.GameViewAddMoveCount(b, int32(g.MoveCount))
|
||||||
fb.GameViewAddEndReason(b, endReason)
|
fb.GameViewAddEndReason(b, endReason)
|
||||||
fb.GameViewAddSeats(b, seats)
|
fb.GameViewAddSeats(b, seats)
|
||||||
|
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
|
|||||||
return b.FinishedBytes()
|
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.
|
// encodeBlockList builds a BlockList payload.
|
||||||
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
func encodeBlockList(r backendclient.BlockListResp) []byte {
|
||||||
b := flatbuffers.NewBuilder(256)
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
MsgFriendsList = "friends.list"
|
MsgFriendsList = "friends.list"
|
||||||
MsgFriendsIncoming = "friends.incoming"
|
MsgFriendsIncoming = "friends.incoming"
|
||||||
|
MsgFriendsOutgoing = "friends.outgoing"
|
||||||
MsgFriendRequest = "friends.request"
|
MsgFriendRequest = "friends.request"
|
||||||
MsgFriendRespond = "friends.respond"
|
MsgFriendRespond = "friends.respond"
|
||||||
MsgFriendCancel = "friends.cancel"
|
MsgFriendCancel = "friends.cancel"
|
||||||
@@ -37,6 +38,7 @@ const (
|
|||||||
func registerStage8(r *Registry, backend *backendclient.Client) {
|
func registerStage8(r *Registry, backend *backendclient.Client) {
|
||||||
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(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[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
|
||||||
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(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 {
|
func friendRequestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsTargetRequest(req.Payload, 0)
|
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) {
|
func TestFriendRequestForwardsTarget(t *testing.T) {
|
||||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
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" {
|
if r.URL.Path != "/api/v1/user/games" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if string(g.Id()) != "g-1" {
|
if string(g.Id()) != "g-1" {
|
||||||
t.Errorf("game id = %q, want g-1", g.Id())
|
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
|
var seat fb.SeatView
|
||||||
g.Seats(&seat, 1)
|
g.Seats(&seat, 1)
|
||||||
if string(seat.DisplayName()) != "Ann" {
|
if string(seat.DisplayName()) != "Ann" {
|
||||||
|
|||||||
+13
-2
@@ -66,6 +66,9 @@ table GameView {
|
|||||||
move_count:int;
|
move_count:int;
|
||||||
end_reason:string;
|
end_reason:string;
|
||||||
seats:[SeatView];
|
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).
|
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||||
@@ -389,6 +392,13 @@ table IncomingRequestList {
|
|||||||
requests:[AccountRef];
|
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).
|
// FriendCode is a freshly issued one-time add-a-friend code (returned once).
|
||||||
table FriendCode {
|
table FriendCode {
|
||||||
code:string;
|
code:string;
|
||||||
@@ -492,8 +502,9 @@ table MatchFoundEvent {
|
|||||||
|
|
||||||
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
// NotificationEvent is a lightweight "something changed, re-poll" signal that
|
||||||
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
// drives the lobby badge (incoming friend requests, invitations). kind is a sub-
|
||||||
// discriminator ("friend_request", "friend_added", "invitation", "game_started");
|
// discriminator ("friend_request", "friend_added", "friend_declined", "invitation",
|
||||||
// the client re-fetches its lobby counters on any of them.
|
// "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 {
|
table NotificationEvent {
|
||||||
kind:string;
|
kind:string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,20 @@ func (rcv *GameView) SeatsLength() int {
|
|||||||
return 0
|
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) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(10)
|
builder.StartObject(11)
|
||||||
}
|
}
|
||||||
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
|
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 {
|
func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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 {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
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 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/ });
|
const activeRow = page.getByRole('button', { name: /Ann/ });
|
||||||
await expect(activeRow).toBeVisible();
|
await expect(activeRow).toBeVisible();
|
||||||
await activeRow.click();
|
await activeRow.click();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures';
|
|||||||
async function loginLobby(page: Page): Promise<void> {
|
async function loginLobby(page: Page): Promise<void> {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
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> {
|
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 expect(send).toBeEnabled();
|
||||||
|
|
||||||
await send.click(); // the mock creates it and returns to the lobby
|
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 }) => {
|
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('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// No guest-login click: the Mini App authenticates from initData and lands on the lobby.
|
// 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.
|
// The Telegram themeParams override the background token at runtime.
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
+28
-4
@@ -189,6 +189,7 @@
|
|||||||
rackIds = cached.view.rack.map((_, i) => i);
|
rackIds = cached.view.rack.map((_, i) => i);
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
|
void loadFriends();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -201,6 +202,9 @@
|
|||||||
} else if (e.kind === 'your_turn' && e.gameId === id) void load();
|
} 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 === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
|
||||||
else if (e.kind === 'nudge' && e.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.
|
// 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>());
|
let requested = $state(new Set<string>());
|
||||||
const noop = () => {};
|
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) {
|
async function addFriend(accountId: string) {
|
||||||
try {
|
try {
|
||||||
await gateway.friendRequest(accountId);
|
await gateway.friendRequest(accountId);
|
||||||
requested = new Set([...requested, accountId]);
|
requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends
|
||||||
showToast(t('friends.requestSent'));
|
showToast(t('friends.requestSent'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -707,9 +729,11 @@
|
|||||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||||
...(!app.profile?.isGuest
|
...(!app.profile?.isGuest
|
||||||
? opponents.map((s) =>
|
? opponents.map((s) =>
|
||||||
requested.has(s.accountId)
|
friends.has(s.accountId)
|
||||||
? { label: t('game.requestSent'), onclick: noop, disabled: true }
|
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
|
||||||
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
|
: 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) }]),
|
...(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 { NotificationEvent } from './scrabblefb/notification-event.js';
|
||||||
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
export { NudgeEvent } from './scrabblefb/nudge-event.js';
|
||||||
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-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 { PlayTile } from './scrabblefb/play-tile.js';
|
||||||
export { Profile } from './scrabblefb/profile.js';
|
export { Profile } from './scrabblefb/profile.js';
|
||||||
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.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;
|
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) {
|
static startGameView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(10);
|
builder.startObject(11);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||||
@@ -144,12 +149,16 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
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 {
|
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
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.startGameView(builder);
|
||||||
GameView.addId(builder, idOffset);
|
GameView.addId(builder, idOffset);
|
||||||
GameView.addVariant(builder, variantOffset);
|
GameView.addVariant(builder, variantOffset);
|
||||||
@@ -161,6 +170,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
|||||||
GameView.addMoveCount(builder, moveCount);
|
GameView.addMoveCount(builder, moveCount);
|
||||||
GameView.addEndReason(builder, endReasonOffset);
|
GameView.addEndReason(builder, endReasonOffset);
|
||||||
GameView.addSeats(builder, seatsOffset);
|
GameView.addSeats(builder, seatsOffset);
|
||||||
|
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
||||||
return GameView.endGameView(builder);
|
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) ---
|
// --- friends (Stage 8) ---
|
||||||
friendsList(): Promise<AccountRef[]>;
|
friendsList(): Promise<AccountRef[]>;
|
||||||
friendsIncoming(): 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>;
|
friendRequest(accountId: string): Promise<void>;
|
||||||
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
friendRespond(requesterId: string, accept: boolean): Promise<void>;
|
||||||
friendCancel(accountId: string): Promise<void>;
|
friendCancel(accountId: string): Promise<void>;
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
|||||||
turnTimeoutSecs: g.turnTimeoutSecs(),
|
turnTimeoutSecs: g.turnTimeoutSecs(),
|
||||||
moveCount: g.moveCount(),
|
moveCount: g.moveCount(),
|
||||||
endReason: s(g.endReason()),
|
endReason: s(g.endReason()),
|
||||||
|
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -587,6 +588,16 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
|
|||||||
return out;
|
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[] {
|
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
|
||||||
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
|
||||||
const out: AccountRef[] = [];
|
const out: AccountRef[] = [];
|
||||||
@@ -678,6 +689,7 @@ function emptyGame(): GameView {
|
|||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: 0,
|
||||||
seats: [],
|
seats: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export const en = {
|
|||||||
'game.exportGcg': 'Export GCG',
|
'game.exportGcg': 'Export GCG',
|
||||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||||
'game.requestSent': 'Request sent',
|
'game.requestSent': 'Request sent',
|
||||||
|
'game.alreadyFriends': '✓ In friends',
|
||||||
|
|
||||||
'time.minutes': '{n} min',
|
'time.minutes': '{n} min',
|
||||||
'time.hours': '{n} h',
|
'time.hours': '{n} h',
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.exportGcg': 'Экспорт GCG',
|
'game.exportGcg': 'Экспорт GCG',
|
||||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||||
'game.requestSent': 'Запрос отправлен',
|
'game.requestSent': 'Запрос отправлен',
|
||||||
|
'game.alreadyFriends': '✓ В друзьях',
|
||||||
|
|
||||||
'time.minutes': '{n} мин',
|
'time.minutes': '{n} мин',
|
||||||
'time.hours': '{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 pendingMatch: string | null = null;
|
||||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||||
|
private outgoing: AccountRef[] = [];
|
||||||
private blocks: AccountRef[] = [];
|
private blocks: AccountRef[] = [];
|
||||||
private invitations: Invitation[] = mockInvitations();
|
private invitations: Invitation[] = mockInvitations();
|
||||||
private readonly stats: Stats = { ...MOCK_STATS };
|
private readonly stats: Stats = { ...MOCK_STATS };
|
||||||
@@ -155,6 +156,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||||
seats: [
|
seats: [
|
||||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: 'robot', displayName: 'Robo', 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[]> {
|
async friendsIncoming(): Promise<AccountRef[]> {
|
||||||
return this.incoming.map((f) => ({ ...f }));
|
return this.incoming.map((f) => ({ ...f }));
|
||||||
}
|
}
|
||||||
async friendRequest(_accountId: string): Promise<void> {
|
async friendsOutgoing(): Promise<AccountRef[]> {
|
||||||
// The real backend requires a shared game; the mock simply acknowledges.
|
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> {
|
async friendRespond(requesterId: string, accept: boolean): Promise<void> {
|
||||||
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
|
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
|
// 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.
|
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
|
||||||
export const MOCK_FRIENDS: AccountRef[] = [
|
// Ann is the active game's opponent but deliberately not a friend, so the in-game
|
||||||
{ accountId: 'ann', displayName: 'Ann' },
|
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
|
||||||
{ accountId: 'kaya', displayName: 'Kaya' },
|
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
|
||||||
];
|
|
||||||
|
|
||||||
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
||||||
|
|
||||||
@@ -144,6 +143,7 @@ function activeGame(): MockGame {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: G1_MOVES.length,
|
moveCount: G1_MOVES.length,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
|
||||||
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)],
|
||||||
},
|
},
|
||||||
moves: G1_MOVES,
|
moves: G1_MOVES,
|
||||||
@@ -177,6 +177,7 @@ function finishedG2(): MockGame {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: 2,
|
moveCount: 2,
|
||||||
endReason: 'normal',
|
endReason: 'normal',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
|
||||||
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)],
|
||||||
},
|
},
|
||||||
moves: [
|
moves: [
|
||||||
@@ -211,6 +212,7 @@ function finishedG3(): MockGame {
|
|||||||
turnTimeoutSecs: 86400,
|
turnTimeoutSecs: 86400,
|
||||||
moveCount: 1,
|
moveCount: 1,
|
||||||
endReason: 'resignation',
|
endReason: 'resignation',
|
||||||
|
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
|
||||||
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)],
|
||||||
},
|
},
|
||||||
moves: [
|
moves: [
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface GameView {
|
|||||||
turnTimeoutSecs: number;
|
turnTimeoutSecs: number;
|
||||||
moveCount: number;
|
moveCount: number;
|
||||||
endReason: string;
|
endReason: string;
|
||||||
|
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
|
||||||
|
lastActivityUnix: number;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
|||||||
turnTimeoutSecs: 0,
|
turnTimeoutSecs: 0,
|
||||||
moveCount: 0,
|
moveCount: 0,
|
||||||
endReason: '',
|
endReason: '',
|
||||||
|
lastActivityUnix: 0,
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async friendsIncoming() {
|
async friendsIncoming() {
|
||||||
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
return codec.decodeIncomingList(await exec('friends.incoming', codec.empty()));
|
||||||
},
|
},
|
||||||
|
async friendsOutgoing() {
|
||||||
|
return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty()));
|
||||||
|
},
|
||||||
async friendRequest(accountId) {
|
async friendRequest(accountId) {
|
||||||
await exec('friends.request', codec.encodeTarget(accountId));
|
await exec('friends.request', codec.encodeTarget(accountId));
|
||||||
},
|
},
|
||||||
|
|||||||
+41
-16
@@ -9,6 +9,7 @@
|
|||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { resultBadge } from '../lib/result';
|
import { resultBadge } from '../lib/result';
|
||||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||||
|
import { groupGames } from '../lib/lobbysort';
|
||||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||||
|
|
||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
@@ -46,8 +47,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const myId = $derived(app.session?.userId ?? '');
|
const myId = $derived(app.session?.userId ?? '');
|
||||||
const active = $derived(games.filter((g) => g.status === 'active'));
|
const groups = $derived(groupGames(games, myId));
|
||||||
const finished = $derived(games.filter((g) => g.status !== 'active'));
|
|
||||||
|
|
||||||
function opponents(g: GameView): string {
|
function opponents(g: GameView): string {
|
||||||
return g.seats
|
return g.seats
|
||||||
@@ -129,25 +129,26 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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}
|
{#if group.list.length}
|
||||||
<section>
|
<section>
|
||||||
<h2>{t(group.h as 'lobby.activeGames')}</h2>
|
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||||||
{#each group.list as g (g.id)}
|
<div class="list">
|
||||||
{@const b = resultBadge(g, myId)}
|
{#each group.list as g (g.id)}
|
||||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
||||||
<span class="info">
|
<span class="info">
|
||||||
<span class="who">{opponents(g) || '—'}</span>
|
<span class="who">{opponents(g) || '—'}</span>
|
||||||
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
|
<span class="sub">{scoreline(g)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="emoji">{b.emoji}</span>
|
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if !active.length && !finished.length && !invitations.length}
|
{#if !games.length && !invitations.length}
|
||||||
<p class="empty">{t('lobby.noActive')}</p>
|
<p class="empty">{t('lobby.noActive')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +187,6 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.row,
|
|
||||||
.invite {
|
.invite {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -202,6 +202,31 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
user-select: none;
|
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 {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user