Web client (warp-web) with E2EE, and two sync fixes
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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 16:22:11 +02:00
1 parent adc18e7cf3
commit b6f3be6ac4
90 files changed
+6608 -472

No files matched your search

+50 -14
View File
@@ -34,6 +34,14 @@ type typingTracker struct {
// 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 {
@@ -48,6 +56,7 @@ func newTypingTracker(timeout time.Duration, notifier *Notifier, now func() time
now: now,
rooms: map[string]map[string]time.Time{},
timers: map[typingKey]*time.Timer{},
changed: map[string]int64{},
}
}
@@ -89,6 +98,7 @@ func (t *typingTracker) set(roomID, userID string, typing bool, timeout time.Dur
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
@@ -106,6 +116,7 @@ func (t *typingTracker) expire(key typingKey) {
if len(users) == 0 {
delete(t.rooms, key.roomID)
}
t.bump(key.roomID)
}
t.mu.Unlock()
@@ -136,20 +147,42 @@ func (t *typingTracker) users(roomID string) []string {
return out
}
// rooms with anybody typing, which is how a sync knows a room has an
// ephemeral change without asking per room.
func (t *typingTracker) activeRooms() []string {
// 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
}
now := t.now()
out := make([]string, 0, len(t.rooms))
for roomID, users := range t.rooms {
for _, expires := range users {
if expires.After(now) {
out = append(out, roomID)
break
}
// 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)
@@ -168,6 +201,7 @@ func (t *typingTracker) close() {
delete(t.timers, key)
}
t.rooms = map[string]map[string]time.Time{}
t.changed = map[string]int64{}
}
// TypingRequest is PUT /rooms/{roomID}/typing/{userID}.
@@ -202,14 +236,16 @@ func (s *API) SetTyping(ctx context.Context, req TypingRequest) error {
return nil
}
// typingEvent is the m.typing EDU of a room, or nil when nobody is typing.
// 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) *EphemeralEvent {
func (s *API) typingEvent(roomID string, empty bool) *EphemeralEvent {
users := s.typing.users(roomID)
if len(users) == 0 {
if len(users) == 0 && !empty {
return nil
}
content, err := json.Marshal(map[string][]string{"user_ids": users})