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
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
Direction,
|
||||
EventStatus,
|
||||
EventType,
|
||||
MsgType,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
} from 'matrix-js-sdk'
|
||||
import { DecryptionFailureCode } from 'matrix-js-sdk/lib/crypto-api'
|
||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'
|
||||
import type { MessageStatus } from '@warp/ui'
|
||||
import { sameDay } from '@/lib/format'
|
||||
import type { EncryptedFile } from './attachments'
|
||||
import type { MediaSource } from './media'
|
||||
|
||||
// Turns a room's live timeline into what the timeline view draws: messages
|
||||
// grouped by sender, day separators and one-line notices for state changes.
|
||||
// The SDK keeps the events; this is a pure projection of them.
|
||||
|
||||
export interface MediaInfo {
|
||||
mxc?: string
|
||||
/** The thumbnail the sender uploaded, when there is one. */
|
||||
thumbMxc?: string
|
||||
/** Set in an encrypted room: the file is ciphertext, this decrypts it. */
|
||||
file?: EncryptedFile
|
||||
thumbFile?: EncryptedFile
|
||||
thumbMimetype?: string
|
||||
mimetype?: string
|
||||
size?: number
|
||||
w?: number
|
||||
h?: number
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface MessageItem {
|
||||
kind: 'message'
|
||||
key: string
|
||||
eventId?: string
|
||||
sender: string
|
||||
senderName: string
|
||||
avatarMxc?: string
|
||||
own: boolean
|
||||
ts: number
|
||||
msgtype: string
|
||||
body: string
|
||||
media?: MediaInfo
|
||||
edited: boolean
|
||||
redacted: boolean
|
||||
/** Still ciphertext: being decrypted, or it can't be (see `utd`). */
|
||||
encrypted: boolean
|
||||
/** Why this session can't decrypt it. */
|
||||
utd?: string
|
||||
/** Sent end-to-end encrypted. */
|
||||
e2ee: boolean
|
||||
status?: MessageStatus
|
||||
/** First of a run of messages from one sender: shows the name. */
|
||||
first: boolean
|
||||
/** Last of a run: shows the time and status. */
|
||||
last: boolean
|
||||
event: MatrixEvent
|
||||
}
|
||||
|
||||
export interface NoticeItem {
|
||||
kind: 'notice'
|
||||
key: string
|
||||
ts: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface DayItem {
|
||||
kind: 'day'
|
||||
key: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
export type TimelineItem = MessageItem | NoticeItem | DayItem
|
||||
|
||||
// Messages from one sender this close together form one group.
|
||||
const groupGapMs = 5 * 60_000
|
||||
|
||||
function nameOf(room: Room, userId: string): string {
|
||||
return room.getMember(userId)?.name ?? userId
|
||||
}
|
||||
|
||||
function statusOf(ev: MatrixEvent): MessageStatus | undefined {
|
||||
switch (ev.status) {
|
||||
case EventStatus.NOT_SENT:
|
||||
return 'failed'
|
||||
case EventStatus.SENDING:
|
||||
case EventStatus.QUEUED:
|
||||
case EventStatus.ENCRYPTING:
|
||||
return 'sending'
|
||||
case EventStatus.CANCELLED:
|
||||
return undefined
|
||||
default:
|
||||
return 'sent'
|
||||
}
|
||||
}
|
||||
|
||||
/** A one-line description of a state change, or null to leave it out. */
|
||||
function describeState(ev: MatrixEvent, room: Room): string | null {
|
||||
const sender = nameOf(room, ev.getSender() ?? '')
|
||||
const content = ev.getContent()
|
||||
const prev = ev.getPrevContent()
|
||||
switch (ev.getType()) {
|
||||
case EventType.RoomCreate:
|
||||
return `${sender} created the room`
|
||||
case EventType.RoomName:
|
||||
return content.name ? `${sender} named the room ${content.name}` : `${sender} removed the room name`
|
||||
case EventType.RoomTopic:
|
||||
return content.topic ? `${sender} changed the topic to “${content.topic}”` : `${sender} removed the topic`
|
||||
case EventType.RoomAvatar:
|
||||
return `${sender} changed the room avatar`
|
||||
case EventType.RoomEncryption:
|
||||
return `${sender} turned on end-to-end encryption`
|
||||
case EventType.RoomTombstone:
|
||||
return `${sender} upgraded this room`
|
||||
case EventType.RoomMember: {
|
||||
const target = ev.getStateKey() ?? ''
|
||||
const targetName = content.displayname ?? prev.displayname ?? target
|
||||
const reason = content.reason ? `: ${content.reason}` : ''
|
||||
switch (content.membership) {
|
||||
case 'join':
|
||||
if (prev.membership === 'join') {
|
||||
if (content.displayname !== prev.displayname) {
|
||||
if (!content.displayname) return `${prev.displayname ?? target} removed their display name`
|
||||
if (!prev.displayname) return `${target} set their display name to ${content.displayname}`
|
||||
return `${prev.displayname} changed their name to ${content.displayname}`
|
||||
}
|
||||
if (content.avatar_url !== prev.avatar_url) return `${targetName} changed their avatar`
|
||||
return null
|
||||
}
|
||||
return `${targetName} joined`
|
||||
case 'invite':
|
||||
return `${sender} invited ${targetName}`
|
||||
case 'leave':
|
||||
if (target !== ev.getSender()) {
|
||||
if (prev.membership === 'ban') return `${sender} unbanned ${targetName}`
|
||||
if (prev.membership === 'invite') return `${sender} withdrew ${targetName}’s invitation`
|
||||
return `${sender} removed ${targetName}${reason}`
|
||||
}
|
||||
return prev.membership === 'invite' ? `${targetName} declined the invitation` : `${targetName} left`
|
||||
case 'ban':
|
||||
return `${sender} banned ${targetName}${reason}`
|
||||
case 'knock':
|
||||
return `${targetName} asked to join`
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** What a message this session can't decrypt says about why, honestly. */
|
||||
export function utdText(ev: MatrixEvent, cryptoRunning: boolean): string | undefined {
|
||||
if (!ev.isDecryptionFailure()) return cryptoRunning ? undefined : 'End-to-end encryption didn’t start in this browser, so it can’t read this message.'
|
||||
switch (ev.decryptionFailureReason) {
|
||||
case DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP:
|
||||
case DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED:
|
||||
return 'Sent before this session signed in. Verify this session or restore your key backup to read it.'
|
||||
case DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP:
|
||||
return 'Sent before this session signed in, and its key isn’t in your key backup.'
|
||||
case DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED:
|
||||
return 'Sent before you joined this room, so you can’t read it.'
|
||||
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
|
||||
return 'The sender only shares keys with verified sessions. Verify this session to read new messages.'
|
||||
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD:
|
||||
return 'The sender’s session didn’t share the key to this message with you.'
|
||||
case DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX:
|
||||
return 'This session only has the key to later messages from this sender.'
|
||||
case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED:
|
||||
return 'Hidden: the sender’s identity changed since you verified them.'
|
||||
case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE:
|
||||
return 'Hidden: sent from a session its owner hasn’t verified.'
|
||||
case DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID:
|
||||
return 'Waiting for the key to this message. It comes from the sender’s session, which may be offline.'
|
||||
default:
|
||||
return 'Can’t decrypt this message.'
|
||||
}
|
||||
}
|
||||
|
||||
/** Where to load a message's file or thumbnail from. */
|
||||
export function mediaSource(m: MediaInfo, thumb = false): MediaSource | undefined {
|
||||
if (thumb && m.thumbFile) return { file: m.thumbFile, mimetype: m.thumbMimetype ?? 'image/jpeg' }
|
||||
if (thumb && m.thumbMxc) return m.thumbMxc
|
||||
if (m.file) return { file: m.file, mimetype: m.mimetype }
|
||||
return m.mxc
|
||||
}
|
||||
|
||||
function mediaOf(content: RoomMessageEventContent): MediaInfo | undefined {
|
||||
const msgtype = content.msgtype as string
|
||||
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video && msgtype !== MsgType.Audio && msgtype !== MsgType.File) {
|
||||
return undefined
|
||||
}
|
||||
const c = content as RoomMessageEventContent & {
|
||||
url?: string
|
||||
file?: EncryptedFile
|
||||
filename?: string
|
||||
info?: {
|
||||
mimetype?: string
|
||||
size?: number
|
||||
w?: number
|
||||
h?: number
|
||||
thumbnail_url?: string
|
||||
thumbnail_file?: EncryptedFile
|
||||
thumbnail_info?: { mimetype?: string }
|
||||
}
|
||||
}
|
||||
return {
|
||||
mxc: c.url ?? c.file?.url,
|
||||
thumbMxc: c.info?.thumbnail_url ?? c.info?.thumbnail_file?.url,
|
||||
file: c.file,
|
||||
thumbFile: c.info?.thumbnail_file,
|
||||
thumbMimetype: c.info?.thumbnail_info?.mimetype,
|
||||
mimetype: c.info?.mimetype,
|
||||
size: c.info?.size,
|
||||
w: c.info?.w,
|
||||
h: c.info?.h,
|
||||
filename: c.filename || c.body || 'file',
|
||||
}
|
||||
}
|
||||
|
||||
/** Every event the view shows, oldest first: the live timeline then your pending ones. */
|
||||
function displayEvents(room: Room): MatrixEvent[] {
|
||||
return [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()]
|
||||
}
|
||||
|
||||
export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
|
||||
const me = c.getUserId() ?? ''
|
||||
const cryptoRunning = Boolean(c.getCrypto())
|
||||
const events = displayEvents(room)
|
||||
|
||||
// An event is read when someone else's receipt is on it or on anything
|
||||
// after it, or someone else has written after it.
|
||||
const readByOthers = new Set<string>()
|
||||
let seen = false
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]!
|
||||
const id = ev.getId()
|
||||
if (!id || ev.status) continue
|
||||
if (!seen) {
|
||||
const sender = ev.getSender()
|
||||
if (sender && sender !== me) seen = true
|
||||
else if (room.getUsersReadUpTo(ev).some((u) => u !== me)) seen = true
|
||||
}
|
||||
if (seen) readByOthers.add(id)
|
||||
}
|
||||
|
||||
const items: TimelineItem[] = []
|
||||
let lastTs = 0
|
||||
let prev: MessageItem | null = null
|
||||
|
||||
for (const ev of events) {
|
||||
const type = ev.getType()
|
||||
if (ev.isRelation('m.replace') || type === EventType.Reaction || type === EventType.RoomRedaction) continue
|
||||
if (ev.status === EventStatus.CANCELLED) continue
|
||||
|
||||
const ts = ev.getTs()
|
||||
if (!lastTs || !sameDay(lastTs, ts)) {
|
||||
items.push({ kind: 'day', key: `day-${ts}`, ts })
|
||||
prev = null
|
||||
}
|
||||
lastTs = ts
|
||||
|
||||
const key = ev.getTxnId() ?? ev.getId() ?? `${ts}-${items.length}`
|
||||
const isMessage = type === EventType.RoomMessage || type === EventType.Sticker || type === EventType.RoomMessageEncrypted
|
||||
|
||||
if (!isMessage) {
|
||||
if (!ev.isState()) continue
|
||||
const text = describeState(ev, room)
|
||||
if (text) {
|
||||
items.push({ kind: 'notice', key, ts, text })
|
||||
prev = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const sender = ev.getSender() ?? ''
|
||||
const own = sender === me
|
||||
const content = ev.getContent<RoomMessageEventContent>()
|
||||
// Still ciphertext, or failed: the SDK then makes it an m.room.message
|
||||
// with its own "Unable to decrypt" text, which is never shown.
|
||||
const encrypted = type === EventType.RoomMessageEncrypted || ev.isDecryptionFailure()
|
||||
const redacted = ev.isRedacted()
|
||||
const status = own ? statusOf(ev) : undefined
|
||||
const item: MessageItem = {
|
||||
kind: 'message',
|
||||
key,
|
||||
eventId: ev.getId(),
|
||||
sender,
|
||||
senderName: nameOf(room, sender),
|
||||
avatarMxc: room.getMember(sender)?.getMxcAvatarUrl() ?? undefined,
|
||||
own,
|
||||
ts,
|
||||
msgtype: type === EventType.Sticker ? 'm.sticker' : String(content.msgtype ?? ''),
|
||||
body: redacted ? '' : String(content.body ?? ''),
|
||||
media: redacted || encrypted ? undefined : type === EventType.Sticker ? stickerMedia(ev) : mediaOf(content),
|
||||
edited: Boolean(ev.replacingEventId()),
|
||||
redacted,
|
||||
encrypted,
|
||||
utd: encrypted ? utdText(ev, cryptoRunning) : undefined,
|
||||
e2ee: ev.isEncrypted(),
|
||||
status: status === 'sent' && ev.getId() && readByOthers.has(ev.getId()!) ? 'read' : status,
|
||||
first: true,
|
||||
last: true,
|
||||
event: ev,
|
||||
}
|
||||
if (prev && prev.sender === sender && ts - prev.ts < groupGapMs) {
|
||||
item.first = false
|
||||
prev.last = false
|
||||
}
|
||||
items.push(item)
|
||||
prev = item
|
||||
}
|
||||
|
||||
// Only the newest message of a run says "Sent" or "Read"; one that is
|
||||
// still sending or failed always says so.
|
||||
for (const it of items) {
|
||||
if (it.kind === 'message' && it.own && !it.last && (it.status === 'sent' || it.status === 'read')) {
|
||||
it.status = undefined
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function stickerMedia(ev: MatrixEvent): MediaInfo {
|
||||
const c = ev.getContent() as { url?: string; body?: string; info?: { w?: number; h?: number; mimetype?: string } }
|
||||
return { mxc: c.url, w: c.info?.w, h: c.info?.h, mimetype: c.info?.mimetype, filename: c.body ?? 'sticker' }
|
||||
}
|
||||
|
||||
/** Whether there is older history to page back to. */
|
||||
export function canPaginateBack(room: Room): boolean {
|
||||
const tl = room.getLiveTimeline()
|
||||
if (tl.getEvents().some((e) => e.getType() === EventType.RoomCreate)) return false
|
||||
return tl.getPaginationToken(Direction.Backward) !== null
|
||||
}
|
||||
|
||||
/** The newest event that came from the server, for read receipts. */
|
||||
export function lastSyncedEvent(room: Room): MatrixEvent | undefined {
|
||||
const events = room.getLiveTimeline().getEvents()
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]!
|
||||
if (ev.getId() && !ev.status && !ev.getId()!.startsWith('~')) return ev
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
Reference in new issue
Block a user