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; 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 ( + + ) +} + +/** 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 +

Everyone in {room.name} can read this

+
+ + + +
+ +
+
+ {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'}} + +

    + {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 ( +
    { + e.preventDefault() + send() + }} + className="border-t border-border/60 px-3 pt-2.5 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-3" + > + {error && ( +

    + {error} +

    + )} +
    + +