Voice and video calls: TURN endpoint and web client calls

Server: GET /voip/turnServer, configured under client_api.turn. A shared
secret (coturn use-auth-secret) gives each user expiring TURN REST API
credentials; static credentials are the fallback; with nothing configured
it answers {} so clients still try a direct connection.

Web client: 1:1 calls (Matrix VoIP via matrix-js-sdk) in rooms with one
other person. Voice and video buttons in the room header, a ringing card
with answer, answer without video and decline, and a call panel that
stays up across rooms: mute, camera, screen sharing, expand, hang up,
ring tones. Call events show in the timeline and room list. Works around
the SDK keeping a failed microphone request forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 18:04:02 +02:00
1 parent 69ce927b6d
commit 325431a5be
25 files changed
+930 -2

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import type { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed'
import { computed, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { WIcon } from '@warp/ui'
import type { IconName } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import { formatDuration } from '@/lib/format'
import { useCall } from '@/stores/call'
// The call on screen: a ringing card for an incoming call, else a panel in
// the corner that a video call can expand to fill the window. It sits above
// every view, so a call goes on while you move between rooms.
const call = useCall()
const router = useRouter()
const expanded = ref(false)
function feedVideo(feed: CallFeed | undefined): MediaStream | undefined {
if (!feed || feed.isVideoMuted() || !feed.stream.getVideoTracks().length) return undefined
return feed.stream
}
const view = computed(() => {
void call.version
const c = call.call
if (!c) return null
const remoteCamera = feedVideo(c.remoteUsermediaFeed)
const remoteScreen = c.remoteScreensharingStream
const localCamera = feedVideo(c.localUsermediaFeed)
const localScreen = c.localScreensharingStream
// Their screen beats their camera on the big stage; whatever is left of
// theirs or yours goes in the small picture.
const main = remoteScreen ?? remoteCamera
const pip = remoteScreen && remoteCamera ? remoteCamera : (localScreen ?? localCamera)
return {
main,
pip,
pipMirrored: pip !== undefined && pip === localCamera,
remoteAudio: c.remoteUsermediaStream,
micMuted: c.isMicrophoneMuted(),
cameraOn: Boolean(localCamera),
sharing: c.isScreensharing(),
remoteOnHold: c.isRemoteOnHold(),
localOnHold: c.isLocalOnHold(),
roomId: c.roomId,
}
})
const stage = computed(() => call.phase !== 'ringing' && Boolean(view.value?.main || view.value?.pip || call.isVideo))
const canShare = typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getDisplayMedia)
const status = computed(() => {
const v = view.value
switch (call.phase) {
case 'ringing':
return call.isVideo ? 'Incoming video call' : 'Incoming voice call'
case 'calling':
return 'Calling…'
case 'connecting':
return 'Connecting…'
case 'connected':
if (v?.localOnHold) return 'You’re on hold'
if (v?.remoteOnHold) return 'On hold'
return formatDuration(call.length)
case 'ended':
return call.endReason
default:
return ''
}
})
// Streams go to the media elements by hand: srcObject is not an attribute.
const mainEl = ref<HTMLVideoElement>()
const pipEl = ref<HTMLVideoElement>()
const audioEl = ref<HTMLAudioElement>()
function bind(el: HTMLMediaElement | undefined, stream: MediaStream | undefined) {
if (!el || el.srcObject === (stream ?? null)) return
el.srcObject = stream ?? null
if (stream) void el.play().catch(() => undefined)
}
watchEffect(() => bind(mainEl.value, view.value?.main), { flush: 'post' })
watchEffect(() => bind(pipEl.value, view.value?.pip), { flush: 'post' })
watchEffect(() => bind(audioEl.value, view.value?.remoteAudio), { flush: 'post' })
watch(
() => call.call,
(c) => {
if (!c) expanded.value = false
},
)
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded.value) expanded.value = false
}
window.addEventListener('keydown', onKey)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
function openRoom() {
const roomId = view.value?.roomId
if (!roomId) return
expanded.value = false
router.push({ name: 'room', params: { roomId } })
}
interface Control {
icon: IconName
label: string
action: () => void
on?: boolean
tone?: 'danger' | 'accept'
}
const controls = computed<Control[]>(() => {
const v = view.value
if (!v) return []
if (call.phase === 'ringing') {
const list: Control[] = [{ icon: 'phone-off', label: 'Decline', action: call.decline, tone: 'danger' }]
if (call.isVideo) list.push({ icon: 'phone', label: 'Answer without video', action: () => call.answer(false) })
list.push({ icon: call.isVideo ? 'video' : 'phone', label: 'Answer', action: () => call.answer(), tone: 'accept' })
return list
}
if (call.phase === 'ended') return [{ icon: 'close', label: 'Close', action: call.hangup }]
const list: Control[] = [
{ icon: v.micMuted ? 'mic-off' : 'mic', label: v.micMuted ? 'Unmute' : 'Mute', action: call.toggleMic, on: v.micMuted },
{ icon: v.cameraOn ? 'video' : 'video-off', label: v.cameraOn ? 'Turn camera off' : 'Turn camera on', action: call.toggleCamera, on: !v.cameraOn && call.isVideo },
]
if (canShare && call.phase === 'connected') {
list.push({ icon: 'screen-share', label: v.sharing ? 'Stop sharing' : 'Share screen', action: call.toggleScreenShare, on: v.sharing })
}
list.push({ icon: 'phone-off', label: 'Hang up', action: call.hangup, tone: 'danger' })
return list
})
function controlClass(c: Control): string {
if (c.tone === 'danger') return 'bg-danger text-[#ffffff] hover:brightness-110'
if (c.tone === 'accept') return 'bg-success text-[#ffffff] hover:brightness-110'
if (c.on) return 'bg-[#eceeee] text-[#111418] hover:bg-[#ffffff]'
return 'bg-[#ffffff1f] text-[#eceeee] hover:bg-[#ffffff33]'
}
</script>
<template>
<section
v-if="call.call && view"
:class="[
'fixed z-40 flex flex-col overflow-hidden bg-[#111418] text-[#eceeee] shadow-float',
expanded ? 'inset-0' : 'right-4 bottom-4 left-4 rounded-lg sm:left-auto sm:w-[360px]',
]"
:aria-label="`Call with ${call.peerName}`"
:role="call.phase === 'ringing' ? 'alertdialog' : 'region'"
>
<audio ref="audioEl" autoplay class="hidden" />
<div v-if="stage" :class="['relative bg-[#000000]', expanded ? 'min-h-0 flex-1' : 'aspect-video']">
<video v-show="view.main" ref="mainEl" autoplay playsinline muted class="h-full w-full object-contain" />
<div v-if="!view.main" class="absolute inset-0 flex items-center justify-center">
<MxcAvatar :name="call.peerName" :mxc="call.peerAvatarMxc" :size="expanded ? 120 : 72" />
</div>
<video
v-show="view.pip"
ref="pipEl"
autoplay
playsinline
muted
:class="['absolute right-3 bottom-3 w-1/4 min-w-24 rounded-md bg-[#000000] object-cover shadow-float', view.pipMirrored && '-scale-x-100']"
/>
<button
v-if="call.isVideo || view.main"
type="button"
class="absolute top-2 right-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 bg-[#00000080] text-[#eceeee] hover:bg-[#000000b3]"
:aria-label="expanded ? 'Shrink' : 'Expand'"
:title="expanded ? 'Shrink' : 'Expand'"
@click="expanded = !expanded"
>
<WIcon :name="expanded ? 'minimize' : 'maximize'" :size="18" />
</button>
</div>
<div class="flex items-center gap-3 px-4 pt-4">
<MxcAvatar v-if="!stage" :name="call.peerName" :mxc="call.peerAvatarMxc" :size="48" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left text-[#eceeee]" @click="openRoom">
<span class="body-strong block truncate">{{ call.peerName }}</span>
<span class="small block truncate text-[#eceeeeb3] tabular-nums" aria-live="polite">{{ status }}</span>
</button>
</div>
<div class="flex items-center justify-center gap-3 px-4 pt-3 pb-4">
<button
v-for="c in controls"
:key="c.label"
type="button"
:class="['flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border-0 transition-colors', controlClass(c)]"
:aria-label="c.label"
:aria-pressed="c.on === undefined || c.tone ? undefined : c.on"
:title="c.label"
@click="c.action()"
>
<WIcon :name="c.icon" :size="22" />
</button>
</div>
</section>
</template>
+5
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WBanner, WButton, WIcon, WIconButton, WLogo, WSpinner } from '@warp/ui'
import CallView from '@/components/CallView.vue'
import CreateRoomDialog from '@/components/CreateRoomDialog.vue'
import EncryptionDialog from '@/components/EncryptionDialog.vue'
import JoinRoomDialog from '@/components/JoinRoomDialog.vue'
@@ -13,6 +14,7 @@ import SettingsDialog from '@/components/SettingsDialog.vue'
import StartChatDialog from '@/components/StartChatDialog.vue'
import VerificationDialog from '@/components/VerificationDialog.vue'
import { formatListTime, localpart } from '@/lib/format'
import { useCall } from '@/stores/call'
import { useChat } from '@/stores/chat'
import { useProfile } from '@/stores/profile'
import { useSecurity } from '@/stores/security'
@@ -20,6 +22,7 @@ import { useSession } from '@/stores/session'
const session = useSession()
const chat = useChat()
const call = useCall()
const profile = useProfile()
const security = useSecurity()
const route = useRoute()
@@ -40,6 +43,7 @@ async function start() {
if (session.phase === 'ready') {
chat.attach()
security.attach()
call.attach()
profile.load()
}
} catch (e) {
@@ -204,5 +208,6 @@ const ownName = computed(() => profile.displayName || localpart(session.userId))
<SettingsDialog :open="dialog === 'settings'" @update:open="dialog = $event ? 'settings' : null" />
<EncryptionDialog mode="unlock" :open="dialog === 'verify'" @update:open="dialog = $event ? 'verify' : null" />
<VerificationDialog />
<CallView />
</div>
</template>
+9
View File
@@ -43,6 +43,15 @@ export function sameDay(a: number, b: number): boolean {
return startOfDay(a) === startOfDay(b)
}
/** A call's length: "0:42", "12:05", "1:02:09". */
export function formatDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = String(s % 60).padStart(2, '0')
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
export function formatBytes(n: number | undefined): string {
+38 -1
View File
@@ -101,6 +101,36 @@ function statusOf(ev: MatrixEvent): MessageStatus | undefined {
}
}
/** Whether an m.call.invite offers video. */
export function isVideoCallInvite(ev: MatrixEvent): boolean {
const sdp = ev.getContent().offer?.sdp
return typeof sdp === 'string' && sdp.includes('m=video')
}
/** A one-line description of a call event, or null for the signalling in between. */
function describeCall(ev: MatrixEvent, room: Room): string | null {
const sender = nameOf(room, ev.getSender() ?? '')
switch (ev.getType()) {
case EventType.CallInvite:
return `${sender} started a ${isVideoCallInvite(ev) ? 'video' : 'voice'} call`
case EventType.CallReject:
return `${sender} declined the call`
case EventType.CallHangup:
switch (ev.getContent().reason) {
case 'invite_timeout':
return 'The call wasn’t answered'
case 'user_busy':
return `${sender} was busy`
case 'ice_failed':
return 'The call couldn’t connect'
default:
return `${sender} ended the call`
}
default:
return null
}
}
/** 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() ?? '')
@@ -272,7 +302,14 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
const isMessage = type === EventType.RoomMessage || type === EventType.Sticker || type === EventType.RoomMessageEncrypted
if (!isMessage) {
if (!ev.isState()) continue
if (!ev.isState()) {
const text = describeCall(ev, room)
if (text) {
items.push({ kind: 'notice', key, ts, text })
prev = null
}
continue
}
const text = describeState(ev, room)
if (text) {
items.push({ kind: 'notice', key, ts, text })
+67
View File
@@ -0,0 +1,67 @@
// The ring of an incoming call and the ringback of an outgoing one, made
// with Web Audio rather than shipped as sound files. One tone plays at a
// time. A browser that hasn't seen the page interacted with yet may keep
// the audio context suspended: the call still shows, just silently.
export type Tone = 'ring' | 'ringback'
// Each pattern repeats: [frequencies played together, seconds on, seconds off].
const patterns: Record<Tone, { freqs: number[]; on: number; off: number }[]> = {
// Two short bursts, a pause: a phone ringing.
ring: [
{ freqs: [440, 480], on: 0.4, off: 0.2 },
{ freqs: [440, 480], on: 0.4, off: 2 },
],
// The long tone you hear while the other side rings.
ringback: [{ freqs: [440, 480], on: 1.5, off: 3 }],
}
let ctx: AudioContext | null = null
let playing: Tone | null = null
let timer = 0
let gain: GainNode | null = null
function context(): AudioContext | null {
try {
ctx ??= new AudioContext()
if (ctx.state === 'suspended') void ctx.resume().catch(() => undefined)
return ctx
} catch {
return null
}
}
export function startTone(tone: Tone) {
if (playing === tone) return
stopTone()
const ac = context()
if (!ac) return
playing = tone
gain = ac.createGain()
gain.gain.value = tone === 'ring' ? 0.12 : 0.06
gain.connect(ac.destination)
const steps = patterns[tone]
let i = 0
const step = () => {
if (playing !== tone || !gain) return
const s = steps[i++ % steps.length]!
const start = ac.currentTime
for (const f of s.freqs) {
const osc = ac.createOscillator()
osc.frequency.value = f
osc.connect(gain)
osc.start(start)
osc.stop(start + s.on)
}
timer = window.setTimeout(step, (s.on + s.off) * 1000)
}
step()
}
export function stopTone() {
playing = null
window.clearTimeout(timer)
timer = 0
gain?.disconnect()
gain = null
}
+340
View File
@@ -0,0 +1,340 @@
import { CallEvent, CallFeedEvent, createNewMatrixCall, type MatrixCall, type MatrixClient } from 'matrix-js-sdk'
import { CallDirection, CallErrorCode, CallParty, CallState, CallType, type CallError } from 'matrix-js-sdk/lib/webrtc/call'
import { CallEventHandlerEvent } from 'matrix-js-sdk/lib/webrtc/callEventHandler'
import type { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { errorMessage } from '@/lib/format'
import { client } from '@/matrix/client'
import { startTone, stopTone } from '@/matrix/tones'
import { useToasts } from '@/stores/toasts'
// Voice and video calls between two people: Matrix's 1:1 VoIP (m.call.*
// events in the room, WebRTC between the browsers), which the SDK runs.
// In an encrypted room the call events are encrypted like any other; the
// media itself is always encrypted by WebRTC (DTLS-SRTP).
//
// One call at a time. A call that comes in while one is going is answered
// "busy", as a phone would.
export type CallPhase = 'ringing' | 'calling' | 'connecting' | 'connected' | 'ended'
/** How long the ended call stays on screen, saying why. */
const endedLingerMs = 2500
export const useCall = defineStore('call', () => {
const call = shallowRef<MatrixCall | null>(null)
/** Bumps on every change the call view shows. */
const version = ref(0)
/** Seconds since the call connected. */
const length = ref(0)
/** Why the call ended, once it has. */
const endReason = ref('')
let attached: MatrixClient | null = null
let clearTimer = 0
/** The call got as far as connecting media, at some point. */
let connected = false
/** The feeds whose mute state is listened to. */
let feeds: CallFeed[] = []
const phase = computed<CallPhase | null>(() => {
void version.value
const c = call.value
if (!c) return null
switch (c.state) {
case CallState.Ringing:
return 'ringing'
case CallState.Fledgling:
case CallState.WaitLocalMedia:
case CallState.CreateOffer:
case CallState.InviteSent:
return c.direction === CallDirection.Inbound ? 'connecting' : 'calling'
case CallState.CreateAnswer:
case CallState.Connecting:
return 'connecting'
case CallState.Connected:
return 'connected'
default:
return 'ended'
}
})
const isVideo = computed(() => {
void version.value
return call.value?.type === CallType.Video
})
const peerName = computed(() => {
void version.value
const c = call.value
if (!c) return ''
const member = c.getOpponentMember()
if (member) return member.name
const room = attached?.getRoom(c.roomId)
return room?.name ?? c.invitee ?? ''
})
const peerAvatarMxc = computed(() => {
void version.value
return call.value?.getOpponentMember()?.getMxcAvatarUrl() ?? undefined
})
function bump() {
version.value++
}
// A feed muting its camera or microphone is not a change in the call's
// feeds, so each feed is listened to as well.
function watchFeeds(c: MatrixCall | null) {
for (const f of feeds) {
f.off(CallFeedEvent.MuteStateChanged, bump)
f.off(CallFeedEvent.NewStream, bump)
}
feeds = c ? c.getFeeds() : []
for (const f of feeds) {
f.on(CallFeedEvent.MuteStateChanged, bump)
f.on(CallFeedEvent.NewStream, bump)
}
}
function onFeeds() {
watchFeeds(call.value)
bump()
}
function tones() {
const p = phase.value
if (p === 'ringing') startTone('ring')
else if (p === 'calling') startTone('ringback')
else stopTone()
}
function onState(state: CallState) {
if (state === CallState.Connected) connected = true
bump()
tones()
}
function onLength(seconds: number) {
length.value = seconds
}
function onError(err: CallError) {
// The hangup that follows says the rest.
if (err.code === CallErrorCode.NoUserMedia) {
forgetFailedMedia()
useToasts().error(isVideo.value ? 'Can’t use your camera or microphone. Check that the browser may use them.' : 'Can’t use your microphone. Check that the browser may use it.')
} else {
useToasts().error(`Call failed: ${errorMessage(err)}`)
}
}
function onHangup(c: MatrixCall) {
if (c !== call.value) return
endReason.value = describeHangup(c, connected)
bump()
stopTone()
window.clearTimeout(clearTimer)
clearTimer = window.setTimeout(() => {
if (call.value === c) release()
}, endedLingerMs)
}
function onReplaced(newCall: MatrixCall) {
// Both of you called at once: the SDK keeps one of the two calls.
show(newCall)
}
function listen(c: MatrixCall, on: boolean) {
const m = on ? 'on' : 'off'
c[m](CallEvent.State, onState)
c[m](CallEvent.FeedsChanged, onFeeds)
c[m](CallEvent.LocalHoldUnhold, bump)
c[m](CallEvent.RemoteHoldUnhold, bump)
c[m](CallEvent.LengthChanged, onLength)
c[m](CallEvent.Error, onError)
c[m](CallEvent.Hangup, onHangup)
c[m](CallEvent.Replaced, onReplaced)
}
function show(c: MatrixCall) {
if (call.value) listen(call.value, false)
window.clearTimeout(clearTimer)
call.value = c
connected = false
length.value = 0
endReason.value = ''
listen(c, true)
watchFeeds(c)
bump()
tones()
}
function release() {
if (call.value) listen(call.value, false)
watchFeeds(null)
window.clearTimeout(clearTimer)
call.value = null
stopTone()
bump()
}
function onIncoming(incoming: MatrixCall) {
if (call.value && phase.value !== 'ended') {
incoming.hangup(CallErrorCode.UserBusy, false)
return
}
show(incoming)
// Hung up (or answered elsewhere) before we did anything.
if (incoming.callHasEnded()) onHangup(incoming)
}
/** Starts listening for calls on the running client. */
function attach() {
const c = client()
if (attached === c) return
if (attached) attached.off(CallEventHandlerEvent.Incoming, onIncoming)
release()
attached = c
c.on(CallEventHandlerEvent.Incoming, onIncoming)
}
/** Whether this browser can make calls at all. */
function supported(): boolean {
return Boolean(attached?.supportsVoip())
}
async function place(roomId: string, invitee: string | undefined, video: boolean) {
if (call.value && phase.value !== 'ended') {
useToasts().error('You’re already in a call.')
return
}
const c = createNewMatrixCall(client(), roomId, { invitee })
if (!c) {
useToasts().error('This browser can’t make calls.')
return
}
show(c)
try {
if (video) await c.placeVideoCall()
else await c.placeVoiceCall()
} catch (e) {
// The SDK has hung up already and said why through CallEvent.Error.
console.error('call: ', e)
}
}
/** Answers the ringing call; a video call with the camera off when `video` is false. */
async function answer(video = true) {
const c = call.value
if (!c || phase.value !== 'ringing') return
stopTone()
try {
await c.answer(true, video && c.type === CallType.Video)
} catch (e) {
console.error('call: ', e)
}
}
function decline() {
const c = call.value
if (!c) return
if (phase.value === 'ringing') c.reject()
else hangup()
}
function hangup() {
const c = call.value
if (!c) return
if (phase.value === 'ended') release()
else c.hangup(CallErrorCode.UserHangup, false)
}
async function toggleMic() {
const c = call.value
if (!c) return
await c.setMicrophoneMuted(!c.isMicrophoneMuted())
bump()
}
async function toggleCamera() {
const c = call.value
if (!c) return
await c.setLocalVideoMuted(!c.isLocalVideoMuted())
bump()
}
async function toggleScreenShare() {
const c = call.value
if (!c) return
try {
await c.setScreensharingEnabled(!c.isScreensharing())
} catch (e) {
useToasts().error(`Can’t share your screen: ${errorMessage(e)}`)
}
bump()
}
function toggleHold() {
const c = call.value
if (!c) return
c.setRemoteOnHold(!c.isRemoteOnHold())
bump()
}
return {
call,
version,
length,
endReason,
phase,
isVideo,
peerName,
peerAvatarMxc,
attach,
supported,
place,
answer,
decline,
hangup,
toggleMic,
toggleCamera,
toggleScreenShare,
toggleHold,
}
})
/**
* The SDK's media handler queues every request for the microphone and
* camera behind the previous one, and never lets go of a request that
* failed: once you said no, every later call would fail the same way until
* the page reloads. Dropping the failed request lets the next call ask again.
*/
function forgetFailedMedia() {
const handler = client().getMediaHandler() as unknown as { getMediaStreamPromise?: Promise<MediaStream> }
handler.getMediaStreamPromise = undefined
}
function describeHangup(c: MatrixCall, connected: boolean): string {
switch (c.hangupReason) {
case CallErrorCode.UserBusy:
return 'Busy'
case CallErrorCode.InviteTimeout:
return c.direction === CallDirection.Inbound ? 'Missed call' : 'No answer'
case CallErrorCode.AnsweredElsewhere:
return 'Answered in another session'
case CallErrorCode.IceFailed:
return 'Couldn’t connect'
case CallErrorCode.NoUserMedia:
return 'No microphone or camera'
case CallErrorCode.Replaced:
return 'Call replaced'
default:
if (!connected) {
const outbound = c.direction === CallDirection.Outbound
if (c.hangupParty === CallParty.Remote) return outbound ? 'Declined' : 'Missed call'
return outbound ? 'Call cancelled' : 'Declined'
}
return 'Call ended'
}
}
+5
View File
@@ -19,6 +19,7 @@ import {
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import { client } from '@/matrix/client'
import { isVideoCallInvite } from '@/matrix/timeline'
import { onShieldsChanged } from '@/matrix/trust'
// The room list and the client's connection state, as plain reactive data
@@ -59,6 +60,10 @@ export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSende
// 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.CallInvite) {
const text = isVideoCallInvite(ev) ? 'Video call' : 'Voice call'
return withSender ? `${senderName(ev, me, room)}: ${text}` : text
}
if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null
if (ev.isRelation('m.replace')) return null
const content = ev.getContent()
+21
View File
@@ -11,6 +11,7 @@ import { errorMessage, formatBytes } from '@/lib/format'
import { client, cryptoError } from '@/matrix/client'
import { acceptIdentity, identityWarnings, withdrawVerification, type IdentityWarning } from '@/matrix/crypto'
import { sendFile, sendText } from '@/matrix/send'
import { useCall } from '@/stores/call'
import { useChat } from '@/stores/chat'
import { useSecurity } from '@/stores/security'
import { useToasts } from '@/stores/toasts'
@@ -18,6 +19,7 @@ import { useToasts } from '@/stores/toasts'
const props = defineProps<{ roomId: string }>()
const chat = useChat()
const call = useCall()
const security = useSecurity()
const toasts = useToasts()
const router = useRouter()
@@ -52,6 +54,21 @@ const canSend = computed(() => {
const infoOpen = ref(false)
// Calls are between two people: offered in a room with just you and one
// other person in it.
const callee = computed(() => {
void chat.version
const r = room.value
if (!r || r.getJoinedMemberCount() !== 2) return undefined
return r.getJoinedMembers().find((m) => m.userId !== me)?.userId
})
const canCall = computed(() => Boolean(callee.value) && call.supported())
// One call at a time, wherever it is.
const inCall = computed(() => Boolean(call.call) && call.phase !== 'ended')
function startCall(video: boolean) {
void call.place(props.roomId, callee.value, video)
}
// Someone in an encrypted room whose identity changed: said before you
// write to them, until you acknowledge it.
const warnings = ref<IdentityWarning[]>([])
@@ -277,6 +294,10 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
<span v-if="!encrypted" class="caption hidden items-center gap-1 text-ink-muted sm:flex">
<WIcon name="shield-alert" :size="14" /> Not encrypted
</span>
<template v-if="canCall">
<WIconButton icon="phone" label="Voice call" :disabled="inCall" @click="startCall(false)" />
<WIconButton icon="video" label="Video call" :disabled="inCall" @click="startCall(true)" />
</template>
<WIconButton icon="users" :label="infoOpen ? 'Hide room info' : 'Room info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
</header>