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
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:
1 parent
325431a5be
commit
76dfc98a86
49 files changed
+3849
-432
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user