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

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:
Ilia Denisov
2026-06-17 11:12:38 +02:00
parent d53ff18a67
commit aaac816dc2
51 changed files with 1000 additions and 111 deletions
@@ -13,11 +13,12 @@ import (
)
type ChatMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
UnreadSeats int16
}
@@ -17,13 +17,14 @@ type chatMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UnreadSeats postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +66,31 @@ func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTabl
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UnreadSeatsColumn = postgres.IntegerColumn("unread_seats")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn, UnreadSeatsColumn}
)
return chatMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
UnreadSeats: UnreadSeatsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,