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
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:
1 parent
adc18e7cf3
commit
b6f3be6ac4
90 files changed
+6608
-472
No files matched your search
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user