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
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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