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,373 @@
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
KnownMembership,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
NotificationCountType,
|
||||
Preset,
|
||||
RoomEvent,
|
||||
RoomMemberEvent,
|
||||
RoomStateEvent,
|
||||
SyncState,
|
||||
UserEvent,
|
||||
Visibility,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
} from 'matrix-js-sdk'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { client } from '@/matrix/client'
|
||||
|
||||
// The room list and the client's connection state, as plain reactive data
|
||||
// derived from the SDK. The SDK's objects change in place and emit events;
|
||||
// every event that can change what is on screen bumps `version` (at most
|
||||
// once a frame), and the computed views below re-derive from the SDK.
|
||||
|
||||
export interface RoomSummary {
|
||||
roomId: string
|
||||
name: string
|
||||
avatarMxc?: string
|
||||
preview: string
|
||||
ts: number
|
||||
unread: number
|
||||
highlight: number
|
||||
encrypted: boolean
|
||||
isDirect: boolean
|
||||
/** The other person in a direct message. */
|
||||
dmUserId?: string
|
||||
presence?: 'online' | 'away' | 'offline'
|
||||
}
|
||||
|
||||
export interface InviteSummary {
|
||||
roomId: string
|
||||
name: string
|
||||
avatarMxc?: string
|
||||
inviter?: string
|
||||
inviterName?: string
|
||||
isDirect: boolean
|
||||
ts: number
|
||||
}
|
||||
|
||||
/** The text a room list shows for an event, or null when it shows nothing. */
|
||||
export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSender: boolean): string | null {
|
||||
if (ev.isRedacted()) return 'Message deleted'
|
||||
const type = ev.getType()
|
||||
// A message that failed to decrypt reads as m.room.message with the SDK's
|
||||
// own placeholder text: never show that.
|
||||
if (ev.isDecryptionFailure()) return withSender ? `${senderName(ev, me, room)}: Can’t decrypt this message` : 'Can’t decrypt this message'
|
||||
if (type === EventType.RoomMessageEncrypted) return 'Encrypted message'
|
||||
if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null
|
||||
if (ev.isRelation('m.replace')) return null
|
||||
const content = ev.getContent()
|
||||
let text: string
|
||||
switch (content.msgtype) {
|
||||
case MsgType.Image:
|
||||
text = 'Image'
|
||||
break
|
||||
case MsgType.Video:
|
||||
text = 'Video'
|
||||
break
|
||||
case MsgType.Audio:
|
||||
text = 'Audio'
|
||||
break
|
||||
case MsgType.File:
|
||||
text = `File: ${content.body ?? ''}`
|
||||
break
|
||||
default:
|
||||
text = type === EventType.Sticker ? 'Sticker' : String(content.body ?? '')
|
||||
}
|
||||
if (content.msgtype === MsgType.Emote) {
|
||||
return `* ${room.getMember(ev.getSender() ?? '')?.name ?? ev.getSender()} ${text}`
|
||||
}
|
||||
if (!withSender) return text
|
||||
return `${senderName(ev, me, room)}: ${text}`
|
||||
}
|
||||
|
||||
function senderName(ev: MatrixEvent, me: string, room: Room): string {
|
||||
return ev.getSender() === me ? 'You' : (room.getMember(ev.getSender() ?? '')?.name ?? ev.getSender() ?? '')
|
||||
}
|
||||
|
||||
function directRooms(c: MatrixClient): Map<string, string> {
|
||||
// m.direct maps a user to their DM rooms; flip it to room → user.
|
||||
const out = new Map<string, string>()
|
||||
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
|
||||
for (const [user, rooms] of Object.entries(content)) {
|
||||
if (!Array.isArray(rooms)) continue
|
||||
for (const r of rooms) out.set(r, user)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function presenceOf(c: MatrixClient, userId: string): RoomSummary['presence'] {
|
||||
const p = c.getUser(userId)?.presence
|
||||
if (p === 'online') return 'online'
|
||||
if (p === 'unavailable') return 'away'
|
||||
if (p === 'offline') return 'offline'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function summarize(c: MatrixClient, room: Room, direct: Map<string, string>): RoomSummary {
|
||||
const me = c.getUserId() ?? ''
|
||||
const dmUserId = direct.get(room.roomId)
|
||||
const isDirect = dmUserId !== undefined
|
||||
const events = room.getLiveTimeline().getEvents()
|
||||
let preview = ''
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const text = describeEvent(events[i]!, me, room, !isDirect)
|
||||
if (text !== null) {
|
||||
preview = text
|
||||
break
|
||||
}
|
||||
}
|
||||
let avatarMxc = room.getMxcAvatarUrl() ?? undefined
|
||||
if (!avatarMxc && isDirect) avatarMxc = room.getMember(dmUserId)?.getMxcAvatarUrl() ?? undefined
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
name: room.name,
|
||||
avatarMxc,
|
||||
preview,
|
||||
ts: room.getLastActiveTimestamp(),
|
||||
unread: room.getUnreadNotificationCount(NotificationCountType.Total),
|
||||
highlight: room.getUnreadNotificationCount(NotificationCountType.Highlight),
|
||||
encrypted: room.hasEncryptionStateEvent(),
|
||||
isDirect,
|
||||
dmUserId,
|
||||
presence: isDirect && dmUserId ? presenceOf(c, dmUserId) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeInvite(c: MatrixClient, room: Room): InviteSummary {
|
||||
const me = c.getUserId() ?? ''
|
||||
const own = room.getMember(me)
|
||||
const memberEvent = own?.events.member
|
||||
const inviter = memberEvent?.getSender() ?? room.getDMInviter()
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
name: room.name,
|
||||
avatarMxc: room.getMxcAvatarUrl() ?? undefined,
|
||||
inviter,
|
||||
inviterName: inviter ? (room.getMember(inviter)?.name ?? inviter) : undefined,
|
||||
isDirect: Boolean(memberEvent?.getContent().is_direct),
|
||||
ts: memberEvent?.getTs() ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const encryptionState = {
|
||||
type: EventType.RoomEncryption,
|
||||
state_key: '',
|
||||
content: { algorithm: 'm.megolm.v1.aes-sha2' },
|
||||
}
|
||||
|
||||
export const useChat = defineStore('chat', () => {
|
||||
const version = ref(0)
|
||||
const syncState = shallowRef<SyncState | null>(null)
|
||||
const ready = ref(false)
|
||||
let scheduled = false
|
||||
let attached: MatrixClient | null = null
|
||||
|
||||
function bump() {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
const fire = () => {
|
||||
if (!scheduled) return
|
||||
scheduled = false
|
||||
window.clearTimeout(timer)
|
||||
version.value++
|
||||
}
|
||||
// A background tab gets no animation frames: the timer keeps the
|
||||
// unread count in the tab title (and decryption) moving there.
|
||||
requestAnimationFrame(fire)
|
||||
const timer = window.setTimeout(fire, 250)
|
||||
}
|
||||
|
||||
function onSync(state: SyncState) {
|
||||
syncState.value = state
|
||||
if (state === SyncState.Prepared || state === SyncState.Syncing) ready.value = true
|
||||
bump()
|
||||
}
|
||||
|
||||
/** Starts listening to the running client. */
|
||||
function attach() {
|
||||
const c = client()
|
||||
if (attached === c) return
|
||||
attached = c
|
||||
ready.value = c.isInitialSyncComplete()
|
||||
syncState.value = c.getSyncState()
|
||||
c.on(ClientEvent.Sync, onSync)
|
||||
for (const e of [
|
||||
ClientEvent.Room,
|
||||
ClientEvent.DeleteRoom,
|
||||
ClientEvent.AccountData,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
RoomEvent.Name,
|
||||
RoomEvent.MyMembership,
|
||||
RoomEvent.Receipt,
|
||||
RoomEvent.Redaction,
|
||||
RoomEvent.LocalEchoUpdated,
|
||||
RoomEvent.UnreadNotifications,
|
||||
RoomStateEvent.Events,
|
||||
RoomMemberEvent.Typing,
|
||||
RoomMemberEvent.Name,
|
||||
UserEvent.Presence,
|
||||
// An encrypted event changes in place once it is decrypted.
|
||||
MatrixEventEvent.Decrypted,
|
||||
] as const) {
|
||||
c.on(e as ClientEvent.Room, bump)
|
||||
}
|
||||
bump()
|
||||
}
|
||||
|
||||
function detach() {
|
||||
attached = null
|
||||
ready.value = false
|
||||
syncState.value = null
|
||||
}
|
||||
|
||||
const rooms = computed<RoomSummary[]>(() => {
|
||||
void version.value
|
||||
if (!attached) return []
|
||||
const c = attached
|
||||
const direct = directRooms(c)
|
||||
return c
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === KnownMembership.Join && !isReplaced(r))
|
||||
.map((r) => summarize(c, r, direct))
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
})
|
||||
|
||||
const invites = computed<InviteSummary[]>(() => {
|
||||
void version.value
|
||||
if (!attached) return []
|
||||
const c = attached
|
||||
return c
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === KnownMembership.Invite)
|
||||
.map((r) => summarizeInvite(c, r))
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
})
|
||||
|
||||
/** A room that was upgraded to a newer one the person is in. */
|
||||
function isReplaced(room: Room): boolean {
|
||||
const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, '')
|
||||
const next = tombstone?.getContent().replacement_room
|
||||
return Boolean(next && attached?.getRoom(next)?.getMyMembership() === KnownMembership.Join)
|
||||
}
|
||||
|
||||
const totalUnread = computed(() => rooms.value.reduce((n, r) => n + (r.unread > 0 ? 1 : 0), 0) + invites.value.length)
|
||||
|
||||
// Offline once sync has been failing for a few seconds: one failed
|
||||
// request that the next retry recovers from is not worth a banner.
|
||||
const offline = ref(false)
|
||||
let offlineTimer = 0
|
||||
watch(syncState, (state) => {
|
||||
const failing = state === SyncState.Error || state === SyncState.Reconnecting
|
||||
window.clearTimeout(offlineTimer)
|
||||
if (!failing) offline.value = false
|
||||
else if (!offline.value) offlineTimer = window.setTimeout(() => (offline.value = true), 5000)
|
||||
})
|
||||
|
||||
async function createRoom(opts: {
|
||||
name: string
|
||||
topic?: string
|
||||
isPublic: boolean
|
||||
alias?: string
|
||||
encrypted: boolean
|
||||
}): Promise<string> {
|
||||
const res = await client().createRoom({
|
||||
name: opts.name.trim(),
|
||||
topic: opts.topic?.trim() || undefined,
|
||||
preset: opts.isPublic ? Preset.PublicChat : Preset.PrivateChat,
|
||||
visibility: opts.isPublic ? Visibility.Public : Visibility.Private,
|
||||
room_alias_name: opts.isPublic && opts.alias ? opts.alias.trim() : undefined,
|
||||
initial_state: opts.encrypted ? [encryptionState] : undefined,
|
||||
})
|
||||
return res.room_id
|
||||
}
|
||||
|
||||
/** Whether this session can encrypt a new room: the crypto stack is running. */
|
||||
const canEncrypt = computed(() => {
|
||||
void version.value
|
||||
return Boolean(attached?.getCrypto())
|
||||
})
|
||||
|
||||
/** Opens the existing direct message with the person, or starts one. */
|
||||
async function startDirect(userId: string): Promise<string> {
|
||||
const c = client()
|
||||
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
|
||||
for (const roomId of content[userId] ?? []) {
|
||||
const room = c.getRoom(roomId)
|
||||
if (room && room.getMyMembership() === KnownMembership.Join) return roomId
|
||||
}
|
||||
// Fails early with a clear error when the person doesn't exist.
|
||||
await c.getProfileInfo(userId)
|
||||
// End-to-end encrypted when the other person has a session that can
|
||||
// read it: encrypting to nobody would only lock them out.
|
||||
let encrypt = false
|
||||
const crypto = c.getCrypto()
|
||||
if (crypto) {
|
||||
const devices = await crypto.getUserDeviceInfo([userId], true).catch(() => null)
|
||||
encrypt = (devices?.get(userId)?.size ?? 0) > 0
|
||||
}
|
||||
const res = await c.createRoom({
|
||||
is_direct: true,
|
||||
invite: [userId],
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
initial_state: encrypt ? [encryptionState] : undefined,
|
||||
})
|
||||
const next = { ...content, [userId]: [...(content[userId] ?? []), res.room_id] }
|
||||
await c.setAccountData(EventType.Direct, next)
|
||||
return res.room_id
|
||||
}
|
||||
|
||||
async function join(idOrAlias: string): Promise<string> {
|
||||
const room = await client().joinRoom(idOrAlias.trim())
|
||||
return room.roomId
|
||||
}
|
||||
|
||||
async function acceptInvite(roomId: string) {
|
||||
const c = client()
|
||||
const room = c.getRoom(roomId)
|
||||
const own = room?.getMember(c.getUserId() ?? '')
|
||||
const memberEvent = own?.events.member
|
||||
await c.joinRoom(roomId)
|
||||
// An accepted direct invite goes into m.direct, so it shows as one.
|
||||
if (memberEvent?.getContent().is_direct) {
|
||||
const inviter = memberEvent.getSender()
|
||||
if (inviter) {
|
||||
const content = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record<string, string[]>
|
||||
if (!(content[inviter] ?? []).includes(roomId)) {
|
||||
await c.setAccountData(EventType.Direct, { ...content, [inviter]: [...(content[inviter] ?? []), roomId] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function leave(roomId: string) {
|
||||
const c = client()
|
||||
await c.leave(roomId)
|
||||
await c.forget(roomId, true).catch(() => undefined)
|
||||
bump()
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
ready,
|
||||
syncState,
|
||||
offline,
|
||||
rooms,
|
||||
invites,
|
||||
totalUnread,
|
||||
canEncrypt,
|
||||
attach,
|
||||
detach,
|
||||
bump,
|
||||
createRoom,
|
||||
startDirect,
|
||||
join,
|
||||
acceptInvite,
|
||||
leave,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { client } from '@/matrix/client'
|
||||
|
||||
// Your own display name and avatar, as the homeserver has them.
|
||||
|
||||
export const useProfile = defineStore('profile', () => {
|
||||
const displayName = ref('')
|
||||
const avatarMxc = ref<string>()
|
||||
|
||||
async function load() {
|
||||
const c = client()
|
||||
try {
|
||||
const p = await c.getProfileInfo(c.getUserId() ?? '')
|
||||
displayName.value = p.displayname ?? ''
|
||||
avatarMxc.value = p.avatar_url ?? undefined
|
||||
} catch {
|
||||
// No profile yet; the localpart stands in.
|
||||
}
|
||||
}
|
||||
|
||||
async function setDisplayName(name: string) {
|
||||
await client().setDisplayName(name.trim())
|
||||
displayName.value = name.trim()
|
||||
}
|
||||
|
||||
async function setAvatar(file: File) {
|
||||
const c = client()
|
||||
const res = await c.uploadContent(file, { name: file.name, type: file.type })
|
||||
await c.setAvatarUrl(res.content_uri)
|
||||
avatarMxc.value = res.content_uri
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
await client().setAvatarUrl('')
|
||||
avatarMxc.value = undefined
|
||||
}
|
||||
|
||||
return { displayName, avatarMxc, load, setDisplayName, setAvatar, removeAvatar }
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { MatrixClient } from 'matrix-js-sdk'
|
||||
import {
|
||||
CryptoEvent,
|
||||
VerificationPhase,
|
||||
VerificationRequestEvent,
|
||||
type VerificationRequest,
|
||||
} from 'matrix-js-sdk/lib/crypto-api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import { client } from '@/matrix/client'
|
||||
import {
|
||||
enableBackupIfPossible,
|
||||
encryptionStatus,
|
||||
verifyDevice,
|
||||
verifyOwnSession,
|
||||
type EncryptionStatus,
|
||||
} from '@/matrix/crypto'
|
||||
|
||||
// End-to-end encryption as the views see it: whether this session is
|
||||
// verified, the key backup, and the one verification on screen at a time,
|
||||
// whether this session asked for it or somebody else did.
|
||||
|
||||
export const useSecurity = defineStore('security', () => {
|
||||
/** Null until known, and while the crypto stack isn't running. */
|
||||
const status = shallowRef<EncryptionStatus | null>(null)
|
||||
/** The verification the dialog shows. */
|
||||
const request = shallowRef<VerificationRequest | null>(null)
|
||||
/** Bumps when the request or anybody's trust changes. */
|
||||
const version = ref(0)
|
||||
let attached: MatrixClient | null = null
|
||||
let refreshing: Promise<void> | null = null
|
||||
let again = false
|
||||
|
||||
async function refresh() {
|
||||
if (!attached?.getCrypto()) return
|
||||
// One refresh at a time; changes during one ask for another after it.
|
||||
if (refreshing) {
|
||||
again = true
|
||||
return refreshing
|
||||
}
|
||||
refreshing = (async () => {
|
||||
do {
|
||||
again = false
|
||||
status.value = await encryptionStatus().catch(() => status.value)
|
||||
} while (again)
|
||||
})().finally(() => (refreshing = null))
|
||||
return refreshing
|
||||
}
|
||||
|
||||
function changed() {
|
||||
version.value++
|
||||
void refresh()
|
||||
}
|
||||
|
||||
function onRequest(req: VerificationRequest) {
|
||||
// One verification at a time: a second one while one is running is
|
||||
// turned down rather than stacked.
|
||||
const busy = request.value && request.value.phase !== VerificationPhase.Done && request.value.phase !== VerificationPhase.Cancelled
|
||||
if (busy) {
|
||||
void req.cancel({ reason: 'Already verifying another session', code: 'm.user' }).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
show(req)
|
||||
}
|
||||
|
||||
function onRequestChange() {
|
||||
version.value++
|
||||
const req = request.value
|
||||
if (req?.phase === VerificationPhase.Done) {
|
||||
// Verified by our own other session: it sends the backup key next.
|
||||
if (req.isSelfVerification) void enableBackupIfPossible().then(changed)
|
||||
else changed()
|
||||
}
|
||||
}
|
||||
|
||||
function show(req: VerificationRequest) {
|
||||
request.value?.off(VerificationRequestEvent.Change, onRequestChange)
|
||||
request.value = req
|
||||
req.on(VerificationRequestEvent.Change, onRequestChange)
|
||||
version.value++
|
||||
}
|
||||
|
||||
/** Closes the dialog, cancelling the verification if it is still going. */
|
||||
function dismiss() {
|
||||
const req = request.value
|
||||
if (!req) return
|
||||
req.off(VerificationRequestEvent.Change, onRequestChange)
|
||||
if (req.phase !== VerificationPhase.Done && req.phase !== VerificationPhase.Cancelled) {
|
||||
void req.cancel().catch(() => undefined)
|
||||
}
|
||||
request.value = null
|
||||
}
|
||||
|
||||
/** Starts listening to the running client. */
|
||||
function attach() {
|
||||
const c = client()
|
||||
if (attached === c) return
|
||||
attached = c
|
||||
status.value = null
|
||||
request.value = null
|
||||
if (!c.getCrypto()) return
|
||||
c.on(CryptoEvent.VerificationRequestReceived, onRequest)
|
||||
for (const e of [
|
||||
CryptoEvent.KeyBackupStatus,
|
||||
CryptoEvent.KeyBackupDecryptionKeyCached,
|
||||
CryptoEvent.KeysChanged,
|
||||
CryptoEvent.UserTrustStatusChanged,
|
||||
CryptoEvent.DevicesUpdated,
|
||||
] as const) {
|
||||
c.on(e as CryptoEvent.KeysChanged, changed)
|
||||
}
|
||||
void refresh()
|
||||
}
|
||||
|
||||
async function verifyThisSession() {
|
||||
show(await verifyOwnSession())
|
||||
}
|
||||
|
||||
async function verifySession(userId: string, deviceId: string) {
|
||||
show(await verifyDevice(userId, deviceId))
|
||||
}
|
||||
|
||||
return { status, request, version, attach, refresh, dismiss, verifyThisSession, verifySession }
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { HttpApiEvent, MatrixError, type AuthDict, type LoginResponse, type RegisterResponse } from 'matrix-js-sdk'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { config } from '@/config'
|
||||
import { anonymousClient, client, destroyClient, startClient, stopClient, type Credentials } from '@/matrix/client'
|
||||
import { clearMediaCache } from '@/matrix/media'
|
||||
|
||||
// The person's Matrix session. It is kept in localStorage while "Stay
|
||||
// signed in" is on (the default for a chat client), otherwise in
|
||||
// sessionStorage, which goes with the tab. Signing out logs the session out
|
||||
// on the homeserver and forgets it here, its encryption keys included.
|
||||
|
||||
const storageKey = 'warp.web.session'
|
||||
const deviceName = 'Warp Web'
|
||||
|
||||
function load(): Credentials | null {
|
||||
for (const store of [localStorage, sessionStorage]) {
|
||||
try {
|
||||
const raw = store.getItem(storageKey)
|
||||
if (raw) return JSON.parse(raw) as Credentials
|
||||
} catch {
|
||||
// Unreadable or blocked: treat as signed out.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function save(c: Credentials | null, remember: boolean) {
|
||||
for (const store of [localStorage, sessionStorage]) {
|
||||
try {
|
||||
store.removeItem(storageKey)
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
if (!c) return
|
||||
try {
|
||||
;(remember ? localStorage : sessionStorage).setItem(storageKey, JSON.stringify(c))
|
||||
} catch {
|
||||
// Not kept across reloads; the session still works in this page.
|
||||
}
|
||||
}
|
||||
|
||||
/** What registration on this homeserver asks for. */
|
||||
export interface RegistrationInfo {
|
||||
enabled: boolean
|
||||
/** Every flow on offer needs a registration token. */
|
||||
tokenRequired: boolean
|
||||
/** Some flow takes a token, so offering the field makes sense. */
|
||||
tokenAccepted: boolean
|
||||
/** The server's words when it's off. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface UiaFlow {
|
||||
stages: string[]
|
||||
}
|
||||
|
||||
interface UiaState {
|
||||
session?: string
|
||||
flows?: UiaFlow[]
|
||||
completed?: string[]
|
||||
}
|
||||
|
||||
function uiaState(e: unknown): UiaState | null {
|
||||
if (e instanceof MatrixError && e.httpStatus === 401 && Array.isArray(e.data?.flows)) {
|
||||
return e.data as UiaState
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const supportedStages = new Set(['m.login.dummy', 'm.login.registration_token'])
|
||||
|
||||
export const useSession = defineStore('session', () => {
|
||||
const saved = load()
|
||||
const userId = ref(saved?.userId ?? '')
|
||||
const deviceId = ref(saved?.deviceId ?? '')
|
||||
const phase = ref<'signedOut' | 'starting' | 'ready'>(saved ? 'starting' : 'signedOut')
|
||||
/** Set when the session ended on its own, shown on the sign-in page. */
|
||||
const endedReason = ref('')
|
||||
|
||||
const signedIn = computed(() => phase.value !== 'signedOut')
|
||||
const serverName = computed(() => userId.value.slice(userId.value.indexOf(':') + 1))
|
||||
|
||||
function clear(reason = ''): Promise<void> {
|
||||
// The session is gone for good: its keys in IndexedDB go with it.
|
||||
const done = userId.value && deviceId.value ? destroyClient({ userId: userId.value, deviceId: deviceId.value }) : Promise.resolve(stopClient())
|
||||
clearMediaCache()
|
||||
userId.value = ''
|
||||
deviceId.value = ''
|
||||
phase.value = 'signedOut'
|
||||
endedReason.value = reason
|
||||
save(null, false)
|
||||
return done
|
||||
}
|
||||
|
||||
async function begin(creds: Credentials) {
|
||||
userId.value = creds.userId
|
||||
deviceId.value = creds.deviceId
|
||||
phase.value = 'starting'
|
||||
const c = await startClient(creds)
|
||||
c.on(HttpApiEvent.SessionLoggedOut, () => clear('Your session ended. Sign in again to continue.'))
|
||||
phase.value = 'ready'
|
||||
}
|
||||
|
||||
/** Picks the saved session back up on page load. */
|
||||
async function restore() {
|
||||
if (!saved || phase.value === 'ready') return
|
||||
try {
|
||||
await begin(saved)
|
||||
} catch (e) {
|
||||
if (e instanceof MatrixError && e.errcode === 'M_UNKNOWN_TOKEN') {
|
||||
clear('Your session ended. Sign in again to continue.')
|
||||
return
|
||||
}
|
||||
// Anything else (the homeserver unreachable, say) keeps the session
|
||||
// and its encryption keys: the shell offers to try again.
|
||||
stopClient()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function adopt(res: LoginResponse | RegisterResponse, keep: boolean) {
|
||||
if (!res.access_token || !res.device_id) throw new Error('The homeserver didn’t start a session.')
|
||||
const creds: Credentials = {
|
||||
baseUrl: config.homeserverUrl,
|
||||
userId: res.user_id,
|
||||
deviceId: res.device_id,
|
||||
accessToken: res.access_token,
|
||||
}
|
||||
save(creds, keep)
|
||||
await begin(creds)
|
||||
}
|
||||
|
||||
async function signIn(username: string, password: string, keep: boolean) {
|
||||
endedReason.value = ''
|
||||
const res = await anonymousClient().loginRequest({
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: username.trim() },
|
||||
password,
|
||||
initial_device_display_name: deviceName,
|
||||
})
|
||||
await adopt(res, keep)
|
||||
}
|
||||
|
||||
async function registrationInfo(): Promise<RegistrationInfo> {
|
||||
try {
|
||||
await anonymousClient().registerRequest({})
|
||||
// A server that registers without any flow at all.
|
||||
return { enabled: true, tokenRequired: false, tokenAccepted: false }
|
||||
} catch (e) {
|
||||
const uia = uiaState(e)
|
||||
if (uia) {
|
||||
const flows = (uia.flows ?? []).filter((f) => f.stages.every((s) => supportedStages.has(s)))
|
||||
if (!flows.length) {
|
||||
return {
|
||||
enabled: false,
|
||||
tokenRequired: false,
|
||||
tokenAccepted: false,
|
||||
reason: 'This homeserver asks for a sign-up step this client doesn’t support yet.',
|
||||
}
|
||||
}
|
||||
const withToken = flows.filter((f) => f.stages.includes('m.login.registration_token'))
|
||||
return { enabled: true, tokenRequired: withToken.length === flows.length, tokenAccepted: withToken.length > 0 }
|
||||
}
|
||||
if (e instanceof MatrixError && e.httpStatus === 403) {
|
||||
return { enabled: false, tokenRequired: false, tokenAccepted: false, reason: e.data?.error }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers with the user-interactive auth flow: dummy and token stages. */
|
||||
async function register(username: string, password: string, token: string, keep: boolean) {
|
||||
endedReason.value = ''
|
||||
const c = anonymousClient()
|
||||
const body = {
|
||||
username: username.trim(),
|
||||
password,
|
||||
initial_device_display_name: deviceName,
|
||||
}
|
||||
let auth: AuthDict | undefined
|
||||
for (let step = 0; step < 6; step++) {
|
||||
try {
|
||||
const res = await c.registerRequest({ ...body, auth })
|
||||
await adopt(res, keep)
|
||||
return
|
||||
} catch (e) {
|
||||
const uia = uiaState(e)
|
||||
if (!uia) throw e
|
||||
// A stage that failed comes back with its error alongside the flows.
|
||||
if (auth && e instanceof MatrixError && e.data?.errcode) {
|
||||
throw new Error(e.data.error ?? 'The homeserver refused that sign-up step.')
|
||||
}
|
||||
const done = new Set(uia.completed ?? [])
|
||||
const flows = (uia.flows ?? []).filter((f) => f.stages.every((s) => supportedStages.has(s)))
|
||||
const flow =
|
||||
flows.find((f) => (token ? f.stages.includes('m.login.registration_token') : !f.stages.includes('m.login.registration_token'))) ??
|
||||
flows[0]
|
||||
if (!flow) throw new Error('This homeserver asks for a sign-up step this client doesn’t support yet.')
|
||||
const next = flow.stages.find((s) => !done.has(s))
|
||||
if (!next) throw e
|
||||
if (next === 'm.login.registration_token') {
|
||||
if (!token) throw new Error('This homeserver needs a registration token to sign up.')
|
||||
auth = { type: next, token, session: uia.session }
|
||||
} else {
|
||||
auth = { type: next, session: uia.session }
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Registration didn’t complete. Try again.')
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await client().logout(true)
|
||||
} catch {
|
||||
// The token may already be gone; forget it either way.
|
||||
}
|
||||
await clear()
|
||||
// A fresh page drops the crypto stack's memory and the store it still
|
||||
// holds open, so the store is deleted now rather than on the next visit.
|
||||
window.location.replace('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
deviceId,
|
||||
serverName,
|
||||
phase,
|
||||
signedIn,
|
||||
endedReason,
|
||||
restore,
|
||||
signIn,
|
||||
register,
|
||||
registrationInfo,
|
||||
signOut,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Short confirmations after an action ("Password reset for @alice:..."),
|
||||
// and errors that have no form to sit next to.
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
tone: 'success' | 'danger' | 'info'
|
||||
text: string
|
||||
}
|
||||
|
||||
export const useToasts = defineStore('toasts', () => {
|
||||
const items = ref<Toast[]>([])
|
||||
let next = 1
|
||||
|
||||
function push(tone: Toast['tone'], text: string, ms = 5000) {
|
||||
const id = next++
|
||||
items.value.push({ id, tone, text })
|
||||
window.setTimeout(() => dismiss(id), ms)
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
items.value = items.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
success: (text: string) => push('success', text),
|
||||
error: (text: string) => push('danger', text, 8000),
|
||||
info: (text: string) => push('info', text),
|
||||
dismiss,
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user