Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docs/adr/0018-room-chat.md
Original file line number Diff line number Diff line change
@@ -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 <song>" 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".
2 changes: 2 additions & 0 deletions server/cmd/syncphony/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
147 changes: 147 additions & 0 deletions server/internal/chat/chat.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading