Files
Warp/server/internal/syncapi/typing.go
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
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
Web client (warp-web) with E2EE, and two sync fixes
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>
2026-10-18 16:22:11 +02:00

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}
}