CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/web: a Matrix client for the browser on Vue 3, Tailwind and matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional, separate service: warp-web embeds the built app, serves /config.json and proxies only /_matrix/client and /_matrix/media to the homeserver (or, with -public-homeserver, lets the browser call it directly). Sign-in and sign-up with registration tokens, room list, timeline with back-pagination, text, images, video and files over authenticated media, local echo with sent/read/failed states, receipts, typing, invites, creating, joining and leaving rooms, room info, settings, phone layout. End-to-end encryption through the Rust crypto: encrypted messages and files, emoji verification, key backup setup and restore. apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package, and the icons the client needs. syncapi fixes found with the client: - A room joined inside the sync window was sent as the join alone with limited: false, so clients thought they had the whole room. It is now read like an initial sync; a profile change is not mistaken for a join. - A live typing notification was reported on every sync, so every long poll returned at once while anybody typed. Typing now has a position in the sync token and is reported when it changes, including the empty list when the last person stops. Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job with a proxy smoke test, docs. PLAN.md moves out of the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
7.3 KiB
Go
260 lines
7.3 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Typing notifications are held in memory and nowhere else.
|
|
//
|
|
// They expire in seconds, they are worthless after a restart — nobody is
|
|
// still typing the message they were typing when the server went down —
|
|
// and they arrive one per keystroke-burst per room. Writing them to disk
|
|
// would be the busiest table in the database, holding the least valuable
|
|
// data in it. Dendrite reaches the same conclusion.
|
|
//
|
|
// The cost of the choice is that a restart clears them, which a client
|
|
// cannot tell from everybody having stopped typing, and that a second
|
|
// process would not see them. The second only matters when Warp stops
|
|
// being one process, which PLAN.md says is not the plan.
|
|
|
|
// typingTracker holds who is typing in which room, and expires them.
|
|
type typingTracker struct {
|
|
timeout time.Duration
|
|
notifier *Notifier
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
// rooms is room ID -> user ID -> when the notification expires.
|
|
rooms map[string]map[string]time.Time
|
|
// timers hold the expiry wake-ups, so that a notification running out
|
|
// reaches a blocked sync rather than waiting for one to ask.
|
|
timers map[typingKey]*time.Timer
|
|
closed bool
|
|
|
|
// pos counts changes, and changed is room ID -> the position of its
|
|
// last one. Together they are the typing stream a sync token names:
|
|
// a sync reports a room's typing when it changed since the client's
|
|
// position, once, rather than on every sync while anybody types,
|
|
// which would answer every long poll at once: a busy loop.
|
|
pos int64
|
|
changed map[string]int64
|
|
}
|
|
|
|
type typingKey struct {
|
|
roomID string
|
|
userID string
|
|
}
|
|
|
|
func newTypingTracker(timeout time.Duration, notifier *Notifier, now func() time.Time) *typingTracker {
|
|
return &typingTracker{
|
|
timeout: timeout,
|
|
notifier: notifier,
|
|
now: now,
|
|
rooms: map[string]map[string]time.Time{},
|
|
timers: map[typingKey]*time.Timer{},
|
|
changed: map[string]int64{},
|
|
}
|
|
}
|
|
|
|
// set records that a user is typing in a room for a while, or stops them.
|
|
//
|
|
// The timeout a client asks for is honoured up to the configured cap: a
|
|
// client that asks for an hour would leave the room claiming somebody was
|
|
// typing long after they closed it.
|
|
func (t *typingTracker) set(roomID, userID string, typing bool, timeout time.Duration) {
|
|
t.mu.Lock()
|
|
if t.closed {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
key := typingKey{roomID: roomID, userID: userID}
|
|
if timer, ok := t.timers[key]; ok {
|
|
timer.Stop()
|
|
delete(t.timers, key)
|
|
}
|
|
|
|
switch {
|
|
case !typing:
|
|
if users, ok := t.rooms[roomID]; ok {
|
|
delete(users, userID)
|
|
if len(users) == 0 {
|
|
delete(t.rooms, roomID)
|
|
}
|
|
}
|
|
default:
|
|
if timeout <= 0 || timeout > t.timeout {
|
|
timeout = t.timeout
|
|
}
|
|
users, ok := t.rooms[roomID]
|
|
if !ok {
|
|
users = map[string]time.Time{}
|
|
t.rooms[roomID] = users
|
|
}
|
|
users[userID] = t.now().Add(timeout)
|
|
t.timers[key] = time.AfterFunc(timeout, func() { t.expire(key) })
|
|
}
|
|
t.bump(roomID)
|
|
t.mu.Unlock()
|
|
|
|
// Outside the lock: the notifier takes its own, and a sync woken by it
|
|
// will want to read this tracker.
|
|
t.notifier.NotifyRoom(roomID)
|
|
}
|
|
|
|
// expire drops a notification whose timer has fired and wakes the room.
|
|
func (t *typingTracker) expire(key typingKey) {
|
|
t.mu.Lock()
|
|
delete(t.timers, key)
|
|
users, ok := t.rooms[key.roomID]
|
|
if ok {
|
|
delete(users, key.userID)
|
|
if len(users) == 0 {
|
|
delete(t.rooms, key.roomID)
|
|
}
|
|
t.bump(key.roomID)
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
if ok {
|
|
t.notifier.NotifyRoom(key.roomID)
|
|
}
|
|
}
|
|
|
|
// users lists who is typing in a room, sorted, skipping anyone whose
|
|
// notification has run out.
|
|
//
|
|
// The expiry check is here as well as in the timer because the two answer
|
|
// different failure modes: the timer wakes a blocked client, and this makes
|
|
// sure a notification is never *reported* after its deadline even if the
|
|
// timer has not run yet.
|
|
func (t *typingTracker) users(roomID string) []string {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
now := t.now()
|
|
out := make([]string, 0, len(t.rooms[roomID]))
|
|
for userID, expires := range t.rooms[roomID] {
|
|
if expires.After(now) {
|
|
out = append(out, userID)
|
|
}
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// bump records a change in a room. The caller holds the lock.
|
|
func (t *typingTracker) bump(roomID string) {
|
|
t.pos++
|
|
t.changed[roomID] = t.pos
|
|
}
|
|
|
|
// position is the top of the typing stream.
|
|
func (t *typingTracker) position() int64 {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return t.pos
|
|
}
|
|
|
|
// changedIn reports whether a room's typing changed after since, up to
|
|
// and including to. A since beyond the tracker's position is from before a
|
|
// restart, which forgot everything: then every room it knows of counts.
|
|
func (t *typingTracker) changedIn(roomID string, since, to int64) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
pos, ok := t.changed[roomID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return since > t.pos || (pos > since && pos <= to)
|
|
}
|
|
|
|
// changedRooms lists the rooms whose typing changed after since, up to and
|
|
// including to, which is how a sync knows a room has an ephemeral change
|
|
// without asking per room.
|
|
func (t *typingTracker) changedRooms(since, to int64) []string {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
out := []string{}
|
|
for roomID, pos := range t.changed {
|
|
if since > t.pos || (pos > since && pos <= to) {
|
|
out = append(out, roomID)
|
|
}
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// close stops every timer. A server shutting down should not be held up by
|
|
// a typing notification.
|
|
func (t *typingTracker) close() {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
t.closed = true
|
|
for key, timer := range t.timers {
|
|
timer.Stop()
|
|
delete(t.timers, key)
|
|
}
|
|
t.rooms = map[string]map[string]time.Time{}
|
|
t.changed = map[string]int64{}
|
|
}
|
|
|
|
// TypingRequest is PUT /rooms/{roomID}/typing/{userID}.
|
|
type TypingRequest struct {
|
|
RoomID string
|
|
|
|
// User is the full user ID said to be typing, which the handler has
|
|
// already checked is the caller.
|
|
User string
|
|
|
|
Typing bool
|
|
|
|
// Timeout is how long the notification should last. Zero takes the
|
|
// server's own.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// SetTyping records a typing notification.
|
|
//
|
|
// Being in the room is required: a typing notification is published to
|
|
// everybody in it, so it is a write to the room even though it is not an
|
|
// event in it.
|
|
func (s *API) SetTyping(ctx context.Context, req TypingRequest) error {
|
|
if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.typing.set(req.RoomID, req.User, req.Typing, req.Timeout)
|
|
if s.outbound != nil && s.isLocal(req.User) {
|
|
s.outbound.Typing(ctx, req.RoomID, req.User, req.Typing, req.Timeout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// typingEvent is the m.typing EDU of a room. It is nil when nobody is
|
|
// typing, unless empty asks for the empty list: that is how a client hears
|
|
// that the last person stopped.
|
|
//
|
|
// The event is the whole list rather than a delta, which is what the spec
|
|
// defines: a client replaces what it had, so a missed update corrects
|
|
// itself on the next one.
|
|
func (s *API) typingEvent(roomID string, empty bool) *EphemeralEvent {
|
|
users := s.typing.users(roomID)
|
|
if len(users) == 0 && !empty {
|
|
return nil
|
|
}
|
|
content, err := json.Marshal(map[string][]string{"user_ids": users})
|
|
if err != nil {
|
|
// Marshalling a list of strings cannot fail; if it somehow did,
|
|
// dropping the notification is better than failing the sync.
|
|
s.log.Error("marshalling a typing notification failed", "room_id", roomID, "error", err)
|
|
return nil
|
|
}
|
|
return &EphemeralEvent{Type: "m.typing", Content: content}
|
|
}
|