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

+373
View File
@@ -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,
}
})
+40
View File
@@ -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 }
})
+124
View File
@@ -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 }
})
+240
View File
@@ -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,
}
})
+34
View File
@@ -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,
}
})