feat(chat): unread read-receipts with lobby/game dot and history-open ack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).
UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
This commit is contained in:
@@ -34,6 +34,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
|
||||||
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
|
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
|
||||||
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
|
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
|
||||||
|
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
|
||||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|||||||
+5
-1
@@ -41,7 +41,11 @@ is exempt), and the `games.list` response carries an `at_game_limit` flag for th
|
|||||||
`internal/social` owns the friend graph (request/accept),
|
`internal/social` owns the friend graph (request/accept),
|
||||||
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
|
||||||
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
messages are length-capped, content-filtered (no links/emails/phone numbers,
|
||||||
including obfuscated forms) and stored with the sender's IP. `internal/account`
|
including obfuscated forms) and stored with the sender's IP. Each message carries an
|
||||||
|
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
|
||||||
|
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
|
||||||
|
clears a nudge when its recipient moves — both record the publish-to-read latency.
|
||||||
|
`internal/account`
|
||||||
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
|
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
|
||||||
development log mailer). The engine now also handles **multi-player drop-out**: in
|
development log mailer). The engine now also handles **multi-player drop-out**: in
|
||||||
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
a 3–4 player game a resignation or timeout drops that seat and the rest play on
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
socialSvc := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
||||||
|
// A nudge the recipient answered by moving is marked read on the move path.
|
||||||
|
games.SetNudgeClearer(socialSvc.ClearNudges)
|
||||||
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
|
||||||
feedbackSvc.SetNotifier(hub)
|
feedbackSvc.SetNotifier(hub)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
|
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
|
||||||
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||||
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
|
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
|
||||||
|
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
|
||||||
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
|
||||||
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
|
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
|
||||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{with .Data}}
|
||||||
|
<h1>Message</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/messages">« messages</a> · <a href="/_gm/games/{{.GameID}}">game</a> · <a href="/_gm/messages?game={{.GameID}}">game messages</a></nav>
|
||||||
|
<section class="panel"><h2>Summary</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Time</b> {{.CreatedAt}}</li>
|
||||||
|
<li><b>Sender</b> <a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a> ({{.Source}})</li>
|
||||||
|
<li><b>IP</b> {{.IP}}</li>
|
||||||
|
<li><b>Kind</b> {{.Kind}}</li>
|
||||||
|
<li><b>Read</b> {{if .Unread}}unread{{else}}read{{end}}</li>
|
||||||
|
<li><b>Message</b> {{.Body}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Read by seat</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Seat</th><th>Player</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Seats}}
|
||||||
|
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}</a></td><td>{{if eq .Role "read"}}<span class="ok">read</span>{{else if eq .Role "unread"}}unread{{else}}sender{{end}}</td></tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3"><span class="note">no seats</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
|
||||||
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
|
||||||
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
|
||||||
|
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
|
||||||
<button type="submit">Filter</button>
|
<button type="submit">Filter</button>
|
||||||
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
</form>
|
</form>
|
||||||
@@ -13,19 +14,20 @@
|
|||||||
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
|
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}
|
{{range .Items}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.CreatedAt}}</td>
|
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
|
||||||
<td>{{.Source}}</td>
|
<td>{{.Source}}</td>
|
||||||
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
|
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
<td>{{.Body}}</td>
|
<td>{{.Body}}</td>
|
||||||
|
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
|
||||||
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
|
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
|
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -94,11 +94,13 @@ type MessagesView struct {
|
|||||||
ExtMask string
|
ExtMask string
|
||||||
GameID string
|
GameID string
|
||||||
UserID string
|
UserID string
|
||||||
|
UnreadOnly bool
|
||||||
FilterQuery template.URL
|
FilterQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
||||||
// card), source, IP, body, game (linked to the game card) and time.
|
// card), source, IP, body, game (linked to the game card), time, and whether it is still
|
||||||
|
// unread by at least one recipient.
|
||||||
type MessageRow struct {
|
type MessageRow struct {
|
||||||
ID string
|
ID string
|
||||||
SenderID string
|
SenderID string
|
||||||
@@ -108,6 +110,32 @@ type MessageRow struct {
|
|||||||
Body string
|
Body string
|
||||||
GameID string
|
GameID string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
|
Unread bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
|
||||||
|
// console message card.
|
||||||
|
type ChatMessageDetailView struct {
|
||||||
|
ID string
|
||||||
|
GameID string
|
||||||
|
SenderID string
|
||||||
|
SenderName string
|
||||||
|
Source string
|
||||||
|
Kind string
|
||||||
|
Body string
|
||||||
|
IP string
|
||||||
|
CreatedAt string
|
||||||
|
Unread bool
|
||||||
|
Seats []ChatSeatStatusRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
|
||||||
|
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
|
||||||
|
type ChatSeatStatusRow struct {
|
||||||
|
Seat int
|
||||||
|
AccountID string
|
||||||
|
DisplayName string
|
||||||
|
Role string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDetailView is one account with its stats, identities and recent games.
|
// UserDetailView is one account with its stats, identities and recent games.
|
||||||
|
|||||||
@@ -45,8 +45,12 @@ type Service struct {
|
|||||||
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
|
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
|
||||||
// these games). Kept as a func so the game package never imports the robot package.
|
// these games). Kept as a func so the game package never imports the robot package.
|
||||||
aiTrigger func(gameID uuid.UUID)
|
aiTrigger func(gameID uuid.UUID)
|
||||||
metrics *gameMetrics
|
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||||
log *zap.Logger
|
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||||
|
// best-effort and kept as a func so the game package never imports the social package.
|
||||||
|
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
|
||||||
|
metrics *gameMetrics
|
||||||
|
log *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service. store and accounts wrap the same pool;
|
// NewService constructs a Service. store and accounts wrap the same pool;
|
||||||
@@ -89,6 +93,14 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
|
|||||||
svc.aiTrigger = trigger
|
svc.aiTrigger = trigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
|
||||||
|
// their move commits. It must be called during startup wiring; the default (nil)
|
||||||
|
// leaves nudges to be cleared only when the recipient opens the move history or chat.
|
||||||
|
// The social package wires its ClearNudges here.
|
||||||
|
func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, accountID uuid.UUID) error) {
|
||||||
|
svc.clearNudges = fn
|
||||||
|
}
|
||||||
|
|
||||||
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
|
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
|
||||||
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
|
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
|
||||||
// installed, so callers can invoke it unconditionally after a create or commit.
|
// installed, so callers can invoke it unconditionally after a create or commit.
|
||||||
@@ -567,6 +579,13 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
|||||||
return MoveResult{}, err
|
return MoveResult{}, err
|
||||||
}
|
}
|
||||||
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
|
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
|
||||||
|
// A nudge the actor answered by moving stops counting as unread (best-effort; the
|
||||||
|
// move has committed, so a cleanup failure is logged, not surfaced).
|
||||||
|
if svc.clearNudges != nil {
|
||||||
|
if err := svc.clearNudges(ctx, gameID, accountID); err != nil {
|
||||||
|
svc.log.Warn("clear nudges after move", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
// Record the seat's think time (turn start to commit) for the move-duration
|
// Record the seat's think time (turn start to commit) for the move-duration
|
||||||
// metric; the timeout path commits separately and is excluded by design.
|
// metric; the timeout path commits separately and is excluded by design.
|
||||||
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/social"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
|
||||||
|
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
|
||||||
|
func TestChatUnreadAndMarkRead(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
|
||||||
|
|
||||||
|
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", ""); err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
// The opponent has it unread; the sender does not.
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); !unread {
|
||||||
|
t.Error("recipient should have the message unread")
|
||||||
|
}
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
|
||||||
|
t.Error("sender should not have their own message unread")
|
||||||
|
}
|
||||||
|
if set, err := svc.UnreadGames(ctx, seats[1]); err != nil {
|
||||||
|
t.Fatalf("unread games: %v", err)
|
||||||
|
} else if !set[gameID] {
|
||||||
|
t.Error("UnreadGames should include the game for the recipient")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading it clears the recipient's bit and reports one entry marked.
|
||||||
|
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("mark read: %v", err)
|
||||||
|
} else if n != 1 {
|
||||||
|
t.Errorf("marked %d, want 1", n)
|
||||||
|
}
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
|
||||||
|
t.Error("message should be read after MarkRead")
|
||||||
|
}
|
||||||
|
// A second MarkRead is a no-op — nothing is left unread.
|
||||||
|
if n, _ := svc.MarkRead(ctx, gameID, seats[1]); n != 0 {
|
||||||
|
t.Errorf("second mark read marked %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
|
||||||
|
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
|
||||||
|
// targeting invariant, now observable through the unread bitmask.
|
||||||
|
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move
|
||||||
|
|
||||||
|
// A waiting player nudges; only the awaited seat 0 is targeted.
|
||||||
|
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
|
||||||
|
t.Error("the awaited seat should have the nudge unread")
|
||||||
|
}
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
|
||||||
|
t.Error("a non-awaited waiting seat must not receive the nudge")
|
||||||
|
}
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
|
||||||
|
t.Error("the nudging sender must not have it unread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTextMessageUnreadReachesAllOpponents checks a text message in a three-player game marks both
|
||||||
|
// opponents (every seated player but the sender) unread.
|
||||||
|
func TestTextMessageUnreadReachesAllOpponents(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move and may chat
|
||||||
|
|
||||||
|
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hello all", ""); err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range []int{1, 2} {
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[s]); !unread {
|
||||||
|
t.Errorf("seat %d should have the text message unread", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
|
||||||
|
t.Error("the sender should not have their own message unread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNudgeClearedByMove checks the move path clears a nudge the actor answered by moving: the
|
||||||
|
// game service's wired NudgeClearer flips the awaited seat's nudge to read on commit.
|
||||||
|
func TestNudgeClearedByMove(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
gameSvc, gameID, seats, play := newDraftGame(t)
|
||||||
|
socialSvc := newSocialService()
|
||||||
|
gameSvc.SetNudgeClearer(socialSvc.ClearNudges)
|
||||||
|
|
||||||
|
// The waiting seat 1 nudges the to-move seat 0.
|
||||||
|
if _, err := socialSvc.Nudge(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("nudge: %v", err)
|
||||||
|
}
|
||||||
|
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); !unread {
|
||||||
|
t.Fatal("seat 0 should have the nudge unread before moving")
|
||||||
|
}
|
||||||
|
// Seat 0 answers by moving — the commit clears its pending nudge.
|
||||||
|
if _, err := gameSvc.SubmitPlay(ctx, gameID, seats[0], play.Tiles); err != nil {
|
||||||
|
t.Fatalf("submit play: %v", err)
|
||||||
|
}
|
||||||
|
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); unread {
|
||||||
|
t.Error("the nudge should be cleared once the awaited player has moved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
|
||||||
|
// breakdown of the message card.
|
||||||
|
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newSocialService()
|
||||||
|
gameID, seats := newGameWithSeats(t, 2)
|
||||||
|
|
||||||
|
msg, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
// The unread-only filter (scoped to the game) lists the still-unread message.
|
||||||
|
if items, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); err != nil {
|
||||||
|
t.Fatalf("admin list: %v", err)
|
||||||
|
} else if len(items) != 1 || items[0].ID != msg.ID {
|
||||||
|
t.Fatalf("unread filter = %+v, want the one message", items)
|
||||||
|
}
|
||||||
|
// The detail card marks the sender 'sender' and the recipient 'unread'.
|
||||||
|
card, err := svc.AdminMessageDetail(ctx, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
roles := map[uuid.UUID]string{}
|
||||||
|
for _, s := range card.Seats {
|
||||||
|
roles[s.AccountID] = s.Role
|
||||||
|
}
|
||||||
|
if roles[seats[0]] != "sender" {
|
||||||
|
t.Errorf("sender role = %q, want sender", roles[seats[0]])
|
||||||
|
}
|
||||||
|
if roles[seats[1]] != "unread" {
|
||||||
|
t.Errorf("recipient role = %q, want unread", roles[seats[1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the recipient reads, the filter drops it and the seat flips to 'read'.
|
||||||
|
if _, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
|
||||||
|
t.Fatalf("mark read: %v", err)
|
||||||
|
}
|
||||||
|
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); len(items) != 0 {
|
||||||
|
t.Errorf("unread filter should be empty after read, got %d", len(items))
|
||||||
|
}
|
||||||
|
card2, err := svc.AdminMessageDetail(ctx, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail after read: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range card2.Seats {
|
||||||
|
if s.AccountID == seats[1] && s.Role != "read" {
|
||||||
|
t.Errorf("recipient role after read = %q, want read", s.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ChatMessages struct {
|
type ChatMessages struct {
|
||||||
MessageID uuid.UUID `sql:"primary_key"`
|
MessageID uuid.UUID `sql:"primary_key"`
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
SenderID uuid.UUID
|
SenderID uuid.UUID
|
||||||
Kind string
|
Kind string
|
||||||
Body string
|
Body string
|
||||||
SenderIP *string
|
SenderIP *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UnreadSeats int16
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ type chatMessagesTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
MessageID postgres.ColumnString
|
MessageID postgres.ColumnString
|
||||||
GameID postgres.ColumnString
|
GameID postgres.ColumnString
|
||||||
SenderID postgres.ColumnString
|
SenderID postgres.ColumnString
|
||||||
Kind postgres.ColumnString
|
Kind postgres.ColumnString
|
||||||
Body postgres.ColumnString
|
Body postgres.ColumnString
|
||||||
SenderIP postgres.ColumnString
|
SenderIP postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
UnreadSeats postgres.ColumnInteger
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -65,29 +66,31 @@ func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTabl
|
|||||||
|
|
||||||
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
|
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
|
||||||
var (
|
var (
|
||||||
MessageIDColumn = postgres.StringColumn("message_id")
|
MessageIDColumn = postgres.StringColumn("message_id")
|
||||||
GameIDColumn = postgres.StringColumn("game_id")
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
SenderIDColumn = postgres.StringColumn("sender_id")
|
SenderIDColumn = postgres.StringColumn("sender_id")
|
||||||
KindColumn = postgres.StringColumn("kind")
|
KindColumn = postgres.StringColumn("kind")
|
||||||
BodyColumn = postgres.StringColumn("body")
|
BodyColumn = postgres.StringColumn("body")
|
||||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
|
UnreadSeatsColumn = postgres.IntegerColumn("unread_seats")
|
||||||
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
|
||||||
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn, UnreadSeatsColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return chatMessagesTable{
|
return chatMessagesTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
MessageID: MessageIDColumn,
|
MessageID: MessageIDColumn,
|
||||||
GameID: GameIDColumn,
|
GameID: GameIDColumn,
|
||||||
SenderID: SenderIDColumn,
|
SenderID: SenderIDColumn,
|
||||||
Kind: KindColumn,
|
Kind: KindColumn,
|
||||||
Body: BodyColumn,
|
Body: BodyColumn,
|
||||||
SenderIP: SenderIPColumn,
|
SenderIP: SenderIPColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
|
UnreadSeats: UnreadSeatsColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- A per-message bitmask of the seats that have NOT yet read this chat entry: bit i is
|
||||||
|
-- set while seat i still has the message unread, and is cleared when that seat reads it
|
||||||
|
-- (opens the move history / chat, or — for a nudge — takes its move). A text message
|
||||||
|
-- starts with the bits of every seated recipient except the sender; a nudge starts with
|
||||||
|
-- only the awaited player's bit. The mask is inverted so "anything unread" is a plain
|
||||||
|
-- `unread_seats <> 0`, which the lobby badge, the admin filter and the unread gauge all
|
||||||
|
-- use. The read time itself is not retained. See docs/ARCHITECTURE.md §8 (Read receipts).
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN unread_seats smallint NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Partial index serving the unread scans (per-viewer lobby lookup, admin unread filter,
|
||||||
|
-- the unread-count gauge): only the comparatively few still-unread rows are indexed.
|
||||||
|
CREATE INDEX chat_messages_unread_idx ON chat_messages (game_id) WHERE unread_seats <> 0;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
DROP INDEX chat_messages_unread_idx;
|
||||||
|
ALTER TABLE chat_messages DROP COLUMN unread_seats;
|
||||||
@@ -108,6 +108,10 @@ type gameDTO struct {
|
|||||||
// game, the finish time once finished.
|
// game, the finish time once finished.
|
||||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
Seats []seatDTO `json:"seats"`
|
Seats []seatDTO `json:"seats"`
|
||||||
|
// UnreadChat is a per-viewer flag: the requesting player has at least one unread chat
|
||||||
|
// entry (message or nudge) in this game. It seeds the lobby and in-game unread badge.
|
||||||
|
// gameDTOFromGame leaves it false (it has no viewer); the handlers fill it.
|
||||||
|
UnreadChat bool `json:"unread_chat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func (s *Server) registerRoutes() {
|
|||||||
if s.social != nil {
|
if s.social != nil {
|
||||||
u.POST("/games/:id/chat", s.handleChatPost)
|
u.POST("/games/:id/chat", s.handleChatPost)
|
||||||
u.GET("/games/:id/chat", s.handleChatList)
|
u.GET("/games/:id/chat", s.handleChatList)
|
||||||
|
u.POST("/games/:id/chat/read", s.handleChatRead)
|
||||||
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)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -82,6 +83,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
}
|
}
|
||||||
gm.GET("/messages", s.consoleMessages)
|
gm.GET("/messages", s.consoleMessages)
|
||||||
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
gm.GET("/messages.csv", s.consoleMessagesCSV)
|
||||||
|
gm.GET("/messages/:id", s.consoleChatMessageDetail)
|
||||||
gm.GET("/dictionary", s.consoleDictionary)
|
gm.GET("/dictionary", s.consoleDictionary)
|
||||||
gm.POST("/dictionary/upload", s.consoleUploadDictionary)
|
gm.POST("/dictionary/upload", s.consoleUploadDictionary)
|
||||||
gm.POST("/dictionary/install", s.consoleInstallDictionary)
|
gm.POST("/dictionary/install", s.consoleInstallDictionary)
|
||||||
@@ -187,10 +189,11 @@ func (s *Server) consoleMessages(c *gin.Context) {
|
|||||||
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
||||||
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
||||||
filter := social.AdminMessageFilter{
|
filter := social.AdminMessageFilter{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
SenderID: userID,
|
SenderID: userID,
|
||||||
NameMask: c.Query("name"),
|
NameMask: c.Query("name"),
|
||||||
ExtMask: c.Query("ext"),
|
ExtMask: c.Query("ext"),
|
||||||
|
UnreadOnly: c.Query("unread") == "1",
|
||||||
}
|
}
|
||||||
total, _ := s.social.AdminCountMessages(ctx, filter)
|
total, _ := s.social.AdminCountMessages(ctx, filter)
|
||||||
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
|
||||||
@@ -211,10 +214,14 @@ func (s *Server) consoleMessages(c *gin.Context) {
|
|||||||
if strings.TrimSpace(filter.ExtMask) != "" {
|
if strings.TrimSpace(filter.ExtMask) != "" {
|
||||||
q.Set("ext", filter.ExtMask)
|
q.Set("ext", filter.ExtMask)
|
||||||
}
|
}
|
||||||
|
if filter.UnreadOnly {
|
||||||
|
q.Set("unread", "1")
|
||||||
|
}
|
||||||
view := adminconsole.MessagesView{
|
view := adminconsole.MessagesView{
|
||||||
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
Pager: adminconsole.NewPager(page, adminPageSize, total),
|
||||||
NameMask: filter.NameMask,
|
NameMask: filter.NameMask,
|
||||||
ExtMask: filter.ExtMask,
|
ExtMask: filter.ExtMask,
|
||||||
|
UnreadOnly: filter.UnreadOnly,
|
||||||
FilterQuery: template.URL(q.Encode()),
|
FilterQuery: template.URL(q.Encode()),
|
||||||
}
|
}
|
||||||
if filter.GameID != uuid.Nil {
|
if filter.GameID != uuid.Nil {
|
||||||
@@ -227,12 +234,41 @@ func (s *Server) consoleMessages(c *gin.Context) {
|
|||||||
view.Items = append(view.Items, adminconsole.MessageRow{
|
view.Items = append(view.Items, adminconsole.MessageRow{
|
||||||
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
|
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
|
||||||
Source: m.Source, IP: m.SenderIP, Body: m.Body,
|
Source: m.Source, IP: m.SenderIP, Body: m.Body,
|
||||||
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt),
|
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), Unread: m.UnreadSeats != 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "messages", "messages", "Messages", view)
|
s.renderConsole(c, "messages", "messages", "Messages", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleChatMessageDetail renders one chat message with the per-seat read breakdown of
|
||||||
|
// its game (who has read it, who has not).
|
||||||
|
func (s *Server) consoleChatMessageDetail(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/messages")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
card, err := s.social.AdminMessageDetail(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, social.ErrMessageNotFound) {
|
||||||
|
s.renderConsoleMessage(c, "Not found", "no such message", "/_gm/messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := adminconsole.ChatMessageDetailView{
|
||||||
|
ID: card.ID.String(), GameID: card.GameID.String(), SenderID: card.SenderID.String(),
|
||||||
|
SenderName: card.SenderName, Source: card.Source, Kind: card.Kind, Body: card.Body,
|
||||||
|
IP: card.SenderIP, CreatedAt: fmtTime(card.CreatedAt), Unread: card.UnreadSeats != 0,
|
||||||
|
}
|
||||||
|
for _, st := range card.Seats {
|
||||||
|
view.Seats = append(view.Seats, adminconsole.ChatSeatStatusRow{
|
||||||
|
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: st.DisplayName, Role: st.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.renderConsole(c, "chatmessage", "messages", "Message", view)
|
||||||
|
}
|
||||||
|
|
||||||
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
|
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
|
||||||
const adminMessagesExportCap = 100000
|
const adminMessagesExportCap = 100000
|
||||||
|
|
||||||
@@ -243,10 +279,11 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
|
|||||||
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
|
||||||
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
|
||||||
filter := social.AdminMessageFilter{
|
filter := social.AdminMessageFilter{
|
||||||
GameID: gameID,
|
GameID: gameID,
|
||||||
SenderID: userID,
|
SenderID: userID,
|
||||||
NameMask: c.Query("name"),
|
NameMask: c.Query("name"),
|
||||||
ExtMask: c.Query("ext"),
|
ExtMask: c.Query("ext"),
|
||||||
|
UnreadOnly: c.Query("unread") == "1",
|
||||||
}
|
}
|
||||||
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
|
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -256,12 +293,16 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
|
|||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
|
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
|
||||||
w := csv.NewWriter(c.Writer)
|
w := csv.NewWriter(c.Writer)
|
||||||
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
|
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id", "read"})
|
||||||
for _, m := range items {
|
for _, m := range items {
|
||||||
|
read := "read"
|
||||||
|
if m.UnreadSeats != 0 {
|
||||||
|
read = "unread"
|
||||||
|
}
|
||||||
// The sender name and message body are user-controlled; defuse spreadsheet formula
|
// The sender name and message body are user-controlled; defuse spreadsheet formula
|
||||||
// injection so a moderator opening the export can't trigger a formula.
|
// injection so a moderator opening the export can't trigger a formula.
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(),
|
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), read,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|||||||
@@ -439,10 +439,16 @@ func (s *Server) handleListGames(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
memo := map[string]string{}
|
memo := map[string]string{}
|
||||||
|
// One query seeds the lobby's per-card unread badge for every listed game.
|
||||||
|
var unread map[uuid.UUID]bool
|
||||||
|
if s.social != nil {
|
||||||
|
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
|
||||||
|
}
|
||||||
out := make([]gameDTO, 0, len(games))
|
out := make([]gameDTO, 0, len(games))
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
dto := gameDTOFromGame(g)
|
dto := gameDTOFromGame(g)
|
||||||
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
s.fillSeatNames(c.Request.Context(), &dto, memo)
|
||||||
|
dto.UnreadChat = unread[g.ID]
|
||||||
out = append(out, dto)
|
out = append(out, dto)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
|
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
|
||||||
@@ -480,6 +486,22 @@ func (s *Server) handleNudge(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, chatDTOFrom(msg))
|
c.JSON(http.StatusOK, chatDTOFrom(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleChatRead marks every chat entry the caller has not yet read in the game as read
|
||||||
|
// — the acknowledgement the client sends when the player opens the move history or chat
|
||||||
|
// — and records each entry's publish-to-read latency. The client calls it only when it
|
||||||
|
// believes something is unread, so the backend is not hit on every history open.
|
||||||
|
func (s *Server) handleChatRead(c *gin.Context) {
|
||||||
|
uid, gameID, ok := s.userGame(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.social.MarkRead(c.Request.Context(), gameID, uid); err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
// userGame reads the authenticated account and the :id game param, aborting with the
|
// userGame reads the authenticated account and the :id game param, aborting with the
|
||||||
// right status when either is missing. ok is false when the request was aborted.
|
// right status when either is missing. ok is false when the request was aborted.
|
||||||
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
|
||||||
@@ -504,5 +526,24 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||||
|
if uid, ok := userID(c); ok {
|
||||||
|
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, dto)
|
c.JSON(http.StatusOK, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO,
|
||||||
|
// when the social domain is present. It is best-effort: a lookup error leaves the flag
|
||||||
|
// false (a missing badge) rather than failing the surrounding game response.
|
||||||
|
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
|
||||||
|
if s.social == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gid, err := uuid.Parse(g.ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
|
||||||
|
g.UnreadChat = unread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func (s *Server) handleGameState(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||||
|
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
|
||||||
c.JSON(http.StatusOK, dto)
|
c.JSON(http.StatusOK, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package social
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,16 +25,21 @@ type AdminMessage struct {
|
|||||||
Body string
|
Body string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
// UnreadSeats is the message's unread bitmask; non-zero means at least one recipient
|
||||||
|
// has not read it. The console shows a plain read/unread flag derived from it.
|
||||||
|
UnreadSeats int16
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that
|
// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that
|
||||||
// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched
|
// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched
|
||||||
// case-insensitively against the sender's display name / any identity's external id.
|
// case-insensitively against the sender's display name / any identity's external id;
|
||||||
|
// UnreadOnly keeps only messages still unread by at least one recipient.
|
||||||
type AdminMessageFilter struct {
|
type AdminMessageFilter struct {
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
SenderID uuid.UUID
|
SenderID uuid.UUID
|
||||||
NameMask string
|
NameMask string
|
||||||
ExtMask string
|
ExtMask string
|
||||||
|
UnreadOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminListMessages returns the filtered chat messages — real messages only, nudges
|
// AdminListMessages returns the filtered chat messages — real messages only, nudges
|
||||||
@@ -75,12 +82,15 @@ func adminMessageWhere(f AdminMessageFilter) (string, []any) {
|
|||||||
args = append(args, ext)
|
args = append(args, ext)
|
||||||
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
||||||
}
|
}
|
||||||
|
if f.UnreadOnly {
|
||||||
|
where += ` AND m.unread_seats <> 0`
|
||||||
|
}
|
||||||
return where, args
|
return where, args
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
|
func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
|
||||||
where, args := adminMessageWhere(f)
|
where, args := adminMessageWhere(f)
|
||||||
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at
|
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at, m.unread_seats
|
||||||
FROM backend.chat_messages m
|
FROM backend.chat_messages m
|
||||||
JOIN backend.accounts a ON a.account_id = m.sender_id
|
JOIN backend.accounts a ON a.account_id = m.sender_id
|
||||||
WHERE ` + where +
|
WHERE ` + where +
|
||||||
@@ -94,7 +104,7 @@ WHERE ` + where +
|
|||||||
var out []AdminMessage
|
var out []AdminMessage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m AdminMessage
|
var m AdminMessage
|
||||||
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt); err != nil {
|
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt, &m.UnreadSeats); err != nil {
|
||||||
return nil, fmt.Errorf("social: scan admin message: %w", err)
|
return nil, fmt.Errorf("social: scan admin message: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, m)
|
out = append(out, m)
|
||||||
@@ -111,3 +121,78 @@ func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (i
|
|||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminSeatRead is one game seat's status for a message's per-seat read breakdown: the
|
||||||
|
// seat index, its occupant's display name, and its role for the message — "sender",
|
||||||
|
// "read" or "unread". Still-empty (open) seats are omitted.
|
||||||
|
type AdminSeatRead struct {
|
||||||
|
Seat int
|
||||||
|
AccountID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMessageCard is one chat message with the per-seat read breakdown, for the
|
||||||
|
// console's message detail page.
|
||||||
|
type AdminMessageCard struct {
|
||||||
|
AdminMessage
|
||||||
|
Kind string
|
||||||
|
Seats []AdminSeatRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMessageDetail loads one chat message and the read status of every seated player
|
||||||
|
// in its game, for the moderation message card. The per-seat status is derived from the
|
||||||
|
// message's unread bitmask (a set bit is an unread recipient seat); the sender's own
|
||||||
|
// seat is marked "sender".
|
||||||
|
func (svc *Service) AdminMessageDetail(ctx context.Context, messageID uuid.UUID) (AdminMessageCard, error) {
|
||||||
|
return svc.store.adminMessageCard(ctx, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) adminMessageCard(ctx context.Context, messageID uuid.UUID) (AdminMessageCard, error) {
|
||||||
|
var d AdminMessageCard
|
||||||
|
q := `SELECT m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.kind, m.body, COALESCE(m.sender_ip, ''), m.created_at, m.unread_seats
|
||||||
|
FROM backend.chat_messages m
|
||||||
|
JOIN backend.accounts a ON a.account_id = m.sender_id
|
||||||
|
WHERE m.message_id = $1`
|
||||||
|
if err := s.db.QueryRowContext(ctx, q, messageID).Scan(
|
||||||
|
&d.GameID, &d.SenderID, &d.SenderName, &d.Source, &d.Kind, &d.Body, &d.SenderIP, &d.CreatedAt, &d.UnreadSeats,
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return AdminMessageCard{}, ErrMessageNotFound
|
||||||
|
}
|
||||||
|
return AdminMessageCard{}, fmt.Errorf("social: admin message card: %w", err)
|
||||||
|
}
|
||||||
|
d.ID = messageID
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT p.seat, COALESCE(p.account_id::text, ''), p.display_name
|
||||||
|
FROM backend.game_players p WHERE p.game_id = $1 ORDER BY p.seat`, d.GameID)
|
||||||
|
if err != nil {
|
||||||
|
return AdminMessageCard{}, fmt.Errorf("social: admin message seats: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
seat int
|
||||||
|
accStr string
|
||||||
|
seatNam string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&seat, &accStr, &seatNam); err != nil {
|
||||||
|
return AdminMessageCard{}, fmt.Errorf("social: scan message seat: %w", err)
|
||||||
|
}
|
||||||
|
if accStr == "" {
|
||||||
|
continue // a still-empty (open) seat has no reader
|
||||||
|
}
|
||||||
|
accID, _ := uuid.Parse(accStr)
|
||||||
|
sr := AdminSeatRead{Seat: seat, AccountID: accID, DisplayName: seatNam}
|
||||||
|
switch {
|
||||||
|
case accID == d.SenderID:
|
||||||
|
sr.Role = "sender"
|
||||||
|
case d.UnreadSeats&(int16(1)<<uint(seat)) != 0:
|
||||||
|
sr.Role = "unread"
|
||||||
|
default:
|
||||||
|
sr.Role = "read"
|
||||||
|
}
|
||||||
|
d.Seats = append(d.Seats, sr)
|
||||||
|
}
|
||||||
|
return d, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
|||||||
if err := Clean(body); err != nil {
|
if err := Clean(body); err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
|
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
return Message{}, ErrNudgeTooSoon
|
return Message{}, ErrNudgeTooSoon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
@@ -258,8 +258,22 @@ func parseIP(raw string) *string {
|
|||||||
return &canon
|
return &canon
|
||||||
}
|
}
|
||||||
|
|
||||||
// insertChatMessage stores one chat row and returns it.
|
// recipientMask is the initial unread bitmask for a text message: a set bit for every
|
||||||
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (Message, error) {
|
// seated recipient (by seat index) other than the sender. Still-empty (open) seats are
|
||||||
|
// skipped. With at most four seats the result always fits the smallint column.
|
||||||
|
func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 {
|
||||||
|
var mask int16
|
||||||
|
for i, id := range seats {
|
||||||
|
if id == uuid.Nil || id == senderID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mask |= int16(1) << uint(i)
|
||||||
|
}
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
|
||||||
|
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, fmt.Errorf("social: new message id: %w", err)
|
return Message{}, fmt.Errorf("social: new message id: %w", err)
|
||||||
@@ -271,7 +285,8 @@ func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUI
|
|||||||
stmt := table.ChatMessages.INSERT(
|
stmt := table.ChatMessages.INSERT(
|
||||||
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
|
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
|
||||||
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
|
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
|
||||||
).VALUES(id, gameID, senderID, kind, body, ipVal).
|
table.ChatMessages.UnreadSeats,
|
||||||
|
).VALUES(id, gameID, senderID, kind, body, ipVal, unreadMask).
|
||||||
RETURNING(table.ChatMessages.AllColumns)
|
RETURNING(table.ChatMessages.AllColumns)
|
||||||
var row model.ChatMessages
|
var row model.ChatMessages
|
||||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package social
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkRead clears viewerID's unread bit on every chat entry of the game they have not
|
||||||
|
// yet read — the acknowledgement the client sends when the player opens the move
|
||||||
|
// history (or the chat). For each entry that flips to read it records the
|
||||||
|
// publish-to-read latency. It returns the number of entries marked, and is a harmless
|
||||||
|
// no-op (zero) when the viewer holds no seat or has nothing unread, so the caller may
|
||||||
|
// invoke it without first checking participation.
|
||||||
|
func (svc *Service) MarkRead(ctx context.Context, gameID, viewerID uuid.UUID) (int, error) {
|
||||||
|
ctx, span := svc.tracer.Start(ctx, "social.MarkRead",
|
||||||
|
trace.WithAttributes(attribute.String("game.id", gameID.String())))
|
||||||
|
defer span.End()
|
||||||
|
marks, err := svc.store.markRead(ctx, gameID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
now := svc.now()
|
||||||
|
for _, m := range marks {
|
||||||
|
svc.metrics.recordRead(ctx, m.kind, now.Sub(m.createdAt))
|
||||||
|
}
|
||||||
|
span.SetAttributes(attribute.Int("marked.count", len(marks)))
|
||||||
|
return len(marks), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearNudges marks accountID's pending nudges in the game read once they have acted
|
||||||
|
// (taken their move): a nudge answered by moving stops counting as unread. It records
|
||||||
|
// the publish-to-read latency for each nudge it clears. It satisfies game.NudgeClearer
|
||||||
|
// and is wired into the move path; failures are the caller's to log (the move has
|
||||||
|
// already committed).
|
||||||
|
func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID) error {
|
||||||
|
ctx, span := svc.tracer.Start(ctx, "social.ClearNudges",
|
||||||
|
trace.WithAttributes(attribute.String("game.id", gameID.String())))
|
||||||
|
defer span.End()
|
||||||
|
times, err := svc.store.clearNudges(ctx, gameID, accountID)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := svc.now()
|
||||||
|
for _, t := range times {
|
||||||
|
svc.metrics.recordRead(ctx, kindNudge, now.Sub(t))
|
||||||
|
}
|
||||||
|
span.SetAttributes(attribute.Int("marked.count", len(times)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnreadGames returns the set of games in which viewerID has at least one unread chat
|
||||||
|
// entry, for seeding the lobby's per-card unread badge in a single query.
|
||||||
|
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||||
|
return svc.store.unreadGames(ctx, viewerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnread reports whether viewerID has any unread chat entry in the game, for the
|
||||||
|
// per-game unread flag of a single game's state and move-result views.
|
||||||
|
func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
|
||||||
|
return svc.store.hasUnread(ctx, gameID, viewerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMark is one chat entry that flipped from unread to read, carrying what the
|
||||||
|
// publish-to-read latency metric needs.
|
||||||
|
type readMark struct {
|
||||||
|
kind string
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// markRead clears the viewer's seat bit on the game's entries still unread by them,
|
||||||
|
// resolving the seat through the game_players join, and returns the cleared entries.
|
||||||
|
// The bitwise terms are cast through int4 so the operators resolve unambiguously.
|
||||||
|
func (s *Store) markRead(ctx context.Context, gameID, viewerID uuid.UUID) ([]readMark, error) {
|
||||||
|
const q = `UPDATE backend.chat_messages m
|
||||||
|
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
||||||
|
FROM backend.game_players p
|
||||||
|
WHERE p.game_id = m.game_id AND p.account_id = $2
|
||||||
|
AND m.game_id = $1 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0
|
||||||
|
RETURNING m.kind, m.created_at`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, gameID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("social: mark read: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []readMark
|
||||||
|
for rows.Next() {
|
||||||
|
var m readMark
|
||||||
|
if err := rows.Scan(&m.kind, &m.createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("social: scan mark read: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearNudges clears the actor's seat bit on the game's still-unread nudges and returns
|
||||||
|
// their post times (for the latency metric).
|
||||||
|
func (s *Store) clearNudges(ctx context.Context, gameID, accountID uuid.UUID) ([]time.Time, error) {
|
||||||
|
const q = `UPDATE backend.chat_messages m
|
||||||
|
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
||||||
|
FROM backend.game_players p
|
||||||
|
WHERE p.game_id = m.game_id AND p.account_id = $2
|
||||||
|
AND m.game_id = $1 AND m.kind = 'nudge' AND (m.unread_seats::int & (1 << p.seat::int)) <> 0
|
||||||
|
RETURNING m.created_at`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, gameID, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("social: clear nudges: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []time.Time
|
||||||
|
for rows.Next() {
|
||||||
|
var t time.Time
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
return nil, fmt.Errorf("social: scan clear nudges: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// unreadGames returns the games where viewerID has an unread entry, resolving their
|
||||||
|
// seat per game through the game_players join.
|
||||||
|
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||||
|
const q = `SELECT DISTINCT m.game_id
|
||||||
|
FROM backend.chat_messages m
|
||||||
|
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $1
|
||||||
|
WHERE m.unread_seats <> 0 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("social: unread games: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[uuid.UUID]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("social: scan unread games: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasUnread reports whether viewerID has an unread entry in the one game.
|
||||||
|
func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
|
||||||
|
const q = `SELECT EXISTS(
|
||||||
|
SELECT 1 FROM backend.chat_messages m
|
||||||
|
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $2
|
||||||
|
WHERE m.game_id = $1 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0)`
|
||||||
|
var ok bool
|
||||||
|
if err := s.db.QueryRowContext(ctx, q, gameID, viewerID).Scan(&ok); err != nil {
|
||||||
|
return false, fmt.Errorf("social: has unread: %w", err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countUnread counts the chat entries with at least one recipient seat still unread,
|
||||||
|
// for the chat_unread_messages gauge.
|
||||||
|
func (s *Store) countUnread(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM backend.chat_messages WHERE unread_seats <> 0`).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("social: count unread: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package social
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
@@ -15,7 +16,8 @@ const meterName = "scrabble/backend/social"
|
|||||||
// no-ops (see defaultSocialMetrics); SetMetrics installs the real meter during
|
// no-ops (see defaultSocialMetrics); SetMetrics installs the real meter during
|
||||||
// startup wiring.
|
// startup wiring.
|
||||||
type socialMetrics struct {
|
type socialMetrics struct {
|
||||||
messages metric.Int64Counter
|
messages metric.Int64Counter
|
||||||
|
readDuration metric.Float64Histogram
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultSocialMetrics returns instruments backed by a no-op meter.
|
// defaultSocialMetrics returns instruments backed by a no-op meter.
|
||||||
@@ -31,7 +33,13 @@ func newSocialMetrics(meter metric.Meter) *socialMetrics {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("chat_messages_total")
|
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("chat_messages_total")
|
||||||
}
|
}
|
||||||
return &socialMetrics{messages: c}
|
h, err := meter.Float64Histogram("chat_read_duration",
|
||||||
|
metric.WithDescription("Time from a chat entry being posted to a recipient reading it, in seconds, labelled by kind (message/nudge)."),
|
||||||
|
metric.WithUnit("s"))
|
||||||
|
if err != nil {
|
||||||
|
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("chat_read_duration")
|
||||||
|
}
|
||||||
|
return &socialMetrics{messages: c, readDuration: h}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMetrics installs the meter the social domain records to. It must be called
|
// SetMetrics installs the meter the social domain records to. It must be called
|
||||||
@@ -41,9 +49,31 @@ func (svc *Service) SetMetrics(meter metric.Meter) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
svc.metrics = newSocialMetrics(meter)
|
svc.metrics = newSocialMetrics(meter)
|
||||||
|
// chat_unread_messages observes the current backlog of unread chat entries at scrape
|
||||||
|
// time (the partial index keeps the count cheap). A registration error is ignored —
|
||||||
|
// the gauge is best-effort observability.
|
||||||
|
_, _ = meter.Int64ObservableGauge("chat_unread_messages",
|
||||||
|
metric.WithDescription("Chat entries with at least one recipient seat that has not read them yet."),
|
||||||
|
metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
|
||||||
|
n, err := svc.store.countUnread(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.Observe(n)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordChat counts one posted chat entry of the given kind (message or nudge).
|
// recordChat counts one posted chat entry of the given kind (message or nudge).
|
||||||
func (m *socialMetrics) recordChat(ctx context.Context, kind string) {
|
func (m *socialMetrics) recordChat(ctx context.Context, kind string) {
|
||||||
m.messages.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
|
m.messages.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordRead records one chat entry's publish-to-read latency, labelled by kind. A
|
||||||
|
// non-positive duration (clock skew) is dropped.
|
||||||
|
func (m *socialMetrics) recordRead(ctx context.Context, kind string, d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.readDuration.Record(ctx, d.Seconds(), metric.WithAttributes(attribute.String("kind", kind)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tracerName scopes the social domain's OpenTelemetry spans.
|
||||||
|
const tracerName = "scrabble/backend/social"
|
||||||
|
|
||||||
// GameReader is the slice of the game domain the social package needs: the seated
|
// GameReader is the slice of the game domain the social package needs: the seated
|
||||||
// accounts in seat order, the seat index whose turn it is, and the game status, plus
|
// accounts in seat order, the seat index whose turn it is, and the game status, plus
|
||||||
// a shared-game test. game.Service satisfies it, so chat, nudge and the
|
// a shared-game test. game.Service satisfies it, so chat, nudge and the
|
||||||
@@ -71,6 +76,8 @@ var (
|
|||||||
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
|
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
|
||||||
// ErrNotParticipant is returned when an account is not seated in the game.
|
// ErrNotParticipant is returned when an account is not seated in the game.
|
||||||
ErrNotParticipant = errors.New("social: account is not a player in this game")
|
ErrNotParticipant = errors.New("social: account is not a player in this game")
|
||||||
|
// ErrMessageNotFound is returned when an admin message lookup finds no such message.
|
||||||
|
ErrMessageNotFound = errors.New("social: message not found")
|
||||||
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
||||||
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
||||||
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
||||||
@@ -104,6 +111,7 @@ type Service struct {
|
|||||||
games GameReader
|
games GameReader
|
||||||
pub notify.Publisher
|
pub notify.Publisher
|
||||||
metrics *socialMetrics
|
metrics *socialMetrics
|
||||||
|
tracer trace.Tracer
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +124,7 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
|
|||||||
games: games,
|
games: games,
|
||||||
pub: notify.Nop{},
|
pub: notify.Nop{},
|
||||||
metrics: defaultSocialMetrics(),
|
metrics: defaultSocialMetrics(),
|
||||||
|
tracer: otel.Tracer(tracerName),
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -514,11 +514,24 @@ disguised robot stays indistinguishable from a person.
|
|||||||
has a **Messages** section that lists posted messages (nudges excluded)
|
has a **Messages** section that lists posted messages (nudges excluded)
|
||||||
newest-first with the sender's resolved name, **source** (guest / robot / oldest
|
newest-first with the sender's resolved name, **source** (guest / robot / oldest
|
||||||
identity kind), IP and game, searchable by sender name / external-id glob masks and
|
identity kind), IP and game, searchable by sender name / external-id glob masks and
|
||||||
pinnable to one game or sender (linked from the game and user cards).
|
pinnable to one game or sender (linked from the game and user cards). It also offers an
|
||||||
|
**unread-only filter** and a read/unread column, and each message has a detail card with
|
||||||
|
the **per-seat read breakdown** (sender / read / unread).
|
||||||
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
|
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
|
||||||
the opponent may nudge **once per hour per game**; it is not allowed on one's own
|
the opponent may nudge **once per hour per game**; it is not allowed on one's own
|
||||||
turn. The platform-native delivery runs through the gateway and the platform
|
turn. The platform-native delivery runs through the gateway and the platform
|
||||||
side-service.
|
side-service.
|
||||||
|
- **Read receipts**: each `chat_messages` row carries an `unread_seats` bitmask — a set
|
||||||
|
bit per recipient seat that has **not** yet read it (the sender's own bit is never set).
|
||||||
|
A text message seeds the bits of every seated recipient; a nudge seeds only the awaited
|
||||||
|
player's. A seat's bit clears when that player **opens the move history or the chat**
|
||||||
|
(`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
|
||||||
|
history open is not a constant backend call), and a **nudge additionally clears when its
|
||||||
|
recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is
|
||||||
|
inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
|
||||||
|
`unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
|
||||||
|
unread filter and the unread gauge all use. On each clear the publish-to-read latency is
|
||||||
|
recorded; the read time itself is not retained.
|
||||||
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
|
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
|
||||||
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
|
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
|
||||||
block toggles — all editable through `account.UpdateProfile`, which validates them:
|
block toggles — all editable through `account.UpdateProfile`, which validates them:
|
||||||
@@ -551,7 +564,8 @@ disguised robot stays indistinguishable from a person.
|
|||||||
`game_moves` (the move journal), `complaints` and `account_stats`, and the
|
`game_moves` (the move journal), `complaints` and `account_stats`, and the
|
||||||
social/lobby tables `friendships` (the request/accept graph, its status admitting
|
social/lobby tables `friendships` (the request/accept graph, its status admitting
|
||||||
`declined`), `blocks`
|
`declined`), `blocks`
|
||||||
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
|
(per-user blocks), `chat_messages` (per-game chat and nudges, carrying the per-message
|
||||||
|
`unread_seats` read bitmask), `email_confirmations`
|
||||||
(pending confirm-codes), `game_invitations` / `game_invitation_invitees`
|
(pending confirm-codes), `game_invitations` / `game_invitation_invitees`
|
||||||
(friend-game invitations), `friend_codes` (one-time add-a-friend codes),
|
(friend-game invitations), `friend_codes` (one-time add-a-friend codes),
|
||||||
`game_drafts` (a player's in-progress rack order + board composition per
|
`game_drafts` (a player's in-progress rack order + board composition per
|
||||||
@@ -743,7 +757,9 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
|
||||||
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
console, below); counters `games_started_total`, `games_abandoned_total` (a
|
||||||
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
|
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
|
||||||
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
|
`robot_games_finished_total`; a histogram `chat_read_duration` (chat publish-to-read
|
||||||
|
latency by `kind`); observable gauges `game_cache_active` and `chat_unread_messages`
|
||||||
|
(chat entries with `unread_seats <> 0`); the gateway
|
||||||
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
|
||||||
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
|
||||||
(scrabble_en/scrabble_ru/erudit_ru).
|
(scrabble_en/scrabble_ru/erudit_ru).
|
||||||
|
|||||||
+5
-2
@@ -191,8 +191,11 @@ is awaited at most once per hour (the
|
|||||||
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
|
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
|
||||||
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
||||||
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
||||||
tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat
|
tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat
|
||||||
message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened.
|
entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game
|
||||||
|
in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading
|
||||||
|
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
||||||
|
also clears the moment its recipient **takes their move**.
|
||||||
|
|
||||||
### Profile & settings
|
### Profile & settings
|
||||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||||
|
|||||||
@@ -196,8 +196,11 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
||||||
(«<соперник>: жду вашего хода»).
|
(«<соперник>: жду вашего хода»).
|
||||||
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
||||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
|
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата —
|
||||||
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
|
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в
|
||||||
|
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже
|
||||||
|
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
||||||
|
его получатель **делает ход**.
|
||||||
|
|
||||||
### Профиль и настройки
|
### Профиль и настройки
|
||||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ out-of-app push to that connector for recipients with no live in-app stream
|
|||||||
|
|
||||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
The message-type catalog: `auth.telegram`, `auth.guest`,
|
||||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post` and the play-loop ops;
|
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||||
live events
|
live events
|
||||||
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events —
|
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events —
|
||||||
and `game_over`/`notify` — carry the state delta the client applies without a `game.state`
|
and `game_over`/`notify` — carry the state delta the client applies without a `game.state`
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ type GameResp struct {
|
|||||||
EndReason string `json:"end_reason"`
|
EndReason string `json:"end_reason"`
|
||||||
LastActivityUnix int64 `json:"last_activity_unix"`
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
||||||
Seats []SeatResp `json:"seats"`
|
Seats []SeatResp `json:"seats"`
|
||||||
|
UnreadChat bool `json:"unread_chat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
||||||
@@ -468,6 +469,13 @@ func (c *Client) Nudge(ctx context.Context, userID, gameID string) (ChatResp, er
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkChatRead acknowledges that the caller has read the game's chat (sent when they open
|
||||||
|
// the move history or chat), so the backend clears their unread bits and records the
|
||||||
|
// publish-to-read latency.
|
||||||
|
func (c *Client) MarkChatRead(ctx context.Context, userID, gameID string) error {
|
||||||
|
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/chat/read"), userID, "", struct{}{}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// GamesList returns the caller's active and finished games.
|
// GamesList returns the caller's active and finished games.
|
||||||
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
|
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
|
||||||
var out GameListResp
|
var out GameListResp
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
|
|||||||
EndReason: g.EndReason,
|
EndReason: g.EndReason,
|
||||||
Seats: seats,
|
Seats: seats,
|
||||||
LastActivityUnix: g.LastActivityUnix,
|
LastActivityUnix: g.LastActivityUnix,
|
||||||
|
UnreadChat: g.UnreadChat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const (
|
|||||||
MsgGameHistory = "game.history"
|
MsgGameHistory = "game.history"
|
||||||
MsgChatList = "chat.list"
|
MsgChatList = "chat.list"
|
||||||
MsgChatNudge = "chat.nudge"
|
MsgChatNudge = "chat.nudge"
|
||||||
|
MsgChatRead = "chat.read"
|
||||||
MsgDraftGet = "draft.get"
|
MsgDraftGet = "draft.get"
|
||||||
MsgDraftSave = "draft.save"
|
MsgDraftSave = "draft.save"
|
||||||
MsgGameHide = "game.hide"
|
MsgGameHide = "game.hide"
|
||||||
@@ -121,6 +122,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
|
|||||||
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
|
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
|
||||||
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
|
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
|
||||||
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
|
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgChatRead] = Op{Handler: markChatReadHandler(backend), Auth: true}
|
||||||
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
|
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
|
||||||
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
|
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
|
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
|
||||||
@@ -451,6 +453,18 @@ func nudgeHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markChatReadHandler acknowledges the caller has read the game's chat. It reuses
|
||||||
|
// GameActionRequest for the game id and echoes an Ack.
|
||||||
|
func markChatReadHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
|
||||||
|
if err := backend.MarkChatRead(ctx, req.UserID, string(in.GameId())); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeAck(true), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getDraftHandler returns the player's saved composition. It reuses
|
// getDraftHandler returns the player's saved composition. It reuses
|
||||||
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
|
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
|
||||||
func getDraftHandler(backend *backendclient.Client) Handler {
|
func getDraftHandler(backend *backendclient.Client) Handler {
|
||||||
|
|||||||
@@ -213,6 +213,39 @@ func TestHideGameForwardsToBackend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMarkChatReadForwardsToBackend checks chat.read reuses GameActionRequest, POSTs to the
|
||||||
|
// game's /chat/read endpoint with the caller's id, and echoes an Ack.
|
||||||
|
func TestMarkChatReadForwardsToBackend(t *testing.T) {
|
||||||
|
var hit bool
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hit = true
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/user/games/g-1/chat/read" {
|
||||||
|
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("X-User-ID"); got != "u-1" {
|
||||||
|
t.Errorf("X-User-ID = %q, want u-1", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgChatRead)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("chat.read not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
if !hit {
|
||||||
|
t.Error("backend not called")
|
||||||
|
}
|
||||||
|
if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() {
|
||||||
|
t.Error("ack not ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
func TestGamesListRoundTripDecodesSeatNames(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-9" {
|
if got := r.Header.Get("X-User-ID"); got != "u-9" {
|
||||||
@@ -221,7 +254,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":"scrabble_en","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}]}],"at_game_limit":true}`))
|
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`))
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -243,6 +276,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
|
|||||||
if g.LastActivityUnix() != 1717000000 {
|
if g.LastActivityUnix() != 1717000000 {
|
||||||
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
|
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
|
||||||
}
|
}
|
||||||
|
if !g.UnreadChat() {
|
||||||
|
t.Error("unread_chat = false, want true (the backend flagged unread for the viewer)")
|
||||||
|
}
|
||||||
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" {
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ table GameView {
|
|||||||
// vs_ai marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
// vs_ai marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||||
// are disabled in the client. A robot-filled random game keeps vs_ai=false.
|
// are disabled in the client. A robot-filled random game keeps vs_ai=false.
|
||||||
vs_ai:bool;
|
vs_ai:bool;
|
||||||
|
// unread_chat is a per-viewer flag: the requesting player has at least one unread chat
|
||||||
|
// entry (message or nudge) in this game. It drives the lobby and in-game unread badge.
|
||||||
|
unread_chat:bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
// MoveRecord is one decoded move (a committed play, or a hint preview).
|
||||||
|
|||||||
@@ -185,8 +185,20 @@ func (rcv *GameView) MutateVsAi(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(28, n)
|
return rcv._tab.MutateBoolSlot(28, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) UnreadChat() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *GameView) MutateUnreadChat(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(30, n)
|
||||||
|
}
|
||||||
|
|
||||||
func GameViewStart(builder *flatbuffers.Builder) {
|
func GameViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(13)
|
builder.StartObject(14)
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -230,6 +242,9 @@ func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix
|
|||||||
func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) {
|
func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) {
|
||||||
builder.PrependBoolSlot(12, vsAi, false)
|
builder.PrependBoolSlot(12, vsAi, false)
|
||||||
}
|
}
|
||||||
|
func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) {
|
||||||
|
builder.PrependBoolSlot(13, unreadChat, false)
|
||||||
|
}
|
||||||
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type GameView struct {
|
|||||||
LastActivityUnix int64
|
LastActivityUnix int64
|
||||||
// VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
|
// VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
|
// UnreadChat is a per-viewer flag: the requesting player has unread chat in this game.
|
||||||
|
UnreadChat bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
|
||||||
@@ -161,6 +163,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
|
|||||||
fb.GameViewAddSeats(b, seats)
|
fb.GameViewAddSeats(b, seats)
|
||||||
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
|
||||||
fb.GameViewAddVsAi(b, g.VsAI)
|
fb.GameViewAddVsAi(b, g.VsAI)
|
||||||
|
fb.GameViewAddUnreadChat(b, g.UnreadChat)
|
||||||
return fb.GameViewEnd(b)
|
return fb.GameViewEnd(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Chat from './Chat.svelte';
|
import Chat from './Chat.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { app, handleError, clearChatUnread } from '../lib/app.svelte';
|
import { app, handleError, markChatRead } from '../lib/app.svelte';
|
||||||
import { canSendChat, sentThisTurn } from '../lib/chatlimit';
|
import { canSendChat, sentThisTurn } from '../lib/chatlimit';
|
||||||
import type { ChatMessage, StateView } from '../lib/model';
|
import type { ChatMessage, StateView } from '../lib/model';
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
messages = await gateway.chatList(id);
|
messages = await gateway.chatList(id);
|
||||||
clearChatUnread(id);
|
markChatRead(id);
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-20
@@ -8,7 +8,7 @@
|
|||||||
import Rack from './Rack.svelte';
|
import Rack from './Rack.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
@@ -71,6 +71,19 @@
|
|||||||
// landscape, where it is docked open in the left panel. Gates the per-seat add-friend
|
// landscape, where it is docked open in the left panel. Gates the per-seat add-friend
|
||||||
// affordance and its confirm reset so both work regardless of layout.
|
// affordance and its confirm reset so both work regardless of layout.
|
||||||
const historyShown = $derived(historyOpen || landscape);
|
const historyShown = $derived(historyOpen || landscape);
|
||||||
|
// A nonce bumped to replay the 💬 fade-blink each time the history is shown with unread chat.
|
||||||
|
let chatBlink = $state(0);
|
||||||
|
// Opening the move history counts as reading the chat (even without entering it): when the
|
||||||
|
// history is shown with unread present, play the 💬 fade-blink (a two-cycle nudge) and mark the
|
||||||
|
// chat read. In landscape the history is docked open, so a message arriving while it is visible
|
||||||
|
// blinks and is read at once. markChatRead clears the flag (and acks the backend), so this
|
||||||
|
// settles after one pass and re-fires only when a new message raises unread again.
|
||||||
|
$effect(() => {
|
||||||
|
if (historyShown && app.chatUnread[id]) {
|
||||||
|
chatBlink++;
|
||||||
|
markChatRead(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const variant = $derived(view?.game.variant ?? 'scrabble_en');
|
const variant = $derived(view?.game.variant ?? 'scrabble_en');
|
||||||
const board = $derived(replay(moves));
|
const board = $derived(replay(moves));
|
||||||
@@ -154,6 +167,8 @@
|
|||||||
gateway.draftGet(id).catch(() => ''),
|
gateway.draftGet(id).catch(() => ''),
|
||||||
]);
|
]);
|
||||||
view = st;
|
view = st;
|
||||||
|
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||||
|
seedChatUnread(id, st.game.unreadChat);
|
||||||
moves = hist.moves;
|
moves = hist.moves;
|
||||||
setCachedGame(id, st, hist.moves, draft);
|
setCachedGame(id, st, hist.moves, draft);
|
||||||
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
|
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
|
||||||
@@ -601,6 +616,9 @@
|
|||||||
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
||||||
function applyMoveResult(r: MoveResult) {
|
function applyMoveResult(r: MoveResult) {
|
||||||
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
||||||
|
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
|
||||||
|
// moving is already cleared server-side, so reconcile the unread flag from it.
|
||||||
|
seedChatUnread(id, r.game.unreadChat);
|
||||||
moves = [...moves, r.move];
|
moves = [...moves, r.move];
|
||||||
// A committed move clears the actor's draft on the server, so clear the cached draft too;
|
// A committed move clears the actor's draft on the server, so clear the cached draft too;
|
||||||
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
|
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
|
||||||
@@ -937,7 +955,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
|
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
|
||||||
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
|
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#each view.game.seats as s (s.seat)}
|
||||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
<div class="nm">{seatName(s)}</div>
|
<div class="nm">{seatName(s)}</div>
|
||||||
@@ -970,7 +988,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||||
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="hgridwrap">
|
<div class="hgridwrap">
|
||||||
@@ -1378,26 +1396,34 @@
|
|||||||
.fico {
|
.fico {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
/* The unread-chat count: on the score bar's corner and on the history's 💬 icon. */
|
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
|
||||||
.cbadge {
|
when the history is opened with unread present, rather than carrying a count badge. */
|
||||||
|
.unread-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
.sbadge-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 0.68rem;
|
top: 4px;
|
||||||
font-weight: 700;
|
right: 6px;
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
border-radius: 999px;
|
|
||||||
min-width: 15px;
|
|
||||||
padding: 0 3px;
|
|
||||||
line-height: 1.4;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.sbadge {
|
.chat-ico {
|
||||||
top: 2px;
|
display: inline-block;
|
||||||
right: 4px;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.hicon .cbadge {
|
.chat-ico.blink {
|
||||||
top: -1px;
|
animation: chat-blink 1s ease-in-out 2;
|
||||||
right: -1px;
|
}
|
||||||
|
@keyframes chat-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -103,8 +103,13 @@ vsAi():boolean {
|
|||||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unreadChat():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 30);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
static startGameView(builder:flatbuffers.Builder) {
|
static startGameView(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(13);
|
builder.startObject(14);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
|
||||||
@@ -171,12 +176,16 @@ static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) {
|
|||||||
builder.addFieldInt8(12, +vsAi, +false);
|
builder.addFieldInt8(12, +vsAi, +false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addUnreadChat(builder:flatbuffers.Builder, unreadChat:boolean) {
|
||||||
|
builder.addFieldInt8(13, +unreadChat, +false);
|
||||||
|
}
|
||||||
|
|
||||||
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, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean):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, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean):flatbuffers.Offset {
|
||||||
GameView.startGameView(builder);
|
GameView.startGameView(builder);
|
||||||
GameView.addId(builder, idOffset);
|
GameView.addId(builder, idOffset);
|
||||||
GameView.addVariant(builder, variantOffset);
|
GameView.addVariant(builder, variantOffset);
|
||||||
@@ -191,6 +200,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
|
|||||||
GameView.addSeats(builder, seatsOffset);
|
GameView.addSeats(builder, seatsOffset);
|
||||||
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
GameView.addLastActivityUnix(builder, lastActivityUnix);
|
||||||
GameView.addVsAi(builder, vsAi);
|
GameView.addVsAi(builder, vsAi);
|
||||||
|
GameView.addUnreadChat(builder, unreadChat);
|
||||||
return GameView.endGameView(builder);
|
return GameView.endGameView(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-11
@@ -61,8 +61,10 @@ export const app = $state<{
|
|||||||
localeLocked: boolean;
|
localeLocked: boolean;
|
||||||
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
||||||
notifications: number;
|
notifications: number;
|
||||||
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
|
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
|
||||||
chatUnread: Record<string, number>;
|
* game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
|
||||||
|
* raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
|
||||||
|
chatUnread: Record<string, boolean>;
|
||||||
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
|
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
|
||||||
* with friend requests) and the Settings → Info badge. */
|
* with friend requests) and the Settings → Info badge. */
|
||||||
feedbackReplyUnread: boolean;
|
feedbackReplyUnread: boolean;
|
||||||
@@ -149,9 +151,29 @@ export function dismissStaleInvite(): void {
|
|||||||
app.staleInvite = false;
|
app.staleInvite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
|
/**
|
||||||
export function clearChatUnread(gameId: string): void {
|
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
|
||||||
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
|
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
|
||||||
|
* stream raises unread (bumpChatUnread) rather than seeding it from a GameView.
|
||||||
|
*/
|
||||||
|
export function seedChatUnread(gameId: string, unread: boolean): void {
|
||||||
|
if ((app.chatUnread[gameId] ?? false) !== unread) {
|
||||||
|
app.chatUnread = { ...app.chatUnread, [gameId]: unread };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markChatRead clears a game's unread badge and tells the backend the player has read the chat —
|
||||||
|
* called when the move history or the chat opens. It hits the backend only when something is
|
||||||
|
* actually unread, so opening the history does not call the backend on every tap. The clear is
|
||||||
|
* optimistic; a failed ack restores the badge so a later open retries.
|
||||||
|
*/
|
||||||
|
export function markChatRead(gameId: string): void {
|
||||||
|
if (!app.chatUnread[gameId]) return;
|
||||||
|
app.chatUnread = { ...app.chatUnread, [gameId]: false };
|
||||||
|
void gateway.markChatRead(gameId).catch(() => {
|
||||||
|
app.chatUnread = { ...app.chatUnread, [gameId]: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
|
/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
|
||||||
@@ -220,15 +242,18 @@ function openStream(): void {
|
|||||||
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
|
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
|
||||||
router.route.params.id === e.message.gameId;
|
router.route.params.id === e.message.gameId;
|
||||||
if (!inComms) {
|
if (!inComms) {
|
||||||
if (e.message.kind !== 'nudge') {
|
app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true };
|
||||||
const gid = e.message.gameId;
|
|
||||||
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
|
|
||||||
}
|
|
||||||
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
||||||
}
|
}
|
||||||
} else if (e.kind === 'nudge') {
|
} else if (e.kind === 'nudge') {
|
||||||
// Name the nudger (their per-game seat name, carried on the event), so the toast reads
|
// A nudge only reaches the awaited player; raise their unread badge for the game (unless
|
||||||
// "<opponent>: …" like the your-turn toast; fall back to the plain phrase when absent.
|
// they are already in its chat), then toast — naming the nudger (their per-game seat name,
|
||||||
|
// carried on the event), so it reads "<opponent>: …" like the your-turn toast; fall back to
|
||||||
|
// the plain phrase when absent.
|
||||||
|
const inComms =
|
||||||
|
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
|
||||||
|
router.route.params.id === e.gameId;
|
||||||
|
if (!inComms) app.chatUnread = { ...app.chatUnread, [e.gameId]: true };
|
||||||
showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
|
showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
|
||||||
} else if (e.kind === 'your_turn') {
|
} else if (e.kind === 'your_turn') {
|
||||||
// Name the player who moved just before this one (the previous seat in turn order), so the
|
// Name the player who moved just before this one (the previous seat in turn order), so the
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export interface GatewayClient {
|
|||||||
chatPost(gameId: string, body: string): Promise<ChatMessage>;
|
chatPost(gameId: string, body: string): Promise<ChatMessage>;
|
||||||
chatList(gameId: string): Promise<ChatMessage[]>;
|
chatList(gameId: string): Promise<ChatMessage[]>;
|
||||||
nudge(gameId: string): Promise<ChatMessage>;
|
nudge(gameId: string): Promise<ChatMessage>;
|
||||||
|
/** Acknowledge the caller has read the game's chat (sent when they open the move history
|
||||||
|
* or chat), so the backend clears their unread bits and records the read latency. */
|
||||||
|
markChatRead(gameId: string): Promise<void>;
|
||||||
|
|
||||||
// --- feedback ---
|
// --- feedback ---
|
||||||
/** feedbackSubmit sends a feedback message with an optional single attachment. */
|
/** feedbackSubmit sends a feedback message with an optional single attachment. */
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ describe('codec', () => {
|
|||||||
fb.GameView.addSeats(b, seats);
|
fb.GameView.addSeats(b, seats);
|
||||||
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
|
||||||
fb.GameView.addVsAi(b, true);
|
fb.GameView.addVsAi(b, true);
|
||||||
|
fb.GameView.addUnreadChat(b, true);
|
||||||
const game = fb.GameView.endGameView(b);
|
const game = fb.GameView.endGameView(b);
|
||||||
const games = fb.GameList.createGamesVector(b, [game]);
|
const games = fb.GameList.createGamesVector(b, [game]);
|
||||||
fb.GameList.startGameList(b);
|
fb.GameList.startGameList(b);
|
||||||
@@ -251,6 +252,7 @@ describe('codec', () => {
|
|||||||
expect(gl.games[0].seats[0].score).toBe(13);
|
expect(gl.games[0].seats[0].score).toBe(13);
|
||||||
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
|
||||||
expect(gl.games[0].vsAi).toBe(true);
|
expect(gl.games[0].vsAi).toBe(true);
|
||||||
|
expect(gl.games[0].unreadChat).toBe(true);
|
||||||
expect(gl.atGameLimit).toBe(true);
|
expect(gl.atGameLimit).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ function decodeGameView(g: fb.GameView): GameView {
|
|||||||
endReason: s(g.endReason()),
|
endReason: s(g.endReason()),
|
||||||
lastActivityUnix: Number(g.lastActivityUnix()),
|
lastActivityUnix: Number(g.lastActivityUnix()),
|
||||||
vsAi: g.vsAi(),
|
vsAi: g.vsAi(),
|
||||||
|
unreadChat: g.unreadChat(),
|
||||||
seats,
|
seats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -813,6 +814,7 @@ function emptyGame(): GameView {
|
|||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: 0,
|
lastActivityUnix: 0,
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
seats: [],
|
seats: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function gameView(id: string): GameView {
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function gameView(moveCount: number, over = false): GameView {
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status: over ? 'finished' : 'active',
|
status: over ? 'finished' : 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 1,
|
toMove: 1,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||||
vsAi: true,
|
vsAi: true,
|
||||||
|
unreadChat: false,
|
||||||
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: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
@@ -211,6 +212,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
endReason: '',
|
endReason: '',
|
||||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
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: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
@@ -487,6 +489,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
this.feedbackReplyUnread = true;
|
this.feedbackReplyUnread = true;
|
||||||
this.emit({ kind: 'notify', sub: 'admin_reply' });
|
this.emit({ kind: 'notify', sub: 'admin_reply' });
|
||||||
}
|
}
|
||||||
|
// The mock holds no server-side unread state; the read ack is a no-op (the client clears its
|
||||||
|
// own unread flag optimistically).
|
||||||
|
async markChatRead(): Promise<void> {}
|
||||||
|
|
||||||
async nudge(gameId: string): Promise<ChatMessage> {
|
async nudge(gameId: string): Promise<ChatMessage> {
|
||||||
const g = this.game(gameId);
|
const g = this.game(gameId);
|
||||||
const msg: ChatMessage = {
|
const msg: ChatMessage = {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function activeGame(): MockGame {
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
@@ -176,6 +177,7 @@ function finishedG2(): MockGame {
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
@@ -213,6 +215,7 @@ function finishedG3(): MockGame {
|
|||||||
variant: 'scrabble_ru',
|
variant: 'scrabble_ru',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export interface GameView {
|
|||||||
lastActivityUnix: number;
|
lastActivityUnix: number;
|
||||||
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
||||||
vsAi: boolean;
|
vsAi: boolean;
|
||||||
|
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
|
||||||
|
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
|
||||||
|
* move result); the live-event GameView leaves it false (events bump unread instead). */
|
||||||
|
unreadChat: boolean;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status,
|
status,
|
||||||
players: 2,
|
players: 2,
|
||||||
toMove: 0,
|
toMove: 0,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
|||||||
variant: 'scrabble_en',
|
variant: 'scrabble_en',
|
||||||
dictVersion: 'v1',
|
dictVersion: 'v1',
|
||||||
vsAi: false,
|
vsAi: false,
|
||||||
|
unreadChat: false,
|
||||||
status,
|
status,
|
||||||
players: seats.length,
|
players: seats.length,
|
||||||
toMove,
|
toMove,
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async nudge(id) {
|
async nudge(id) {
|
||||||
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
|
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
|
||||||
},
|
},
|
||||||
|
async markChatRead(id) {
|
||||||
|
await exec('chat.read', codec.encodeGameAction(id));
|
||||||
|
},
|
||||||
async feedbackSubmit(body, attachment, attachmentName, channel) {
|
async feedbackSubmit(body, attachment, attachmentName, channel) {
|
||||||
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
|
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
|
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
@@ -33,6 +33,9 @@
|
|||||||
try {
|
try {
|
||||||
const list = await gateway.gamesList();
|
const list = await gateway.gamesList();
|
||||||
games = list.games;
|
games = list.games;
|
||||||
|
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
||||||
|
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
|
||||||
|
for (const g of games) seedChatUnread(g.id, g.unreadChat);
|
||||||
atGameLimit = list.atGameLimit;
|
atGameLimit = list.atGameLimit;
|
||||||
if (!guest) {
|
if (!guest) {
|
||||||
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||||
@@ -262,7 +265,10 @@
|
|||||||
onclick={() => openGame(g)}
|
onclick={() => openGame(g)}
|
||||||
>
|
>
|
||||||
<span class="info">
|
<span class="info">
|
||||||
<span class="who">{opponents(g) || '—'}</span>
|
<span class="who">
|
||||||
|
<span class="who-name">{opponents(g) || '—'}</span>
|
||||||
|
{#if app.chatUnread[g.id]}<span class="unread-dot"></span>{/if}
|
||||||
|
</span>
|
||||||
<span class="sub">{scoreline(g)}</span>
|
<span class="sub">{scoreline(g)}</span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
|
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
|
||||||
@@ -441,11 +447,25 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.who {
|
.who {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.who-name {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
/* A small red dot beside the opponent name: this game has an unread chat entry. */
|
||||||
|
.unread-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
.sub {
|
.sub {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user