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:
+1
-1
Submodule nobilis updated: c04d211b44...6fd10f745e
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user