Files
Warp/apps/web/src/components/RoomComposer.vue
T
tajniak81andClaude Opus 5.5 829b236efe
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 / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
Desktop: notifications, tray, auto-update, installer; web: sliding sync, voice messages, notifications
Web client (also what the desktop app runs):

- Simplified Sliding Sync (MSC4186) when the homeserver advertises
  org.matrix.simplified_msc3575. src/matrix/sliding.ts answers the SDK's
  GET /sync with a sliding sync request and rewrites the response into
  the /sync shape, as the Android app does; the SDK's own sliding sync
  mode doesn't sort thread replies into threads. 20 rooms first, then
  every room (up to 1000); to_device, e2ee, account data, receipts and
  typing extensions; M_UNKNOWN_POS starts a new connection. The SDK's
  /members?at=<sync token> drops the token, which here is ours. Settings
  can switch back to /sync.
- Notifications for new messages and incoming calls while Warp isn't in
  front, by the account's push rules, evaluated after decryption. The
  browser's Notification API once turned on in Settings, or the desktop
  app's bridge (window.warpDesktop). One per room; a click opens it.
- Voice messages: record from the composer, sent as m.audio with
  MSC3245's voice marker and MSC1767's duration and waveform (encrypted
  in encrypted rooms); a waveform player in the timeline.
- @warp/ui: a slot for extra composer buttons; bell-off, play and pause
  icons.

Desktop app:

- System notifications through Wails' notifications service, fed by
  /_desktop/bridge.js (/_desktop/notify, JSON only); clicking one shows
  the window and opens the room.
- Tray icon with Open and Quit; closing the window hides it (a one-time
  notification says so), File > Quit ends it. The page title goes to
  the window and the tray tooltip, the unread count to the taskbar or
  Dock badge.
- Auto-update from a Wails update manifest at DESKTOP_UPDATE_URL: a quiet
  check a minute after start and every six hours, a notification when a
  newer version exists, Help > Check for updates opens the update window.
  Optional signature key (DESKTOP_UPDATE_KEY). Linux package installs
  leave updates to the package manager. build/manifest writes the
  manifest.
- Per-user NSIS installer (make desktop-windows-installer), the macOS
  bundle zipped for the updater, a desktop CI job, and a Desktop release
  workflow that builds all three platforms and the manifest.

Tested on Windows against a local homeserver: sliding sync with E2EE,
threads and a server restart; notifications (toast history), close to
tray, relaunch restoring the window; voice messages recorded, sent and
played, encrypted too; the update check finding a newer version. Not
tested: installing an update, the NSIS installer, CI, macOS and Linux.
Code signing needs certificates and isn't done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:01:02 +02:00

230 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { Room } from 'matrix-js-sdk'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { WBanner, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
import { drafts } from '@/lib/drafts'
import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import { sendFile, sendText, sendVoice } from '@/matrix/send'
import { canRecord, formatDuration, VoiceRecorder } from '@/matrix/voice'
import type { MessageItem } from '@/matrix/timeline'
import { useChat } from '@/stores/chat'
import { useToasts } from '@/stores/toasts'
// Writing to a room, or to one of its threads: the text field with its
// draft, typing notifications, uploads, and the message being replied to
// above it. The room view and the thread pane each have one.
const props = defineProps<{ room: Room; threadId?: string; placeholder: string }>()
const replyTo = defineModel<MessageItem | null>('replyTo', { default: null })
const chat = useChat()
const toasts = useToasts()
const me = client().getUserId() ?? ''
const composer = ref<InstanceType<typeof WComposer>>()
const encrypted = computed(() => {
void chat.version
return props.room.hasEncryptionStateEvent()
})
// An encrypted room can't be written to without the crypto stack.
const cryptoMissing = computed(() => encrypted.value && !chat.canEncrypt)
const canSend = computed(() => {
void chat.version
return props.room.currentState.maySendMessage(me)
})
/** Whether files dropped on the room can be sent. */
const canWrite = computed(() => canSend.value && !cryptoMissing.value)
const draftKey = computed(() => (props.threadId ? `${props.room.roomId}/${props.threadId}` : props.room.roomId))
const text = ref(drafts.get(draftKey.value) ?? '')
watch(text, (v) => (v ? drafts.set(draftKey.value, v) : drafts.delete(draftKey.value)))
// Typing notifications: on while there is text, renewed every few seconds,
// off when it's sent or cleared. Matrix has them per room, threads or not.
let typingUntil = 0
function setTyping(on: boolean) {
const now = Date.now()
if (on && now < typingUntil) return
if (!on && typingUntil === 0) return
typingUntil = on ? now + 4000 : 0
client()
.sendTyping(props.room.roomId, on, on ? 6000 : 0)
.catch(() => undefined)
}
watch(text, (v) => setTyping(Boolean(v.trim())))
onBeforeUnmount(() => setTyping(false))
// Choosing a message to reply to puts the cursor in the field.
watch(replyTo, (v) => v && nextTick(() => composer.value?.focus()))
function target() {
return { threadId: props.threadId, replyTo: replyTo.value?.eventId }
}
function send(body: string) {
setTyping(false)
const to = target()
replyTo.value = null
sendText(client(), props.room.roomId, body, to).catch((e) => {
// A failed send stays in the timeline with Retry; say why once.
toasts.error(errorMessage(e))
})
}
// Uploads.
const uploads = ref<{ id: number; name: string; loaded: number; total: number }[]>([])
let uploadId = 0
let maxUpload: number | null | undefined
async function sendFiles(list: FileList | File[]) {
// Copied first: the composer clears its file input (and so the live
// FileList) as soon as it has handed the files over.
const files = Array.from(list)
if (!canWrite.value) return
if (maxUpload === undefined) {
maxUpload = await client()
.getMediaConfig(true)
.then((c) => c['m.upload.size'] ?? null)
.catch(() => null)
}
const to = target()
replyTo.value = null
for (const file of files) {
if (maxUpload && file.size > maxUpload) {
toasts.error(`${file.name} is ${formatBytes(file.size)}; this homeserver takes files up to ${formatBytes(maxUpload)}.`)
continue
}
const id = ++uploadId
uploads.value.push({ id, name: file.name, loaded: 0, total: file.size })
sendFile(
client(),
props.room,
file,
(p) => {
const u = uploads.value.find((x) => x.id === id)
if (u) Object.assign(u, { loaded: p.loaded, total: p.total })
},
to,
)
.catch((e) => toasts.error(`Can’t send ${file.name}: ${errorMessage(e)}`))
.finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
}
}
// Voice messages: the record button replaces typing until it is sent or
// thrown away.
const recordable = canRecord()
const recording = ref(false)
const elapsed = ref(0)
const levels = ref<number[]>([])
let recorder: VoiceRecorder | null = null
async function startRecording() {
if (recording.value || !canWrite.value) return
const rec = new VoiceRecorder()
rec.onTick = (ms, level) => {
elapsed.value = ms
levels.value = [...levels.value.slice(-47), level]
}
rec.onLimit = () => void stopRecording(true)
try {
await rec.start()
} catch (e) {
rec.cancel()
toasts.error(e instanceof DOMException && e.name === 'NotAllowedError' ? 'Allow the microphone to record a voice message.' : `Can’t record: ${errorMessage(e)}`)
return
}
recorder = rec
elapsed.value = 0
levels.value = []
recording.value = true
}
async function stopRecording(send: boolean) {
const rec = recorder
recorder = null
recording.value = false
if (!rec) return
if (!send) {
rec.cancel()
return
}
try {
const voice = await rec.stop()
if (voice.durationMs < 700) return
const to = target()
replyTo.value = null
const id = ++uploadId
uploads.value.push({ id, name: 'voice message', loaded: 0, total: 0 })
await sendVoice(client(), props.room, voice, to).finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
} catch (e) {
toasts.error(`Can’t send the voice message: ${errorMessage(e)}`)
}
}
onBeforeUnmount(() => recorder?.cancel())
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
e.preventDefault()
sendFiles(files)
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && recording.value) {
e.stopPropagation()
void stopRecording(false)
return
}
if (e.key === 'Escape' && replyTo.value) {
e.stopPropagation()
replyTo.value = null
}
}
defineExpose({ sendFiles, canWrite, focus: () => composer.value?.focus() })
</script>
<template>
<div class="flex flex-col gap-2">
<div v-for="u in uploads" :key="u.id" class="flex items-center gap-3 rounded-md bg-surface-raised px-3 py-2">
<WSpinner :size="16" />
<span class="small min-w-0 flex-1 truncate">Uploading {{ u.name }}</span>
<span class="caption text-ink-muted tabular-nums">{{ u.total ? Math.round((u.loaded / u.total) * 100) : 0 }}%</span>
</div>
<WBanner v-if="cryptoMissing" tone="warning" title="End-to-end encryption didn’t start">
This browser can’t read or send messages in this room. Reload the page to try again{{ cryptoError() ? ` (${cryptoError()})` : '' }}.
</WBanner>
<WBanner v-else-if="!canSend" tone="info">You don’t have permission to post in this room.</WBanner>
<div v-else class="flex flex-col gap-1" @paste="onPaste" @keydown="onKey">
<div v-if="replyTo" class="flex items-center gap-2 rounded-md border-l-2 border-teal-strong bg-surface-raised py-1 pr-1 pl-3">
<WIcon name="reply" :size="16" class="shrink-0 text-ink-muted" />
<p class="small m-0 min-w-0 flex-1 truncate">
Replying to <span class="font-semibold">{{ replyTo.own ? 'yourself' : replyTo.senderName }}</span>
<span class="text-ink-muted"> · {{ replyTo.media ? replyTo.media.filename : replyTo.body }}</span>
</p>
<WIconButton icon="close" label="Cancel reply" @click="replyTo = null" />
</div>
<div v-if="recording" class="wp-composer items-center" role="group" aria-label="Recording a voice message">
<WIconButton icon="trash" label="Discard the voice message" @click="stopRecording(false)" />
<span class="size-2.5 shrink-0 animate-pulse rounded-full bg-danger" aria-hidden="true" />
<span class="body tabular-nums">{{ formatDuration(elapsed) }}</span>
<div class="flex h-6 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden" aria-hidden="true">
<span v-for="(l, i) in levels" :key="i" class="w-[3px] shrink-0 rounded-full bg-ink-muted" :style="{ height: `${Math.max(12, Math.round(l * 100))}%` }" />
</div>
<button type="button" class="wp-composer-send" aria-label="Send the voice message" @click="stopRecording(true)">
<WIcon name="send" :size="20" />
</button>
</div>
<WComposer v-else ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles">
<template v-if="recordable && !text.trim()" #actions>
<WIconButton icon="mic" label="Record a voice message" @click="startRecording" />
</template>
</WComposer>
</div>
</div>
</template>