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