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

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