The window half of a call between everybody in a room

One capture, lent to every leg: five connections asking the machine for
the same camera get one capture and four refusals, so the group opens it
once and hands it to each connection - and stops it only when the last
one goes.

A bar at the top of a room where a call is already happening, since
nobody was invited to it. The header's call buttons start one where the
conversation is a room and ring somebody where it is a person, because
those are different things.

The grid needed nothing: it was built for a call of five and had only
ever been fed by two.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 14:01:02 -04:00
co-authored by Claude Opus 5
parent f4d7b21e8d
commit ce64d76ab8
8 changed files with 533 additions and 6 deletions
+1 -1
Submodule nobilis updated: c04d211b44...6fd10f745e
+19
View File
@@ -2924,6 +2924,25 @@ img.smilie {
word-break: break-all;
}
/* A call happening in this room, which nobody was invited to: it sits where
the call itself would, above the log and outside the scroller. */
.room-call-bar {
display: flex;
align-items: center;
gap: var(--spacing-s);
flex: none;
margin: var(--spacing-xs) var(--spacing-m) 0;
padding: var(--spacing-xs) var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container);
}
.room-call-bar .small {
flex: 1;
min-width: 0;
}
.emoji-empty {
padding: var(--spacing-m);
text-align: center;
+44
View File
@@ -269,6 +269,50 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
)
}
/**
* A call already happening in this room, and the way into it.
*
* A group call has no invitation: it is a piece of room state saying people
* are in one, so the only way anybody learns of it is by being shown that
* they could join. Which is what this is - the bar Element shows at the top
* of a room, in the place a call would appear.
*/
export function RoomCallBar({ bufferId }: { bufferId: string }): JSX.Element | null {
const store = useStore()
const members = useChat((s) => s.callMembers[bufferId]) ?? []
const activeCall = useChat((s) => s.activeCall)
const buffers = useChat((s) => s.buffers)
// Nothing to offer while this end is already in it, and nothing to offer
// when nobody is.
if (members.length === 0 || activeCall?.bufferId === bufferId) return null
// Whose account this is, out of its id: a Matrix account is named for the
// user it signs in as, and this end being in the call from another device
// is still this end being in it.
const accountId = buffers.find((b) => b.id === bufferId)?.accountId ?? ''
const ownUserId = accountId.startsWith('matrix:') ? accountId.slice('matrix:'.length) : accountId
const others = members.filter((m) => m.user_id !== ownUserId)
if (others.length === 0) return null
const who =
others.length === 1
? `${others[0].user_id} is in a call`
: `${others.length} people are in a call`
return (
<div className="room-call-bar">
<Icon name="videocam" size={16} />
<span className="small ellipsis">{who}</span>
<button type="button" className="button" onClick={() => void store.joinMatrixGroupCall(bufferId, false)}>
Join
</button>
<button type="button" className="button" onClick={() => void store.joinMatrixGroupCall(bufferId, true)}>
Join with video
</button>
</div>
)
}
/**
* Which screen or window to show the room.
*
@@ -745,8 +745,28 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
header rather than inside the call. */}
{isMatrix && buffer.kind !== 'server' && !matrixCall && (
<>
<IconButton name="call" title="Voice call" onClick={() => void store.callMatrix(buffer.id, false)} />
<IconButton name="videocam" title="Video call" onClick={() => void store.callMatrix(buffer.id, true)} />
{/* A room's call is between everybody in it and a direct message's
is between two people, which are different things to start: one
is joined and the other rings somebody. The room gets the first
and the DM the second, because that is what each one is. */}
<IconButton
name="call"
title={buffer.kind === 'channel' ? 'Start a call in this room' : 'Voice call'}
onClick={() =>
buffer.kind === 'channel'
? void store.joinMatrixGroupCall(buffer.id, false)
: void store.callMatrix(buffer.id, false)
}
/>
<IconButton
name="videocam"
title={buffer.kind === 'channel' ? 'Start a video call in this room' : 'Video call'}
onClick={() =>
buffer.kind === 'channel'
? void store.joinMatrixGroupCall(buffer.id, true)
: void store.callMatrix(buffer.id, true)
}
/>
</>
)}
{isMatrix && matrixCall?.bufferId === buffer.id && (
+4 -1
View File
@@ -3,7 +3,7 @@ import { Icon } from './Icon'
import { MessageRow, type MessageMode } from './MessageRow'
import { LiveCards } from './LiveCards'
import { CallView } from './CallView'
import { CallStage } from './CallStage'
import { CallStage, RoomCallBar } from './CallStage'
import { StreamStage } from './StreamStage'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
@@ -456,6 +456,9 @@ export function MessageList(): JSX.Element {
and the conversation it is in are the same conversation. It moves to
a corner of its own only when you walk away from it - see App. */}
{activeCall?.bufferId === bufferId && <CallStage mode="inline" />}
{/* And a call already happening here, which nobody was invited to
because a group call has no invitation - only people in it. */}
<RoomCallBar bufferId={bufferId} />
{/* And a Kick stream in the same place, for the same reason: the picture
and the chat about it are one conversation. Never both at once - the
store refuses to start a stream during a call. */}
+291
View File
@@ -28,6 +28,30 @@ export interface MatrixCallEvent {
content: Record<string, any>
}
/** Somebody in a room's call, as the room's own state says. */
export interface CallMember {
user_id: string
membership: { device_id?: string; expires_ts?: number }
}
/** The key that names one participant: a person on one device. */
export function memberKey(userId: string, deviceId: string): string {
return `${userId}|${deviceId}`
}
/**
* Whether this end is the one that calls, for a given pair.
*
* Both ends see each other arrive, and if both call, both answer and the call
* collides with itself. The rule has to be a property of the pair rather than
* of who arrived first, because "first" is not something two clients can
* agree on - so the smaller key calls the larger, which both sides compute
* the same way.
*/
export function shouldOffer(ownKey: string, theirKey: string): boolean {
return ownKey < theirKey
}
/** A call being offered to this account, waiting to be answered or refused. */
export interface RingingCall {
accountId: string
@@ -44,6 +68,12 @@ export interface RingingCall {
export interface CallSurface {
send: (bufferId: string, type: string, content: Record<string, unknown>) => void
iceServers: (accountId: string) => Promise<RTCIceServer[]>
/** Publishes this end's membership of the room's call, and says who we are
* in it - the user and the device, since one person may be in from two. */
joinMembership: (bufferId: string) => Promise<{ userId: string; deviceId: string; roomId?: string } | null>
leaveMembership: (bufferId: string) => Promise<void>
/** Who the room says is in the call. */
callMembers: (bufferId: string) => Promise<CallMember[]>
onRinging: (call: RingingCall | null) => void
onPhase: (phase: CallPhase, call: ActiveCall | null) => void
onError: (message: string) => void
@@ -70,10 +100,49 @@ export function offerHasVideo(sdp: string): boolean {
return /^m=video/m.test(sdp)
}
/** One other participant in a group call, and the connection to them. */
export interface Peer {
key: string
userId: string
call: Call
remote: MediaStream | null
}
/**
* A call in a room, between everybody in it.
*
* A mesh rather than a conference: one peer connection per pair, which is
* what Matrix has without a media server standing in the middle. That is fine
* for the handful of people a room call usually is and would not be fine for
* thirty - the cost is one upload stream per other participant, which is the
* trade every meshed client makes.
*
* Who is in it is `m.call.member` state in the room, which is how anybody
* learns there is a call to join at all: a group call has no invitation.
*/
export interface GroupCall {
accountId: string
bufferId: string
ownKey: string
video: boolean
peers: Map<string, Peer>
/** This end's camera and microphone, opened once and lent to every leg. */
local: MediaStream | null
/** The whole call's id, on every signal, so a second call in the same room
* is a second call rather than a crossed line. */
confId: string
}
export class MatrixCalls {
private surface: CallSurface
private active: ActiveCall | null = null
private ringing: RingingCall | null = null
private group: GroupCall | null = null
/** The relay list this call was set up with, so an answering leg uses the
* same one the offering legs did rather than asking again per invite. */
private iceForGroup: RTCIceServer[] = []
/** Keeps this end's membership from expiring while it is still here. */
private heartbeat: ReturnType<typeof setInterval> | null = null
constructor(surface: CallSurface) {
this.surface = surface
@@ -140,6 +209,224 @@ export class MatrixCalls {
this.surface.onPhase('ended', null)
}
get currentGroup(): GroupCall | null {
return this.group
}
/**
* Joins the room's call, starting one if nobody is in it yet.
*
* Joining *is* starting: publishing this end's membership is what makes a
* call exist, and the difference between the two is only whether anybody
* was there first.
*/
async joinGroup(accountId: string, bufferId: string, video: boolean): Promise<void> {
if (this.active || this.group) {
this.surface.onError('Already in a call')
return
}
const own = await this.surface.joinMembership(bufferId)
if (!own) return
this.iceForGroup = await this.surface.iceServers(accountId)
const ownKey = memberKey(own.userId, own.deviceId)
// Opened once here rather than per leg: five connections asking the
// machine for the same camera get one capture and four refusals.
let local: MediaStream | null = null
try {
local = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
video
})
} catch {
try {
local = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
if (video) this.surface.onError('No camera here - joining with audio only')
} catch (e) {
this.surface.onError(`Couldn't open the microphone: ${(e as Error).message}`)
await this.surface.leaveMembership(bufferId)
return
}
}
this.group = {
accountId,
bufferId,
ownKey,
video,
local,
peers: new Map(),
// Named for the room rather than for the buffer: each account calls the
// same room by a different buffer id, and a conference named after one
// of those is a conference the other end never joins.
confId: `moho-conf-${own.roomId ?? bufferId}`
}
this.surface.onPhase('connecting', null)
// Refreshed while this end is still here: a membership carries an expiry
// so that a client which dies mid-call stops being a participant, and
// one that is alive has to keep saying so.
this.heartbeat = setInterval(() => void this.surface.joinMembership(bufferId), 30_000)
await this.meet(await this.surface.callMembers(bufferId))
}
/**
* Brings the peer list into line with who the room says is in the call.
*
* Called on joining and on every membership change, so somebody arriving
* gets called and somebody leaving is hung up on - which is the whole of
* group call bookkeeping.
*/
async meet(members: CallMember[]): Promise<void> {
const group = this.group
if (!group) return
const present = new Set<string>()
for (const member of members) {
const key = memberKey(member.user_id, member.membership?.device_id ?? '')
if (key === group.ownKey) continue
present.add(key)
if (group.peers.has(key)) continue
// Only one end of each pair offers; the other waits for the invite.
if (!shouldOffer(group.ownKey, key)) continue
await this.callPeer(key, member.user_id)
}
// And anybody the room no longer lists has left the call, whether they
// said so or their membership simply ran out.
for (const [key, peer] of [...group.peers]) {
if (present.has(key)) continue
void peer.call.hangUp('user_hangup', true)
group.peers.delete(key)
}
this.surface.onPhase(group.peers.size > 0 ? 'connected' : 'connecting', null)
}
/** Rings one participant of a group call. */
private async callPeer(key: string, userId: string): Promise<void> {
const group = this.group
if (!group) return
const iceServers = await this.surface.iceServers(group.accountId)
const callId = `${group.confId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const call = this.buildPeer(group, callId, key, userId, true, iceServers)
group.peers.set(key, { key, userId, call, remote: null })
try {
await call.invite(group.video)
} catch (e) {
this.surface.onError(`Couldn't reach ${userId}: ${(e as Error).message}`)
group.peers.delete(key)
}
}
/** Leaves the call, telling the room and everybody in it. */
leaveGroup(): void {
const group = this.group
if (!group) return
this.group = null
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = null
for (const peer of group.peers.values()) void peer.call.hangUp('user_hangup', true)
// The shared capture is nobody's leg to stop, so it is stopped here.
for (const track of group.local?.getTracks() ?? []) track.stop()
void this.surface.leaveMembership(group.bufferId)
this.surface.onPhase('ended', null)
}
/**
* One connection in the mesh.
*
* The same `Call` a one-to-one call uses; what differs is only that every
* signal names the conference and the person it is for, so five people in a
* room do not answer each other's offers.
*/
private buildPeer(
group: GroupCall,
callId: string,
theirKey: string,
userId: string,
outgoing: boolean,
iceServers: RTCIceServer[]
): Call {
const call: Call = new Call({
id: callId,
outgoing,
iceServers,
local: group.local,
handlers: {
send: (signal) => {
const base = {
call_id: callId,
version: '1',
party_id: group.ownKey,
conf_id: group.confId,
// Who it is for. Every signal is a room event that everybody
// sees, so without this each offer would be answered by
// everybody in the room at once.
dest_session_id: theirKey
}
const type =
signal.kind === 'invite'
? 'm.call.invite'
: signal.kind === 'answer'
? 'm.call.answer'
: signal.kind === 'candidates'
? 'm.call.candidates'
: signal.kind === 'negotiate'
? 'm.call.negotiate'
: 'm.call.hangup'
this.surface.send(group.bufferId, type, { ...base, ...signal.content })
},
onRemoteStream: (stream) => {
const peer = group.peers.get(theirKey)
if (peer) peer.remote = stream
this.surface.onPhase('connected', null)
},
onPhase: (phase) => {
if (phase === 'ended') group.peers.delete(theirKey)
this.surface.onPhase(phase === 'ended' && group.peers.size === 0 ? 'connecting' : phase, null)
},
onError: (message) => this.surface.onError(`${userId}: ${message}`)
}
})
return call
}
/**
* A signal belonging to the group call, rather than to a call of two.
*
* Answers with whether it was consumed, so the one-to-one path below can
* carry on ignoring everything that is not its own.
*/
private handleGroup(event: MatrixCallEvent): boolean {
const group = this.group
if (!group) return false
const { content, kind, callId } = event
if (content.conf_id !== group.confId) return false
// Somebody else's leg of the mesh, seen because room events are seen by
// everybody. Not ours to answer.
if (content.dest_session_id && content.dest_session_id !== group.ownKey) return true
const theirKey = (content.party_id as string) ?? ''
const peer = [...group.peers.values()].find((p) => p.call.id === callId)
if (kind === 'm.call.invite' && !peer) {
const sdp = content.offer?.sdp as string | undefined
if (!sdp) return true
const call = this.buildPeer(group, callId, theirKey, event.from, false, this.iceForGroup)
group.peers.set(theirKey, { key: theirKey, userId: event.from, call, remote: null })
void call.accept(sdp, group.video)
return true
}
if (!peer) return true
if (kind === 'm.call.answer') {
const sdp = content.answer?.sdp as string | undefined
if (sdp) void peer.call.takeAnswer(sdp)
} else if (kind === 'm.call.candidates') {
void peer.call.addCandidates((content.candidates as RTCIceCandidateInit[] | undefined) ?? [])
} else if (kind === 'm.call.negotiate') {
const description = content.description as RTCSessionDescriptionInit | undefined
if (description) void peer.call.takeNegotiation(description)
} else if (kind === 'm.call.hangup' || kind === 'm.call.reject') {
void peer.call.hangUp('user_hangup', false)
group.peers.delete(peer.key)
}
return true
}
private build(
accountId: string,
bufferId: string,
@@ -201,6 +488,10 @@ export class MatrixCalls {
handle(event: MatrixCallEvent): void {
const { kind, callId, content } = event
// A group call's own signalling, which is addressed rather than broadcast
// even though every event in the room is seen by everybody.
if (content.conf_id && this.handleGroup(event)) return
if (event.own) {
const answeredElsewhere =
kind === 'm.call.answer' && this.ringing?.callId === callId && content.party_id !== 'moho'
+25 -1
View File
@@ -69,17 +69,31 @@ export class Call {
private answered = false
/** The screen track while one is being shared, so it can be stopped. */
private screen: MediaStreamTrack | null = null
/** Media opened by somebody else and lent to this leg - see the
* constructor. Never stopped here, because it is not this leg's to stop. */
private shared: MediaStream | null = null
constructor(opts: {
id: string
outgoing: boolean
iceServers: RTCIceServer[]
handlers: CallHandlers
/**
* A camera and microphone already open, to use instead of opening more.
*
* A call between two people opens its own. A call between five is five
* connections, and asking the machine for the camera five times gets
* five captures of the same camera - or, on most machines, one capture
* and four failures. So the group opens it once and hands it to each
* leg.
*/
local?: MediaStream | null
}) {
this.id = opts.id
this.outgoing = opts.outgoing
this.handlers = opts.handlers
this.partyId = `moho-${Math.random().toString(36).slice(2, 10)}`
this.shared = opts.local ?? null
this.pc = new RTCPeerConnection({ iceServers: opts.iceServers })
// Candidates are found for as long as a call lasts, not only at the
@@ -164,6 +178,12 @@ export class Call {
* "video call" on a desktop with no webcam actually wants.
*/
private async openLocal(video: boolean): Promise<void> {
// Already open, and open for several legs at once: take it as it is.
if (this.shared) {
this.local = this.shared
for (const track of this.shared.getTracks()) this.pc.addTrack(track, this.shared)
return
}
const audio = { echoCancellation: true, noiseSuppression: true }
try {
this.local = await navigator.mediaDevices.getUserMedia({ audio, video })
@@ -303,7 +323,11 @@ export class Call {
async hangUp(reason?: string, tell = true): Promise<void> {
if (this.ringTimer) clearTimeout(this.ringTimer)
if (tell) this.handlers.send({ kind: 'hangup', content: reason ? { reason } : {} })
for (const track of this.local?.getTracks() ?? []) track.stop()
// Not the shared capture: other legs of the same call are still using it,
// and stopping it here would take the camera away from all of them.
if (!this.shared) {
for (const track of this.local?.getTracks() ?? []) track.stop()
}
this.screen?.stop()
this.screen = null
this.pc.close()
+127 -1
View File
@@ -26,7 +26,7 @@ import { bufferDisplayName, isImageFile, resolveMediaUrl } from '../lib/util'
import { DM_GROUP_ID, isDirectMessage } from '../lib/groups'
import { ircNetworkFor } from '../lib/networks'
import { parseDeepLink, type IrcLink } from '../../../shared/deeplink'
import { MatrixCalls, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
import { MatrixCalls, type CallMember, type MatrixCallEvent, type RingingCall } from '../lib/matrixcall'
import type { CallPhase } from '../lib/webrtc'
import { Levels } from '../lib/levels'
import type { PopoutState } from '../../../shared/ipc'
@@ -426,6 +426,13 @@ export interface ChatState {
ringingCall: RingingCall | null
/** The call put away without being hung up. */
callMinimized: boolean
/**
* Who is in each room's call, by buffer.
*
* A group call has no invitation - it is a piece of room state saying
* people are in one - so this is how a room offers a call to join.
*/
callMembers: Record<string, CallMember[]>
/**
* The Kick stream being watched, if any.
*
@@ -532,6 +539,7 @@ const INITIAL: ChatState = {
discordFriends: {},
ringingCall: null,
callMinimized: false,
callMembers: {},
watching: null,
watchMinimized: false,
screenSources: null,
@@ -775,8 +783,43 @@ export class ChatStore {
return []
}
},
// A group call is joined rather than answered, so this end says it is in
// the room's call and reads back who else is - see matrixcall.ts.
joinMembership: async (bufferId) => {
try {
return await window.moho.rpc<{ userId: string; deviceId: string }>('setMatrixCallMembership', {
bufferId,
joined: true
})
} catch (e) {
this.toast('error', `Couldn't join the call: ${(e as Error).message}`)
return null
}
},
leaveMembership: async (bufferId) => {
await window.moho.rpc('setMatrixCallMembership', { bufferId, joined: false }).catch(() => {})
},
callMembers: async (bufferId) =>
window.moho.rpc<CallMember[]>('listMatrixCallMembers', { bufferId }).catch(() => []),
onRinging: (call) => this.set({ ringingCall: call }),
onPhase: (phase, call) => {
// A group call reports with no `call` of its own, because it is not one
// connection but several - the room is the call. Its phase moves the
// same state the one-to-one path does, and only leaving clears it.
const group = this.matrixCalls.currentGroup
if (group && phase !== 'ended') {
this.set({
activeCall: {
accountId: group.accountId,
bufferId: group.bufferId,
phase,
video: group.video,
muted: this.state.activeCall?.muted ?? false,
sharingScreen: false
}
})
return
}
if (!call || phase === 'ended') {
this.set({ activeCall: null })
return
@@ -1775,9 +1818,41 @@ export class ChatStore {
}
hangUpMatrixCall(): void {
// Whichever kind is up: leaving a room's call withdraws the membership
// that says this end is in it, as well as hanging up on everybody.
if (this.matrixCalls.currentGroup) {
this.matrixCalls.leaveGroup()
this.set({ activeCall: null })
return
}
this.matrixCalls.end()
}
/**
* Joins the call in this room, starting one if nobody is in it.
*
* Joining is starting: publishing this end's membership is what makes a
* call exist, and the difference is only whether anybody was there first.
*/
async joinMatrixGroupCall(bufferId: string, video: boolean): Promise<void> {
const account = this.accountFor(bufferId)
if (!account) return
await this.matrixCalls.joinGroup(account.id, bufferId, video)
const group = this.matrixCalls.currentGroup
if (!group) return
this.set({
callMinimized: false,
activeCall: {
accountId: account.id,
bufferId,
phase: 'connecting',
video,
muted: false,
sharingScreen: false
}
})
}
/**
* Puts the screen picker on screen and waits for a choice.
*
@@ -1812,7 +1887,49 @@ export class ChatStore {
* is one - is the same list, longer. The view draws whatever it is given
* and does not need to learn anything new to draw five.
*/
/** Somebody's name in a room, or their id where the roster has not said. */
private matrixDisplayName(bufferId: string, userId: string): string {
const roster = this.state.presenceByBuffer[bufferId] ?? []
return roster.find((m) => m.userId === userId)?.nick ?? userId
}
callTiles(): CallTile[] {
// A call between everybody in a room: one tile for this end and one per
// person answered, which is what the fitted grid was built for.
const group = this.matrixCalls.currentGroup
if (group) {
// This end's own capture, which exists whether or not anybody has
// answered yet - it is opened once for the whole call rather than per
// connection.
const ours = group.local
this.levels.watch('self', ours)
const tiles: CallTile[] = [
{
id: 'self',
label: this.ownNameIn(group.bufferId) || 'You',
stream: ours,
hasVideo: !!ours?.getVideoTracks().some((t) => t.readyState === 'live' && !t.muted),
self: true,
muted: !!this.state.activeCall?.muted,
speaking: !this.state.activeCall?.muted && this.levels.speaking('self')
}
]
for (const peer of group.peers.values()) {
this.levels.watch(peer.key, peer.remote)
// Only once there is something to show: a tile for somebody who has
// not answered yet is a picture of nobody.
if (!peer.remote) continue
tiles.push({
id: peer.key,
label: this.matrixDisplayName(group.bufferId, peer.userId),
stream: peer.remote,
hasVideo: peer.remote.getVideoTracks().some((t) => t.readyState === 'live' && !t.muted),
speaking: this.levels.speaking(peer.key)
})
}
return tiles
}
const call = this.matrixCalls.current
if (!call) {
this.levels.dispose()
@@ -1980,6 +2097,15 @@ export class ChatStore {
// Somebody is writing. Discord names one person per event and Matrix
// sends the whole set, including an empty one to say everybody
// stopped - so a list replaces, and a single nick is merged in.
// Somebody joined or left a room's call. Both the offer of a call to
// join and, while in one, the signal to meet whoever just arrived.
case 'matrixCallMembers': {
const members = (data.members ?? []) as CallMember[]
this.set({ callMembers: { ...this.state.callMembers, [data.bufferId]: members } })
void this.matrixCalls.meet(members)
break
}
case 'typing': {
const until = Date.now() + (data.expiresInMs ?? 10000)
const current = this.state.typingByBuffer[data.bufferId]