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

+15 -3
View File
@@ -44,15 +44,27 @@ func TestTypingReachesTheOtherMembers(t *testing.T) {
t.Errorf("user_ids = %v, want [%s]", users, alice.UserID)
}
// While she goes on typing, the next sync has nothing new to say. A
// sync that repeated it would return at once every time: clients would
// spin instead of long-polling.
again := ts.sync(bob, result.NextBatch)
if _, ok := ephemeralEvent(again, roomID, "m.typing"); ok {
t.Error("an unchanged m.typing was reported again")
}
// Stopping is reported as the list without them, not as nothing: a
// client replaces what it had.
expectStatus(t, ts.put(fmt.Sprintf("/_matrix/client/v3/rooms/%s/typing/%s",
url.PathEscape(roomID), url.PathEscape(alice.UserID)),
alice.AccessToken, map[string]any{"typing": false}), http.StatusOK)
stopped := ts.sync(bob, result.NextBatch)
if _, ok := ephemeralEvent(stopped, roomID, "m.typing"); ok {
t.Error("m.typing was still reported after the notification was withdrawn")
stopped := ts.sync(bob, again.NextBatch)
typing, ok = ephemeralEvent(stopped, roomID, "m.typing")
if !ok {
t.Fatal("stopping was not reported")
}
if users, _ := typing.Content["user_ids"].([]any); len(users) != 0 {
t.Errorf("user_ids = %v after she stopped, want none", users)
}
}
+39 -1
View File
@@ -260,7 +260,7 @@ func TestSyncRejectsAnUnreadableToken(t *testing.T) {
// "s-1" is not here: a negative event position is a /messages token
// from inside backfilled history, and a valid ?since=. Only the event
// stream goes below zero.
for _, since := range []string{"nonsense", "s1_-1", "sx", "s1_2_3_4_5_6_7_8"} {
for _, since := range []string{"nonsense", "s1_-1", "sx", "s1_2_3_4_5_6_7_8_9"} {
rec := ts.get("/_matrix/client/v3/sync?since="+url.QueryEscape(since), alice.AccessToken)
expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM")
}
@@ -404,6 +404,44 @@ func TestSyncReportsALeaveAndThenStops(t *testing.T) {
}
}
func TestSyncIncrementalSendsARoomJoinedInTheWindowInFull(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "hunter2-hunter2-hunter2")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat", "name": "Carthage"})
ts.sendMessage(alice, roomID, "before bob joined")
since := ts.sync(bob, "").NextBatch
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join",
bob.AccessToken, map[string]any{}), http.StatusOK)
// To bob the room is new: he gets it as an initial sync would give it,
// the history he may see and the state, not just his own join.
room, ok := ts.sync(bob, since).Rooms.Join[roomID]
if !ok {
t.Fatal("the joined room is not in the join section")
}
if got := bodiesOf(room.Timeline.Events); !contains(got, "before bob joined") {
t.Errorf("timeline = %v, want the history before the join", got)
}
all := append(typesOf(room.State.Events), typesOf(room.Timeline.Events)...)
for _, want := range []string{"m.room.create", "m.room.name", "m.room.power_levels"} {
if !contains(all, want) {
t.Errorf("state and timeline = %v, want %s", all, want)
}
}
// A later change to bob's own profile moves his membership too, but is
// no new join: that sync carries only what is new.
since = ts.sync(bob, since).NextBatch
expectStatus(t, ts.put("/_matrix/client/v3/profile/"+url.PathEscape(bob.UserID)+"/displayname",
bob.AccessToken, map[string]any{"displayname": "Bob"}), http.StatusOK)
room = ts.sync(bob, since).Rooms.Join[roomID]
if got := bodiesOf(room.Timeline.Events); contains(got, "before bob joined") {
t.Errorf("a profile change resent the history: %v", got)
}
}
func TestSyncSummaryNamesARoomWithNoName(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
+16
View File
@@ -367,6 +367,22 @@ func (r *API) Membership(ctx context.Context, roomID, userID string) (string, er
return entry.Membership, nil
}
// MembershipBefore returns a user's membership in a room as of just before
// an event, empty when they had none. Sync asks it of the user's own latest
// membership event, to tell a join from a profile change.
func (r *API) MembershipBefore(ctx context.Context, roomID, userID, eventID string) (string, error) {
entries, err := r.store.StateBefore(ctx, roomID, eventID)
if err != nil {
return "", err
}
for _, e := range entries {
if e.Type == event.TypeMember && e.StateKey == userID {
return e.Membership, nil
}
}
return "", nil
}
// roomAndRules reads a room and the rules its events are under.
func (r *API) roomAndRules(ctx context.Context, roomID string) (storage.Room, roomversion.Rules, error) {
room, err := r.store.Room(ctx, roomID)
+1 -1
View File
@@ -975,7 +975,7 @@ func (s *API) slidingExtensions(
if ext.Typing.on() {
out := &EphemeralExtension{Rooms: map[string]EphemeralEvent{}}
for _, roomID := range joinedOnly(extensionRooms(ext.Typing, req.Body, selected)) {
e := s.typingEvent(roomID)
e := s.typingEvent(roomID, false)
current := ""
if e != nil {
current = string(e.Content)
+53 -9
View File
@@ -320,6 +320,7 @@ func (s *API) build(ctx context.Context, req SyncRequest, filter Filter, since T
return Response{}, false, err
}
to := tokenAt(positions)
to.Typing = s.typing.position()
resp := Response{
Rooms: Rooms{
@@ -443,6 +444,13 @@ func (s *API) buildRooms(ctx context.Context, req SyncRequest, filter Filter, si
to: to,
full: since.IsInitial() || req.FullState || room.Position > since.PDU,
}
if room.Membership == event.MembershipJoin && !since.IsInitial() && room.Position > since.PDU {
joined, err := s.isNewJoin(ctx, req, room)
if err != nil {
return err
}
window.joined = joined
}
switch room.Membership {
case event.MembershipJoin:
@@ -490,6 +498,23 @@ type roomWindow struct {
// the client's position: an initial sync, ?full_state=true, or a room
// the user has only just joined.
full bool
// joined is a room the user joined inside the window. To their client
// the room is new, so it is read as an initial sync reads it: the
// recent history they may see and the state before it, not only the
// events since the client's position, which would be their own join.
joined bool
}
// isNewJoin reports whether the user's membership event inside the window
// is a join into the room, rather than a profile change of a member who
// was already in it.
func (s *API) isNewJoin(ctx context.Context, req SyncRequest, room roomserver.MembershipAt) (bool, error) {
before, err := s.rooms.MembershipBefore(ctx, room.RoomID, req.User, room.EventID)
if err != nil {
return false, err
}
return before != event.MembershipJoin, nil
}
// from is the position a stream is read from, which is the start of it for
@@ -501,6 +526,15 @@ func (w roomWindow) from(pos int64) int64 {
return pos
}
// timelineSince is the position a room's timeline and state are read from:
// the client's, or the start of the room for one joined in the window.
func (w roomWindow) timelineSince() int64 {
if w.joined {
return 0
}
return w.since.PDU
}
// changedRooms is the set of rooms this sync has to look at.
//
// On an initial sync that is all of them. On an incremental one it is the
@@ -543,12 +577,13 @@ func (s *API) changedRooms(ctx context.Context, req SyncRequest, filter Filter,
}
}
// Typing lives in memory and has no position at all: a notification
// that started and stopped between two syncs is simply never reported,
// and one that is live is reported every time until it expires.
for _, roomID := range s.typing.activeRooms() {
if slices.Contains(joined, roomID) {
out[roomID] = struct{}{}
// Typing lives in memory, with a position of its own that counts
// changes: a room is looked at when its typing changed in the window.
if to.Typing != since.Typing {
for _, roomID := range s.typing.changedRooms(since.Typing, to.Typing) {
if slices.Contains(joined, roomID) {
out[roomID] = struct{}{}
}
}
}
@@ -638,7 +673,7 @@ func (s *API) roomSyncRequest(req SyncRequest, filter Filter, window roomWindow,
return roomserver.RoomSyncRequest{
RoomID: room.RoomID,
User: req.User,
Since: window.since.PDU,
Since: window.timelineSince(),
To: window.to.PDU,
TimelineLimit: filter.TimelineLimit(),
TimelineFilter: filter.TimelineFilter(),
@@ -667,8 +702,17 @@ func (s *API) roomEphemeral(ctx context.Context, userID, roomID string, window r
out := []EphemeralEvent{}
ephemeralFilter := filter.EphemeralFilter()
if e := s.typingEvent(roomID); e != nil && matchesEventFilter(e.Type, "", ephemeralFilter) {
out = append(out, *e)
// A full read reports whoever is typing now; an incremental one
// reports the room's list when it changed, even when it is now empty.
var typing *EphemeralEvent
if window.full {
typing = s.typingEvent(roomID, false)
} else if window.to.Typing != window.since.Typing &&
s.typing.changedIn(roomID, window.since.Typing, window.to.Typing) {
typing = s.typingEvent(roomID, true)
}
if typing != nil && matchesEventFilter(typing.Type, "", ephemeralFilter) {
out = append(out, *typing)
}
byRoom, err := s.roomReceipts(ctx, userID, []string{roomID},
+11 -5
View File
@@ -18,13 +18,13 @@ import (
//
// The wire form is
//
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}_{typing}
//
// with trailing zeroes left off, so a client that has only ever seen room
// events carries "s12" rather than "s12_0_0_0_0_0_0". The device list
// position came with Phase 7 and the notification count position with
// Phase 8, each added at the end, which is what lets every token handed
// out before them go on parsing. The leading "s" is there
// Phase 8 and the typing position after them, each added at the end,
// which is what lets every token handed out before them go on parsing. The leading "s" is there
// to make a token recognisable in a log and to leave room for a second
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
// own — without any ambiguity about which is which.
@@ -42,10 +42,15 @@ type Token struct {
// Notification is the push server's notification count stream.
Notification int64
// Typing is the typing tracker's position. It lives in memory and
// starts again at zero when the server restarts, so it is compared
// for difference, never for order.
Typing int64
}
// tokenPositions is how many positions a token carries.
const tokenPositions = 7
const tokenPositions = 8
// tokenPrefix marks a classic /sync token.
const tokenPrefix = "s"
@@ -56,7 +61,7 @@ func (t Token) IsInitial() bool { return t == Token{} }
// String renders the wire form.
func (t Token) String() string {
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification}
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification, t.Typing}
// Trailing zeroes carry no information, and leaving them off keeps the
// common token short.
last := 0
@@ -123,6 +128,7 @@ func ParseToken(raw string) (Token, error) {
DeviceList: positions[5],
Notification: positions[6],
Typing: positions[7],
}, nil
}
+13 -7
View File
@@ -17,6 +17,12 @@ func TestTokenRoundTrips(t *testing.T) {
{"events and receipts", syncapi.Token{PDU: 12, Receipt: 3}, "s12_3"},
{
"every stream",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6, Notification: 7, Typing: 8},
"s1_2_3_4_5_6_7_8",
},
{
// A token from before the typing position.
"seven streams",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6, Notification: 7},
"s1_2_3_4_5_6_7",
},
@@ -87,13 +93,13 @@ func TestParseTokenAcceptsAShortToken(t *testing.T) {
func TestParseTokenRejectsNonsense(t *testing.T) {
for _, raw := range []string{
"nonsense", // no prefix
"t1_2", // a prefix this build does not write
"s", // a prefix and nothing else
"s1_-1", // a negative position outside the event stream
"sx", // not a number
"s1_", // a trailing separator
"s1_2_3_4_5_6_7_8", // more positions than there are streams
"nonsense", // no prefix
"t1_2", // a prefix this build does not write
"s", // a prefix and nothing else
"s1_-1", // a negative position outside the event stream
"sx", // not a number
"s1_", // a trailing separator
"s1_2_3_4_5_6_7_8_9", // more positions than there are streams
"s1.5",
} {
if _, err := syncapi.ParseToken(raw); err == nil {
+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})