2023-11-30 22:59:19 -05:00
/*
2025-01-14 14:46:39 +00:00
Copyright 2023, 2024, 2025 New Vector Ltd.
2023-11-30 22:59:19 -05:00
2025-02-18 17:59:58 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-06 10:22:13 +02:00
Please see LICENSE in the repository root for full details.
2023-11-30 22:59:19 -05:00
*/
2024-01-20 20:39:12 -05:00
import {
2025-09-15 17:49:07 +02:00
type BaseKeyProvider ,
2025-11-07 08:44:44 +01:00
type ConnectionState ,
2025-08-27 14:01:01 +02:00
ExternalE2EEKeyProvider ,
2025-10-13 15:43:12 +02:00
type Room as LivekitRoom ,
2025-10-22 18:50:16 -04:00
type RoomOptions ,
2024-01-20 20:39:12 -05:00
} from "livekit-client" ;
2025-11-17 17:42:37 +01:00
import { type Room as MatrixRoom } from "matrix-js-sdk" ;
2024-09-11 01:27:24 -04:00
import {
2023-11-30 22:59:19 -05:00
combineLatest ,
2024-01-20 20:39:12 -05:00
distinctUntilChanged ,
2025-10-13 15:43:12 +02:00
EMPTY ,
2024-01-20 20:39:12 -05:00
filter ,
2024-07-03 15:08:30 -04:00
fromEvent ,
2024-01-20 20:39:12 -05:00
map ,
merge ,
2025-10-13 15:43:12 +02:00
NEVER ,
type Observable ,
2023-11-30 22:59:19 -05:00
of ,
2025-09-03 16:50:43 +02:00
pairwise ,
2024-08-08 17:21:47 -04:00
race ,
2023-11-30 22:59:19 -05:00
scan ,
2024-07-03 15:08:30 -04:00
skip ,
2025-09-15 15:41:15 +01:00
skipWhile ,
2023-11-30 22:59:19 -05:00
startWith ,
2025-10-13 15:43:12 +02:00
Subject ,
2024-07-25 17:52:23 -04:00
switchAll ,
2024-01-20 20:39:12 -05:00
switchMap ,
2024-08-08 17:21:47 -04:00
switchScan ,
take ,
2025-09-24 21:26:16 -04:00
tap ,
2025-09-03 16:50:43 +02:00
throttleTime ,
2024-01-20 20:39:12 -05:00
timer ,
2023-11-30 22:59:19 -05:00
} from "rxjs" ;
2025-11-06 15:26:17 +01:00
import { logger as rootLogger } from "matrix-js-sdk/lib/logger" ;
2025-11-07 08:44:44 +01:00
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc" ;
2025-09-24 21:39:36 -04:00
import { type IWidgetApiRequest } from "matrix-widget-api" ;
2023-11-30 22:59:19 -05:00
import {
2024-05-16 12:32:18 -04:00
LocalUserMediaViewModel ,
2024-12-11 09:27:55 +00:00
type MediaViewModel ,
2025-10-14 10:46:57 +02:00
type RemoteUserMediaViewModel ,
2024-01-20 20:39:12 -05:00
ScreenShareViewModel ,
2024-12-11 09:27:55 +00:00
type UserMediaViewModel ,
2025-11-07 08:44:44 +01:00
} from "../MediaViewModel" ;
2025-11-07 17:36:16 -05:00
import { accumulate , generateItems , pauseWhen } from "../../utils/observable" ;
2024-12-19 15:54:28 +00:00
import {
duplicateTiles ,
2025-11-05 12:56:58 +01:00
MatrixRTCMode ,
matrixRTCMode ,
2024-12-19 15:54:28 +00:00
playReactionsSound ,
showReactions ,
2025-11-07 08:44:44 +01:00
} from "../../settings/settings" ;
import { isFirefox } from "../../Platform" ;
import { setPipEnabled$ } from "../../controls" ;
import { TileStore } from "../TileStore" ;
import { gridLikeLayout } from "../GridLikeLayout" ;
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout" ;
import { oneOnOneLayout } from "../OneOnOneLayout" ;
import { pipLayout } from "../PipLayout" ;
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement" ;
2024-12-19 15:54:28 +00:00
import {
type RaisedHandInfo ,
type ReactionInfo ,
type ReactionOption ,
2025-11-07 08:44:44 +01:00
} from "../../reactions" ;
import { shallowEquals } from "../../utils/array" ;
import { type MediaDevices } from "../MediaDevices" ;
2025-11-07 17:36:16 -05:00
import { type Behavior } from "../Behavior" ;
2025-11-07 08:44:44 +01:00
import { E2eeType } from "../../e2ee/e2eeType" ;
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider" ;
import { type MuteStates } from "../MuteStates" ;
import { getUrlParams } from "../../UrlParams" ;
import { type ProcessorState } from "../../livekit/TrackProcessorContext" ;
import { ElementWidgetActions , widget } from "../../widget" ;
import { UserMedia } from "../UserMedia.ts" ;
import { ScreenShare } from "../ScreenShare.ts" ;
2025-10-13 16:24:55 +02:00
import {
2025-10-14 10:46:57 +02:00
type GridLayoutMedia ,
type Layout ,
type LayoutMedia ,
type OneOnOneLayoutMedia ,
type SpotlightExpandedLayoutMedia ,
type SpotlightLandscapeLayoutMedia ,
type SpotlightPortraitLayoutMedia ,
2025-11-07 08:44:44 +01:00
} from "../layout-types.ts" ;
import { type ElementCallError } from "../../utils/errors.ts" ;
import { type ObservableScope } from "../ObservableScope.ts" ;
2025-11-17 14:30:16 +01:00
import {
createLocalMembership$ ,
type LocalMemberConnectionState ,
} from "./localMember/LocalMembership.ts" ;
2025-11-05 18:57:24 +01:00
import { createLocalTransport$ } from "./localMember/LocalTransport.ts" ;
2025-11-06 12:08:46 +01:00
import {
createMemberships$ ,
membershipsAndTransports$ ,
2025-11-07 08:44:44 +01:00
} from "../SessionBehaviors.ts" ;
2025-11-04 20:24:15 +01:00
import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts" ;
2025-11-05 18:57:24 +01:00
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts" ;
2025-11-12 12:09:31 +01:00
import {
createMatrixLivekitMembers$ ,
type MatrixLivekitMember ,
} from "./remoteMembers/MatrixLivekitMembers.ts" ;
2025-11-07 08:44:44 +01:00
import {
2025-11-17 11:37:58 +01:00
type AutoLeaveReason ,
2025-11-07 08:44:44 +01:00
createCallNotificationLifecycle$ ,
createReceivedDecline$ ,
createSentCallNotification$ ,
} from "./CallNotificationLifecycle.ts" ;
2025-11-11 15:51:48 +01:00
import {
2025-11-17 17:42:37 +01:00
createDMMember$ ,
2025-11-11 15:51:48 +01:00
createMatrixMemberMetadata$ ,
createRoomMembers$ ,
} from "./remoteMembers/MatrixMemberMetadata.ts" ;
2025-10-30 01:13:06 +01:00
2025-11-06 15:26:17 +01:00
const logger = rootLogger . getChild ( "[CallViewModel]" );
2025-10-30 01:13:06 +01:00
//TODO
// Larger rename
// member,membership -> rtcMember
// participant -> livekitParticipant
// matrixLivekitItem -> callMember
// js-sdk
// callMembership -> rtcMembership
2025-08-08 17:15:47 +02:00
export interface CallViewModelOptions {
encryptionSystem : EncryptionSystem ;
autoLeaveWhenOthersLeft? : boolean ;
2025-08-25 13:49:01 +02:00
/**
* If the call is started in a way where we want it to behave like a telephone usecase
* If we sent a notification event, we want the ui to show a ringing state
*/
2025-09-03 16:52:40 +02:00
waitForCallPickup? : boolean ;
2025-10-22 18:50:16 -04:00
/** Optional factory to create LiveKit rooms, mainly for testing purposes. */
livekitRoomFactory ?: ( options? : RoomOptions ) => LivekitRoom ;
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
connectionState$? : Behavior < ConnectionState >;
2025-08-08 17:15:47 +02:00
}
2025-08-25 13:49:01 +02:00
2025-09-03 16:50:43 +02:00
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8 ;
export const THROTTLE_SOUND_EFFECT_MS = 500 ;
2024-07-25 17:52:23 -04:00
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3 ;
2024-11-08 10:23:19 -05:00
// How long the footer should be shown for when hovering over or interacting
// with the interface
const showFooterMs = 4000 ;
2024-01-20 20:39:12 -05:00
export type GridMode = "grid" | "spotlight" ;
2024-07-03 15:08:30 -04:00
export type WindowMode = "normal" | "narrow" | "flat" | "pip" ;
2024-01-20 20:39:12 -05:00
2024-11-06 04:36:48 -05:00
interface LayoutScanState {
layout : Layout | null ;
tiles : TileStore ;
}
2024-01-20 20:39:12 -05:00
type MediaItem = UserMedia | ScreenShare ;
2025-11-07 14:04:40 +01:00
type AudioLivekitItem = {
livekitRoom : LivekitRoom ;
participants : string [];
url : string ;
};
2025-11-17 11:37:58 +01:00
2025-10-21 00:07:48 -04:00
/**
2025-11-17 18:22:25 +01:00
* The return of createCallViewModel$
* This interface represents the root snapshot for the call view. Snapshots in EC with rxjs behave like snapshot trees.
* They are a list of observables and objects containing observables to allow for a very granular update mechanism.
*
* This allows to have one huge call view model that represents the entire view without a unnecessary amount of updates.
*
* (Mocking this interface should allow building a full view in all states.)
2025-10-21 00:07:48 -04:00
*/
2025-11-17 18:22:25 +01:00
export interface CallViewModel {
2025-11-17 11:37:58 +01:00
// lifecycle
2025-11-17 18:22:25 +01:00
autoLeave$ : Observable < AutoLeaveReason >;
2025-11-17 14:55:00 +01:00
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
2025-11-17 18:22:25 +01:00
callPickupState$ : Behavior <
2025-11-17 11:37:58 +01:00
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
> ;
2025-11-17 18:22:25 +01:00
leave$ : Observable < "user" | AutoLeaveReason > ;
2025-11-17 14:55:00 +01:00
/** Call to initiate hangup. Use in conbination with connectino state track the async hangup process. */
2025-11-17 18:22:25 +01:00
hangup : () => void ;
2025-11-17 11:37:58 +01:00
// joining
2025-11-17 18:22:25 +01:00
join : () => LocalMemberConnectionState ;
2025-11-17 11:37:58 +01:00
// screen sharing
2025-11-17 14:55:00 +01:00
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
2025-11-17 18:22:25 +01:00
toggleScreenSharing : (() => void ) | null ;
2025-11-17 14:55:00 +01:00
/**
* Whether we are sharing our screen.
*/
2025-11-17 18:22:25 +01:00
sharingScreen$ : Behavior < boolean >;
2025-11-17 11:37:58 +01:00
// UI interactions
2025-11-17 14:55:00 +01:00
/**
* Callback for when the user taps the call view.
*/
2025-11-17 18:22:25 +01:00
tapScreen : () => void ;
2025-11-17 14:55:00 +01:00
/**
* Callback for when the user taps the call's controls.
*/
2025-11-17 18:22:25 +01:00
tapControls : () => void ;
2025-11-17 14:55:00 +01:00
/**
* Callback for when the user hovers over the call view.
*/
2025-11-17 18:22:25 +01:00
hoverScreen : () => void ;
2025-11-17 14:55:00 +01:00
/**
* Callback for when the user stops hovering over the call view.
*/
2025-11-17 18:22:25 +01:00
unhoverScreen : () => void ;
2025-11-17 11:37:58 +01:00
// errors
2025-11-17 14:55:00 +01:00
/**
* If there is a configuration error with the call (e.g. misconfigured E2EE).
* This is a fatal error that prevents the call from being created/joined.
* Should render a blocking error screen.
*/
2025-11-17 18:22:25 +01:00
configError$ : Behavior < ElementCallError | null >;
2025-11-17 11:37:58 +01:00
// participants and counts
2025-11-17 14:55:00 +01:00
/**
* The number of participants currently in the call.
*
* - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one Matrix user if they join from
* multiple devices.
*/
2025-11-17 18:22:25 +01:00
participantCount$ : Behavior < number >;
2025-11-17 14:55:00 +01:00
/** Participants sorted by livekit room so they can be used in the audio rendering */
2025-11-17 18:22:25 +01:00
audioParticipants$ : Behavior < AudioLivekitItem [] >;
2025-11-17 14:55:00 +01:00
/** List of participants raising their hand */
2025-11-17 18:22:25 +01:00
handsRaised$ : Behavior < Record < string , RaisedHandInfo >>;
2025-11-17 14:55:00 +01:00
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
2025-11-17 18:22:25 +01:00
reactions$ : Behavior < Record < string , ReactionOption >>;
2025-11-17 17:42:37 +01:00
2025-11-17 18:22:25 +01:00
ringOverlay$ : Behavior < null | {
2025-11-17 17:42:37 +01:00
name : string ;
/** roomId or userId for the avatar generation. */
idForAvatar : string ;
text : string ;
avatarMxc ?: string ;
}>;
2025-11-17 11:37:58 +01:00
// sounds and events
2025-11-17 18:22:25 +01:00
joinSoundEffect$ : Observable < void >;
leaveSoundEffect$ : Observable < void >;
2025-11-17 14:55:00 +01:00
/**
* Emits an event every time a new hand is raised in
* the call.
*/
2025-11-17 18:22:25 +01:00
newHandRaised$ : Observable < { value : number ; playSounds : boolean } > ;
2025-11-17 14:55:00 +01:00
/**
* Emits an event every time a new screenshare is started in
* the call.
*/
2025-11-17 18:22:25 +01:00
newScreenShare$ : Observable < { value : number ; playSounds : boolean } > ;
2025-11-17 14:55:00 +01:00
/**
* Emits an array of reactions that should be played.
*/
2025-11-17 18:22:25 +01:00
audibleReactions$ : Observable < string [] >;
2025-11-17 14:55:00 +01:00
/**
* Emits an array of reactions that should be visible on the screen.
*/
// DISCUSSION move this into a reaction file
2025-11-17 18:22:25 +01:00
visibleReactions$ : Behavior <
2025-11-17 11:37:58 +01:00
{ sender : string ; emoji : string ; startX : number }[]
> ;
// window/layout
2025-11-17 14:55:00 +01:00
/**
* The general shape of the window.
*/
2025-11-17 18:22:25 +01:00
windowMode$ : Behavior < WindowMode >;
spotlightExpanded$ : Behavior < boolean >;
toggleSpotlightExpanded$ : Behavior < (() => void ) | null > ;
gridMode$ : Behavior < GridMode >;
setGridMode : ( value : GridMode ) => void ;
2025-11-17 11:37:58 +01:00
// media view models and layout
2025-11-17 18:22:25 +01:00
grid$ : Behavior < UserMediaViewModel [] >;
spotlight$ : Behavior < MediaViewModel [] >;
pip$ : Behavior < UserMediaViewModel | null >;
2025-11-17 14:55:00 +01:00
/**
* The layout of tiles in the call interface.
*/
2025-11-17 18:22:25 +01:00
layout$ : Behavior < Layout >;
2025-11-17 14:55:00 +01:00
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
2025-11-17 18:22:25 +01:00
tileStoreGeneration$ : Behavior < number >;
showSpotlightIndicators$ : Behavior < boolean >;
showSpeakingIndicators$ : Behavior < boolean >;
2025-11-17 11:37:58 +01:00
// header/footer visibility
2025-11-17 18:22:25 +01:00
showHeader$ : Behavior < boolean >;
showFooter$ : Behavior < boolean >;
2025-11-17 11:37:58 +01:00
// audio routing
2025-11-17 14:55:00 +01:00
/**
* Whether audio is currently being output through the earpiece.
*/
2025-11-17 18:22:25 +01:00
earpieceMode$ : Behavior < boolean >;
2025-11-17 14:55:00 +01:00
/**
* Callback to toggle between the earpiece and the loudspeaker.
*
* This will be `null` in case the target does not exist in the list
* of available audio outputs.
*/
2025-11-17 18:22:25 +01:00
audioOutputSwitcher$ : Behavior < {
2025-11-17 11:37:58 +01:00
targetOutput : "earpiece" | "speaker" ;
switch : () => void ;
} | null > ;
// connection state
2025-11-17 14:55:00 +01:00
/**
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
2025-11-17 18:22:25 +01:00
reconnecting$ : Behavior < boolean >;
}
/**
* A view model providing all the application logic needed to show the in-call
* UI (may eventually be expanded to cover the lobby and feedback screens in the
* future).
*/
// Throughout this class and related code we must distinguish between MatrixRTC
// state and LiveKit state. We use the common terminology of room "members", RTC
// "memberships", and LiveKit "participants".
export function createCallViewModel$ (
scope : ObservableScope ,
// A call is permanently tied to a single Matrix room
matrixRTCSession : MatrixRTCSession ,
matrixRoom : MatrixRoom ,
mediaDevices : MediaDevices ,
muteStates : MuteStates ,
options : CallViewModelOptions ,
handsRaisedSubject$ : Observable < Record < string , RaisedHandInfo >>,
reactionsSubject$ : Observable < Record < string , ReactionInfo >>,
trackProcessorState$ : Behavior < ProcessorState >,
) : CallViewModel {
const userId = matrixRoom . client . getUserId () ! ;
const deviceId = matrixRoom . client . getDeviceId () ! ;
const livekitKeyProvider = getE2eeKeyProvider (
options . encryptionSystem ,
matrixRTCSession ,
);
// Each hbar seperates a block of input variables required for the CallViewModel to function.
// The outputs of this block is written under the hbar.
//
// For mocking purposes it is recommended to only mock the functions creating those outputs.
// All other fields are just temp computations for the mentioned output.
// The class does not need anything except the values underneath the bar.
// The creation of the values under the bar are all tested independently and testing the callViewModel Should
// not test their cretation. Call view model only needs:
// - memberships$ via createMemberships$
// - localMembership via createLocalMembership$
// - callLifecycle via createCallNotificationLifecycle$
// - matrixMemberMetadataStore via createMatrixMemberMetadata$
// ------------------------------------------------------------------------
// memberships$
const memberships$ = createMemberships$ ( scope , matrixRTCSession );
// ------------------------------------------------------------------------
// matrixLivekitMembers$ AND localMembership
const membershipsAndTransports = membershipsAndTransports$ (
scope ,
memberships$ ,
);
const localTransport$ = createLocalTransport$ ({
scope : scope ,
memberships$ : memberships$ ,
client : matrixRoom.client ,
roomId : matrixRoom.roomId ,
useOldestMember$ : scope.behavior (
matrixRTCMode . value$ . pipe ( map (( v ) => v === MatrixRTCMode . Legacy )),
),
});
const connectionFactory = new ECConnectionFactory (
matrixRoom . client ,
mediaDevices ,
trackProcessorState$ ,
livekitKeyProvider ,
getUrlParams (). controlledAudioDevices ,
options . livekitRoomFactory ,
);
const connectionManager = createConnectionManager$ ({
scope : scope ,
connectionFactory : connectionFactory ,
inputTransports$ : scope.behavior (
combineLatest (
[ localTransport$ , membershipsAndTransports . transports$ ],
( localTransport , transports ) => {
const localTransportAsArray = localTransport ? [ localTransport ] : [];
return transports . mapInner (( transports ) => [
... localTransportAsArray ,
... transports ,
]);
},
2025-11-12 12:09:31 +01:00
),
2025-11-17 18:22:25 +01:00
),
logger : logger ,
});
const matrixLivekitMembers$ = createMatrixLivekitMembers$ ({
scope : scope ,
membershipsWithTransport$ :
membershipsAndTransports.membershipsWithTransport$ ,
connectionManager : connectionManager ,
});
const connectOptions$ = scope . behavior (
matrixRTCMode . value$ . pipe (
map (( mode ) => ({
encryptMedia : livekitKeyProvider !== undefined ,
// TODO. This might need to get called again on each cahnge of matrixRTCMode...
matrixRTCMode : mode ,
})),
),
);
const localMembership = createLocalMembership$ ({
scope : scope ,
muteStates : muteStates ,
mediaDevices : mediaDevices ,
connectionManager : connectionManager ,
matrixRTCSession : matrixRTCSession ,
matrixRoom : matrixRoom ,
localTransport$ : localTransport$ ,
trackProcessorState$ : trackProcessorState$ ,
widget ,
options : connectOptions$ ,
logger : logger.getChild ( `[ ${ Date . now () } ]` ),
});
const localRtcMembership$ = scope . behavior (
memberships$ . pipe (
map (
( memberships ) =>
memberships . value . find (
( membership ) =>
membership . userId === userId && membership . deviceId === deviceId ,
) ?? null ,
2025-11-17 11:37:58 +01:00
),
2025-11-17 18:22:25 +01:00
),
);
const localMatrixLivekitMemberUninitialized = {
membership$ : localRtcMembership$ ,
participant$ : localMembership.participant$ ,
connection$ : localMembership.connection$ ,
userId : userId ,
};
const localMatrixLivekitMember$ : Behavior < MatrixLivekitMember | null > =
scope . behavior (
localRtcMembership$ . pipe (
switchMap (( membership ) => {
if ( ! membership ) return of ( null );
return of (
// casting is save here since we know that localRtcMembership$ is !== null since we reached this case.
localMatrixLivekitMemberUninitialized as MatrixLivekitMember ,
);
}),
2025-11-17 11:37:58 +01:00
),
);
2025-10-03 14:43:22 -04:00
2025-11-17 18:22:25 +01:00
// ------------------------------------------------------------------------
// callLifecycle
2025-11-17 11:37:58 +01:00
2025-11-17 18:22:25 +01:00
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { callPickupState$ , autoLeave$ } = createCallNotificationLifecycle$ ({
scope : scope ,
memberships$ : memberships$ ,
sentCallNotification$ : createSentCallNotification$ ( scope , matrixRTCSession ),
receivedDecline$ : createReceivedDecline$ ( matrixRoom ),
options : options ,
localUser : { userId : userId , deviceId : deviceId },
});
// ------------------------------------------------------------------------
// matrixMemberMetadataStore
const matrixRoomMembers$ = createRoomMembers$ ( scope , matrixRoom );
const matrixMemberMetadataStore = createMatrixMemberMetadata$ (
scope ,
scope . behavior ( memberships$ . pipe ( map (( mems ) => mems . value ))),
matrixRoomMembers$ ,
);
const dmMember$ = createDMMember$ ( scope , matrixRoomMembers$ , matrixRoom );
const noUserToCallInRoom$ = scope . behavior (
matrixRoomMembers$ . pipe (
map (
( roomMembersMap ) =>
roomMembersMap . size === 1 && roomMembersMap . get ( userId ) !== undefined ,
2025-11-17 17:42:37 +01:00
),
2025-11-17 18:22:25 +01:00
),
);
const ringOverlay$ = scope . behavior (
combineLatest ([ noUserToCallInRoom$ , dmMember$ , callPickupState$ ]). pipe (
map (([ noUserToCallInRoom , dmMember , callPickupState ]) => {
// No overlay if not in ringing state
if ( callPickupState !== "ringing" || noUserToCallInRoom ) return null ;
const name = dmMember ? dmMember.rawDisplayName : matrixRoom.name ;
const id = dmMember ? dmMember.userId : matrixRoom.roomId ;
const text = dmMember
? `Waiting for ${ name } to join…`
: "Waiting for other participants…" ;
const avatarMxc = dmMember
? ( dmMember . getMxcAvatarUrl ? .() ?? undefined )
: ( matrixRoom . getMxcAvatarUrl () ?? undefined );
return {
name : name ?? id ,
idForAvatar : id ,
text ,
avatarMxc ,
};
}),
),
);
// CODESMELL?
// This is functionally the same Observable as leave$, except here it's
// hoisted to the top of the class. This enables the cyclic dependency between
// leave$ -> autoLeave$ -> callPickupState$ -> livekitConnectionState$ ->
// localConnection$ -> transports$ -> joined$ -> leave$.
const leaveHoisted$ = new Subject <
"user" | "timeout" | "decline" | "allOthersLeft"
> ();
2025-11-17 17:42:37 +01:00
2025-11-17 18:22:25 +01:00
/**
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
const reconnecting$ = localMembership . reconnecting$ ;
const pretendToBeDisconnected$ = reconnecting$ ;
const audioParticipants$ = scope . behavior (
matrixLivekitMembers$ . pipe (
switchMap (( membersWithEpoch ) => {
const members = membersWithEpoch . value ;
const a$ = combineLatest (
members . map (( member ) =>
combineLatest ([ member . connection$ , member . participant$ ]). pipe (
map (([ connection , participant ]) => {
// do not render audio for local participant
if ( ! connection || ! participant || participant . isLocal )
return null ;
const livekitRoom = connection . livekitRoom ;
const url = connection . transport . livekit_service_url ;
return {
url ,
livekitRoom ,
participant : participant.identity ,
};
}),
2025-11-10 15:55:01 +01:00
),
2025-11-17 11:37:58 +01:00
),
2025-11-17 18:22:25 +01:00
);
return a$ ;
}),
map (( members ) =>
members . reduce < AudioLivekitItem [] >(( acc , curr ) => {
if ( ! curr ) return acc ;
const existing = acc . find (( item ) => item . url === curr . url );
if ( existing ) {
existing . participants . push ( curr . participant );
} else {
acc . push ({
livekitRoom : curr.livekitRoom ,
participants : [ curr . participant ],
url : curr.url ,
});
}
return acc ;
}, []),
),
),
[],
);
const handsRaised$ = scope . behavior (
handsRaisedSubject$ . pipe ( pauseWhen ( pretendToBeDisconnected$ )),
);
const reactions$ = scope . behavior (
reactionsSubject$ . pipe (
map (( v ) =>
Object . fromEntries (
Object . entries ( v ). map (([ a , { reactionOption }]) => [
a ,
reactionOption ,
]),
2025-06-23 19:02:36 +02:00
),
),
2025-11-17 18:22:25 +01:00
pauseWhen ( pretendToBeDisconnected$ ),
),
);
2025-06-23 19:02:36 +02:00
2025-11-17 18:22:25 +01:00
/**
* List of user media (camera feeds) that we want tiles for.
*/
// TODO this also needs the local participant to be added.
const userMedia$ = scope . behavior < UserMedia [] >(
combineLatest ([
localMatrixLivekitMember$ ,
matrixLivekitMembers$ ,
duplicateTiles . value$ ,
]). pipe (
// Generate a collection of MediaItems from the list of expected (whether
// present or missing) LiveKit participants.
generateItems (
function * ([
localMatrixLivekitMember ,
{ value : matrixLivekitMembers },
duplicateTiles ,
]) {
let localParticipantId = undefined ;
// add local member if available
if ( localMatrixLivekitMember ) {
const { userId , participant$ , connection$ , membership$ } =
localMatrixLivekitMember ;
localParticipantId = ` ${ userId } : ${ membership$ . value . deviceId } ` ; // should be membership$.value.membershipID which is not optional
// const participantId = membership$.value.membershipID;
if ( localParticipantId ) {
2025-11-12 12:09:31 +01:00
for ( let dup = 0 ; dup < 1 + duplicateTiles ; dup ++ ) {
yield {
2025-11-17 18:22:25 +01:00
keys : [
dup ,
localParticipantId ,
userId ,
participant$ ,
connection$ ,
],
2025-11-12 12:09:31 +01:00
data : undefined ,
};
}
}
2025-11-17 18:22:25 +01:00
}
// add remote members that are available
for ( const {
2025-11-07 17:36:16 -05:00
userId ,
participant$ ,
connection$ ,
2025-11-17 18:22:25 +01:00
membership$ ,
} of matrixLivekitMembers ) {
const participantId = ` ${ userId } : ${ membership$ . value . deviceId } ` ;
if ( participantId === localParticipantId ) continue ;
// const participantId = membership$.value?.identity;
for ( let dup = 0 ; dup < 1 + duplicateTiles ; dup ++ ) {
yield {
keys : [ dup , participantId , userId , participant$ , connection$ ],
data : undefined ,
};
}
}
},
(
scope ,
_data$ ,
dup ,
participantId ,
userId ,
participant$ ,
connection$ ,
) => {
const livekitRoom$ = scope . behavior (
connection$ . pipe ( map (( c ) => c ? . livekitRoom )),
2025-11-17 11:37:58 +01:00
);
2025-11-17 18:22:25 +01:00
const focusUrl$ = scope . behavior (
connection$ . pipe ( map (( c ) => c ? . transport . livekit_service_url )),
2025-11-17 11:37:58 +01:00
);
2025-11-17 18:22:25 +01:00
const displayName$ = scope . behavior (
matrixMemberMetadataStore
. createDisplayNameBehavior$ ( userId )
. pipe ( map (( name ) => name ?? userId )),
2025-11-17 11:37:58 +01:00
);
2025-11-17 18:22:25 +01:00
return new UserMedia (
scope ,
` ${ participantId } : ${ dup } ` ,
userId ,
participant$ ,
options . encryptionSystem ,
livekitRoom$ ,
focusUrl$ ,
mediaDevices ,
pretendToBeDisconnected$ ,
displayName$ ,
matrixMemberMetadataStore . createAvatarUrlBehavior$ ( userId ),
handsRaised$ . pipe ( map (( v ) => v [ participantId ] ? . time ?? null )),
reactions$ . pipe ( map (( v ) => v [ participantId ] ?? undefined )),
2025-11-17 11:37:58 +01:00
);
2025-11-17 18:22:25 +01:00
},
2024-11-06 04:36:48 -05:00
),
2025-11-17 18:22:25 +01:00
),
);
2024-11-06 04:36:48 -05:00
2025-11-17 18:22:25 +01:00
/**
* List of all media items (user media and screen share media) that we want
* tiles for.
*/
const mediaItems$ = scope . behavior < MediaItem [] >(
userMedia$ . pipe (
switchMap (( userMedia ) =>
userMedia . length === 0
? of ([])
: combineLatest (
userMedia . map (( m ) => m . screenShares$ ),
(... screenShares ) => [... userMedia , ... screenShares . flat ( 1 )],
),
2025-06-18 17:14:21 -04:00
),
2025-11-17 18:22:25 +01:00
),
);
2024-07-03 15:08:30 -04:00
2025-11-17 18:22:25 +01:00
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
const screenShares$ = scope . behavior < ScreenShare [] >(
mediaItems$ . pipe (
map (( mediaItems ) =>
mediaItems . filter (( m ) : m is ScreenShare => m instanceof ScreenShare ),
2025-07-12 00:20:44 -04:00
),
2025-11-17 18:22:25 +01:00
),
);
const joinSoundEffect$ = userMedia$ . pipe (
pairwise (),
filter (
([ prev , current ]) =>
current . length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current . length > prev . length ,
),
map (() => {}),
throttleTime ( THROTTLE_SOUND_EFFECT_MS ),
);
2024-01-20 20:39:12 -05:00
2025-11-17 18:22:25 +01:00
/**
* The number of participants currently in the call.
*
* - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one Matrix user if they join from
* multiple devices.
*/
const participantCount$ = scope . behavior (
matrixLivekitMembers$ . pipe ( map (( ms ) => ms . value . length )),
);
const leaveSoundEffect$ = combineLatest ([ callPickupState$ , userMedia$ ]). pipe (
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile (([ c ]) => c !== null && c !== "success" ),
map (([, userMedia ]) => userMedia ),
pairwise (),
filter (
([ prev , current ]) =>
current . length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current . length < prev . length ,
),
map (() => {}),
throttleTime ( THROTTLE_SOUND_EFFECT_MS ),
);
const userHangup$ = new Subject < void >();
const widgetHangup$ =
widget === null
? NEVER
: (
fromEvent (
widget . lazyActions ,
ElementWidgetActions . HangupCall ,
) as Observable < CustomEvent < IWidgetApiRequest >>
). pipe (
tap (( ev ) => {
widget ! . api . transport . reply ( ev . detail , {});
}),
);
const leave$ : Observable < "user" | "timeout" | "decline" | "allOthersLeft" > =
merge (
autoLeave$ ,
merge ( userHangup$ , widgetHangup$ ). pipe ( map (() => "user" as const )),
). pipe (
scope . share ,
tap (( reason ) => leaveHoisted$ . next ( reason )),
2025-11-17 11:37:58 +01:00
);
2024-01-20 20:39:12 -05:00
2025-11-17 18:22:25 +01:00
const spotlightSpeaker$ = scope . behavior < UserMediaViewModel | null >(
userMedia$ . pipe (
switchMap (( mediaItems ) =>
mediaItems . length === 0
? of ([])
: combineLatest (
mediaItems . map (( m ) =>
m . vm . speaking$ . pipe ( map (( s ) => [ m , s ] as const )),
),
),
),
scan < ( readonly [ UserMedia , boolean ])[], UserMedia | undefined , null > (
( prev , mediaItems ) => {
// Only remote users that are still in the call should be sticky
const [ stickyMedia , stickySpeaking ] =
( ! prev ? . vm . local && mediaItems . find (([ m ]) => m === prev )) || [];
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
return stickySpeaking
? stickyMedia !
: // Otherwise, select any remote user who is speaking
( mediaItems . find (([ m , s ]) => ! m . vm . local && s ) ? .[ 0 ] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems . find (([ m ]) => ! m . vm . local ) ? .[ 0 ] ??
// Otherwise, spotlight the local user
mediaItems . find (([ m ]) => m . vm . local ) ? .[ 0 ]);
},
null ,
),
map (( speaker ) => speaker ? . vm ?? null ),
),
);
const grid$ = scope . behavior < UserMediaViewModel [] >(
userMedia$ . pipe (
switchMap (( mediaItems ) => {
const bins = mediaItems . map (( m ) =>
m . bin$ . pipe ( map (( bin ) => [ m , bin ] as const )),
);
// Sort the media by bin order and generate a tile for each one
return bins . length === 0
? of ([])
: combineLatest ( bins , (... bins ) =>
bins . sort (([, bin1 ], [, bin2 ]) => bin1 - bin2 ). map (([ m ]) => m . vm ),
);
2024-11-11 08:25:16 -05:00
}),
2025-11-17 18:22:25 +01:00
distinctUntilChanged ( shallowEquals ),
),
);
const spotlight$ = scope . behavior < MediaViewModel [] >(
screenShares$ . pipe (
switchMap (( screenShares ) => {
if ( screenShares . length > 0 ) {
return of ( screenShares . map (( m ) => m . vm ));
}
return spotlightSpeaker$ . pipe (
map (( speaker ) => ( speaker ? [ speaker ] : [])),
);
}),
distinctUntilChanged < MediaViewModel [] >( shallowEquals ),
),
);
const pip$ = scope . behavior < UserMediaViewModel | null >(
combineLatest ([
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
screenShares$ ,
spotlightSpeaker$ ,
mediaItems$ ,
]). pipe (
switchMap (([ screenShares , spotlight , mediaItems ]) => {
if ( screenShares . length > 0 ) {
return spotlightSpeaker$ ;
}
if ( ! spotlight || spotlight . local ) {
return of ( null );
}
const localUserMedia = mediaItems . find (
( m ) => m . vm instanceof LocalUserMediaViewModel ,
) as UserMedia | undefined ;
const localUserMediaViewModel = localUserMedia ? . vm as
| LocalUserMediaViewModel
| undefined ;
if ( ! localUserMediaViewModel ) {
return of ( null );
}
return localUserMediaViewModel . alwaysShow$ . pipe (
map (( alwaysShow ) => {
if ( alwaysShow ) {
return localUserMediaViewModel ;
}
2024-07-25 17:51:00 -04:00
2025-11-17 18:22:25 +01:00
return null ;
}),
);
}),
),
);
const hasRemoteScreenShares$ : Observable < boolean > = spotlight$ . pipe (
map (( spotlight ) =>
spotlight . some (( vm ) => ! vm . local && vm instanceof ScreenShareViewModel ),
),
distinctUntilChanged (),
);
const pipEnabled$ = scope . behavior ( setPipEnabled$ , false );
const naturalWindowMode$ = scope . behavior < WindowMode >(
fromEvent ( window , "resize" ). pipe (
map (() => {
const height = window . innerHeight ;
const width = window . innerWidth ;
if ( height <= 400 && width <= 340 ) return "pip" ;
// Our layouts for flat windows are better at adapting to a small width
// than our layouts for narrow windows are at adapting to a small height,
// so we give "flat" precedence here
if ( height <= 600 ) return "flat" ;
if ( width <= 600 ) return "narrow" ;
return "normal" ;
}),
),
"normal" ,
);
2025-11-17 11:37:58 +01:00
2025-11-17 18:22:25 +01:00
/**
* The general shape of the window.
*/
const windowMode$ = scope . behavior < WindowMode >(
pipEnabled$ . pipe (
switchMap (( pip ) => ( pip ? of < WindowMode >( "pip" ) : naturalWindowMode$ )),
),
);
const spotlightExpandedToggle$ = new Subject < void >();
const spotlightExpanded$ = scope . behavior < boolean >(
spotlightExpandedToggle$ . pipe ( accumulate ( false , ( expanded ) => ! expanded )),
);
const gridModeUserSelection$ = new Subject < GridMode >();
/**
* The layout mode of the media tile grid.
*/
const gridMode$ =
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
scope . behavior < GridMode >(
gridModeUserSelection$ . pipe (
switchMap (( userSelection ) =>
( userSelection === "spotlight"
? EMPTY
: combineLatest ([ hasRemoteScreenShares$ , windowMode$ ]). pipe (
skip ( userSelection === null ? 0 : 1 ),
map (
([ hasScreenShares , windowMode ]) : GridMode =>
hasScreenShares || windowMode === "flat"
? "spotlight"
: "grid" ,
2025-11-17 11:37:58 +01:00
),
2025-11-17 18:22:25 +01:00
)
). pipe ( startWith ( userSelection ?? "grid" )),
2025-11-17 11:37:58 +01:00
),
2025-07-12 00:20:44 -04:00
),
2025-11-17 18:22:25 +01:00
"grid" ,
2025-11-17 11:37:58 +01:00
);
2024-07-26 06:57:49 -04:00
2025-11-17 18:22:25 +01:00
const setGridMode = ( value : GridMode ) : void => {
gridModeUserSelection$ . next ( value );
};
const gridLayoutMedia$ : Observable < GridLayoutMedia > = combineLatest (
[ grid$ , spotlight$ ],
( grid , spotlight ) => ({
type : "grid" ,
spotlight : spotlight.some (( vm ) => vm instanceof ScreenShareViewModel )
? spotlight
: undefined ,
grid ,
}),
);
const spotlightLandscapeLayoutMedia$ : Observable < SpotlightLandscapeLayoutMedia > =
combineLatest ([ grid$ , spotlight$ ], ( grid , spotlight ) => ({
type : "spotlight-landscape" ,
spotlight ,
grid ,
}));
const spotlightPortraitLayoutMedia$ : Observable < SpotlightPortraitLayoutMedia > =
combineLatest ([ grid$ , spotlight$ ], ( grid , spotlight ) => ({
type : "spotlight-portrait" ,
spotlight ,
grid ,
}));
const spotlightExpandedLayoutMedia$ : Observable < SpotlightExpandedLayoutMedia > =
combineLatest ([ spotlight$ , pip$ ], ( spotlight , pip ) => ({
type : "spotlight-expanded" ,
spotlight ,
pip : pip ?? undefined ,
}));
const oneOnOneLayoutMedia$ : Observable < OneOnOneLayoutMedia | null > =
mediaItems$ . pipe (
map (( mediaItems ) => {
if ( mediaItems . length !== 2 ) return null ;
const local = mediaItems . find (( vm ) => vm . vm . local ) ? . vm as
| LocalUserMediaViewModel
| undefined ;
const remote = mediaItems . find (( vm ) => ! vm . vm . local ) ? . vm as
| RemoteUserMediaViewModel
| undefined ;
// There might not be a remote tile if there are screen shares, or if
// only the local user is in the call and they're using the duplicate
// tiles option
if ( ! remote || ! local ) return null ;
return { type : "one-on-one" , local , remote };
}),
2025-11-17 11:37:58 +01:00
);
2024-08-08 17:21:47 -04:00
2025-11-17 18:22:25 +01:00
const pipLayoutMedia$ : Observable < LayoutMedia > = spotlight$ . pipe (
map (( spotlight ) => ({ type : "pip" , spotlight })),
);
2024-08-08 17:21:47 -04:00
2025-11-17 18:22:25 +01:00
/**
* The media to be used to produce a layout.
*/
const layoutMedia$ = scope . behavior < LayoutMedia >(
windowMode$ . pipe (
switchMap (( windowMode ) => {
switch ( windowMode ) {
case "normal" :
return gridMode$ . pipe (
switchMap (( gridMode ) => {
switch ( gridMode ) {
case "grid" :
return oneOnOneLayoutMedia$ . pipe (
switchMap (( oneOnOne ) =>
oneOnOne === null ? gridLayoutMedia$ : of ( oneOnOne ),
),
);
case "spotlight" :
return spotlightExpanded$ . pipe (
switchMap (( expanded ) =>
expanded
? spotlightExpandedLayoutMedia$
: spotlightLandscapeLayoutMedia$ ,
),
);
}
}),
);
case "narrow" :
return oneOnOneLayoutMedia$ . pipe (
switchMap (( oneOnOne ) =>
oneOnOne === null
? combineLatest ([ grid$ , spotlight$ ], ( grid , spotlight ) =>
grid . length > smallMobileCallThreshold ||
spotlight . some (( vm ) => vm instanceof ScreenShareViewModel )
? spotlightPortraitLayoutMedia$
: gridLayoutMedia$ ,
). pipe ( switchAll ())
: // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
spotlightExpandedLayoutMedia$ ,
),
);
case "flat" :
return gridMode$ . pipe (
switchMap (( gridMode ) => {
switch ( gridMode ) {
case "grid" :
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayoutMedia$ ;
case "spotlight" :
return spotlightExpandedLayoutMedia$ ;
}
}),
);
case "pip" :
return pipLayoutMedia$ ;
}
}),
),
);
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
// first render a layout. To deal with this we assume initially that no tiles
// are visible, and loop the data back into the layouts with a Subject.
const visibleTiles$ = new Subject < number >();
const setVisibleTiles = ( value : number ) : void => visibleTiles$ . next ( value );
const layoutInternals$ = scope . behavior < LayoutScanState & { layout : Layout }>(
combineLatest ([
layoutMedia$ ,
visibleTiles$ . pipe ( startWith ( 0 ), distinctUntilChanged ()),
]). pipe (
scan <
[ LayoutMedia , number ],
LayoutScanState & { layout : Layout },
LayoutScanState
> (
({ tiles : prevTiles }, [ media , visibleTiles ]) => {
let layout : Layout ;
let newTiles : TileStore ;
switch ( media . type ) {
case "grid" :
2025-11-17 11:37:58 +01:00
case "spotlight-landscape" :
case "spotlight-portrait" :
2025-11-17 18:22:25 +01:00
[ layout , newTiles ] = gridLikeLayout (
media ,
visibleTiles ,
setVisibleTiles ,
prevTiles ,
2025-11-17 11:37:58 +01:00
);
2025-11-17 18:22:25 +01:00
break ;
2025-11-17 11:37:58 +01:00
case "spotlight-expanded" :
2025-11-17 18:22:25 +01:00
[ layout , newTiles ] = spotlightExpandedLayout ( media , prevTiles );
break ;
2025-11-17 11:37:58 +01:00
case "one-on-one" :
2025-11-17 18:22:25 +01:00
[ layout , newTiles ] = oneOnOneLayout ( media , prevTiles );
break ;
case "pip" :
[ layout , newTiles ] = pipLayout ( media , prevTiles );
break ;
2025-11-17 11:37:58 +01:00
}
2024-08-08 17:21:47 -04:00
2025-11-17 18:22:25 +01:00
return { layout , tiles : newTiles };
},
{ layout : null , tiles : TileStore.empty () },
2025-11-17 11:37:58 +01:00
),
2025-11-17 18:22:25 +01:00
),
);
2024-12-19 15:54:28 +00:00
2025-11-17 18:22:25 +01:00
/**
* The layout of tiles in the call interface.
*/
const layout$ = scope . behavior < Layout >(
layoutInternals$ . pipe ( map (({ layout }) => layout )),
);
2025-06-26 05:08:57 -04:00
2025-11-17 18:22:25 +01:00
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
const tileStoreGeneration$ = scope . behavior < number >(
layoutInternals$ . pipe ( map (({ tiles }) => tiles . generation )),
);
const showSpotlightIndicators$ = scope . behavior < boolean >(
layout$ . pipe ( map (( l ) => l . type !== "grid" )),
);
const showSpeakingIndicators$ = scope . behavior < boolean >(
layout$ . pipe (
switchMap (( l ) => {
switch ( l . type ) {
case "spotlight-landscape" :
case "spotlight-portrait" :
// If the spotlight is showing the active speaker, we can do without
// speaking indicators as they're a redundant visual cue. But if
// screen sharing feeds are in the spotlight we still need them.
return l . spotlight . media$ . pipe (
map (( models : MediaViewModel []) =>
models . some (( m ) => m instanceof ScreenShareViewModel ),
),
);
// In expanded spotlight layout, the active speaker is always shown in
// the picture-in-picture tile so there is no need for speaking
// indicators. And in one-on-one layout there's no question as to who is
// speaking.
case "spotlight-expanded" :
case "one-on-one" :
return of ( false );
default :
return of ( true );
}
}),
),
);
const toggleSpotlightExpanded$ = scope . behavior < (() => void ) | null > (
windowMode$ . pipe (
switchMap (( mode ) =>
mode === "normal"
? layout$ . pipe (
map (
( l ) =>
l . type === "spotlight-landscape" ||
l . type === "spotlight-expanded" ,
),
)
: of ( false ),
2025-11-17 11:37:58 +01:00
),
2025-11-17 18:22:25 +01:00
distinctUntilChanged (),
map (( enabled ) =>
enabled ? () : void => spotlightExpandedToggle$ . next () : null ,
2025-11-17 11:37:58 +01:00
),
2025-11-17 18:22:25 +01:00
),
);
const screenTap$ = new Subject < void >();
const controlsTap$ = new Subject < void >();
const screenHover$ = new Subject < void >();
const screenUnhover$ = new Subject < void >();
const showHeader$ = scope . behavior < boolean >(
windowMode$ . pipe ( map (( mode ) => mode !== "pip" && mode !== "flat" )),
);
const showFooter$ = scope . behavior < boolean >(
windowMode$ . pipe (
switchMap (( mode ) => {
switch ( mode ) {
case "pip" :
return of ( false );
case "normal" :
case "narrow" :
return of ( true );
case "flat" :
// Sadly Firefox has some layering glitches that prevent the footer
// from appearing properly. They happen less often if we never hide
// the footer.
if ( isFirefox ()) return of ( true );
// Show/hide the footer in response to interactions
return merge (
screenTap$ . pipe ( map (() => "tap screen" as const )),
controlsTap$ . pipe ( map (() => "tap controls" as const )),
screenHover$ . pipe ( map (() => "hover" as const )),
). pipe (
switchScan (( state , interaction ) => {
switch ( interaction ) {
case "tap screen" :
return state
? // Toggle visibility on tap
of ( false )
: // Hide after a timeout
timer ( showFooterMs ). pipe (
map (() => false ),
startWith ( true ),
);
case "tap controls" :
// The user is interacting with things, so reset the timeout
return timer ( showFooterMs ). pipe (
map (() => false ),
startWith ( true ),
);
case "hover" :
// Show on hover and hide after a timeout
return race (
timer ( showFooterMs ),
screenUnhover$ . pipe ( take ( 1 )),
). pipe (
map (() => false ),
startWith ( true ),
);
}
}, false ),
startWith ( false ),
);
}
}),
),
);
2024-12-19 15:54:28 +00:00
2025-11-17 18:22:25 +01:00
/**
* Whether audio is currently being output through the earpiece.
*/
const earpieceMode$ = scope . behavior < boolean >(
combineLatest (
[ mediaDevices . audioOutput . available$ , mediaDevices . audioOutput . selected$ ],
( available , selected ) =>
selected !== undefined &&
available . get ( selected . id ) ? . type === "earpiece" ,
),
);
2024-12-19 15:54:28 +00:00
2025-11-17 18:22:25 +01:00
/**
* Callback to toggle between the earpiece and the loudspeaker.
*
* This will be `null` in case the target does not exist in the list
* of available audio outputs.
*/
const audioOutputSwitcher$ = scope . behavior < {
targetOutput : "earpiece" | "speaker" ;
switch : () => void ;
} | null > (
combineLatest (
[ mediaDevices . audioOutput . available$ , mediaDevices . audioOutput . selected$ ],
( available , selected ) => {
const selectionType = selected && available . get ( selected . id ) ? . type ;
// If we are in any output mode other than speaker switch to speaker.
const newSelectionType : "earpiece" | "speaker" =
selectionType === "speaker" ? "earpiece" : "speaker" ;
const newSelection = [... available ]. find (
([, d ]) => d . type === newSelectionType ,
);
if ( newSelection === undefined ) return null ;
const [ id ] = newSelection ;
return {
targetOutput : newSelectionType ,
switch : () : void => mediaDevices . audioOutput . select ( id ),
};
},
),
);
2024-08-08 17:21:47 -04:00
2025-11-17 18:22:25 +01:00
/**
* Emits an array of reactions that should be visible on the screen.
*/
// DISCUSSION move this into a reaction file
// const {visibleReactions$, audibleReactions$} = reactionsObservables$(showReactionSetting$, )
const visibleReactions$ = scope . behavior (
showReactions . value$ . pipe (
switchMap (( show ) => ( show ? reactions$ : of ({}))),
scan <
Record < string , ReactionOption >,
{ sender : string ; emoji : string ; startX : number }[]
> (( acc , latest ) => {
const newSet : { sender : string ; emoji : string ; startX : number }[] = [];
for ( const [ sender , reaction ] of Object . entries ( latest )) {
const startX =
acc . find (( v ) => v . sender === sender && v . emoji ) ? . startX ??
Math . ceil ( Math . random () * 80 ) + 10 ;
newSet . push ({ sender , emoji : reaction.emoji , startX });
}
return newSet ;
}, []),
),
);
2025-09-24 13:54:54 -04:00
2025-11-17 18:22:25 +01:00
/**
* Emits an array of reactions that should be played.
*/
const audibleReactions$ = playReactionsSound . value$ . pipe (
switchMap (( show ) =>
show ? reactions$ : of < Record < string , ReactionOption >>({}),
),
map (( reactions ) => Object . values ( reactions ). map (( v ) => v . name )),
scan < string [] , { playing : string []; newSounds : string [] }>(
( acc , latest ) => {
return {
playing : latest.filter (
( v ) => acc . playing . includes ( v ) || acc . newSounds . includes ( v ),
),
newSounds : latest.filter (
( v ) => ! acc . playing . includes ( v ) && ! acc . newSounds . includes ( v ),
),
};
},
{ playing : [], newSounds : [] },
),
map (( v ) => v . newSounds ),
);
const newHandRaised$ = handsRaised$ . pipe (
map (( v ) => Object . keys ( v ). length ),
scan (
( acc , newValue ) => ({
value : newValue ,
playSounds : newValue > acc . value ,
}),
{ value : 0 , playSounds : false },
),
filter (( v ) => v . playSounds ),
);
const newScreenShare$ = screenShares$ . pipe (
map (( v ) => v . length ),
scan (
( acc , newValue ) => ({
value : newValue ,
playSounds : newValue > acc . value ,
}),
{ value : 0 , playSounds : false },
),
filter (( v ) => v . playSounds ),
);
2025-09-24 13:54:54 -04:00
2025-11-17 18:22:25 +01:00
/**
* Whether we are sharing our screen.
*/
// reassigned here to make it publicly accessible
const sharingScreen$ = localMembership . sharingScreen$ ;
2025-11-17 11:37:58 +01:00
2025-11-17 18:22:25 +01:00
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
// reassigned here to make it publicly accessible
const toggleScreenSharing = localMembership . toggleScreenSharing ;
const join = localMembership . requestConnect ;
// TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
join ();
return {
autoLeave$ : autoLeave$ ,
callPickupState$ : callPickupState$ ,
ringOverlay$ : ringOverlay$ ,
leave$ : leave$ ,
hangup : () : void => userHangup$ . next (),
join : join ,
toggleScreenSharing : toggleScreenSharing ,
sharingScreen$ : sharingScreen$ ,
tapScreen : () : void => screenTap$ . next (),
tapControls : () : void => controlsTap$ . next (),
hoverScreen : () : void => screenHover$ . next (),
unhoverScreen : () : void => screenUnhover$ . next (),
configError$ : localMembership.configError$ ,
participantCount$ : participantCount$ ,
audioParticipants$ : audioParticipants$ ,
handsRaised$ : handsRaised$ ,
reactions$ : reactions$ ,
joinSoundEffect$ : joinSoundEffect$ ,
leaveSoundEffect$ : leaveSoundEffect$ ,
newHandRaised$ : newHandRaised$ ,
newScreenShare$ : newScreenShare$ ,
audibleReactions$ : audibleReactions$ ,
visibleReactions$ : visibleReactions$ ,
windowMode$ : windowMode$ ,
spotlightExpanded$ : spotlightExpanded$ ,
toggleSpotlightExpanded$ : toggleSpotlightExpanded$ ,
gridMode$ : gridMode$ ,
setGridMode : setGridMode ,
grid$ : grid$ ,
spotlight$ : spotlight$ ,
pip$ : pip$ ,
layout$ : layout$ ,
tileStoreGeneration$ : tileStoreGeneration$ ,
showSpotlightIndicators$ : showSpotlightIndicators$ ,
showSpeakingIndicators$ : showSpeakingIndicators$ ,
showHeader$ : showHeader$ ,
showFooter$ : showFooter$ ,
earpieceMode$ : earpieceMode$ ,
audioOutputSwitcher$ : audioOutputSwitcher$ ,
reconnecting$ : reconnecting$ ,
};
2023-11-30 22:59:19 -05:00
}
2025-08-28 17:45:14 +02:00
// TODO-MULTI-SFU // Setup and update the keyProvider which was create by `createRoom` was a thing before. Now we never update if the E2EEsystem changes
// do we need this?
2025-09-15 17:49:07 +02:00
function getE2eeKeyProvider (
2025-08-28 17:45:14 +02:00
e2eeSystem : EncryptionSystem ,
rtcSession : MatrixRTCSession ,
2025-09-15 17:49:07 +02:00
) : BaseKeyProvider | undefined {
2025-08-28 17:45:14 +02:00
if ( e2eeSystem . kind === E2eeType . NONE ) return undefined ;
if ( e2eeSystem . kind === E2eeType . PER_PARTICIPANT ) {
const keyProvider = new MatrixKeyProvider ();
keyProvider . setRTCSession ( rtcSession );
2025-09-15 17:49:07 +02:00
return keyProvider ;
2025-08-28 17:45:14 +02:00
} else if ( e2eeSystem . kind === E2eeType . SHARED_KEY && e2eeSystem . secret ) {
const keyProvider = new ExternalE2EEKeyProvider ();
keyProvider
. setKey ( e2eeSystem . secret )
. catch (( e ) => logger . error ( "Failed to set shared key for E2EE" , e ));
2025-09-15 17:49:07 +02:00
return keyProvider ;
2025-08-28 17:45:14 +02:00
}
}