From 6ebd45d49a8b9e3a90409dd8ecd92703b33ab835 Mon Sep 17 00:00:00 2001
From: Collin Pendleton
Date: Wed, 14 Oct 2026 10:50:46 -0500
Subject: [PATCH 1/3] Room chat: ADR 0018, data model, API and realtime
(MAD-842)
A room has a chat. Messages are plain text, trimmed, 1-500 characters,
and remember the song that was playing. Members and guests read and
post; paired displays read along. You can delete your own, and the
room's owner or an admin can delete anyone's.
- Migration 00033 adds chat_messages. A night isn't a row until it
ends, so ending one gives it the room's messages that have no night.
- POST/GET /rooms/{id}/messages (newest first, paged by ID) and
DELETE /rooms/{id}/messages/{messageId}.
- chat.message and chat.deleted on the room socket.
- Sending is rate limited with the reactions' token bucket, now
userLimiter with a rate per use: a burst of 5, then one every 2s.
- screens.chat (floating, log or off) says how the big screen shows it.
Left out of an update, it stays as it was.
Co-Authored-By: Claude Opus 5.5
---
api/openapi.yaml | 143 +++++-
docs/adr/0018-room-chat.md | 50 +++
server/cmd/syncphony/main.go | 2 +
server/internal/chat/chat.go | 147 ++++++
server/internal/chat/chat_test.go | 102 +++++
server/internal/httpapi/accounts_test.go | 4 +-
server/internal/httpapi/api.gen.go | 422 ++++++++++++++++++
server/internal/httpapi/chat.go | 85 ++++
server/internal/httpapi/chat_test.go | 146 ++++++
server/internal/httpapi/displays.go | 37 +-
server/internal/httpapi/guests.go | 6 +-
server/internal/httpapi/realtime.go | 5 +
server/internal/httpapi/rooms.go | 8 +-
server/internal/httpapi/screens_test.go | 21 +-
server/internal/httpapi/server.go | 11 +-
server/internal/httpapi/server_test.go | 1 +
server/internal/nights/nights.go | 5 +
server/internal/realtime/realtime.go | 4 +
server/internal/rooms/rooms.go | 24 +
server/internal/store/chat.sql.gen.go | 143 ++++++
.../internal/store/migrations/00033_chat.sql | 23 +
server/internal/store/models.gen.go | 11 +
server/internal/store/queries/chat.sql | 24 +
web/src/api/schema.gen.ts | 169 ++++++-
24 files changed, 1567 insertions(+), 26 deletions(-)
create mode 100644 docs/adr/0018-room-chat.md
create mode 100644 server/internal/chat/chat.go
create mode 100644 server/internal/chat/chat_test.go
create mode 100644 server/internal/httpapi/chat.go
create mode 100644 server/internal/httpapi/chat_test.go
create mode 100644 server/internal/store/chat.sql.gen.go
create mode 100644 server/internal/store/migrations/00033_chat.sql
create mode 100644 server/internal/store/queries/chat.sql
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 0d07860..63389b7 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -2390,6 +2390,92 @@ paths:
default:
$ref: "#/components/responses/Error"
+ /rooms/{roomId}/messages:
+ parameters:
+ - $ref: "#/components/parameters/RoomId"
+ get:
+ operationId: listChatMessages
+ summary: The room's chat, newest first
+ description: |
+ A page of the room's chat (ADR 0018), newest first, across nights.
+ For the next (older) page, pass the last message's `id` as
+ `before`. Deleted messages are left out. New messages arrive on the
+ room socket as `chat.message`, deletions as `chat.deleted`. Paired
+ displays can read the chat too.
+ tags: [rooms]
+ security:
+ - session: []
+ - display: []
+ parameters:
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ default: 50
+ - name: before
+ in: query
+ description: Only messages older than this message.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Messages, newest first
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/ChatMessage"
+ default:
+ $ref: "#/components/responses/Error"
+ post:
+ operationId: sendChatMessage
+ summary: Send a message to the room's chat
+ description: |
+ Plain text, trimmed, 1–500 characters (`invalid_input` otherwise).
+ Remembers the song playing. Reaches everyone on the room socket as
+ `chat.message`. Members and guests can chat; limited to a burst of
+ a few, then one every couple of seconds per person (`rate_limited`).
+ tags: [rooms]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ChatMessageRequest"
+ responses:
+ "201":
+ description: Sent
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ChatMessage"
+ default:
+ $ref: "#/components/responses/Error"
+
+ /rooms/{roomId}/messages/{messageId}:
+ parameters:
+ - $ref: "#/components/parameters/RoomId"
+ - name: messageId
+ in: path
+ required: true
+ schema:
+ type: string
+ delete:
+ operationId: deleteChatMessage
+ summary: Delete a chat message
+ description: |
+ Your own, or anyone's if you own the room or are an admin
+ (`forbidden` otherwise). It leaves every screen (`chat.deleted`).
+ tags: [rooms]
+ responses:
+ "204":
+ description: Deleted
+ default:
+ $ref: "#/components/responses/Error"
+
/rooms/{roomId}/displays:
parameters:
- $ref: "#/components/parameters/RoomId"
@@ -5060,6 +5146,15 @@ components:
maximum: 2
default: 1.5
description: How strongly the screens move with the music.
+ chat:
+ type: string
+ enum: [floating, log, "off"]
+ default: floating
+ description: |
+ How the room's chat shows on big screens (ADR 0018). `floating`:
+ messages drift up the sides like reactions. `log`: the last few
+ in a corner column. `off`: not at all. Left out of an update, it
+ stays as it was.
RoomGames:
type: object
@@ -7179,6 +7274,50 @@ components:
type: string
format: date-time
+ ChatMessageRequest:
+ type: object
+ required: [body]
+ properties:
+ body:
+ type: string
+ maxLength: 2000
+ description: Plain text; trimmed, then 1–500 characters.
+
+ ChatMessage:
+ type: object
+ description: A message in a room's chat (ADR 0018).
+ required: [id, roomId, userId, body, createdAt]
+ properties:
+ id:
+ type: string
+ description: Time-ordered, so IDs sort as the messages were sent.
+ roomId:
+ type: string
+ userId:
+ type: string
+ body:
+ type: string
+ nightId:
+ type: string
+ description: The night it was sent in, once that night has ended.
+ itemId:
+ type: string
+ description: The song playing when it was sent.
+ track:
+ $ref: "#/components/schemas/QueuedTrack"
+ createdAt:
+ type: string
+ format: date-time
+
+ ChatMessageDeleted:
+ type: object
+ required: [roomId, id]
+ properties:
+ roomId:
+ type: string
+ id:
+ type: string
+
Display:
type: object
required: [id, roomId, name, audio, createdAt, lastSeenAt]
@@ -7831,6 +7970,8 @@ components:
your own while the room's scores are `private`.
- `game.queue`: QueueGame. A queue game started, changed, or is
done. Sent after hello for each that's up.
+ - `chat.message`: ChatMessage. Someone sent a message to the room.
+ - `chat.deleted`: ChatMessageDeleted. A message was deleted.
A paired display connects with its display cookie instead, to its
own room only. So does a signed-in user with `display=1`: either way
@@ -7847,7 +7988,7 @@ components:
properties:
type:
type: string
- enum: [hello, queue.updated, nowplaying.updated, playback.notice, member.joined, member.left, link.status, room.updated, room.deleted, reaction.sent, hearts.updated, night.ended, guests.updated, members.updated, game.round, game.scores, game.queue]
+ enum: [hello, queue.updated, nowplaying.updated, playback.notice, member.joined, member.left, link.status, room.updated, room.deleted, reaction.sent, hearts.updated, night.ended, guests.updated, members.updated, game.round, game.scores, game.queue, chat.message, chat.deleted]
version:
type: integer
format: int64
diff --git a/docs/adr/0018-room-chat.md b/docs/adr/0018-room-chat.md
new file mode 100644
index 0000000..bef0b64
--- /dev/null
+++ b/docs/adr/0018-room-chat.md
@@ -0,0 +1,50 @@
+# ADR 0018: Room chat
+
+- **Status:** accepted
+- **Date:** 2026-10-14
+- **Issues:** MAD-836 (Phase 13), stage 3: MAD-842..845
+
+## Context
+
+A room already has reactions: an emoji floats up the big screen and is gone. People in a room also want to say things ("who queued this?", "turn it up"), to everyone, wherever they are in the app, and read back what was said later. Phase 13 adds room chat: a panel in the room, a small popup on any screen, and messages on the TV.
+
+Reactions aren't kept, so they needed no rules about storage, retention or deleting. Chat does.
+
+## Decision
+
+### Messages belong to a room, and are kept with its night
+
+Migration 00033 adds `chat_messages`: the room, the sender, the text, the song that was playing when it was sent (`queue_item_id`, nullable), when, and `deleted_at`.
+
+- **Nights.** A night isn't a row until it ends (ADR 0016), so a message can't name its night as it's sent. When a night ends, every message in the room not yet given a night, up to the end, is given this one (`night_id`). Messages sent before a night's first song belong to it too. Chat is read back by paging (`GET /rooms/{id}/messages?before=&limit=`, newest first), which runs across nights.
+- **Not in the Wrapped.** The recap is about the music, and chat often isn't. A night's messages stay readable in the chat panel, and `night_id` is there for a recap or export that wants them later.
+- **Kept until the room or the sender goes.** Messages are short (500 characters at most), so a busy room adds kilobytes a night. Nothing is pruned. Deleting the room deletes its chat. Deleting an account deletes its messages, guests' included.
+- **The song.** Each message keeps the queue item playing when it was sent, and the API returns that song's snapshot with it, so the panel can show "during " without another request. A song later deleted from the room leaves the message without one.
+
+### Who can read, post and delete
+
+- **Members and guests** of the room read and post. Guests are part of the night, and chat is how they say hi. **Paired displays** read (`listChatMessages` is a display operation) but never post.
+- **Deleting.** Anyone can delete their own messages. The room's owner and server admins can delete anyone's: that's the moderation there is. A deleted message's text is cleared at once and it leaves every screen (`chat.deleted`). The row stays, so a page boundary never shifts under someone scrolling.
+- **No other moderation.** No reports, mutes or word filters. Rooms are friends on a self-hosted server. Whoever runs the room can delete, and can remove someone from it.
+
+### Plain text, limited like reactions
+
+A message is plain text: trimmed, 1–500 characters (counted in runes), never empty or only whitespace. Links aren't made clickable, and nothing is rendered as HTML or Markdown.
+
+Sending is rate limited per person with the same token bucket as reactions, with a smaller burst: 5 messages, then one every 2 seconds (`rate_limited`, with `Retry-After`).
+
+### Realtime
+
+`chat.message` (ChatMessage) and `chat.deleted` (`{roomId, id}`) go out on the room's topic, so every screen with the room open hears them: the room socket stays open on every screen of the app (`RoomLive`), and on the TV.
+
+### Where it shows
+
+- **The panel.** A Chat button on the room page (and a tap on a popup) opens it: a bottom sheet on a phone, a side panel on a computer. Unread counts show on the button and the Room tab. Read state is per device, kept in local storage: the newest message seen.
+- **Popups anywhere.** A small glass bubble near the top, gone after about 5 seconds, never for your own messages or while the panel is open. A burst collapses into one bubble. Each device can mute them; the unread count still counts. They sit in their own slot, not the toaster's, so an error toast is never pushed out.
+- **The big screen.** A room setting, `screens.chat`: `floating` (default) drifts messages up the screen as glass speech bubbles, like reactions; `log` keeps the last few in a corner column; `off` shows none. Bubbles stay in the side margins, clear of the lyrics and the now-playing title, and only a few show at once. The desktop visualizer floats them the same way.
+
+## Consequences
+
+- Chat adds a write per message, and an update per night end over that night's messages, both indexed by room.
+- There are no DMs, threads, images, reactions to messages or push notifications. A message only reaches someone with the app open.
+- A room's owner can read back everything said in it. That's said where chat starts: "Everyone in the room can read this".
diff --git a/server/cmd/syncphony/main.go b/server/cmd/syncphony/main.go
index 73a53ef..dc79a36 100644
--- a/server/cmd/syncphony/main.go
+++ b/server/cmd/syncphony/main.go
@@ -22,6 +22,7 @@ import (
"github.com/madeofpendletonwool/syncphony/server/internal/auth"
"github.com/madeofpendletonwool/syncphony/server/internal/autopilot"
"github.com/madeofpendletonwool/syncphony/server/internal/backup"
+ "github.com/madeofpendletonwool/syncphony/server/internal/chat"
"github.com/madeofpendletonwool/syncphony/server/internal/clips"
"github.com/madeofpendletonwool/syncphony/server/internal/config"
"github.com/madeofpendletonwool/syncphony/server/internal/discover"
@@ -375,6 +376,7 @@ func run() error {
BaseURL: cfg.BaseURL, TrustedProxies: cfg.TrustedProxies,
}
api.SavedArtists = savedartists.New(db, roomSvc, nil)
+ api.Chat = chat.New(db, a.bus)
if len(graph.Sources()) > 0 {
api.Graph = graph
// Saved artists' pictures come from the graph's Deezer answers.
diff --git a/server/internal/chat/chat.go b/server/internal/chat/chat.go
new file mode 100644
index 0000000..e2d6dc9
--- /dev/null
+++ b/server/internal/chat/chat.go
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Package chat keeps a room's chat (MAD-842, ADR 0018): plain-text
+// messages to everyone in the room, each with the song that was playing.
+// Messages are kept with the night they were sent in (nights gives them
+// theirs when it ends) and reach every screen over the room's topic.
+package chat
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/madeofpendletonwool/syncphony/server/internal/realtime"
+ "github.com/madeofpendletonwool/syncphony/server/internal/store"
+)
+
+// Errors returned by Service.
+var (
+ ErrNotFound = errors.New("message not found")
+ ErrForbidden = errors.New("only its sender, the room's owner or an admin can delete a message")
+)
+
+// InvalidInputError is a message that can't be sent, such as an empty one.
+type InvalidInputError struct{ Message string }
+
+func (e *InvalidInputError) Error() string { return e.Message }
+
+// Limits.
+const (
+ // MaxBody is the longest message, in characters.
+ MaxBody = 500
+ // DefaultPage and MaxPage are how many messages a page holds.
+ DefaultPage = 50
+ MaxPage = 100
+)
+
+// Message is a chat message, with the song that was playing when it was
+// sent, if it's still in the room. Published as realtime.ChatMessage.
+type Message struct {
+ store.ChatMessage
+ Item *store.QueueItem
+}
+
+// Deleted is a message that was deleted. Published as
+// realtime.ChatMessageDeleted.
+type Deleted struct {
+ RoomID, ID string
+}
+
+// Service keeps rooms' chat.
+type Service struct {
+ db *store.Store
+ bus realtime.Bus
+ // Now is the clock. Default store.Now.
+ Now func() time.Time
+}
+
+// New returns a Service.
+func New(db *store.Store, bus realtime.Bus) *Service {
+ return &Service{db: db, bus: bus, Now: store.Now}
+}
+
+// Send posts a message to a room as userID. itemID is the song playing,
+// "" if none.
+func (s *Service) Send(ctx context.Context, roomID, userID, body, itemID string) (Message, error) {
+ body = strings.TrimSpace(body)
+ switch n := utf8.RuneCountInString(body); {
+ case !utf8.ValidString(body):
+ return Message{}, &InvalidInputError{"that message isn't valid text"}
+ case n == 0:
+ return Message{}, &InvalidInputError{"say something first"}
+ case n > MaxBody:
+ return Message{}, &InvalidInputError{"messages are 500 characters at most"}
+ }
+ m := store.ChatMessage{ID: store.NewID(), RoomID: roomID, UserID: userID, Body: body, CreatedAt: s.Now()}
+ var item *store.QueueItem
+ if itemID != "" {
+ if it, err := s.db.GetQueueItem(ctx, itemID); err == nil && it.RoomID == roomID {
+ m.QueueItemID, item = sql.NullString{String: itemID, Valid: true}, &it
+ }
+ }
+ err := s.db.InsertChatMessage(ctx, store.InsertChatMessageParams{
+ ID: m.ID, RoomID: m.RoomID, UserID: m.UserID, Body: m.Body, QueueItemID: m.QueueItemID, CreatedAt: m.CreatedAt,
+ })
+ if err != nil {
+ return Message{}, err
+ }
+ out := Message{ChatMessage: m, Item: item}
+ s.bus.Publish(realtime.RoomTopic(roomID), realtime.Event{Type: realtime.ChatMessage, Data: out})
+ return out, nil
+}
+
+// List returns a page of a room's messages, newest first: those before
+// the message before ("" for the newest).
+func (s *Service) List(ctx context.Context, roomID, before string, limit int) ([]Message, error) {
+ if limit <= 0 {
+ limit = DefaultPage
+ }
+ rows, err := s.db.ListChatMessages(ctx, store.ListChatMessagesParams{RoomID: roomID, Before: before, Limit: int64(min(limit, MaxPage))})
+ if err != nil {
+ return nil, err
+ }
+ // Chat runs through a song, so a page names few of them.
+ items := map[string]*store.QueueItem{}
+ out := make([]Message, len(rows))
+ for i, m := range rows {
+ out[i] = Message{ChatMessage: m}
+ if !m.QueueItemID.Valid {
+ continue
+ }
+ id := m.QueueItemID.String
+ it, ok := items[id]
+ if !ok {
+ if row, err := s.db.GetQueueItem(ctx, id); err == nil {
+ it = &row
+ } else if !store.IsNotFound(err) {
+ return nil, err
+ }
+ items[id] = it
+ }
+ out[i].Item = it
+ }
+ return out, nil
+}
+
+// Delete deletes a message. Its sender may, and so may a moderator: the
+// room's owner or an admin.
+func (s *Service) Delete(ctx context.Context, roomID, messageID, userID string, moderator bool) error {
+ m, err := s.db.GetChatMessage(ctx, store.GetChatMessageParams{ID: messageID, RoomID: roomID})
+ if store.IsNotFound(err) || (err == nil && m.DeletedAt.Valid) {
+ return ErrNotFound
+ } else if err != nil {
+ return err
+ }
+ if m.UserID != userID && !moderator {
+ return ErrForbidden
+ }
+ if err := s.db.DeleteChatMessage(ctx, store.DeleteChatMessageParams{DeletedAt: sql.NullTime{Time: s.Now(), Valid: true}, ID: m.ID}); err != nil {
+ return err
+ }
+ s.bus.Publish(realtime.RoomTopic(roomID), realtime.Event{Type: realtime.ChatMessageDeleted, Data: Deleted{RoomID: roomID, ID: m.ID}})
+ return nil
+}
diff --git a/server/internal/chat/chat_test.go b/server/internal/chat/chat_test.go
new file mode 100644
index 0000000..8158f1e
--- /dev/null
+++ b/server/internal/chat/chat_test.go
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package chat_test
+
+import (
+ "errors"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/madeofpendletonwool/syncphony/server/internal/chat"
+ "github.com/madeofpendletonwool/syncphony/server/internal/realtime"
+ "github.com/madeofpendletonwool/syncphony/server/internal/store"
+)
+
+func setup(t *testing.T) (*chat.Service, *realtime.Local, store.Room, store.User, store.User) {
+ t.Helper()
+ ctx := t.Context()
+ db, err := store.Open(ctx, filepath.Join(t.TempDir(), "syncphony.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ user := func(name string) store.User {
+ u, err := db.CreateUser(ctx, store.CreateUserParams{ID: store.NewID(), Username: name, DisplayName: name, Color: "#000", Role: store.RoleMember, CreatedAt: store.Now()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return u
+ }
+ alice, bob := user("alice"), user("bob")
+ r, err := db.CreateRoom(ctx, store.CreateRoomParams{ID: store.NewID(), Name: "Living room", OwnerID: alice.ID, FairnessMode: store.FairnessRoundRobin, Settings: "{}", CreatedAt: store.Now()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ bus := realtime.NewLocal()
+ return chat.New(db, bus), bus, r, alice, bob
+}
+
+func TestSendListDelete(t *testing.T) {
+ ctx := t.Context()
+ s, bus, room, alice, bob := setup(t)
+ sub := bus.Subscribe(realtime.RoomTopic(room.ID))
+ defer sub.Close()
+
+ for _, body := range []string{"", " \n ", strings.Repeat("a", chat.MaxBody+1), "\xff"} {
+ var invalid *chat.InvalidInputError
+ if _, err := s.Send(ctx, room.ID, alice.ID, body, ""); !errors.As(err, &invalid) {
+ t.Errorf("%q: %v", body, err)
+ }
+ }
+ // An item that isn't in the room is left off.
+ m, err := s.Send(ctx, room.ID, alice.ID, " hi ", "nope")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Body != "hi" || m.QueueItemID.Valid || m.Item != nil {
+ t.Fatalf("sent: %+v", m)
+ }
+ if e := <-sub.C; e.Type != realtime.ChatMessage || e.Data.(chat.Message).ID != m.ID {
+ t.Fatalf("event: %+v", e)
+ }
+ var ids []string
+ for i := range 4 {
+ m, err := s.Send(ctx, room.ID, bob.ID, strings.Repeat("x", i+1), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids = append(ids, m.ID)
+ <-sub.C
+ }
+
+ page, err := s.List(ctx, room.ID, "", 3)
+ if err != nil || len(page) != 3 || page[0].ID != ids[3] || page[2].ID != ids[1] {
+ t.Fatalf("page: %+v %v", page, err)
+ }
+ rest, err := s.List(ctx, room.ID, page[2].ID, 0)
+ if err != nil || len(rest) != 2 || rest[1].ID != m.ID {
+ t.Fatalf("rest: %+v %v", rest, err)
+ }
+
+ // Bob can't delete Alice's; the room's owner can delete Bob's.
+ if err := s.Delete(ctx, room.ID, m.ID, bob.ID, false); !errors.Is(err, chat.ErrForbidden) {
+ t.Fatalf("bob deleting alice's: %v", err)
+ }
+ if err := s.Delete(ctx, room.ID, ids[0], alice.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ if e := <-sub.C; e.Type != realtime.ChatMessageDeleted || e.Data.(chat.Deleted).ID != ids[0] {
+ t.Fatalf("event: %+v", e)
+ }
+ if err := s.Delete(ctx, room.ID, ids[0], alice.ID, true); !errors.Is(err, chat.ErrNotFound) {
+ t.Fatalf("deleting twice: %v", err)
+ }
+ if err := s.Delete(ctx, "elsewhere", ids[1], bob.ID, false); !errors.Is(err, chat.ErrNotFound) {
+ t.Fatalf("another room's: %v", err)
+ }
+ all, _ := s.List(ctx, room.ID, "", 0)
+ if len(all) != 4 {
+ t.Fatalf("after deleting: %d", len(all))
+ }
+}
diff --git a/server/internal/httpapi/accounts_test.go b/server/internal/httpapi/accounts_test.go
index 55469f6..c1f4545 100644
--- a/server/internal/httpapi/accounts_test.go
+++ b/server/internal/httpapi/accounts_test.go
@@ -27,6 +27,7 @@ import (
"github.com/madeofpendletonwool/syncphony/server/internal/autopilot"
"github.com/madeofpendletonwool/syncphony/server/internal/avatar"
"github.com/madeofpendletonwool/syncphony/server/internal/backup"
+ "github.com/madeofpendletonwool/syncphony/server/internal/chat"
"github.com/madeofpendletonwool/syncphony/server/internal/clips"
"github.com/madeofpendletonwool/syncphony/server/internal/discover"
"github.com/madeofpendletonwool/syncphony/server/internal/dj"
@@ -148,7 +149,7 @@ func newEnv(t *testing.T) *env {
pilot.Now, pilot.Memory = e.clock, &dj.Memory{DB: db, Now: e.clock}
t.Cleanup(pilot.Close)
api := &httpapi.Server{
- Version: "test", StartedAt: e.clock(), Admin: e.server, Backups: e.backups, Auth: e.svc, Links: e.links, Lyrics: ly, LinerNotes: notes, Artwork: art, Palettes: e.palettes, BeatMaps: beatMaps, Rooms: e.rooms, Queue: qs, Playback: e.playback, Nights: e.nights, Playlists: playlists.New(db, e.rooms, e.links), SavedArtists: savedartists.New(db, e.rooms, nil), Games: e.games, Bus: e.bus, Presence: presence,
+ Version: "test", StartedAt: e.clock(), Admin: e.server, Backups: e.backups, Auth: e.svc, Links: e.links, Lyrics: ly, LinerNotes: notes, Artwork: art, Palettes: e.palettes, BeatMaps: beatMaps, Rooms: e.rooms, Queue: qs, Playback: e.playback, Nights: e.nights, Playlists: playlists.New(db, e.rooms, e.links), SavedArtists: savedartists.New(db, e.rooms, nil), Chat: chat.New(db, e.bus), Games: e.games, Bus: e.bus, Presence: presence,
Suggest: suggest.New(db, e.rooms, e.links), Autopilot: pilot, Discover: discover.New(db),
BaseURL: e.base, PingEvery: 50 * time.Millisecond,
}
@@ -156,6 +157,7 @@ func newEnv(t *testing.T) *env {
api.Clips = clips.New(e.links, ff, clips.Config{})
t.Cleanup(api.Clips.Close)
}
+ api.Chat.Now = e.clock
api.Discover.Rooms = &discover.RoomSource{DB: db, CanEnter: e.rooms.CanEnter, Now: e.clock}
e.api = api
mux := http.NewServeMux()
diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go
index 652221b..0a9a5de 100644
--- a/server/internal/httpapi/api.gen.go
+++ b/server/internal/httpapi/api.gen.go
@@ -1109,6 +1109,8 @@ func (e RoomAutopilotAdventure) Valid() bool {
// Defines values for RoomEventType.
const (
+ RoomEventTypeChatDeleted RoomEventType = "chat.deleted"
+ RoomEventTypeChatMessage RoomEventType = "chat.message"
RoomEventTypeGameQueue RoomEventType = "game.queue"
RoomEventTypeGameRound RoomEventType = "game.round"
RoomEventTypeGameScores RoomEventType = "game.scores"
@@ -1131,6 +1133,10 @@ const (
// Valid indicates whether the value is a known member of the RoomEventType enum.
func (e RoomEventType) Valid() bool {
switch e {
+ case RoomEventTypeChatDeleted:
+ return true
+ case RoomEventTypeChatMessage:
+ return true
case RoomEventTypeGameQueue:
return true
case RoomEventTypeGameRound:
@@ -1428,6 +1434,27 @@ func (e RoomMembersChangedChange) Valid() bool {
}
}
+// Defines values for RoomScreensChat.
+const (
+ RoomScreensChatFloating RoomScreensChat = "floating"
+ RoomScreensChatLog RoomScreensChat = "log"
+ RoomScreensChatOff RoomScreensChat = "off"
+)
+
+// Valid indicates whether the value is a known member of the RoomScreensChat enum.
+func (e RoomScreensChat) Valid() bool {
+ switch e {
+ case RoomScreensChatFloating:
+ return true
+ case RoomScreensChatLog:
+ return true
+ case RoomScreensChatOff:
+ return true
+ default:
+ return false
+ }
+}
+
// Defines values for RoomScreensLook.
const (
Auto RoomScreensLook = "auto"
@@ -2141,6 +2168,38 @@ type Ceremony struct {
Options map[string]interface{} `json:"options"`
}
+// ChatMessage A message in a room's chat (ADR 0018).
+type ChatMessage struct {
+ Body string `json:"body"`
+ CreatedAt time.Time `json:"createdAt"`
+
+ // Id Time-ordered, so IDs sort as the messages were sent.
+ Id string `json:"id"`
+
+ // ItemId The song playing when it was sent.
+ ItemId *string `json:"itemId,omitempty"`
+
+ // NightId The night it was sent in, once that night has ended.
+ NightId *string `json:"nightId,omitempty"`
+ RoomId string `json:"roomId"`
+
+ // Track The track as it was when queued; still shown if its service is offline.
+ Track *QueuedTrack `json:"track,omitempty"`
+ UserId string `json:"userId"`
+}
+
+// ChatMessageDeleted defines model for ChatMessageDeleted.
+type ChatMessageDeleted struct {
+ Id string `json:"id"`
+ RoomId string `json:"roomId"`
+}
+
+// ChatMessageRequest defines model for ChatMessageRequest.
+type ChatMessageRequest struct {
+ // Body Plain text; trimmed, then 1–500 characters.
+ Body string `json:"body"`
+}
+
// ClaimPlayerRequest defines model for ClaimPlayerRequest.
type ClaimPlayerRequest struct {
// DeviceId A stable ID the device makes up and keeps.
@@ -4209,6 +4268,8 @@ type RoomAutopilotAdventure string
// your own while the room's scores are `private`.
// - `game.queue`: QueueGame. A queue game started, changed, or is
// done. Sent after hello for each that's up.
+// - `chat.message`: ChatMessage. Someone sent a message to the room.
+// - `chat.deleted`: ChatMessageDeleted. A message was deleted.
//
// A paired display connects with its display cookie instead, to its
// own room only. So does a signed-in user with `display=1`: either way
@@ -4517,6 +4578,12 @@ type RoomPermissionsChange struct {
// RoomScreens How the room's big screens show the music (MAD-779). Set from any
// phone by whoever manages the room; every screen follows.
type RoomScreens struct {
+ // Chat How the room's chat shows on big screens (ADR 0018). `floating`:
+ // messages drift up the sides like reactions. `log`: the last few
+ // in a corner column. `off`: not at all. Left out of an update, it
+ // stays as it was.
+ Chat *RoomScreensChat `json:"chat,omitempty"`
+
// Intensity How strongly the screens move with the music.
Intensity float32 `json:"intensity"`
@@ -4529,6 +4596,12 @@ type RoomScreens struct {
Scene string `json:"scene"`
}
+// RoomScreensChat How the room's chat shows on big screens (ADR 0018). `floating`:
+// messages drift up the sides like reactions. `log`: the last few
+// in a corner column. `off`: not at all. Left out of an update, it
+// stays as it was.
+type RoomScreensChat string
+
// RoomScreensLook `stage`: now playing, lyrics and what's next. `visualizer`:
// full-screen visuals with now playing small. `auto`: the stage,
// with visuals taking over for songs with no words to sing.
@@ -5361,6 +5434,14 @@ type GetHistoryParams struct {
UserId *string `form:"userId,omitempty" json:"userId,omitempty"`
}
+// ListChatMessagesParams defines parameters for ListChatMessages.
+type ListChatMessagesParams struct {
+ Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
+
+ // Before Only messages older than this message.
+ Before *string `form:"before,omitempty" json:"before,omitempty"`
+}
+
// ListNightsParams defines parameters for ListNights.
type ListNightsParams struct {
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
@@ -5582,6 +5663,9 @@ type CreateGuestPassJSONRequestBody = CreateGuestPassRequest
// CreateRoomInviteJSONRequestBody defines body for CreateRoomInvite for application/json ContentType.
type CreateRoomInviteJSONRequestBody = CreateRoomInviteRequest
+// SendChatMessageJSONRequestBody defines body for SendChatMessage for application/json ContentType.
+type SendChatMessageJSONRequestBody = ChatMessageRequest
+
// TransferRoomJSONRequestBody defines body for TransferRoom for application/json ContentType.
type TransferRoomJSONRequestBody = TransferRoomRequest
@@ -6100,6 +6184,15 @@ type ServerInterface interface {
// AddRoomMember Let someone into the room (owner or admin)
// (PUT /rooms/{roomId}/members/{userId})
AddRoomMember(w http.ResponseWriter, r *http.Request, roomId RoomId, userId string)
+ // ListChatMessages The room's chat, newest first
+ // (GET /rooms/{roomId}/messages)
+ ListChatMessages(w http.ResponseWriter, r *http.Request, roomId RoomId, params ListChatMessagesParams)
+ // SendChatMessage Send a message to the room's chat
+ // (POST /rooms/{roomId}/messages)
+ SendChatMessage(w http.ResponseWriter, r *http.Request, roomId RoomId)
+ // DeleteChatMessage Delete a chat message
+ // (DELETE /rooms/{roomId}/messages/{messageId})
+ DeleteChatMessage(w http.ResponseWriter, r *http.Request, roomId RoomId, messageId string)
// ListNights The room's past nights, and their songs of the night
// (GET /rooms/{roomId}/nights)
ListNights(w http.ResponseWriter, r *http.Request, roomId RoomId, params ListNightsParams)
@@ -10494,6 +10587,122 @@ func (siw *ServerInterfaceWrapper) AddRoomMember(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
+// ListChatMessages operation middleware
+func (siw *ServerInterfaceWrapper) ListChatMessages(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "roomId" -------------
+ var roomId RoomId
+
+ err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err})
+ return
+ }
+
+ // Parameter object where we will unmarshal all parameters from the context
+ var params ListChatMessagesParams
+
+ // ------------- Optional query parameter "limit" -------------
+
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""})
+ if err != nil {
+ var requiredError *runtime.RequiredParameterError
+ if errors.As(err, &requiredError) {
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"})
+ } else {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
+ }
+ return
+ }
+
+ // ------------- Optional query parameter "before" -------------
+
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "before", r.URL.Query(), ¶ms.Before, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
+ if err != nil {
+ var requiredError *runtime.RequiredParameterError
+ if errors.As(err, &requiredError) {
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "before"})
+ } else {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "before", Err: err})
+ }
+ return
+ }
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ListChatMessages(w, r, roomId, params)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// SendChatMessage operation middleware
+func (siw *ServerInterfaceWrapper) SendChatMessage(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "roomId" -------------
+ var roomId RoomId
+
+ err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err})
+ return
+ }
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.SendChatMessage(w, r, roomId)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// DeleteChatMessage operation middleware
+func (siw *ServerInterfaceWrapper) DeleteChatMessage(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "roomId" -------------
+ var roomId RoomId
+
+ err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err})
+ return
+ }
+
+ // ------------- Path parameter "messageId" -------------
+ var messageId string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "messageId", r.PathValue("messageId"), &messageId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "messageId", Err: err})
+ return
+ }
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.DeleteChatMessage(w, r, roomId, messageId)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
// ListNights operation middleware
func (siw *ServerInterfaceWrapper) ListNights(w http.ResponseWriter, r *http.Request) {
@@ -12077,6 +12286,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/queue/{itemId}/lyrics", wrapper.GetQueueItemLyrics)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/queue/{itemId}/liner-notes", wrapper.GetQueueItemLinerNotes)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/reactions", wrapper.SendReaction)
+ m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/messages", wrapper.ListChatMessages)
+ m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/messages", wrapper.SendChatMessage)
+ m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/rooms/{roomId}/messages/{messageId}", wrapper.DeleteChatMessage)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/displays", wrapper.ListDisplays)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/displays", wrapper.PairDisplay)
m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}", wrapper.UnpairDisplay)
@@ -18409,6 +18621,120 @@ func (response AddRoomMemberdefaultJSONResponse) VisitAddRoomMemberResponse(w ht
return err
}
+type ListChatMessagesRequestObject struct {
+ RoomId RoomId `json:"roomId"`
+ Params ListChatMessagesParams
+}
+
+type ListChatMessagesResponseObject interface {
+ VisitListChatMessagesResponse(w http.ResponseWriter) error
+}
+
+type ListChatMessages200JSONResponse []ChatMessage
+
+func (response ListChatMessages200JSONResponse) VisitListChatMessagesResponse(w http.ResponseWriter) error {
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(response); err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+ _, err := buf.WriteTo(w)
+ return err
+}
+
+type ListChatMessagesdefaultJSONResponse struct {
+ Body Error
+ StatusCode int
+}
+
+func (response ListChatMessagesdefaultJSONResponse) VisitListChatMessagesResponse(w http.ResponseWriter) error {
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(response.Body); err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(response.StatusCode)
+ _, err := buf.WriteTo(w)
+ return err
+}
+
+type SendChatMessageRequestObject struct {
+ RoomId RoomId `json:"roomId"`
+ Body *SendChatMessageJSONRequestBody
+}
+
+type SendChatMessageResponseObject interface {
+ VisitSendChatMessageResponse(w http.ResponseWriter) error
+}
+
+type SendChatMessage201JSONResponse ChatMessage
+
+func (response SendChatMessage201JSONResponse) VisitSendChatMessageResponse(w http.ResponseWriter) error {
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(response); err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(201)
+ _, err := buf.WriteTo(w)
+ return err
+}
+
+type SendChatMessagedefaultJSONResponse struct {
+ Body Error
+ StatusCode int
+}
+
+func (response SendChatMessagedefaultJSONResponse) VisitSendChatMessageResponse(w http.ResponseWriter) error {
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(response.Body); err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(response.StatusCode)
+ _, err := buf.WriteTo(w)
+ return err
+}
+
+type DeleteChatMessageRequestObject struct {
+ RoomId RoomId `json:"roomId"`
+ MessageId string `json:"messageId"`
+}
+
+type DeleteChatMessageResponseObject interface {
+ VisitDeleteChatMessageResponse(w http.ResponseWriter) error
+}
+
+type DeleteChatMessage204Response struct {
+}
+
+func (response DeleteChatMessage204Response) VisitDeleteChatMessageResponse(w http.ResponseWriter) error {
+ w.WriteHeader(204)
+ return nil
+}
+
+type DeleteChatMessagedefaultJSONResponse struct {
+ Body Error
+ StatusCode int
+}
+
+func (response DeleteChatMessagedefaultJSONResponse) VisitDeleteChatMessageResponse(w http.ResponseWriter) error {
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(response.Body); err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(response.StatusCode)
+ _, err := buf.WriteTo(w)
+ return err
+}
+
type ListNightsRequestObject struct {
RoomId RoomId `json:"roomId"`
Params ListNightsParams
@@ -20485,6 +20811,15 @@ type StrictServerInterface interface {
// AddRoomMember Let someone into the room (owner or admin)
// (PUT /rooms/{roomId}/members/{userId})
AddRoomMember(ctx context.Context, request AddRoomMemberRequestObject) (AddRoomMemberResponseObject, error)
+ // ListChatMessages The room's chat, newest first
+ // (GET /rooms/{roomId}/messages)
+ ListChatMessages(ctx context.Context, request ListChatMessagesRequestObject) (ListChatMessagesResponseObject, error)
+ // SendChatMessage Send a message to the room's chat
+ // (POST /rooms/{roomId}/messages)
+ SendChatMessage(ctx context.Context, request SendChatMessageRequestObject) (SendChatMessageResponseObject, error)
+ // DeleteChatMessage Delete a chat message
+ // (DELETE /rooms/{roomId}/messages/{messageId})
+ DeleteChatMessage(ctx context.Context, request DeleteChatMessageRequestObject) (DeleteChatMessageResponseObject, error)
// ListNights The room's past nights, and their songs of the night
// (GET /rooms/{roomId}/nights)
ListNights(ctx context.Context, request ListNightsRequestObject) (ListNightsResponseObject, error)
@@ -25064,6 +25399,93 @@ func (sh *strictHandler) AddRoomMember(w http.ResponseWriter, r *http.Request, r
}
}
+// ListChatMessages operation middleware
+func (sh *strictHandler) ListChatMessages(w http.ResponseWriter, r *http.Request, roomId RoomId, params ListChatMessagesParams) {
+ var request ListChatMessagesRequestObject
+
+ request.RoomId = roomId
+ request.Params = params
+
+ handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
+ return sh.ssi.ListChatMessages(ctx, request.(ListChatMessagesRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "ListChatMessages")
+ }
+
+ response, err := handler(r.Context(), w, r, request)
+
+ if err != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, err)
+ } else if validResponse, ok := response.(ListChatMessagesResponseObject); ok {
+ if err := validResponse.VisitListChatMessagesResponse(w); err != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, err)
+ }
+ } else if response != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
+ }
+}
+
+// SendChatMessage operation middleware
+func (sh *strictHandler) SendChatMessage(w http.ResponseWriter, r *http.Request, roomId RoomId) {
+ var request SendChatMessageRequestObject
+
+ request.RoomId = roomId
+
+ var body SendChatMessageJSONRequestBody
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
+ return
+ }
+ request.Body = &body
+
+ handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
+ return sh.ssi.SendChatMessage(ctx, request.(SendChatMessageRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "SendChatMessage")
+ }
+
+ response, err := handler(r.Context(), w, r, request)
+
+ if err != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, err)
+ } else if validResponse, ok := response.(SendChatMessageResponseObject); ok {
+ if err := validResponse.VisitSendChatMessageResponse(w); err != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, err)
+ }
+ } else if response != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
+ }
+}
+
+// DeleteChatMessage operation middleware
+func (sh *strictHandler) DeleteChatMessage(w http.ResponseWriter, r *http.Request, roomId RoomId, messageId string) {
+ var request DeleteChatMessageRequestObject
+
+ request.RoomId = roomId
+ request.MessageId = messageId
+
+ handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
+ return sh.ssi.DeleteChatMessage(ctx, request.(DeleteChatMessageRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "DeleteChatMessage")
+ }
+
+ response, err := handler(r.Context(), w, r, request)
+
+ if err != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, err)
+ } else if validResponse, ok := response.(DeleteChatMessageResponseObject); ok {
+ if err := validResponse.VisitDeleteChatMessageResponse(w); err != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, err)
+ }
+ } else if response != nil {
+ sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
+ }
+}
+
// ListNights operation middleware
func (sh *strictHandler) ListNights(w http.ResponseWriter, r *http.Request, roomId RoomId, params ListNightsParams) {
var request ListNightsRequestObject
diff --git a/server/internal/httpapi/chat.go b/server/internal/httpapi/chat.go
new file mode 100644
index 0000000..c201f2e
--- /dev/null
+++ b/server/internal/httpapi/chat.go
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package httpapi
+
+import (
+ "context"
+ "time"
+
+ "github.com/madeofpendletonwool/syncphony/server/internal/auth"
+ "github.com/madeofpendletonwool/syncphony/server/internal/chat"
+)
+
+// Room chat (MAD-842, ADR 0018): members and guests chat in their room,
+// paired displays read along.
+
+// chatRate: messages per person, a smaller burst than reactions'.
+var chatRate = rate{burst: 5, every: 2 * time.Second}
+
+// ListChatMessages pages the room's chat, newest first.
+func (s *Server) ListChatMessages(ctx context.Context, req ListChatMessagesRequestObject) (ListChatMessagesResponseObject, error) {
+ if _, err := s.Rooms.Get(ctx, req.RoomId); err != nil {
+ return nil, err
+ }
+ limit := 0
+ if req.Params.Limit != nil {
+ limit = *req.Params.Limit
+ }
+ ms, err := s.Chat.List(ctx, req.RoomId, deref(req.Params.Before), limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make(ListChatMessages200JSONResponse, len(ms))
+ for i, m := range ms {
+ out[i] = toChatMessage(m)
+ }
+ return out, nil
+}
+
+// SendChatMessage posts to the room's chat, with the song that's playing.
+func (s *Server) SendChatMessage(ctx context.Context, req SendChatMessageRequestObject) (SendChatMessageResponseObject, error) {
+ if _, err := s.Rooms.Get(ctx, req.RoomId); err != nil {
+ return nil, err
+ }
+ u := sessionFrom(ctx).User
+ if wait, ok := s.chats.allow(u.ID, time.Now(), chatRate); !ok {
+ return nil, &auth.RateLimitError{RetryAfter: wait}
+ }
+ itemID := ""
+ if s.Playback != nil {
+ if np, err := s.Playback.NowPlaying(ctx, req.RoomId); err == nil && np.Item != nil {
+ itemID = np.Item.ID
+ }
+ }
+ m, err := s.Chat.Send(ctx, req.RoomId, u.ID, req.Body.Body, itemID)
+ if err != nil {
+ return nil, err
+ }
+ return SendChatMessage201JSONResponse(toChatMessage(m)), nil
+}
+
+// DeleteChatMessage deletes a message: your own, or anyone's in a room
+// you host.
+func (s *Server) DeleteChatMessage(ctx context.Context, req DeleteChatMessageRequestObject) (DeleteChatMessageResponseObject, error) {
+ room, err := s.Rooms.Get(ctx, req.RoomId)
+ if err != nil {
+ return nil, err
+ }
+ u := sessionFrom(ctx).User
+ if err := s.Chat.Delete(ctx, req.RoomId, req.MessageId, u.ID, mayHost(u, room)); err != nil {
+ return nil, err
+ }
+ return DeleteChatMessage204Response{}, nil
+}
+
+func toChatMessage(m chat.Message) ChatMessage {
+ out := ChatMessage{Id: m.ID, RoomId: m.RoomID, UserId: m.UserID, Body: m.Body, CreatedAt: m.CreatedAt}
+ if m.NightID.Valid {
+ out.NightId = &m.NightID.String
+ }
+ if m.Item != nil {
+ t := toQueueItem(*m.Item).Track
+ out.ItemId, out.Track = &m.Item.ID, &t
+ }
+ return out
+}
diff --git a/server/internal/httpapi/chat_test.go b/server/internal/httpapi/chat_test.go
new file mode 100644
index 0000000..ede095f
--- /dev/null
+++ b/server/internal/httpapi/chat_test.go
@@ -0,0 +1,146 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package httpapi_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/madeofpendletonwool/syncphony/server/internal/httpapi"
+)
+
+func TestChat(t *testing.T) {
+ e := newEnv(t)
+ alice := e.admin()
+ bob := e.member(alice, "bob")
+ carol := e.member(alice, "carol")
+ bobMe := me(t, bob)
+ var room httpapi.Room
+ alice.want(http.StatusCreated, "POST", "/rooms", httpapi.CreateRoomRequest{Name: "Living room"}).decode(t, &room)
+ base := "/rooms/" + room.Id
+ tv := pairTV(t, e, alice, room.Id)
+ d := tv.mustDial(room.Id, "")
+ d.expect("hello", nil)
+
+ // A message reaches the room, and remembers the song playing.
+ var snap httpapi.QueueSnapshot
+ alice.want(http.StatusOK, "POST", base+"/queue", addReq(linkFake(t, alice), "t01")).decode(t, &snap)
+ alice.want(http.StatusOK, "PUT", base+"/player", httpapi.ClaimPlayerRequest{DeviceId: "phone", Name: "Alice's phone"})
+ alice.want(http.StatusOK, "POST", base+"/playback", httpapi.PlaybackCommand{Action: httpapi.Play})
+ var sent httpapi.ChatMessage
+ bob.want(http.StatusCreated, "POST", base+"/messages", httpapi.ChatMessageRequest{Body: " who queued this? "}).decode(t, &sent)
+ if sent.Body != "who queued this?" || sent.UserId != bobMe.Id || sent.ItemId == nil || *sent.ItemId != snap.Items[0].Id || sent.Track == nil || sent.Track.Title == "" {
+ t.Fatalf("sent: %+v", sent)
+ }
+ var got httpapi.ChatMessage
+ d.await("chat.message", &got)
+ if got.Id != sent.Id || got.Body != sent.Body || got.Track == nil {
+ t.Fatalf("on the socket: %+v", got)
+ }
+
+ // Plain text, 1-500 characters.
+ for _, body := range []string{"", " \n\t ", strings.Repeat("x", 501)} {
+ if r := bob.do("POST", base+"/messages", httpapi.ChatMessageRequest{Body: body}); r.status != http.StatusBadRequest {
+ t.Errorf("body of %d: %d", len(body), r.status)
+ }
+ }
+ bob.want(http.StatusCreated, "POST", base+"/messages", httpapi.ChatMessageRequest{Body: strings.Repeat("é", 500)})
+
+ // Pages, newest first.
+ for i := range 3 {
+ alice.want(http.StatusCreated, "POST", base+"/messages", httpapi.ChatMessageRequest{Body: "hi " + string(rune('a'+i))})
+ }
+ var page []httpapi.ChatMessage
+ carol.want(http.StatusOK, "GET", base+"/messages?limit=2", nil).decode(t, &page)
+ if len(page) != 2 || page[0].Body != "hi c" || page[1].Body != "hi b" {
+ t.Fatalf("first page: %+v", page)
+ }
+ var older []httpapi.ChatMessage
+ carol.want(http.StatusOK, "GET", base+"/messages?limit=10&before="+page[1].Id, nil).decode(t, &older)
+ if len(older) != 3 || older[0].Body != "hi a" || older[2].Id != sent.Id {
+ t.Fatalf("older: %+v", older)
+ }
+ // The big screen reads along.
+ tv.want(http.StatusOK, "GET", base+"/messages", nil).decode(t, &page)
+ if len(page) != 5 {
+ t.Fatalf("display reads %d", len(page))
+ }
+ if r := tv.do("POST", base+"/messages", httpapi.ChatMessageRequest{Body: "hello"}); r.status != http.StatusUnauthorized {
+ t.Fatalf("display posted: %d", r.status)
+ }
+
+ // Your own, or anyone's if it's your room.
+ carol.want(http.StatusForbidden, "DELETE", base+"/messages/"+sent.Id, nil)
+ bob.want(http.StatusNoContent, "DELETE", base+"/messages/"+sent.Id, nil)
+ var gone httpapi.ChatMessageDeleted
+ d.await("chat.deleted", &gone)
+ if gone.Id != sent.Id || gone.RoomId != room.Id {
+ t.Fatalf("deleted: %+v", gone)
+ }
+ bob.want(http.StatusNotFound, "DELETE", base+"/messages/"+sent.Id, nil)
+ alice.want(http.StatusNoContent, "DELETE", base+"/messages/"+older[1].Id, nil)
+ carol.want(http.StatusOK, "GET", base+"/messages", nil).decode(t, &page)
+ if len(page) != 3 {
+ t.Fatalf("after deleting: %+v", page)
+ }
+
+ // A room you can't open has no chat.
+ var private httpapi.Room
+ carol.want(http.StatusCreated, "POST", "/rooms", httpapi.CreateRoomRequest{Name: "Carol's", Visibility: ptr(httpapi.RoomVisibilityPrivate)}).decode(t, &private)
+ bob.want(http.StatusNotFound, "GET", "/rooms/"+private.Id+"/messages", nil)
+ bob.want(http.StatusNotFound, "POST", "/rooms/"+private.Id+"/messages", httpapi.ChatMessageRequest{Body: "hi"})
+
+ // A burst is fine; a flood isn't.
+ limited := false
+ for range 10 {
+ if r := carol.do("POST", base+"/messages", httpapi.ChatMessageRequest{Body: "spam"}); r.status == http.StatusTooManyRequests {
+ limited = true
+ break
+ }
+ }
+ if !limited {
+ t.Fatal("chat wasn't rate limited")
+ }
+
+ // The night's chat stays with it.
+ e.play(room.Id, snap.Items[0].Id, e.clock().Add(-10*time.Minute))
+ var n httpapi.Night
+ alice.want(http.StatusCreated, "POST", base+"/nights", nil).decode(t, &n)
+ e.advance(time.Minute)
+ alice.want(http.StatusCreated, "POST", base+"/messages", httpapi.ChatMessageRequest{Body: "tomorrow"})
+ alice.want(http.StatusOK, "GET", base+"/messages", nil).decode(t, &page)
+ if page[0].NightId != nil {
+ t.Errorf("after the night: %+v", page[0])
+ }
+ for _, m := range page[1:] {
+ if m.NightId == nil || *m.NightId != n.Id {
+ t.Errorf("tonight's message %q has night %v", m.Body, m.NightId)
+ }
+ }
+}
+
+func TestChatGuests(t *testing.T) {
+ e := newEnv(t)
+ alice := e.admin()
+ e.advance(time.Since(e.clock()))
+ var room, other httpapi.Room
+ alice.want(http.StatusCreated, "POST", "/rooms", httpapi.CreateRoomRequest{Name: "Living room"}).decode(t, &room)
+ alice.want(http.StatusCreated, "POST", "/rooms", httpapi.CreateRoomRequest{Name: "Kitchen"}).decode(t, &other)
+ base := "/rooms/" + room.Id
+ alice.want(http.StatusOK, "PATCH", base, httpapi.UpdateRoomRequest{Guests: &httpapi.RoomGuests{Allowed: true, MaxSongs: 2, CanVote: true}})
+ var pass httpapi.GuestPass
+ alice.want(http.StatusCreated, "POST", base+"/guest-pass", httpapi.CreateGuestPassRequest{ExpiresAt: e.clock().Add(4 * time.Hour)}).decode(t, &pass)
+ guest, _ := e.joinAsGuest(passToken(t, pass), "Sam")
+
+ var m httpapi.ChatMessage
+ guest.want(http.StatusCreated, "POST", base+"/messages", httpapi.ChatMessageRequest{Body: "hey all"}).decode(t, &m)
+ var page []httpapi.ChatMessage
+ guest.want(http.StatusOK, "GET", base+"/messages", nil).decode(t, &page)
+ if len(page) != 1 {
+ t.Fatalf("guest reads %+v", page)
+ }
+ guest.want(http.StatusForbidden, "GET", "/rooms/"+other.Id+"/messages", nil)
+ guest.want(http.StatusNoContent, "DELETE", base+"/messages/"+m.Id, nil)
+}
diff --git a/server/internal/httpapi/displays.go b/server/internal/httpapi/displays.go
index 8c84f36..0775dfb 100644
--- a/server/internal/httpapi/displays.go
+++ b/server/internal/httpapi/displays.go
@@ -45,6 +45,8 @@ var displayOps = map[string]bool{
"GetGameScores": true,
"GetGameClip": true,
"GetQueueGames": true,
+ // The room's chat, floating by (ADR 0018).
+ "ListChatMessages": true,
// The night's Wrapped, played at the end of the night.
"GetRoomRecap": true,
// Playing the room's audio, for displays with audio on (audioOps).
@@ -95,6 +97,8 @@ func displayRoom(req any) (roomID string, ok bool) {
return r.RoomId, true
case GetHeartsRequestObject:
return r.RoomId, true
+ case ListChatMessagesRequestObject:
+ return r.RoomId, true
case GetGuestPassRequestObject:
return r.RoomId, true
case GetGameRoundRequestObject:
@@ -382,43 +386,46 @@ var reactionEmoji = map[ReactionEmoji]bool{
"🔥": true, "❤️": true, "🙌": true, "😂": true, "💃": true, "🎉": true, "😮": true, "👏": true,
}
-// Reactions per person: a burst, refilling a few a second.
-const (
- reactionBurst = 8
- reactionEvery = 300 * time.Millisecond
-)
+// reactionRate: reactions per person, a burst refilling a few a second.
+var reactionRate = rate{burst: 8, every: 300 * time.Millisecond}
+
+// rate is a token bucket's size, and how often it gains a token.
+type rate struct {
+ burst float64
+ every time.Duration
+}
-// reactionLimiter is a token bucket per user.
-type reactionLimiter struct {
+// userLimiter is a token bucket per user.
+type userLimiter struct {
mu sync.Mutex
- buckets map[string]*reactionBucket
+ buckets map[string]*bucket
}
-type reactionBucket struct {
+type bucket struct {
tokens float64
at time.Time
}
// allow takes a token from userID's bucket, reporting how long to wait if
// there's none.
-func (l *reactionLimiter) allow(userID string, now time.Time) (time.Duration, bool) {
+func (l *userLimiter) allow(userID string, now time.Time, r rate) (time.Duration, bool) {
l.mu.Lock()
defer l.mu.Unlock()
if l.buckets == nil {
- l.buckets = map[string]*reactionBucket{}
+ l.buckets = map[string]*bucket{}
}
b := l.buckets[userID]
if b == nil {
if len(l.buckets) > 10_000 {
clear(l.buckets)
}
- b = &reactionBucket{tokens: reactionBurst, at: now}
+ b = &bucket{tokens: r.burst, at: now}
l.buckets[userID] = b
}
- b.tokens = min(reactionBurst, b.tokens+float64(now.Sub(b.at))/float64(reactionEvery))
+ b.tokens = min(r.burst, b.tokens+float64(now.Sub(b.at))/float64(r.every))
b.at = now
if b.tokens < 1 {
- return time.Duration((1 - b.tokens) * float64(reactionEvery)), false
+ return time.Duration((1 - b.tokens) * float64(r.every)), false
}
b.tokens--
return 0, true
@@ -434,7 +441,7 @@ func (s *Server) SendReaction(ctx context.Context, req SendReactionRequestObject
}
u := sessionFrom(ctx).User
now := time.Now()
- if wait, ok := s.reactions.allow(u.ID, now); !ok {
+ if wait, ok := s.reactions.allow(u.ID, now, reactionRate); !ok {
return nil, &auth.RateLimitError{RetryAfter: wait}
}
r := Reaction{Id: store.NewID(), RoomId: req.RoomId, UserId: u.ID, Emoji: req.Body.Emoji, At: now.UTC()}
diff --git a/server/internal/httpapi/guests.go b/server/internal/httpapi/guests.go
index 85b977c..e0e9905 100644
--- a/server/internal/httpapi/guests.go
+++ b/server/internal/httpapi/guests.go
@@ -67,7 +67,11 @@ var guestOps = map[string]bool{
// (the playback engine checks).
"ControlPlayback": true,
"SendReaction": true,
- "GetHearts": true,
+ // Chat (ADR 0018): guests are part of the night.
+ "ListChatMessages": true,
+ "SendChatMessage": true,
+ "DeleteChatMessage": true,
+ "GetHearts": true,
// Games, when the room lets guests play (the games engine checks).
"GetGameRound": true,
"GetGameRoundArtwork": true,
diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go
index d36ad2a..6e5674e 100644
--- a/server/internal/httpapi/realtime.go
+++ b/server/internal/httpapi/realtime.go
@@ -17,6 +17,7 @@ import (
"github.com/coder/websocket/wsjson"
"github.com/madeofpendletonwool/syncphony/server/internal/auth"
+ "github.com/madeofpendletonwool/syncphony/server/internal/chat"
"github.com/madeofpendletonwool/syncphony/server/internal/games"
"github.com/madeofpendletonwool/syncphony/server/internal/nights"
"github.com/madeofpendletonwool/syncphony/server/internal/palette"
@@ -349,6 +350,10 @@ func (rc *roomConn) send(ctx context.Context, e realtime.Event) error {
data = map[string]string{"roomId": d.RoomID}
case Reaction:
data = d
+ case chat.Message:
+ data = toChatMessage(d)
+ case chat.Deleted:
+ data = ChatMessageDeleted{RoomId: d.RoomID, Id: d.ID}
case nights.Hearts:
data = toHearts(d)
case nights.Night:
diff --git a/server/internal/httpapi/rooms.go b/server/internal/httpapi/rooms.go
index d073b6e..1096cd6 100644
--- a/server/internal/httpapi/rooms.go
+++ b/server/internal/httpapi/rooms.go
@@ -144,7 +144,7 @@ func toRoom(r store.Room) Room {
out.Autopilot = RoomAutopilot{On: st.Autopilot.On, Adventure: RoomAutopilotAdventure(st.Autopilot.Adventure), Explore: new(st.Autopilot.ExploreLevel()), EnergyCurve: new(st.Autopilot.EnergyCurveOn())}
out.Guests = RoomGuests{Allowed: st.Guests.Allowed, MaxSongs: st.Guests.SongLimit(), CanVote: st.Guests.CanVote()}
sc := st.Screens.Normal()
- out.Screens = RoomScreens{Look: RoomScreensLook(sc.Look), Scene: sc.Scene, Intensity: float32(*sc.Intensity)}
+ out.Screens = RoomScreens{Look: RoomScreensLook(sc.Look), Scene: sc.Scene, Intensity: float32(*sc.Intensity), Chat: ptr(RoomScreensChat(sc.Chat))}
out.Games = toRoomGames(st.Games)
if out.Fairness.Weights == nil {
out.Fairness.Weights = map[string]int{}
@@ -161,7 +161,11 @@ func fromAutopilot(a RoomAutopilot) rooms.Autopilot {
}
func fromScreens(s RoomScreens) rooms.Screens {
- return rooms.Screens{Look: string(s.Look), Scene: s.Scene, Intensity: ptr(math.Round(float64(s.Intensity)*100) / 100)}
+ out := rooms.Screens{Look: string(s.Look), Scene: s.Scene, Intensity: ptr(math.Round(float64(s.Intensity)*100) / 100)}
+ if s.Chat != nil {
+ out.Chat = string(*s.Chat)
+ }
+ return out
}
func fromGuests(g RoomGuests) rooms.Guests {
diff --git a/server/internal/httpapi/screens_test.go b/server/internal/httpapi/screens_test.go
index 504dfbb..3bb6ee6 100644
--- a/server/internal/httpapi/screens_test.go
+++ b/server/internal/httpapi/screens_test.go
@@ -18,14 +18,23 @@ func TestRoomScreens(t *testing.T) {
base := "/rooms/" + room.Id
// Big screens default to the stage, with visuals for songs with no words.
- if room.Screens != (httpapi.RoomScreens{Look: httpapi.Auto, Scene: "", Intensity: 1.5}) {
+ // Chat floats by.
+ if !sameScreens(room.Screens, httpapi.RoomScreens{Look: httpapi.Auto, Scene: "", Intensity: 1.5, Chat: ptr(httpapi.RoomScreensChatFloating)}) {
t.Fatalf("default screens: %+v", room.Screens)
}
- want := httpapi.RoomScreens{Look: httpapi.Visualizer, Scene: "fluid", Intensity: 1.75}
+ want := httpapi.RoomScreens{Look: httpapi.Visualizer, Scene: "fluid", Intensity: 1.75, Chat: ptr(httpapi.RoomScreensChatLog)}
alice.want(http.StatusOK, "PATCH", base, httpapi.UpdateRoomRequest{Screens: &want}).decode(t, &room)
- if room.Screens != want {
+ if !sameScreens(room.Screens, want) {
t.Fatalf("screens: %+v", room.Screens)
}
+ // Left out, chat stays as it was.
+ alice.want(http.StatusOK, "PATCH", base, httpapi.UpdateRoomRequest{Screens: &httpapi.RoomScreens{Look: httpapi.Visualizer, Scene: "fluid", Intensity: 1.75}}).decode(t, &room)
+ if !sameScreens(room.Screens, want) {
+ t.Fatalf("screens without chat: %+v", room.Screens)
+ }
+ if r := alice.do("PATCH", base, httpapi.UpdateRoomRequest{Screens: &httpapi.RoomScreens{Look: httpapi.Stage, Intensity: 1, Chat: ptr(httpapi.RoomScreensChat("loud"))}}); r.status != http.StatusBadRequest {
+ t.Fatalf("chat loud: %d %s", r.status, r.body)
+ }
// Only whoever manages the room changes them, and only to something sensible.
if r := bob.do("PATCH", base, httpapi.UpdateRoomRequest{Screens: &httpapi.RoomScreens{Look: httpapi.Stage, Intensity: 1}}); r.status != http.StatusForbidden {
t.Fatalf("bob changed the screens: %d", r.status)
@@ -37,7 +46,11 @@ func TestRoomScreens(t *testing.T) {
t.Fatalf("look disco: %d %s", r.status, r.body)
}
alice.want(http.StatusOK, "GET", base, nil).decode(t, &room)
- if room.Screens != want {
+ if !sameScreens(room.Screens, want) {
t.Fatalf("screens after bad changes: %+v", room.Screens)
}
}
+
+func sameScreens(a, b httpapi.RoomScreens) bool {
+ return a.Look == b.Look && a.Scene == b.Scene && a.Intensity == b.Intensity && (a.Chat == nil) == (b.Chat == nil) && (a.Chat == nil || *a.Chat == *b.Chat)
+}
diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go
index aa95c0d..6292aa3 100644
--- a/server/internal/httpapi/server.go
+++ b/server/internal/httpapi/server.go
@@ -23,6 +23,7 @@ import (
"github.com/madeofpendletonwool/syncphony/server/internal/auth"
"github.com/madeofpendletonwool/syncphony/server/internal/autopilot"
"github.com/madeofpendletonwool/syncphony/server/internal/backup"
+ "github.com/madeofpendletonwool/syncphony/server/internal/chat"
"github.com/madeofpendletonwool/syncphony/server/internal/clips"
"github.com/madeofpendletonwool/syncphony/server/internal/discover"
"github.com/madeofpendletonwool/syncphony/server/internal/games"
@@ -81,6 +82,8 @@ type Server struct {
Nights *nights.Service
// Playlists keeps Syncphony's own playlists.
Playlists *playlists.Service
+ // Chat keeps rooms' chat.
+ Chat *chat.Service
// SavedArtists keeps the artists people saved to their Library.
SavedArtists *savedartists.Service
// Discover keeps users' discovery sets and makes their feeds.
@@ -114,7 +117,8 @@ type Server struct {
// believed, for rate limiting by client IP.
TrustedProxies []netip.Prefix
- reactions reactionLimiter
+ reactions userLimiter
+ chats userLimiter
playlists playlistCache
}
@@ -354,7 +358,10 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) {
var invalidPlaylist *playlists.InvalidInputError
var invalidDiscover *discover.InvalidInputError
var invalidSaved *savedartists.InvalidInputError
+ var invalidChat *chat.InvalidInputError
switch {
+ case errors.As(err, &invalidChat):
+ writeJSONError(w, http.StatusBadRequest, "invalid_input", invalidChat.Error())
case errors.As(err, &invalidSaved):
writeJSONError(w, http.StatusBadRequest, "invalid_input", invalidSaved.Error())
case errors.As(err, &invalidDiscover):
@@ -441,6 +448,8 @@ var errorCodes = []struct {
{discover.ErrNotFound, http.StatusNotFound, "not_found"},
{discover.ErrRoomNotFound, http.StatusNotFound, "not_found"},
{savedartists.ErrNotFound, http.StatusNotFound, "not_found"},
+ {chat.ErrNotFound, http.StatusNotFound, "not_found"},
+ {chat.ErrForbidden, http.StatusForbidden, "forbidden"},
{discover.ErrTooMany, http.StatusConflict, "too_many"},
{games.ErrNoRound, http.StatusConflict, "round_closed"},
{games.ErrRoundRunning, http.StatusConflict, "round_running"},
diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go
index ae5b422..797440e 100644
--- a/server/internal/httpapi/server_test.go
+++ b/server/internal/httpapi/server_test.go
@@ -73,6 +73,7 @@ func TestDisplayOpsMatchSpec(t *testing.T) {
var displayRequests = map[string]any{
"GetQueueGames": GetQueueGamesRequestObject{},
"GetRoomRecap": GetRoomRecapRequestObject{},
+ "ListChatMessages": ListChatMessagesRequestObject{},
"GetGameRound": GetGameRoundRequestObject{},
"GetGameRoundArtwork": GetGameRoundArtworkRequestObject{},
"GetGameScores": GetGameScoresRequestObject{},
diff --git a/server/internal/nights/nights.go b/server/internal/nights/nights.go
index c00a08c..4fb2ca8 100644
--- a/server/internal/nights/nights.go
+++ b/server/internal/nights/nights.go
@@ -244,6 +244,11 @@ func (s *Service) end(ctx context.Context, roomID, by string, at time.Time) (Nig
if err != nil {
return err
}
+ // The room's chat since the last night is this night's (ADR 0018).
+ err = q.AssignChatToNight(ctx, store.AssignChatToNightParams{NightID: sql.NullString{String: row.ID, Valid: true}, RoomID: roomID, EndedAt: at})
+ if err != nil {
+ return err
+ }
out = Night{Night: row}
if row.QueueItemID.Valid {
it, err := q.GetQueueItem(ctx, row.QueueItemID.String)
diff --git a/server/internal/realtime/realtime.go b/server/internal/realtime/realtime.go
index 1041633..5597ed0 100644
--- a/server/internal/realtime/realtime.go
+++ b/server/internal/realtime/realtime.go
@@ -56,6 +56,10 @@ const (
// GameQueue carries a queue game (connect the artists, a theme round,
// a bracket) whenever it changes.
GameQueue = "game.queue"
+ // ChatMessage carries a message someone sent to the room's chat.
+ ChatMessage = "chat.message"
+ // ChatMessageDeleted carries a chat message that was deleted.
+ ChatMessageDeleted = "chat.deleted"
)
// Event is something that happened. Data holds domain values (store rows,
diff --git a/server/internal/rooms/rooms.go b/server/internal/rooms/rooms.go
index b83793f..330f420 100644
--- a/server/internal/rooms/rooms.go
+++ b/server/internal/rooms/rooms.go
@@ -131,8 +131,19 @@ type Screens struct {
// Intensity is how strongly the screens move with the music,
// MinIntensity to MaxIntensity. Nil means DefaultIntensity.
Intensity *float64 `json:"intensity,omitempty"`
+ // Chat is how the room's chat shows (ADR 0018): ChatFloating, ChatLog
+ // or ChatOff. Empty means ChatFloating.
+ Chat string `json:"chat,omitempty"`
}
+// How chat shows on the big screen: drifting up the sides, the last few in
+// a corner, or not at all.
+const (
+ ChatFloating = "floating"
+ ChatLog = "log"
+ ChatOff = "off"
+)
+
// Big screen looks: now playing and lyrics, full-screen visuals, or the
// stage with visuals taking over for songs with no words to sing.
const (
@@ -160,6 +171,9 @@ func (s Screens) Normal() Screens {
if len(s.Scene) > maxSceneLen {
s.Scene = ""
}
+ if s.Chat != ChatLog && s.Chat != ChatOff {
+ s.Chat = ChatFloating
+ }
return s
}
@@ -169,6 +183,11 @@ func (s Screens) validate() error {
default:
return &InvalidInputError{"a big screen's look is stage, visualizer or auto"}
}
+ switch s.Chat {
+ case "", ChatFloating, ChatLog, ChatOff:
+ default:
+ return &InvalidInputError{"chat on a big screen is floating, log or off"}
+ }
if s.Intensity != nil && (*s.Intensity < MinIntensity || *s.Intensity > MaxIntensity) {
return &InvalidInputError{fmt.Sprintf("big screen intensity is %v to %v", MinIntensity, MaxIntensity)}
}
@@ -517,7 +536,12 @@ func (s *Service) Update(ctx context.Context, by Actor, id string, u Update) (st
st.ApproveJoins = *u.ApproveJoins
}
if u.Screens != nil {
+ chat := st.Screens.Chat
st.Screens = *u.Screens
+ // Left out, how chat shows stays as it was.
+ if st.Screens.Chat == "" {
+ st.Screens.Chat = chat
+ }
}
if u.Games != nil {
st.Games = *u.Games
diff --git a/server/internal/store/chat.sql.gen.go b/server/internal/store/chat.sql.gen.go
new file mode 100644
index 0000000..1666021
--- /dev/null
+++ b/server/internal/store/chat.sql.gen.go
@@ -0,0 +1,143 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: chat.sql
+
+package store
+
+import (
+ "context"
+ "database/sql"
+ "time"
+)
+
+const assignChatToNight = `-- name: AssignChatToNight :exec
+UPDATE chat_messages SET night_id = ?1
+WHERE room_id = ?2 AND night_id IS NULL AND created_at <= ?3
+`
+
+type AssignChatToNightParams struct {
+ NightID sql.NullString
+ RoomID string
+ EndedAt time.Time
+}
+
+// AssignChatToNight gives a night that just ended the room's messages
+// that have none, up to its end.
+func (q *Queries) AssignChatToNight(ctx context.Context, arg AssignChatToNightParams) error {
+ _, err := q.db.ExecContext(ctx, assignChatToNight, arg.NightID, arg.RoomID, arg.EndedAt)
+ return err
+}
+
+const deleteChatMessage = `-- name: DeleteChatMessage :exec
+UPDATE chat_messages SET body = '', deleted_at = ? WHERE id = ?
+`
+
+type DeleteChatMessageParams struct {
+ DeletedAt sql.NullTime
+ ID string
+}
+
+func (q *Queries) DeleteChatMessage(ctx context.Context, arg DeleteChatMessageParams) error {
+ _, err := q.db.ExecContext(ctx, deleteChatMessage, arg.DeletedAt, arg.ID)
+ return err
+}
+
+const getChatMessage = `-- name: GetChatMessage :one
+SELECT id, room_id, night_id, user_id, body, queue_item_id, created_at, deleted_at FROM chat_messages WHERE id = ? AND room_id = ?
+`
+
+type GetChatMessageParams struct {
+ ID string
+ RoomID string
+}
+
+func (q *Queries) GetChatMessage(ctx context.Context, arg GetChatMessageParams) (ChatMessage, error) {
+ row := q.db.QueryRowContext(ctx, getChatMessage, arg.ID, arg.RoomID)
+ var i ChatMessage
+ err := row.Scan(
+ &i.ID,
+ &i.RoomID,
+ &i.NightID,
+ &i.UserID,
+ &i.Body,
+ &i.QueueItemID,
+ &i.CreatedAt,
+ &i.DeletedAt,
+ )
+ return i, err
+}
+
+const insertChatMessage = `-- name: InsertChatMessage :exec
+INSERT INTO chat_messages (id, room_id, user_id, body, queue_item_id, created_at)
+VALUES (?, ?, ?, ?, ?, ?)
+`
+
+type InsertChatMessageParams struct {
+ ID string
+ RoomID string
+ UserID string
+ Body string
+ QueueItemID sql.NullString
+ CreatedAt time.Time
+}
+
+func (q *Queries) InsertChatMessage(ctx context.Context, arg InsertChatMessageParams) error {
+ _, err := q.db.ExecContext(ctx, insertChatMessage,
+ arg.ID,
+ arg.RoomID,
+ arg.UserID,
+ arg.Body,
+ arg.QueueItemID,
+ arg.CreatedAt,
+ )
+ return err
+}
+
+const listChatMessages = `-- name: ListChatMessages :many
+SELECT id, room_id, night_id, user_id, body, queue_item_id, created_at, deleted_at FROM chat_messages
+WHERE room_id = ?1 AND deleted_at IS NULL
+ AND (CAST(?2 AS TEXT) = '' OR id < CAST(?2 AS TEXT))
+ORDER BY id DESC
+LIMIT ?3
+`
+
+type ListChatMessagesParams struct {
+ RoomID string
+ Before string
+ Limit int64
+}
+
+// ListChatMessages pages a room's messages newest first: those before an
+// ID (” for the newest), leaving out deleted ones.
+func (q *Queries) ListChatMessages(ctx context.Context, arg ListChatMessagesParams) ([]ChatMessage, error) {
+ rows, err := q.db.QueryContext(ctx, listChatMessages, arg.RoomID, arg.Before, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ChatMessage{}
+ for rows.Next() {
+ var i ChatMessage
+ if err := rows.Scan(
+ &i.ID,
+ &i.RoomID,
+ &i.NightID,
+ &i.UserID,
+ &i.Body,
+ &i.QueueItemID,
+ &i.CreatedAt,
+ &i.DeletedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/server/internal/store/migrations/00033_chat.sql b/server/internal/store/migrations/00033_chat.sql
new file mode 100644
index 0000000..67cd45d
--- /dev/null
+++ b/server/internal/store/migrations/00033_chat.sql
@@ -0,0 +1,23 @@
+-- Room chat (MAD-842, ADR 0018): messages to everyone in a room, kept
+-- with the night they were sent in.
+
+-- +goose Up
+CREATE TABLE chat_messages (
+ id TEXT PRIMARY KEY,
+ room_id TEXT NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
+ -- The night the message was sent in, set when that night ends.
+ night_id TEXT REFERENCES nights (id) ON DELETE SET NULL,
+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ -- Plain text, 1-500 characters; cleared when the message is deleted.
+ body TEXT NOT NULL,
+ -- The song playing when it was sent.
+ queue_item_id TEXT REFERENCES queue_items (id) ON DELETE SET NULL,
+ created_at TIMESTAMP NOT NULL,
+ deleted_at TIMESTAMP
+);
+-- IDs are time-ordered, so paging goes by ID.
+CREATE INDEX chat_messages_room ON chat_messages (room_id, id);
+CREATE INDEX chat_messages_unassigned ON chat_messages (room_id) WHERE night_id IS NULL;
+
+-- +goose Down
+DROP TABLE chat_messages;
diff --git a/server/internal/store/models.gen.go b/server/internal/store/models.gen.go
index 5d659a3..4f63a30 100644
--- a/server/internal/store/models.gen.go
+++ b/server/internal/store/models.gen.go
@@ -34,6 +34,17 @@ type BeatMap struct {
UsedAt time.Time
}
+type ChatMessage struct {
+ ID string
+ RoomID string
+ NightID sql.NullString
+ UserID string
+ Body string
+ QueueItemID sql.NullString
+ CreatedAt time.Time
+ DeletedAt sql.NullTime
+}
+
type CredentialsPasskey struct {
ID []byte
UserID string
diff --git a/server/internal/store/queries/chat.sql b/server/internal/store/queries/chat.sql
new file mode 100644
index 0000000..4ca10c4
--- /dev/null
+++ b/server/internal/store/queries/chat.sql
@@ -0,0 +1,24 @@
+-- name: InsertChatMessage :exec
+INSERT INTO chat_messages (id, room_id, user_id, body, queue_item_id, created_at)
+VALUES (?, ?, ?, ?, ?, ?);
+
+-- name: GetChatMessage :one
+SELECT * FROM chat_messages WHERE id = ? AND room_id = ?;
+
+-- ListChatMessages pages a room's messages newest first: those before an
+-- ID ('' for the newest), leaving out deleted ones.
+-- name: ListChatMessages :many
+SELECT * FROM chat_messages
+WHERE room_id = sqlc.arg(room_id) AND deleted_at IS NULL
+ AND (CAST(sqlc.arg(before) AS TEXT) = '' OR id < CAST(sqlc.arg(before) AS TEXT))
+ORDER BY id DESC
+LIMIT sqlc.arg(limit);
+
+-- name: DeleteChatMessage :exec
+UPDATE chat_messages SET body = '', deleted_at = ? WHERE id = ?;
+
+-- AssignChatToNight gives a night that just ended the room's messages
+-- that have none, up to its end.
+-- name: AssignChatToNight :exec
+UPDATE chat_messages SET night_id = sqlc.arg(night_id)
+WHERE room_id = sqlc.arg(room_id) AND night_id IS NULL AND created_at <= sqlc.arg(ended_at);
diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts
index f630415..a3df3a0 100644
--- a/web/src/api/schema.gen.ts
+++ b/web/src/api/schema.gen.ts
@@ -1868,6 +1868,63 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/rooms/{roomId}/messages": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ roomId: components["parameters"]["RoomId"];
+ };
+ cookie?: never;
+ };
+ /**
+ * The room's chat, newest first
+ * @description A page of the room's chat (ADR 0018), newest first, across nights.
+ * For the next (older) page, pass the last message's `id` as
+ * `before`. Deleted messages are left out. New messages arrive on the
+ * room socket as `chat.message`, deletions as `chat.deleted`. Paired
+ * displays can read the chat too.
+ */
+ get: operations["listChatMessages"];
+ put?: never;
+ /**
+ * Send a message to the room's chat
+ * @description Plain text, trimmed, 1–500 characters (`invalid_input` otherwise).
+ * Remembers the song playing. Reaches everyone on the room socket as
+ * `chat.message`. Members and guests can chat; limited to a burst of
+ * a few, then one every couple of seconds per person (`rate_limited`).
+ */
+ post: operations["sendChatMessage"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/rooms/{roomId}/messages/{messageId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ roomId: components["parameters"]["RoomId"];
+ messageId: string;
+ };
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Delete a chat message
+ * @description Your own, or anyone's if you own the room or are an admin
+ * (`forbidden` otherwise). It leaves every screen (`chat.deleted`).
+ */
+ delete: operations["deleteChatMessage"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/rooms/{roomId}/displays": {
parameters: {
query?: never;
@@ -3751,6 +3808,15 @@ export interface components {
* @default 1.5
*/
intensity: number;
+ /**
+ * @description How the room's chat shows on big screens (ADR 0018). `floating`:
+ * messages drift up the sides like reactions. `log`: the last few
+ * in a corner column. `off`: not at all. Left out of an update, it
+ * stays as it was.
+ * @default floating
+ * @enum {string}
+ */
+ chat: "floating" | "log" | "off";
};
/**
* @description The room's music party games (ADR 0015). Off by default. The level
@@ -4969,6 +5035,29 @@ export interface components {
/** Format: date-time */
at: string;
};
+ ChatMessageRequest: {
+ /** @description Plain text; trimmed, then 1–500 characters. */
+ body: string;
+ };
+ /** @description A message in a room's chat (ADR 0018). */
+ ChatMessage: {
+ /** @description Time-ordered, so IDs sort as the messages were sent. */
+ id: string;
+ roomId: string;
+ userId: string;
+ body: string;
+ /** @description The night it was sent in, once that night has ended. */
+ nightId?: string;
+ /** @description The song playing when it was sent. */
+ itemId?: string;
+ track?: components["schemas"]["QueuedTrack"];
+ /** Format: date-time */
+ createdAt: string;
+ };
+ ChatMessageDeleted: {
+ roomId: string;
+ id: string;
+ };
Display: {
id: string;
roomId: string;
@@ -5314,6 +5403,8 @@ export interface components {
* your own while the room's scores are `private`.
* - `game.queue`: QueueGame. A queue game started, changed, or is
* done. Sent after hello for each that's up.
+ * - `chat.message`: ChatMessage. Someone sent a message to the room.
+ * - `chat.deleted`: ChatMessageDeleted. A message was deleted.
*
* A paired display connects with its display cookie instead, to its
* own room only. So does a signed-in user with `display=1`: either way
@@ -5329,7 +5420,7 @@ export interface components {
*/
RoomEvent: {
/** @enum {string} */
- type: "hello" | "queue.updated" | "nowplaying.updated" | "playback.notice" | "member.joined" | "member.left" | "link.status" | "room.updated" | "room.deleted" | "reaction.sent" | "hearts.updated" | "night.ended" | "guests.updated" | "members.updated" | "game.round" | "game.scores" | "game.queue";
+ type: "hello" | "queue.updated" | "nowplaying.updated" | "playback.notice" | "member.joined" | "member.left" | "link.status" | "room.updated" | "room.deleted" | "reaction.sent" | "hearts.updated" | "night.ended" | "guests.updated" | "members.updated" | "game.round" | "game.scores" | "game.queue" | "chat.message" | "chat.deleted";
/**
* Format: int64
* @description Queue version, on `queue.updated` only.
@@ -8653,6 +8744,82 @@ export interface operations {
default: components["responses"]["Error"];
};
};
+ listChatMessages: {
+ parameters: {
+ query?: {
+ limit?: number;
+ /** @description Only messages older than this message. */
+ before?: string;
+ };
+ header?: never;
+ path: {
+ roomId: components["parameters"]["RoomId"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Messages, newest first */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ChatMessage"][];
+ };
+ };
+ default: components["responses"]["Error"];
+ };
+ };
+ sendChatMessage: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ roomId: components["parameters"]["RoomId"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ChatMessageRequest"];
+ };
+ };
+ responses: {
+ /** @description Sent */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ChatMessage"];
+ };
+ };
+ default: components["responses"]["Error"];
+ };
+ };
+ deleteChatMessage: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ roomId: components["parameters"]["RoomId"];
+ messageId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Deleted */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ default: components["responses"]["Error"];
+ };
+ };
listDisplays: {
parameters: {
query?: never;
From a55ebcba2a13734917533f6ba76528d0a78eb0c3 Mon Sep 17 00:00:00 2001
From: Collin Pendleton
Date: Wed, 14 Oct 2026 10:58:15 -0500
Subject: [PATCH 2/3] Room chat on the web: the panel, unread counts and popups
(MAD-843, MAD-844)
A Chat button on the room page opens the chat: a sheet on a phone, a
panel down the side on a computer. Messages are grouped by sender and
time, with a "during " chip whenever the song changed, its
artists linked. Scrolling up loads older pages and keeps your place;
new messages stick to the bottom unless you've scrolled up, then a pill
says how many came in. You can delete your own (anyone's, as the host)
from a hover button or a long press. The composer follows the visual
viewport so the keyboard never covers it, and Enter sends on a computer.
RoomLive keeps the room's chat loaded on every screen. Unread counts,
per device in local storage, show on the Chat button and the Room tab.
A device that's never read a room's chat starts with nothing unread.
New messages pop up anywhere in the app as a small glass bubble near
the top, in their own slot away from the toaster. Never for your own,
or while the chat is open. A burst collapses ("Sam and 2 others").
Tap to open the chat, swipe to dismiss. The Me page can mute them on
this device; the unread count still counts.
Co-Authored-By: Claude Opus 5.5
---
web/src/components/chat/chat-button.tsx | 40 +++
web/src/components/chat/chat-panel.tsx | 338 ++++++++++++++++++++++++
web/src/components/chat/chat-popup.tsx | 89 +++++++
web/src/components/chat/chat.test.tsx | 128 +++++++++
web/src/components/room-live.tsx | 2 +
web/src/components/shell/app-shell.tsx | 4 +
web/src/components/shell/bottom-nav.tsx | 13 +-
web/src/hooks/use-chat.ts | 31 +++
web/src/hooks/use-visual-viewport.ts | 30 +++
web/src/lib/chat.test.ts | 120 +++++++++
web/src/lib/chat.ts | 193 ++++++++++++++
web/src/lib/room-socket.ts | 9 +
web/src/routes/_app/_authed/me.tsx | 19 ++
web/src/routes/_app/_authed/room.tsx | 4 +
14 files changed, 1018 insertions(+), 2 deletions(-)
create mode 100644 web/src/components/chat/chat-button.tsx
create mode 100644 web/src/components/chat/chat-panel.tsx
create mode 100644 web/src/components/chat/chat-popup.tsx
create mode 100644 web/src/components/chat/chat.test.tsx
create mode 100644 web/src/hooks/use-chat.ts
create mode 100644 web/src/hooks/use-visual-viewport.ts
create mode 100644 web/src/lib/chat.test.ts
create mode 100644 web/src/lib/chat.ts
diff --git a/web/src/components/chat/chat-button.tsx b/web/src/components/chat/chat-button.tsx
new file mode 100644
index 0000000..f999db7
--- /dev/null
+++ b/web/src/components/chat/chat-button.tsx
@@ -0,0 +1,40 @@
+import { MessageCircle } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { useChatUnread } from '@/hooks/use-chat'
+import { useMe } from '@/lib/auth'
+import { chatOpen, unreadLabel } from '@/lib/chat'
+import { cn } from '@/lib/utils'
+
+/** Opens the room's chat, with how many messages you haven't read. */
+export function ChatButton({ roomId, className }: { roomId: string; className?: string }) {
+ const me = useMe()
+ const unread = useChatUnread(roomId, me.id)
+ return (
+ chatOpen.set(true)}
+ aria-label={unread > 0 ? `Chat, ${unread} unread` : 'Chat'}
+ className={cn('relative', className)}
+ >
+
+ Chat
+ {unread > 0 && }
+
+ )
+}
+
+/** A small count in the corner of whatever it's in. */
+export function UnreadBadge({ count, className }: { count: number; className?: string }) {
+ return (
+
+ {unreadLabel(count)}
+
+ )
+}
diff --git a/web/src/components/chat/chat-panel.tsx b/web/src/components/chat/chat-panel.tsx
new file mode 100644
index 0000000..c539b30
--- /dev/null
+++ b/web/src/components/chat/chat-panel.tsx
@@ -0,0 +1,338 @@
+import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { ArrowDown, Music2, SendHorizontal, Trash2, X } from 'lucide-react'
+import { AnimatePresence, motion } from 'motion/react'
+import { Dialog } from 'radix-ui'
+import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
+import { errorMessage } from '@/api/errors'
+import { ArtistLinks } from '@/components/linked-names'
+import { Button } from '@/components/ui/button'
+import { UserAvatar } from '@/components/user-avatar'
+import { useVisualViewport } from '@/hooks/use-visual-viewport'
+import { useMe } from '@/lib/auth'
+import { chatOpen, chatQuery, deleteChat, dropChatMessage, groupChat, MAX_CHAT, oldestFirst, putChatMessage, sendChat, type ChatGroup, type ChatMessage } from '@/lib/chat'
+import { laneStyle } from '@/lib/lane'
+import { easeOutExpo } from '@/lib/motion'
+import type { User } from '@/lib/now-playing'
+import { useCurrentRoom, type Room } from '@/lib/room'
+import { useStore } from '@/lib/store'
+import { usersQuery } from '@/lib/users'
+import { cn } from '@/lib/utils'
+
+/** How near the bottom counts as at the bottom, in pixels. */
+const STICK_PX = 48
+
+/**
+ * The room's chat (MAD-843): a sheet from the bottom on a phone, a panel
+ * down the side on a computer. Mounted once in the app shell, so a popup
+ * can open it from any screen.
+ */
+export function ChatPanel() {
+ const open = useStore(chatOpen)
+ const { room } = useCurrentRoom()
+ const router = useRouter()
+ // Following an artist's name out of the chat closes it, as now playing does.
+ useEffect(
+ () =>
+ router.subscribe('onBeforeNavigate', (e) => {
+ if (e.pathChanged) chatOpen.set(false)
+ }),
+ [router],
+ )
+ useEffect(() => {
+ if (!room) chatOpen.set(false)
+ }, [room])
+ const vp = useVisualViewport(open)
+
+ return (
+
+
+ {open && room && (
+
+
+
+
+ {/* Follows the visible part of the page, so the keyboard never covers the composer. */}
+
+
+ )}
+
+
+ )
+}
+
+function Panel({ room }: { room: Room }) {
+ const me = useMe()
+ const chat = useInfiniteQuery(chatQuery(room.id))
+ const users = useQuery(usersQuery)
+ const messages = useMemo(() => oldestFirst(chat.data), [chat.data])
+ const groups = useMemo(() => groupChat(messages), [messages])
+ const host = me.role === 'admin' || room.ownerId === me.id
+ const userById = (id: string) => users.data?.find((u) => u.id === id)
+
+ // Stick to the bottom unless you've scrolled up; then a pill says what's new.
+ const list = useRef(null)
+ const atBottom = useRef(true)
+ // The newest message seen at the bottom, as of the last scroll.
+ const [seenTo, setSeenTo] = useState()
+ const fresh = seenTo ? messages.filter((m) => m.id > seenTo && m.userId !== me.id).length : 0
+ const ends = useRef<{ first?: string; last?: string; height: number }>({ height: 0 })
+ useLayoutEffect(() => {
+ const el = list.current
+ if (!el) return
+ const first = messages[0]?.id
+ const last = messages.at(-1)
+ const prev = ends.current
+ if (prev.last === undefined || atBottom.current || last?.userId === me.id) {
+ el.scrollTop = el.scrollHeight
+ } else if (first !== prev.first && last?.id === prev.last) {
+ // Older messages loaded above: keep what you were reading in place.
+ el.scrollTop += el.scrollHeight - prev.height
+ }
+ ends.current = { first, last: last?.id, height: el.scrollHeight }
+ }, [messages, me.id])
+
+ const onScroll = () => {
+ const el = list.current
+ if (!el) return
+ atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX
+ if (atBottom.current) setSeenTo(messages.at(-1)?.id)
+ ends.current.height = el.scrollHeight
+ // Near the top: the page before.
+ if (el.scrollTop < 120 && chat.hasNextPage && !chat.isFetchingNextPage) void chat.fetchNextPage()
+ }
+ const toBottom = () => {
+ const el = list.current
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
+ setSeenTo(messages.at(-1)?.id)
+ }
+
+ return (
+ <>
+
+
+
+
+ {chat.isFetchingNextPage &&
Loading older messages…
}
+ {chat.isSuccess && !chat.hasNextPage && messages.length > 0 && (
+
The start of the chat
+ )}
+ {chat.isSuccess && messages.length === 0 && (
+
+ No messages yet. Say hi to the room.
+
+ )}
+ {chat.isError &&
{errorMessage(chat.error)}
}
+
+ {groups.map((g) => (
+
+ ))}
+
+
+
+ {fresh > 0 && (
+
+
+ {fresh === 1 ? 'New message' : `${fresh} new messages`}
+
+ )}
+
+
+
+
+ >
+ )
+}
+
+function Group({ group, user, mine, canDelete, roomId }: { group: ChatGroup; user?: User; mine: boolean; canDelete: boolean; roomId: string }) {
+ const first = group.messages[0]
+ return (
+
+ {group.during?.track && (
+
+
+
+ during {group.during.track.title}
+ {' · '}
+
+
+
+ )}
+
+ {user ?
:
}
+
+
+ {!mine && {user?.displayName ?? 'Someone'} }
+
+ {new Date(first.createdAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
+
+
+ {group.messages.map((m) => (
+
+ ))}
+
+
+
+ )
+}
+
+/** Holding a message this long, on a touch screen, offers to delete it. */
+const LONG_PRESS_MS = 500
+
+function Bubble({ m, mine, color, canDelete, roomId }: { m: ChatMessage; mine: boolean; color?: string; canDelete: boolean; roomId: string }) {
+ const queryClient = useQueryClient()
+ const [armed, setArmed] = useState(false)
+ const [error, setError] = useState()
+ const press = useRef>(undefined)
+ const onPointerDown = (e: PointerEvent) => {
+ if (!canDelete || e.pointerType === 'mouse') return
+ press.current = setTimeout(() => setArmed(true), LONG_PRESS_MS)
+ }
+ const cancelPress = () => clearTimeout(press.current)
+ // A long press shows the delete button for a moment.
+ useEffect(() => {
+ if (!armed) return
+ const t = setTimeout(() => setArmed(false), 4000)
+ return () => clearTimeout(t)
+ }, [armed])
+ const remove = () => {
+ setError(undefined)
+ deleteChat(roomId, m.id)
+ .then(() => dropChatMessage(queryClient, roomId, m.id))
+ .catch((e: unknown) => setError(errorMessage(e)))
+ }
+
+ return (
+
+
+ {m.body}
+
+ {canDelete && (
+
+
+
+ )}
+ {error &&
{error} }
+
+ )
+}
+
+const finePointer = () => typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: fine)').matches
+
+function Composer({ roomId, onSent }: { roomId: string; onSent: () => void }) {
+ const queryClient = useQueryClient()
+ const [body, setBody] = useState('')
+ const [sending, setSending] = useState(false)
+ const [error, setError] = useState()
+ const input = useRef(null)
+ const length = [...body.trim()].length
+ const send = () => {
+ if (length === 0 || length > MAX_CHAT || sending) return
+ setSending(true)
+ setError(undefined)
+ sendChat(roomId, body)
+ .then((m) => {
+ putChatMessage(queryClient, roomId, m)
+ setBody('')
+ onSent()
+ input.current?.focus()
+ })
+ .catch((e: unknown) => setError(errorMessage(e)))
+ .finally(() => setSending(false))
+ }
+ const onKeyDown = (e: KeyboardEvent) => {
+ // Enter sends on a computer; on a phone it's a new line, and the button sends.
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && finePointer()) {
+ e.preventDefault()
+ send()
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/components/chat/chat-popup.tsx b/web/src/components/chat/chat-popup.tsx
new file mode 100644
index 0000000..b912bcf
--- /dev/null
+++ b/web/src/components/chat/chat-popup.tsx
@@ -0,0 +1,89 @@
+import { useQuery } from '@tanstack/react-query'
+import { AnimatePresence, motion, type PanInfo } from 'motion/react'
+import { useEffect, useRef } from 'react'
+import { UserAvatar } from '@/components/user-avatar'
+import { useMe } from '@/lib/auth'
+import { chatOpen, chatPopup, chatPopupsMuted, dismissChatPopup, liveChat, popChat, popupTitle } from '@/lib/chat'
+import { easeOutExpo } from '@/lib/motion'
+import { useCurrentRoom } from '@/lib/room'
+import { useStore } from '@/lib/store'
+import { usersQuery } from '@/lib/users'
+
+/** A swipe this far, in pixels, dismisses the popup. */
+const SWIPE_PX = 60
+
+/**
+ * New chat messages, wherever you are in the app (MAD-844): a small glass
+ * bubble near the top that leaves by itself. Tap it to open the chat,
+ * swipe it away. Never for your own messages, while the chat is open, or
+ * on a device that muted them. It has its own slot, so error toasts at
+ * the bottom stay visible.
+ */
+export function ChatPopup() {
+ const me = useMe()
+ const { room } = useCurrentRoom()
+ const live = useStore(liveChat)
+ const open = useStore(chatOpen)
+ const muted = useStore(chatPopupsMuted)
+ const popup = useStore(chatPopup)
+ const users = useQuery(usersQuery)
+
+ // Only what arrives from now on: nothing replays when the shell mounts.
+ const seen = useRef | null>(null)
+ seen.current ??= new Set(live.map((m) => m.id))
+ useEffect(() => {
+ const fresh = live.filter((m) => !seen.current?.has(m.id))
+ for (const m of fresh) {
+ seen.current?.add(m.id)
+ if (m.userId !== me.id && m.roomId === room?.id && !open && !muted) popChat(m)
+ }
+ }, [live, me.id, room?.id, open, muted])
+ useEffect(() => {
+ if (open) dismissChatPopup()
+ }, [open])
+
+ const latest = popup?.messages.at(-1)
+ const sender = users.data?.find((u) => u.id === latest?.userId)
+ const nameOf = (id: string) => users.data?.find((u) => u.id === id)?.displayName ?? 'Someone'
+ // A swipe that springs back isn't a tap.
+ const dragged = useRef(false)
+ const onDragEnd = (_: unknown, info: PanInfo) => {
+ setTimeout(() => (dragged.current = false))
+ if (Math.abs(info.offset.x) > SWIPE_PX || info.offset.y < -SWIPE_PX / 2) dismissChatPopup()
+ }
+
+ return (
+
+
+ {popup && latest && (
+ (dragged.current = true)}
+ onDragEnd={onDragEnd}
+ initial={{ opacity: 0, y: -12 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: -8 }}
+ transition={{ duration: 0.35, ease: easeOutExpo }}
+ onClick={() => {
+ if (dragged.current) return
+ dismissChatPopup()
+ chatOpen.set(true)
+ }}
+ aria-label={`Chat from ${popupTitle(popup.messages.map((m) => m.userId), nameOf)}: ${latest.body}. Open the chat`}
+ className="glass-strong pointer-events-auto flex w-full max-w-sm touch-none items-start gap-2.5 rounded-2xl px-3 py-2.5 text-left shadow-float outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
+ >
+ {sender ? : }
+
+ {popupTitle(popup.messages.map((m) => m.userId), nameOf)}
+ {latest.body}
+
+
+ )}
+
+
+ )
+}
diff --git a/web/src/components/chat/chat.test.tsx b/web/src/components/chat/chat.test.tsx
new file mode 100644
index 0000000..1859503
--- /dev/null
+++ b/web/src/components/chat/chat.test.tsx
@@ -0,0 +1,128 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { Suspense, type ReactNode } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { meQuery } from '@/lib/auth'
+import { chatOpen, chatPopupsMuted, chatQuery, dismissChatPopup, liveChat, oldestFirst, putChatMessage, type ChatMessage } from '@/lib/chat'
+import { chooseRoom, roomsQuery } from '@/lib/room'
+import { usersQuery } from '@/lib/users'
+import { InRouter } from '@/test/router'
+import { ChatPanel } from './chat-panel'
+import { ChatPopup } from './chat-popup'
+
+const api = vi.hoisted(() => ({ GET: vi.fn(), POST: vi.fn(), DELETE: vi.fn() }))
+vi.mock('@/api/client', () => ({ api }))
+
+const ok = (data: unknown, status = 200) => Promise.resolve({ data, response: new Response(null, { status }) })
+
+const users = [
+ { id: 'me', displayName: 'Me', color: '#123456' },
+ { id: 'sam', displayName: 'Sam', color: '#ff0000' },
+ { id: 'alex', displayName: 'Alex', color: '#00ff00' },
+ { id: 'kim', displayName: 'Kim', color: '#0000ff' },
+]
+const msg = (id: string, userId: string, body: string, itemId?: string): ChatMessage => ({
+ id,
+ roomId: 'r1',
+ userId,
+ body,
+ createdAt: '2026-10-14T20:00:00Z',
+ itemId,
+ track: itemId ? ({ provider: 'fake', trackId: 't1', title: 'Glass Lung', artists: ['Hum'], artistIds: [], durationMs: 1, explicit: false } as ChatMessage['track']) : undefined,
+})
+
+function setup(ui: ReactNode, messages: ChatMessage[] = []) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
+ client.setQueryData(meQuery.queryKey, { id: 'me', role: 'member', displayName: 'Me' } as never)
+ client.setQueryData(roomsQuery.queryKey, [{ id: 'r1', name: 'Living room', ownerId: 'sam' }] as never)
+ client.setQueryData(usersQuery.queryKey, users as never)
+ client.setQueryData(chatQuery('r1').queryKey, { pages: [[...messages].reverse()], pageParams: [undefined] })
+ chooseRoom('r1')
+ render(
+
+
+ {ui}
+
+ ,
+ )
+ return client
+}
+
+beforeEach(() => {
+ for (const f of Object.values(api)) f.mockReset()
+ api.GET.mockReturnValue(new Promise(() => {}))
+ chatOpen.set(false)
+ chatPopupsMuted.set(false)
+ liveChat.set([])
+ dismissChatPopup()
+})
+afterEach(cleanup)
+
+describe('ChatPanel', () => {
+ it('shows the room’s messages, with the song they were sent during', async () => {
+ chatOpen.set(true)
+ setup( , [msg('1', 'sam', 'who queued this?', 'i1'), msg('2', 'me', 'me!', 'i1')])
+ expect(await screen.findByText('who queued this?')).toBeTruthy()
+ expect(screen.getByText('Glass Lung')).toBeTruthy()
+ expect(screen.getByText('Sam')).toBeTruthy()
+ expect(screen.getByText('Everyone in Living room can read this')).toBeTruthy()
+ // Only your own messages can go, in someone else's room.
+ expect(screen.getAllByRole('button', { name: 'Delete message' })).toHaveLength(1)
+ })
+
+ it('sends a message', async () => {
+ chatOpen.set(true)
+ const sent = msg('3', 'me', 'turn it up')
+ api.POST.mockReturnValue(ok(sent, 201))
+ const client = setup( )
+ const box = await screen.findByLabelText('Message')
+ const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement
+ expect(send.disabled).toBe(true)
+ fireEvent.change(box, { target: { value: ' turn it up ' } })
+ fireEvent.click(send)
+ await waitFor(() => expect(oldestFirst(client.getQueryData(chatQuery('r1').queryKey)).map((m) => m.id)).toEqual(['3']))
+ expect(api.POST).toHaveBeenCalledWith('/rooms/{roomId}/messages', { params: { path: { roomId: 'r1' } }, body: { body: ' turn it up ' } })
+ expect((box as HTMLTextAreaElement).value).toBe('')
+ })
+
+ it('deletes your own message', async () => {
+ chatOpen.set(true)
+ api.DELETE.mockReturnValue(ok(undefined, 204))
+ const client = setup( , [msg('1', 'me', 'oops')])
+ fireEvent.click(await screen.findByRole('button', { name: 'Delete message' }))
+ await waitFor(() => expect(oldestFirst(client.getQueryData(chatQuery('r1').queryKey))).toEqual([]))
+ expect(api.DELETE).toHaveBeenCalledWith('/rooms/{roomId}/messages/{messageId}', { params: { path: { roomId: 'r1', messageId: '1' } } })
+ })
+})
+
+describe('ChatPopup', () => {
+ it('pops up others’ messages, collapsed, and opens the chat', async () => {
+ const client = setup( )
+ await screen.findByText((_, el) => el?.getAttribute('aria-live') === 'polite')
+ act(() => putChatMessage(client, 'r1', msg('1', 'me', 'mine')))
+ expect(screen.queryByText('mine')).toBeNull()
+ act(() => {
+ putChatMessage(client, 'r1', msg('2', 'kim', 'hey'))
+ putChatMessage(client, 'r1', msg('3', 'alex', 'yo'))
+ putChatMessage(client, 'r1', msg('4', 'sam', 'this one slaps'))
+ })
+ expect(await screen.findByText('this one slaps')).toBeTruthy()
+ expect(screen.getByText('Sam and 2 others')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: /Open the chat/ }))
+ expect(chatOpen.get()).toBe(true)
+ })
+
+ it('stays quiet when muted, or while the chat is open', async () => {
+ const client = setup( )
+ await screen.findByText((_, el) => el?.getAttribute('aria-live') === 'polite')
+ act(() => chatPopupsMuted.set(true))
+ act(() => putChatMessage(client, 'r1', msg('1', 'sam', 'muted')))
+ act(() => {
+ chatPopupsMuted.set(false)
+ chatOpen.set(true)
+ })
+ act(() => putChatMessage(client, 'r1', msg('2', 'sam', 'open')))
+ expect(screen.queryByText('muted')).toBeNull()
+ expect(screen.queryByText('open')).toBeNull()
+ })
+})
diff --git a/web/src/components/room-live.tsx b/web/src/components/room-live.tsx
index c7a66c6..13814d4 100644
--- a/web/src/components/room-live.tsx
+++ b/web/src/components/room-live.tsx
@@ -1,5 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
+import { useChatSync } from '@/hooks/use-chat'
import { useRoomControls } from '@/hooks/use-room-controls'
import { useMe } from '@/lib/auth'
import { player } from '@/lib/now-playing'
@@ -17,6 +18,7 @@ export function RoomLive() {
const me = useMe()
const { room, rooms } = useCurrentRoom()
useRoomSocket(room?.id)
+ useChatSync(room?.id)
const playback = useQuery({ ...playbackQuery(room?.id ?? ''), enabled: !!room })
const users = useQuery(usersQuery)
const commands = useRoomControls(room, playback.data, me, !!me.guest)
diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx
index 04443e6..852fcbb 100644
--- a/web/src/components/shell/app-shell.tsx
+++ b/web/src/components/shell/app-shell.tsx
@@ -7,6 +7,8 @@ import { useBeatSync } from '@/hooks/use-beat-sync'
import { easeOutExpo } from '@/lib/motion'
import { usePlayer } from '@/lib/now-playing'
import { useOnline } from '@/lib/pwa'
+import { ChatPanel } from '@/components/chat/chat-panel'
+import { ChatPopup } from '@/components/chat/chat-popup'
import { PlayNowRequest } from '@/components/room/play-now-request'
import { Toaster } from '@/components/toaster'
import { LightsMode } from '@/components/visualizer/lights-mode'
@@ -75,6 +77,8 @@ export function AppShell() {
+
+
diff --git a/web/src/components/shell/bottom-nav.tsx b/web/src/components/shell/bottom-nav.tsx
index a5d6718..6b0cd27 100644
--- a/web/src/components/shell/bottom-nav.tsx
+++ b/web/src/components/shell/bottom-nav.tsx
@@ -2,7 +2,10 @@ import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { CircleUserRound, Compass, Disc3, Library, Search } from 'lucide-react'
import { motion } from 'motion/react'
+import { UnreadBadge } from '@/components/chat/chat-button'
+import { useChatUnread } from '@/hooks/use-chat'
import { meQuery } from '@/lib/auth'
+import { useCurrentRoom } from '@/lib/room'
import { spring } from '@/lib/motion'
import { cn } from '@/lib/utils'
@@ -19,7 +22,11 @@ const under = (pathname: string, base: string) => pathname === base || pathname.
export function BottomNav() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
- const guest = !!useQuery(meQuery).data?.guest
+ const me = useQuery(meQuery).data
+ const guest = !!me?.guest
+ const { room } = useCurrentRoom()
+ // The room's unread chat shows on the Room tab, wherever you are.
+ const unread = useChatUnread(room?.id, me?.id ?? '')
const shown = tabs.filter((t) => !('members' in t) || !guest)
return (
@@ -39,13 +46,15 @@ export function BottomNav() {
0 ? `${label}, ${unread} unread messages` : undefined}
className={cn(
'relative flex h-full flex-col items-center justify-center gap-0.5 rounded-[1.1rem] text-[0.7rem] font-medium transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
>
-
+
+ {to === '/room' && unread > 0 && }
{label}
diff --git a/web/src/hooks/use-chat.ts b/web/src/hooks/use-chat.ts
new file mode 100644
index 0000000..63f6f29
--- /dev/null
+++ b/web/src/hooks/use-chat.ts
@@ -0,0 +1,31 @@
+import { useInfiniteQuery } from '@tanstack/react-query'
+import { useEffect, useMemo } from 'react'
+import { chatOpen, chatQuery, chatRead, markChatRead, oldestFirst, unreadCount } from '@/lib/chat'
+import { useStore } from '@/lib/store'
+
+/**
+ * Keeps the current room's chat loaded (RoomLive calls it, on every
+ * screen), so unread counts are right wherever you are. A device that has
+ * never read this room's chat starts with nothing unread, and an open
+ * panel reads everything as it comes.
+ */
+export function useChatSync(roomId: string | undefined) {
+ const chat = useInfiniteQuery({ ...chatQuery(roomId ?? ''), enabled: !!roomId })
+ const open = useStore(chatOpen)
+ const marks = useStore(chatRead)
+ const newest = chat.data?.pages[0]?.[0]?.id
+ useEffect(() => {
+ if (!roomId || !chat.isSuccess) return
+ // Nothing to mark yet: an empty chat's first message is unread.
+ if (!marks[roomId]) markChatRead(roomId, newest ?? '0')
+ else if (open && newest) markChatRead(roomId, newest)
+ }, [roomId, chat.isSuccess, newest, open, marks])
+}
+
+/** How many of the room's messages you haven't read on this device. */
+export function useChatUnread(roomId: string | undefined, me: string) {
+ const chat = useInfiniteQuery({ ...chatQuery(roomId ?? ''), enabled: !!roomId })
+ const marks = useStore(chatRead)
+ const lastRead = roomId ? marks[roomId] : undefined
+ return useMemo(() => unreadCount(oldestFirst(chat.data), lastRead, me), [chat.data, lastRead, me])
+}
diff --git a/web/src/hooks/use-visual-viewport.ts b/web/src/hooks/use-visual-viewport.ts
new file mode 100644
index 0000000..b6a8661
--- /dev/null
+++ b/web/src/hooks/use-visual-viewport.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react'
+
+type Viewport = { height: number; offsetTop: number }
+
+function read(): Viewport {
+ const vv = typeof window === 'undefined' ? undefined : window.visualViewport
+ return vv ? { height: vv.height, offsetTop: vv.offsetTop } : { height: typeof window === 'undefined' ? 0 : window.innerHeight, offsetTop: 0 }
+}
+
+/**
+ * The part of the page that's actually visible. On a phone the keyboard
+ * shrinks it without resizing the layout, so anything pinned to the
+ * bottom (a composer) has to follow it to stay above the keyboard.
+ */
+export function useVisualViewport(enabled = true) {
+ const [vp, setVp] = useState(read)
+ useEffect(() => {
+ const vv = window.visualViewport
+ if (!enabled || !vv) return
+ const update = () => setVp(read())
+ update()
+ vv.addEventListener('resize', update)
+ vv.addEventListener('scroll', update)
+ return () => {
+ vv.removeEventListener('resize', update)
+ vv.removeEventListener('scroll', update)
+ }
+ }, [enabled])
+ return vp
+}
diff --git a/web/src/lib/chat.test.ts b/web/src/lib/chat.test.ts
new file mode 100644
index 0000000..f3a07e7
--- /dev/null
+++ b/web/src/lib/chat.test.ts
@@ -0,0 +1,120 @@
+import { QueryClient } from '@tanstack/react-query'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ chatPopup,
+ chatQuery,
+ chatRead,
+ dismissChatPopup,
+ dropChatMessage,
+ groupChat,
+ liveChat,
+ markChatRead,
+ oldestFirst,
+ popChat,
+ popupTitle,
+ putChatMessage,
+ unreadCount,
+ unreadLabel,
+ type ChatMessage,
+} from './chat'
+
+const at = (min: number) => new Date(Date.UTC(2026, 9, 14, 20, min)).toISOString()
+const msg = (id: string, userId: string, min: number, itemId?: string): ChatMessage => ({
+ id,
+ roomId: 'r1',
+ userId,
+ body: `message ${id}`,
+ createdAt: at(min),
+ itemId,
+ track: itemId ? ({ title: `Song ${itemId}`, artists: ['A'] } as ChatMessage['track']) : undefined,
+})
+
+describe('groupChat', () => {
+ it('groups one person’s messages close together', () => {
+ const groups = groupChat([msg('1', 'sam', 0), msg('2', 'sam', 1), msg('3', 'alex', 2), msg('4', 'sam', 3), msg('5', 'sam', 20)])
+ expect(groups.map((g) => g.messages.map((m) => m.id))).toEqual([['1', '2'], ['3'], ['4'], ['5']])
+ })
+
+ it('starts a group, with the song, when the song changes', () => {
+ const groups = groupChat([msg('1', 'sam', 0, 'i1'), msg('2', 'sam', 1, 'i1'), msg('3', 'sam', 2, 'i2'), msg('4', 'sam', 3)])
+ expect(groups.map((g) => [g.messages.map((m) => m.id), g.during?.itemId])).toEqual([
+ [['1', '2'], 'i1'],
+ [['3', '4'], 'i2'],
+ ])
+ })
+})
+
+describe('unreadCount', () => {
+ const ms = [msg('1', 'sam', 0), msg('2', 'me', 1), msg('3', 'alex', 2)]
+ it('counts others’ messages after the last read', () => {
+ expect(unreadCount(ms, '1', 'me')).toBe(1)
+ expect(unreadCount(ms, '0', 'me')).toBe(2)
+ expect(unreadCount(ms, '3', 'me')).toBe(0)
+ })
+ it('counts nothing before a device has read anything', () => {
+ expect(unreadCount(ms, undefined, 'me')).toBe(0)
+ })
+ it('caps the label', () => {
+ expect(unreadLabel(3)).toBe('3')
+ expect(unreadLabel(51)).toBe('50+')
+ })
+})
+
+describe('markChatRead', () => {
+ beforeEach(() => chatRead.set({}))
+ it('only moves forward, and is remembered', () => {
+ markChatRead('r1', '5')
+ markChatRead('r1', '3')
+ expect(chatRead.get()).toEqual({ r1: '5' })
+ expect(JSON.parse(localStorage.getItem('syncphony.chat.read') ?? '{}')).toEqual({ r1: '5' })
+ })
+})
+
+describe('the chat cache', () => {
+ beforeEach(() => liveChat.set([]))
+ it('adds live messages to the newest page once, and drops deleted ones everywhere', () => {
+ const qc = new QueryClient()
+ const key = chatQuery('r1').queryKey
+ qc.setQueryData(key, { pages: [[msg('2', 'sam', 1), msg('1', 'sam', 0)]], pageParams: [undefined] })
+ putChatMessage(qc, 'r1', msg('3', 'alex', 2))
+ putChatMessage(qc, 'r1', msg('3', 'alex', 2))
+ expect(oldestFirst(qc.getQueryData(key)).map((m) => m.id)).toEqual(['1', '2', '3'])
+ expect(liveChat.get().map((m) => m.id)).toEqual(['3'])
+
+ popChat(msg('3', 'alex', 2))
+ dropChatMessage(qc, 'r1', '3')
+ expect(oldestFirst(qc.getQueryData(key)).map((m) => m.id)).toEqual(['1', '2'])
+ expect(liveChat.get()).toEqual([])
+ expect(chatPopup.get()?.messages).toEqual([])
+ dismissChatPopup()
+ })
+})
+
+describe('popups', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => {
+ dismissChatPopup()
+ vi.useRealTimers()
+ })
+
+ it('collapses a burst into one bubble, which leaves by itself', () => {
+ popChat(msg('1', 'sam', 0))
+ const key = chatPopup.get()?.key
+ vi.advanceTimersByTime(3000)
+ popChat(msg('2', 'alex', 0))
+ expect(chatPopup.get()?.key).toBe(key)
+ expect(chatPopup.get()?.messages).toHaveLength(2)
+ vi.advanceTimersByTime(4999)
+ expect(chatPopup.get()).not.toBeNull()
+ vi.advanceTimersByTime(1)
+ expect(chatPopup.get()).toBeNull()
+ })
+
+ it('names who’s talking, latest first', () => {
+ const names: Record = { sam: 'Sam', alex: 'Alex', kim: 'Kim' }
+ const nameOf = (id: string) => names[id]
+ expect(popupTitle(['sam'], nameOf)).toBe('Sam')
+ expect(popupTitle(['alex', 'sam', 'sam'], nameOf)).toBe('Sam and Alex')
+ expect(popupTitle(['kim', 'alex', 'sam'], nameOf)).toBe('Sam and 2 others')
+ })
+})
diff --git a/web/src/lib/chat.ts b/web/src/lib/chat.ts
new file mode 100644
index 0000000..c91b3d1
--- /dev/null
+++ b/web/src/lib/chat.ts
@@ -0,0 +1,193 @@
+import { infiniteQueryOptions, type InfiniteData, type QueryClient } from '@tanstack/react-query'
+import { api } from '@/api/client'
+import { unwrap } from '@/api/errors'
+import type { components } from '@/api/schema.gen'
+import { createStore } from './store'
+
+// Room chat (ADR 0018): a panel in the room, popups anywhere in the app,
+// and messages floating by on the big screen.
+
+export type ChatMessage = components['schemas']['ChatMessage']
+export type ChatLook = NonNullable
+
+/** The longest message, in characters, as the server counts them. */
+export const MAX_CHAT = 500
+
+const PAGE = 50
+
+/** A room's chat, a page at a time, newest first: pages[0][0] is the newest message. */
+export const chatQuery = (roomId: string) =>
+ infiniteQueryOptions({
+ queryKey: ['chat', roomId],
+ queryFn: ({ pageParam }) =>
+ unwrap(api.GET('/rooms/{roomId}/messages', { params: { path: { roomId }, query: { limit: PAGE, before: pageParam } } })),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (last) => (last.length < PAGE ? undefined : last.at(-1)?.id),
+ // The socket keeps it current.
+ staleTime: Infinity,
+ })
+
+export function sendChat(roomId: string, body: string) {
+ return unwrap(api.POST('/rooms/{roomId}/messages', { params: { path: { roomId } }, body: { body } }))
+}
+
+export function deleteChat(roomId: string, messageId: string) {
+ return unwrap(api.DELETE('/rooms/{roomId}/messages/{messageId}', { params: { path: { roomId, messageId } } }))
+}
+
+type Pages = InfiniteData
+
+/** The loaded messages, oldest first. */
+export function oldestFirst(data: Pages | undefined): ChatMessage[] {
+ return data ? data.pages.flat().reverse() : []
+}
+
+/** Messages that arrived over the socket, newest last: what popups and the big screen show. */
+export const liveChat = createStore([])
+
+/** A message from the socket: into the room's chat, and onto the screens that show it as it comes. */
+export function putChatMessage(queryClient: QueryClient, roomId: string, m: ChatMessage) {
+ queryClient.setQueryData(chatQuery(roomId).queryKey, (old: Pages | undefined) => {
+ if (!old || old.pages.flat().some((x) => x.id === m.id)) return old
+ return { ...old, pages: [[m, ...(old.pages[0] ?? [])], ...old.pages.slice(1)] }
+ })
+ liveChat.set((ms) => (ms.some((x) => x.id === m.id) ? ms : [...ms, m].slice(-40)))
+}
+
+/** A deleted message leaves everywhere it shows. */
+export function dropChatMessage(queryClient: QueryClient, roomId: string, id: string) {
+ queryClient.setQueryData(chatQuery(roomId).queryKey, (old: Pages | undefined) =>
+ old ? { ...old, pages: old.pages.map((p) => p.filter((m) => m.id !== id)) } : old,
+ )
+ liveChat.set((ms) => ms.filter((m) => m.id !== id))
+ chatPopup.set((p) => (p ? { ...p, messages: p.messages.filter((m) => m.id !== id) } : p))
+}
+
+// --- Grouping ---------------------------------------------------------------
+
+/** Messages closer together than this, from one person, read as one group. */
+const GROUP_MS = 5 * 60_000
+
+export type ChatGroup = {
+ userId: string
+ messages: ChatMessage[]
+ /** The song playing, shown when it changed since the group before. */
+ during?: ChatMessage
+}
+
+/** Groups messages (oldest first) by sender and time; a new song starts a new group. */
+export function groupChat(messages: ChatMessage[]): ChatGroup[] {
+ const out: ChatGroup[] = []
+ let song: string | undefined
+ for (const m of messages) {
+ const last = out.at(-1)
+ const prev = last?.messages.at(-1)
+ const songChanged = !!m.itemId && m.itemId !== song
+ if (m.itemId) song = m.itemId
+ if (last && prev && !songChanged && last.userId === m.userId && Date.parse(m.createdAt) - Date.parse(prev.createdAt) < GROUP_MS) {
+ last.messages.push(m)
+ } else {
+ out.push({ userId: m.userId, messages: [m], during: songChanged ? m : undefined })
+ }
+ }
+ return out
+}
+
+// --- Reading ----------------------------------------------------------------
+
+const READ_KEY = 'syncphony.chat.read'
+
+function readMarks(): Record {
+ try {
+ const v = JSON.parse(localStorage.getItem(READ_KEY) ?? '{}') as unknown
+ return v && typeof v === 'object' ? (v as Record) : {}
+ } catch {
+ return {}
+ }
+}
+
+/** The newest message read in each room, on this device. */
+export const chatRead = createStore>(readMarks())
+
+/** Marks a room's chat read up to a message. IDs are time-ordered, so later is greater. */
+export function markChatRead(roomId: string, id: string) {
+ const marks = chatRead.get()
+ if (marks[roomId] && marks[roomId] >= id) return
+ chatRead.set({ ...marks, [roomId]: id })
+ try {
+ localStorage.setItem(READ_KEY, JSON.stringify(chatRead.get()))
+ } catch {
+ // Private mode: lasts until reload.
+ }
+}
+
+/** Messages newer than the last read, not counting your own. */
+export function unreadCount(messages: ChatMessage[], lastRead: string | undefined, me: string) {
+ if (!lastRead) return 0
+ return messages.filter((m) => m.id > lastRead && m.userId !== me).length
+}
+
+/** Unread counts past this show as "50+". */
+export const UNREAD_SHOWN = 50
+
+export const unreadLabel = (n: number) => (n > UNREAD_SHOWN ? `${UNREAD_SHOWN}+` : String(n))
+
+// --- The panel and popups ---------------------------------------------------
+
+/** Whether the chat panel is open. Opened from the room, or by tapping a popup. */
+export const chatOpen = createStore(false)
+
+/** The chat popup showing: one bubble, collapsing a burst of messages. */
+export type ChatPopup = { key: number; messages: ChatMessage[] }
+
+export const chatPopup = createStore(null)
+
+/** How long a popup stays up after its latest message. */
+export const POPUP_MS = 5000
+
+let popupTimer: ReturnType | undefined
+let popupKey = 1
+
+/** Shows a message in the popup, joining one that's still up. */
+export function popChat(m: ChatMessage) {
+ chatPopup.set((p) => (p ? { ...p, messages: [...p.messages, m].slice(-10) } : { key: popupKey++, messages: [m] }))
+ clearTimeout(popupTimer)
+ popupTimer = setTimeout(dismissChatPopup, POPUP_MS)
+}
+
+export function dismissChatPopup() {
+ clearTimeout(popupTimer)
+ chatPopup.set(null)
+}
+
+/** "Sam", "Sam and Alex", "Sam and 2 others": who's in a popup, latest first. */
+export function popupTitle(userIds: string[], nameOf: (id: string) => string) {
+ const people = [...new Set([...userIds].reverse())]
+ const first = nameOf(people[0] ?? '')
+ if (people.length <= 1) return first
+ if (people.length === 2) return `${first} and ${nameOf(people[1])}`
+ return `${first} and ${people.length - 1} others`
+}
+
+const MUTE_KEY = 'syncphony.chat.popups-muted'
+
+function readMuted() {
+ try {
+ return localStorage.getItem(MUTE_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+/** Whether this device keeps chat popups quiet. Unread counts still count. */
+export const chatPopupsMuted = createStore(readMuted())
+
+export function setChatPopupsMuted(on: boolean) {
+ chatPopupsMuted.set(on)
+ if (on) dismissChatPopup()
+ try {
+ localStorage.setItem(MUTE_KEY, on ? '1' : '0')
+ } catch {
+ // Private mode: lasts until reload.
+ }
+}
diff --git a/web/src/lib/room-socket.ts b/web/src/lib/room-socket.ts
index 20764ac..3b522fa 100644
--- a/web/src/lib/room-socket.ts
+++ b/web/src/lib/room-socket.ts
@@ -2,6 +2,7 @@ import { useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import type { components } from '@/api/schema.gen'
import { invitesQuery, membersQuery } from './access'
+import { chatQuery, dropChatMessage, putChatMessage, type ChatMessage } from './chat'
import { meQuery } from './auth'
import { syncServerClock } from './clock'
import { displayMeQuery } from './displays'
@@ -136,6 +137,8 @@ function handle(queryClient: QueryClient, roomId: string, ev: RoomEvent) {
// The games still up follow, one event each.
queryClient.setQueryData(queueGamesQuery(roomId).queryKey, [])
hello.members.forEach((u) => upsertUser(queryClient, u))
+ // Chat sent while we were away.
+ void queryClient.invalidateQueries({ queryKey: chatQuery(roomId).queryKey })
break
}
case 'queue.updated': {
@@ -181,6 +184,12 @@ function handle(queryClient: QueryClient, roomId: string, ev: RoomEvent) {
case 'reaction.sent':
addReaction(ev.data as Reaction)
break
+ case 'chat.message':
+ putChatMessage(queryClient, roomId, ev.data as ChatMessage)
+ break
+ case 'chat.deleted':
+ dropChatMessage(queryClient, roomId, (ev.data as components['schemas']['ChatMessageDeleted']).id)
+ break
case 'hearts.updated': {
const h = ev.data as Hearts
queryClient.setQueryData(heartsQuery(roomId, h.itemId).queryKey, h)
diff --git a/web/src/routes/_app/_authed/me.tsx b/web/src/routes/_app/_authed/me.tsx
index f3a8c6d..402568a 100644
--- a/web/src/routes/_app/_authed/me.tsx
+++ b/web/src/routes/_app/_authed/me.tsx
@@ -27,14 +27,17 @@ import { Notice } from '@/components/notice'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { UserAvatar } from '@/components/user-avatar'
import { useMe, useSignedOut, useSignOut, type Me } from '@/lib/auth'
+import { chatPopupsMuted, setChatPopupsMuted } from '@/lib/chat'
import { laneStyle } from '@/lib/lane'
import { easeOutExpo } from '@/lib/motion'
import { useInstall } from '@/lib/pwa'
import { appTitle, serverSettingsQuery } from '@/lib/server'
import { linksQuery } from '@/lib/services'
+import { useStore } from '@/lib/store'
import { setPreference, useThemePreference, type ThemePreference } from '@/lib/theme'
import { relativeTime } from '@/lib/time'
import { cn } from '@/lib/utils'
@@ -117,6 +120,8 @@ function Me() {
+
+
}>
Design system
@@ -131,6 +136,20 @@ function Me() {
)
}
+/** Chat popups on this device (ADR 0018): on by default; the unread count counts either way. */
+function ChatPopupsSetting() {
+ const muted = useStore(chatPopupsMuted)
+ return (
+
+
+
Chat popups
+
New messages in your room pop up on any screen. Just on this device.
+
+ setChatPopupsMuted(!on)} label="Show chat popups" />
+
+ )
+}
+
function ProfileSummary({ me }: { me: Me }) {
return (
<>
diff --git a/web/src/routes/_app/_authed/room.tsx b/web/src/routes/_app/_authed/room.tsx
index 2358185..0803509 100644
--- a/web/src/routes/_app/_authed/room.tsx
+++ b/web/src/routes/_app/_authed/room.tsx
@@ -6,6 +6,7 @@ import { DropdownMenu } from 'radix-ui'
import { useEffect, useState, type ReactNode } from 'react'
import { errorMessage } from '@/api/errors'
import { Artwork } from '@/components/artwork'
+import { ChatButton } from '@/components/chat/chat-button'
import { SaveArtistButton } from '@/components/library/save-artist'
import { PageHeader } from '@/components/page-header'
import { SaveToPlaylistButton } from '@/components/playlists/save-to-playlist'
@@ -320,6 +321,8 @@ function RoomHeader({ room, rooms }: { room: RoomInfo; rooms: RoomInfo[] }) {
)}
+
+
{members.slice(0, 5).map((m) => (
@@ -341,6 +344,7 @@ function RoomHeader({ room, rooms }: { room: RoomInfo; rooms: RoomInfo[] }) {
)}
+
)
}
From 26d78dc07e4e3e5765fe9dec9dd5becabc986282 Mon Sep 17 00:00:00 2001
From: Collin Pendleton
Date: Wed, 14 Oct 2026 10:58:15 -0500
Subject: [PATCH 3/3] Chat on the big screen (MAD-845)
The TV shows the room's chat in the look the room picks (screens.chat,
in the big screen settings): Floating drifts messages across the top of
the screen as glass bubbles with the sender's name in their color, clear
of the lyrics and the title, two lanes and four at most. Side log keeps
the last few from the past 15 minutes in the top right. Off shows none.
A deleted message leaves the screen at once. The app's visualizer shows
the same.
Co-Authored-By: Claude Opus 5.5
---
web/src/components/room/big-screen-look.tsx | 26 ++++
.../components/tv/big-screen-chat.test.tsx | 48 +++++++
web/src/components/tv/big-screen-chat.tsx | 123 ++++++++++++++++++
web/src/components/tv/tv-stage.tsx | 2 +
.../components/visualizer/visualizer-mode.tsx | 8 ++
5 files changed, 207 insertions(+)
create mode 100644 web/src/components/tv/big-screen-chat.test.tsx
create mode 100644 web/src/components/tv/big-screen-chat.tsx
diff --git a/web/src/components/room/big-screen-look.tsx b/web/src/components/room/big-screen-look.tsx
index b250613..8b6ab04 100644
--- a/web/src/components/room/big-screen-look.tsx
+++ b/web/src/components/room/big-screen-look.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { SHOWS } from '@/hooks/use-scene'
+import type { ChatLook } from '@/lib/chat'
import { roomsQuery, type Room } from '@/lib/room'
import { toast } from '@/lib/toast'
@@ -17,6 +18,12 @@ const looks: { value: Screens['look']; label: string; hint: string }[] = [
{ value: 'visualizer', label: 'Visualizer', hint: 'Full-screen visuals, with what’s playing in a corner.' },
]
+const chatLooks: { value: ChatLook; label: string; hint: string }[] = [
+ { value: 'floating', label: 'Floating', hint: 'Messages drift across the top of the screen, then fade.' },
+ { value: 'log', label: 'Side log', hint: 'The last few messages in a corner.' },
+ { value: 'off', label: 'Off', hint: 'The room’s chat stays on phones.' },
+]
+
/**
* How the room's big screens show the music (MAD-779). Every screen
* follows; whoever manages the room changes it.
@@ -41,6 +48,7 @@ export function BigScreenLook({ room, editable }: { room: Room; editable: boolea
const s = room.screens
const set = (next: Partial) => update.mutate({ ...s, ...next })
const shown = intensity ?? s.intensity
+ const chat = s.chat ?? 'floating'
return (
@@ -92,6 +100,24 @@ export function BigScreenLook({ room, editable }: { room: Room; editable: boolea
onValueCommit={([v]) => set({ intensity: v })}
/>
+
+
Chat on the big screen
+
v && set({ chat: v as ChatLook })}
+ aria-label="Chat on the big screen"
+ className="grid w-full grid-cols-3"
+ >
+ {chatLooks.map((l) => (
+
+ {l.label}
+
+ ))}
+
+
{chatLooks.find((l) => l.value === chat)?.hint}
+
{!editable && Whoever runs the room can change these.
}
)
diff --git a/web/src/components/tv/big-screen-chat.test.tsx b/web/src/components/tv/big-screen-chat.test.tsx
new file mode 100644
index 0000000..1db0338
--- /dev/null
+++ b/web/src/components/tv/big-screen-chat.test.tsx
@@ -0,0 +1,48 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { chatQuery, dropChatMessage, liveChat, putChatMessage, type ChatMessage } from '@/lib/chat'
+import type { User } from '@/lib/now-playing'
+import { BigScreenChat } from './big-screen-chat'
+
+const users = [{ id: 'sam', displayName: 'Sam', color: '#ff0000' }] as User[]
+const msg = (id: string, body: string, createdAt = new Date().toISOString()): ChatMessage => ({ id, roomId: 'r1', userId: 'sam', body, createdAt })
+
+function setup(look: 'floating' | 'log' | 'off', messages: ChatMessage[] = []) {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
+ client.setQueryData(chatQuery('r1').queryKey, { pages: [[...messages].reverse()], pageParams: [undefined] })
+ render(
+
+
+ ,
+ )
+ return client
+}
+
+beforeEach(() => liveChat.set([]))
+afterEach(cleanup)
+
+describe('BigScreenChat', () => {
+ it('floats new messages by with who sent them, and drops deleted ones', async () => {
+ const client = setup('floating')
+ act(() => putChatMessage(client, 'r1', msg('1', 'turn it up')))
+ expect(screen.getByText('turn it up')).toBeTruthy()
+ expect(screen.getByText('Sam')).toBeTruthy()
+ act(() => dropChatMessage(client, 'r1', '1'))
+ await waitFor(() => expect(screen.queryByText('turn it up')).toBeNull())
+ })
+
+ it('keeps the last few in a log, leaving out old ones', () => {
+ const old = new Date(Date.now() - 60 * 60_000).toISOString()
+ setup('log', [msg('1', 'an hour ago', old), msg('2', 'one'), msg('3', 'two'), msg('4', 'three'), msg('5', 'four'), msg('6', 'five')])
+ expect(screen.queryByText('two')).toBeNull()
+ expect(screen.getByText('five')).toBeTruthy()
+ expect(screen.getByText('three')).toBeTruthy()
+ })
+
+ it('shows nothing when the room turned it off', () => {
+ const client = setup('off')
+ act(() => putChatMessage(client, 'r1', msg('1', 'hidden')))
+ expect(screen.queryByText('hidden')).toBeNull()
+ })
+})
diff --git a/web/src/components/tv/big-screen-chat.tsx b/web/src/components/tv/big-screen-chat.tsx
new file mode 100644
index 0000000..8f4cffc
--- /dev/null
+++ b/web/src/components/tv/big-screen-chat.tsx
@@ -0,0 +1,123 @@
+import { useInfiniteQuery } from '@tanstack/react-query'
+import { AnimatePresence, motion } from 'motion/react'
+import { useEffect, useRef, useState } from 'react'
+import { chatQuery, liveChat, type ChatLook, type ChatMessage } from '@/lib/chat'
+import { laneStyle } from '@/lib/lane'
+import type { User } from '@/lib/now-playing'
+import { useStore } from '@/lib/store'
+
+/**
+ * The room's chat on a big screen (MAD-845), in the room's chosen look:
+ * floating across the top, a log in the corner, or nothing. The TV and the
+ * app's visualizer both show it.
+ */
+export function BigScreenChat({ roomId, look = 'floating', users }: { roomId: string; look?: ChatLook; users: User[] | undefined }) {
+ if (look === 'off') return null
+ return look === 'log' ? :
+}
+
+/** How long a message takes to drift across, and how many drift at once. */
+const DRIFT_S = 16
+const MAX_ON_SCREEN = 4
+const LANES = 2
+
+type Floater = ChatMessage & { lane: number }
+
+/**
+ * Messages drifting across the top of the screen, right to left, like
+ * reactions: clear of the lyrics and the now-playing title below. Two
+ * lanes, a few at a time; a deleted message leaves at once.
+ */
+export function FloatingChat({ roomId, users }: { roomId: string; users: User[] | undefined }) {
+ const all = useStore(liveChat)
+ // Only what arrives from now on.
+ const seen = useRef | null>(null)
+ seen.current ??= new Set(all.map((m) => m.id))
+ const lanes = useRef(Array(LANES).fill(0))
+ const [floaters, setFloaters] = useState([])
+
+ useEffect(() => {
+ const fresh = all.filter((m) => !seen.current?.has(m.id) && m.roomId === roomId)
+ if (fresh.length === 0) return
+ const added = fresh.map((m) => {
+ seen.current?.add(m.id)
+ // The lane that's been free longest.
+ const lane = lanes.current.indexOf(Math.min(...lanes.current))
+ lanes.current[lane] = Date.now()
+ return { ...m, lane }
+ })
+ setFloaters((f) => [...f, ...added].slice(-MAX_ON_SCREEN))
+ for (const m of added) setTimeout(() => setFloaters((f) => f.filter((x) => x.id !== m.id)), DRIFT_S * 1000)
+ }, [all, roomId])
+
+ // A deleted message leaves the screen at once.
+ const ids = new Set(all.map((m) => m.id))
+ const shown = floaters.filter((f) => ids.has(f.id))
+
+ return (
+
+
+ {shown.map((f) => (
+
+ u.id === f.userId)} />
+
+ ))}
+
+
+ )
+}
+
+/** Messages older than this leave the log. */
+const LOG_FOR_MS = 15 * 60_000
+const LOG_SHOWN = 3
+
+/** The last few messages, in a column in the top right corner. */
+export function ChatLog({ roomId, users }: { roomId: string; users: User[] | undefined }) {
+ const chat = useInfiniteQuery(chatQuery(roomId))
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ const t = setInterval(() => setNow(Date.now()), 30_000)
+ return () => clearInterval(t)
+ }, [])
+ const recent = (chat.data?.pages[0] ?? [])
+ .slice(0, LOG_SHOWN)
+ .filter((m) => now - Date.parse(m.createdAt) < LOG_FOR_MS)
+ .reverse()
+
+ return (
+
+
+ {recent.map((m) => (
+
+ u.id === m.userId)} />
+
+ ))}
+
+
+ )
+}
+
+/** A glass speech bubble with the sender's name in their color. Long messages are clipped. */
+function Bubble({ m, user }: { m: ChatMessage; user?: User }) {
+ return (
+
+ {user?.displayName ?? 'Someone'}
+ {m.body}
+
+ )
+}
diff --git a/web/src/components/tv/tv-stage.tsx b/web/src/components/tv/tv-stage.tsx
index 4e2fccf..f42b007 100644
--- a/web/src/components/tv/tv-stage.tsx
+++ b/web/src/components/tv/tv-stage.tsx
@@ -37,6 +37,7 @@ import { live, useRoomSocket } from '@/lib/room-socket'
import { useStore } from '@/lib/store'
import { usersQuery } from '@/lib/users'
import { cn } from '@/lib/utils'
+import { BigScreenChat } from './big-screen-chat'
import { FloatingReactions } from './floating-reactions'
import { LinerCards } from './liner-cards'
import { QrCode } from './qr-code'
@@ -159,6 +160,7 @@ export function TvStage({
)}
+
+ {room && }