Group voice and video calls with MatrixRTC and LiveKit
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

Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.

Server:
- MSC4140 delayed events (send/state with a delay, plus the
  delayed_events list/restart/cancel/send API), stored and scheduled, so
  a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
  hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
  under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.

Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 19:33:29 +02:00
1 parent 325431a5be
commit 76dfc98a86
49 files changed
+3849 -432

No files matched your search

+44 -1
View File
@@ -114,6 +114,7 @@ function describeCall(ev: MatrixEvent, room: Room): string | null {
case EventType.CallInvite:
return `${sender} started a ${isVideoCallInvite(ev) ? 'video' : 'voice'} call`
case EventType.CallReject:
case EventType.RTCDecline:
return `${sender} declined the call`
case EventType.CallHangup:
switch (ev.getContent().reason) {
@@ -131,6 +132,47 @@ function describeCall(ev: MatrixEvent, room: Room): string | null {
}
}
/**
* Whether a call.member state event (MatrixRTC) says its device is in the
* call. Leaving is an empty event; the legacy form lists memberships.
*/
export function isActiveCallMember(content: Record<string, unknown>): boolean {
if (Object.keys(content).length === 0) return false
if (Array.isArray(content.memberships)) return content.memberships.length > 0
return true
}
/**
* Follows who is in a room's group call along the timeline, to say when
* it started and ended. Each device in the call has a call.member state
* event of its own; a member refreshing theirs says nothing.
*/
class CallTracker {
private readonly inCall = new Set<string>()
constructor(room: Room) {
const start = room.getLiveTimeline().getState(Direction.Backward)
for (const ev of start?.getStateEvents(EventType.GroupCallMemberPrefix) ?? []) {
if (isActiveCallMember(ev.getContent())) this.inCall.add(ev.getStateKey() ?? '')
}
}
describe(ev: MatrixEvent, room: Room): string | null {
const key = ev.getStateKey() ?? ''
const was = this.inCall.has(key)
const now = isActiveCallMember(ev.getContent())
if (was === now) return null
const sender = nameOf(room, ev.getSender() ?? '')
if (now) {
const first = this.inCall.size === 0
this.inCall.add(key)
return first ? `${sender} started a call` : `${sender} joined the call`
}
this.inCall.delete(key)
return this.inCall.size === 0 ? 'The call ended' : `${sender} left the call`
}
}
/** 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() ?? '')
@@ -285,6 +327,7 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
const items: TimelineItem[] = []
let lastTs = 0
let prev: MessageItem | null = null
const calls = new CallTracker(room)
for (const ev of events) {
const type = ev.getType()
@@ -310,7 +353,7 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
}
continue
}
const text = describeState(ev, room)
const text = type === EventType.GroupCallMemberPrefix ? calls.describe(ev, room) : describeState(ev, room)
if (text) {
items.push({ kind: 'notice', key, ts, text })
prev = null