mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Compare commits
1 Commits
release-v0
...
toger5/inc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ae395e66 |
@@ -44,7 +44,7 @@ module.exports = {
|
||||
],
|
||||
// To encourage good usage of RxJS:
|
||||
"rxjs/no-exposed-subjects": "error",
|
||||
"rxjs/finnish": ["error", { names: { "^this$": false } }],
|
||||
"rxjs/finnish": "error",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,7 +9,6 @@ dist-ssr
|
||||
public/config.json
|
||||
backend/synapse_tmp/*
|
||||
/coverage
|
||||
config.json
|
||||
|
||||
# Yarn
|
||||
yarn-error.log
|
||||
|
||||
@@ -21,5 +21,3 @@ turn:
|
||||
external_tls: true
|
||||
keys:
|
||||
devkey: secret
|
||||
room:
|
||||
auto_create: false
|
||||
|
||||
@@ -14,7 +14,6 @@ services:
|
||||
# If the configured homeserver runs on localhost, it'll probably be using
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -102,6 +101,4 @@ services:
|
||||
depends_on:
|
||||
- synapse
|
||||
networks:
|
||||
ecbackend:
|
||||
aliases:
|
||||
- matrix-rtc.m.localhost
|
||||
- ecbackend
|
||||
|
||||
@@ -69,7 +69,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.11.0"
|
||||
android_gradle_plugin = "8.10.1"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.33.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" }
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"default": "Standard",
|
||||
"default_named": "Standard<2> ({{name}} )</2>",
|
||||
"handset": "Ohrhörer",
|
||||
"earpiece": "Ohrhörer",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon{{n}}",
|
||||
"speaker": "Lautsprecher",
|
||||
|
||||
@@ -79,6 +79,11 @@
|
||||
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
|
||||
},
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"earpiece": {
|
||||
"overlay_back_button": "Back to video",
|
||||
"overlay_description": "Only works while using app",
|
||||
"overlay_title": "Earpiece only mode"
|
||||
},
|
||||
"error": {
|
||||
"call_is_not_supported": "Call is not supported",
|
||||
"call_not_found": "Call not found",
|
||||
@@ -105,11 +110,6 @@
|
||||
"knock_reject_heading": "Access denied",
|
||||
"reason": "Reason: {{reason}}"
|
||||
},
|
||||
"handset": {
|
||||
"overlay_back_button": "Back to Speaker Mode",
|
||||
"overlay_description": "Only works while using app",
|
||||
"overlay_title": "Handset Mode"
|
||||
},
|
||||
"hangup_button_label": "End call",
|
||||
"header_label": "Element Call Home",
|
||||
"header_participants_label": "Participants",
|
||||
@@ -181,7 +181,7 @@
|
||||
"change_device_button": "Change audio device",
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"handset": "Handset",
|
||||
"earpiece": "Earpiece",
|
||||
"loudspeaker": "Loudspeaker",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/core": "^2.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.201.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^5.0.0",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CollapseIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Header, LeftNav, RightNav } from "./Header";
|
||||
@@ -42,6 +45,7 @@ interface Props {
|
||||
*/
|
||||
export const AppBar: FC<Props> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon;
|
||||
const onBackClick = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
window.controls.onBackButtonPressed?.();
|
||||
@@ -65,7 +69,7 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
<LeftNav>
|
||||
<Tooltip label={t("common.back")}>
|
||||
<IconButton onClick={onBackClick}>
|
||||
<CollapseIcon />
|
||||
<BackIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</LeftNav>
|
||||
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||
@@ -194,10 +193,6 @@ export interface UrlParams {
|
||||
* The Sentry environment. This is only used in the embedded package of Element Call.
|
||||
*/
|
||||
sentryEnvironment: string | null;
|
||||
/**
|
||||
* Whether and what type of notification EC should send, when the user joins the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
}
|
||||
|
||||
// This is here as a stopgap, but what would be far nicer is a function that
|
||||
@@ -280,11 +275,6 @@ export const getUrlParams = (
|
||||
? HeaderStyle.None
|
||||
: HeaderStyle.Standard);
|
||||
|
||||
const sendNotificationType = ["ring", "notification"].includes(
|
||||
parser.getParam("sendNotificationType") ?? "",
|
||||
)
|
||||
? (parser.getParam("sendNotificationType") as RTCNotificationType)
|
||||
: undefined;
|
||||
const widgetId = parser.getParam("widgetId");
|
||||
const parentUrl = parser.getParam("parentUrl");
|
||||
const isWidget = !!widgetId && !!parentUrl;
|
||||
@@ -339,7 +329,6 @@ export const getUrlParams = (
|
||||
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
|
||||
sentryDsn: parser.getParam("sentryDsn"),
|
||||
sentryEnvironment: parser.getParam("sentryEnvironment"),
|
||||
sendNotificationType,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ exports[`AppBar > renders 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
|
||||
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import classNames from "classnames";
|
||||
import { useObservableState } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import styles from "./ReactionToggleButton.module.css";
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
} from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
raised: boolean;
|
||||
@@ -179,8 +180,12 @@ export function ReactionToggleButton({
|
||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(vm.reactions$)[identifier];
|
||||
const isHandRaised = useObservableState(
|
||||
vm.handsRaised$.pipe(map((v) => !!v[identifier])),
|
||||
);
|
||||
const canReact = useObservableState(
|
||||
vm.reactions$.pipe(map((v) => !v[identifier])),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear whenever the reactions menu state changes.
|
||||
|
||||
@@ -6,7 +6,9 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Subject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
const logger = rootLogger.getChild("[controlled-output]");
|
||||
|
||||
export interface Controls {
|
||||
canEnterPip(): boolean;
|
||||
|
||||
@@ -24,16 +24,16 @@ import {
|
||||
createContext,
|
||||
memo,
|
||||
use,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { fromEvent, map, startWith } from "rxjs";
|
||||
|
||||
import styles from "./Grid.module.css";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
@@ -155,6 +155,11 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
|
||||
);
|
||||
}
|
||||
|
||||
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
|
||||
startWith(null),
|
||||
map(() => window.innerHeight),
|
||||
);
|
||||
|
||||
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||
ref?: Ref<R>;
|
||||
model: LayoutModel;
|
||||
@@ -256,13 +261,7 @@ export function Grid<
|
||||
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
||||
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
||||
|
||||
const windowHeight = useSyncExternalStore(
|
||||
useCallback((onChange) => {
|
||||
window.addEventListener("resize", onChange);
|
||||
return (): void => window.removeEventListener("resize", onChange);
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
const windowHeight = useObservableEagerState(windowHeightObservable$);
|
||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||
const [generation, setGeneration] = useState<number | null>(null);
|
||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||
|
||||
@@ -13,7 +13,6 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||
@@ -33,7 +32,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
|
||||
@@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||
@@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightPortraitLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--grid-gap": string;
|
||||
@@ -66,7 +65,8 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
const withIndicators =
|
||||
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import * as controls from "../controls";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
export interface MatrixAudioRendererProps {
|
||||
/**
|
||||
* The list of participants to render audio for.
|
||||
@@ -71,7 +72,7 @@ export function MatrixAudioRenderer({
|
||||
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
|
||||
if (loggedInvalidIdentities.current.has(identity)) return;
|
||||
logger.warn(
|
||||
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`,
|
||||
`Audio track ${identity} has no matching matrix call member`,
|
||||
`current members: ${Array.from(validIdentities.values())}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
@@ -101,7 +102,7 @@ export function MatrixAudioRenderer({
|
||||
useEffect(() => {
|
||||
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
|
||||
logger.debug(
|
||||
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`,
|
||||
`All audio tracks have a matching matrix call member identity.`,
|
||||
);
|
||||
loggedInvalidIdentities.current.clear();
|
||||
}
|
||||
@@ -181,7 +182,7 @@ interface StereoPanAudioTrackProps {
|
||||
/**
|
||||
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
|
||||
* It main purpose is to remount the AudioTrack component when switching from
|
||||
* audioContext to normal audio playback.
|
||||
* audiooContext to normal audio playback.
|
||||
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
||||
* @param param0
|
||||
* @returns
|
||||
@@ -201,7 +202,7 @@ function AudioTrackWithAudioNodes({
|
||||
const [trackReady, setTrackReady] = useReactiveState(
|
||||
() => false,
|
||||
// We only want the track to reset once both (audioNodes and audioContext) are set.
|
||||
// for unsetting the audioContext its enough if one of the two is undefined.
|
||||
// for unsetting the audioContext its enough if one of the the is undefined.
|
||||
[audioContext && audioNodes],
|
||||
);
|
||||
|
||||
|
||||
@@ -157,13 +157,10 @@ export function useLivekit(
|
||||
useObservableEagerState(
|
||||
useObservable(
|
||||
(room$) =>
|
||||
room$.pipe(
|
||||
switchMap(([room]) =>
|
||||
observeTrackReference$(
|
||||
room.localParticipant,
|
||||
Track.Source.Camera,
|
||||
),
|
||||
),
|
||||
observeTrackReference$(
|
||||
room$.pipe(map(([room]) => room.localParticipant)),
|
||||
Track.Source.Camera,
|
||||
).pipe(
|
||||
map((trackRef) => {
|
||||
const track = trackRef?.publication?.track;
|
||||
return track instanceof LocalVideoTrack ? track : null;
|
||||
@@ -323,18 +320,16 @@ export function useLivekit(
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the requested devices with LiveKit's devices
|
||||
if (room !== undefined && connectionState === ConnectionState.Connected) {
|
||||
if (
|
||||
room !== undefined &&
|
||||
connectionState === ConnectionState.Connected &&
|
||||
!controlledAudioDevices
|
||||
) {
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
selected$: Observable<SelectedDevice | undefined>,
|
||||
): Subscription =>
|
||||
selected$.subscribe((device) => {
|
||||
logger.info(
|
||||
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
||||
room.getActiveDevice(kind),
|
||||
" !== ",
|
||||
device?.id,
|
||||
);
|
||||
if (
|
||||
device !== undefined &&
|
||||
room.getActiveDevice(kind) !== device.id
|
||||
@@ -349,9 +344,7 @@ export function useLivekit(
|
||||
|
||||
const subscriptions = [
|
||||
syncDevice("audioinput", devices.audioInput.selected$),
|
||||
!controlledAudioDevices
|
||||
? syncDevice("audiooutput", devices.audioOutput.selected$)
|
||||
: undefined,
|
||||
syncDevice("audiooutput", devices.audioOutput.selected$),
|
||||
syncDevice("videoinput", devices.videoInput.selected$),
|
||||
// Restart the audio input track whenever we detect that the active media
|
||||
// device has changed to refer to a different hardware device. We do this
|
||||
@@ -391,7 +384,7 @@ export function useLivekit(
|
||||
];
|
||||
|
||||
return (): void => {
|
||||
for (const s of subscriptions) s?.unsubscribe();
|
||||
for (const s of subscriptions) s.unsubscribe();
|
||||
};
|
||||
}
|
||||
}, [room, devices, connectionState, controlledAudioDevices]);
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
} from "react";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { ElementCallReactionEventType, type ReactionOption } from ".";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface ReactionsSenderContextType {
|
||||
supportsReactions: boolean;
|
||||
@@ -70,7 +70,7 @@ export const ReactionsSenderProvider = ({
|
||||
[memberships, myUserId, myDeviceId],
|
||||
);
|
||||
|
||||
const reactions = useBehavior(vm.reactions$);
|
||||
const reactions = useObservableEagerState(vm.reactions$);
|
||||
const myReaction = useMemo(
|
||||
() =>
|
||||
myMembershipIdentifier !== undefined
|
||||
@@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({
|
||||
[myMembershipIdentifier, reactions],
|
||||
);
|
||||
|
||||
const handsRaised = useBehavior(vm.handsRaised$);
|
||||
const handsRaised = useObservableEagerState(vm.handsRaised$);
|
||||
const myRaisedHand = useMemo(
|
||||
() =>
|
||||
myMembershipIdentifier !== undefined
|
||||
|
||||
@@ -61,7 +61,3 @@
|
||||
.overlay > p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: var(--cpd-space-32x);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC } from "react";
|
||||
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
|
||||
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./EarpieceOverlay.module.css";
|
||||
@@ -22,12 +22,12 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
return (
|
||||
<div className={styles.overlay} data-show={show}>
|
||||
<BigIcon className={styles.icon}>
|
||||
<VoiceCallIcon aria-hidden />
|
||||
<EarpieceIcon aria-hidden />
|
||||
</BigIcon>
|
||||
<Heading as="h2" weight="semibold" size="md">
|
||||
{t("handset.overlay_title")}
|
||||
{t("earpiece.overlay_title")}
|
||||
</Heading>
|
||||
<Text>{t("handset.overlay_description")}</Text>
|
||||
<Text>{t("earpiece.overlay_description")}</Text>
|
||||
<Button
|
||||
kind="primary"
|
||||
size="sm"
|
||||
@@ -35,10 +35,8 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
onBackToVideoPressed?.();
|
||||
}}
|
||||
>
|
||||
{t("handset.overlay_back_button")}
|
||||
{t("earpiece.overlay_back_button")}
|
||||
</Button>
|
||||
{/* This spacer is used to give the overlay an offset to the top. */}
|
||||
<div className={styles.spacer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { of } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
@@ -42,7 +43,6 @@ import { MatrixRTCFocusMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
@@ -141,7 +141,7 @@ function createGroupCallView(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(constant([]));
|
||||
).withMemberships(of([]));
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
@@ -71,7 +72,6 @@ import {
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -110,7 +110,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
const muteAllAudio = useObservableEagerState(muteAllAudio$);
|
||||
const leaveSoundContext = useLatest(
|
||||
useAudioContext({
|
||||
sounds: callEventAudioSounds,
|
||||
|
||||
@@ -25,11 +25,11 @@ import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import {
|
||||
VoiceCallSolidIcon,
|
||||
EarpieceIcon,
|
||||
VolumeOnSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -112,7 +112,6 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership
|
||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -150,7 +149,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
?.disconnect()
|
||||
.then(() => {
|
||||
logger.info(
|
||||
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
|
||||
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -252,7 +251,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
const muteAllAudio = useObservableEagerState(muteAllAudio$);
|
||||
|
||||
// This seems like it might be enough logic to use move it into the call view model?
|
||||
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
|
||||
@@ -303,15 +302,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
const windowMode = useObservableEagerState(vm.windowMode$);
|
||||
const layout = useObservableEagerState(vm.layout$);
|
||||
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
|
||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||
const gridMode = useBehavior(vm.gridMode$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const gridMode = useObservableEagerState(vm.gridMode$);
|
||||
const showHeader = useObservableEagerState(vm.showHeader$);
|
||||
const showFooter = useObservableEagerState(vm.showFooter$);
|
||||
const earpieceMode = useObservableEagerState(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$);
|
||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
||||
|
||||
// Ideally we could detect taps by listening for click events and checking
|
||||
@@ -458,9 +457,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
useMemo(() => {
|
||||
if (audioOutputSwitcher === null) return null;
|
||||
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
|
||||
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
|
||||
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon;
|
||||
const label = isEarpieceTarget
|
||||
? t("settings.devices.handset")
|
||||
? t("settings.devices.earpiece")
|
||||
: t("settings.devices.loudspeaker");
|
||||
|
||||
return (
|
||||
@@ -528,12 +527,16 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetHeight,
|
||||
model,
|
||||
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
||||
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
||||
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
||||
const showSpeakingIndicatorsValue = useBehavior(
|
||||
const spotlightExpanded = useObservableEagerState(
|
||||
vm.spotlightExpanded$,
|
||||
);
|
||||
const onToggleExpanded = useObservableEagerState(
|
||||
vm.toggleSpotlightExpanded$,
|
||||
);
|
||||
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||
vm.showSpeakingIndicators$,
|
||||
);
|
||||
const showSpotlightIndicatorsValue = useBehavior(
|
||||
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||
vm.showSpotlightIndicators$,
|
||||
);
|
||||
|
||||
|
||||
@@ -86,14 +86,6 @@ export function useMuteStates(isJoined: boolean): MuteStates {
|
||||
const audio = useMuteState(devices.audioInput, () => {
|
||||
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
|
||||
});
|
||||
useEffect(() => {
|
||||
// If audio is enabled, we need to request the device names again,
|
||||
// because iOS will not be able to switch to the correct device after un-muting.
|
||||
// This is one of the main changes that makes iOS work with bluetooth audio devices.
|
||||
if (audio.enabled) {
|
||||
devices.requestDeviceNames();
|
||||
}
|
||||
}, [audio.enabled, devices]);
|
||||
const isEarpiece = useIsEarpiece();
|
||||
const video = useMuteState(
|
||||
devices.videoInput,
|
||||
|
||||
@@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useObservableState } from "observable-hooks";
|
||||
|
||||
import styles from "./ReactionsOverlay.module.css";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
const reactionsIcons = useBehavior(vm.visibleReactions$);
|
||||
const reactionsIcons = useObservableState(vm.visibleReactions$);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{reactionsIcons.map(({ sender, emoji, startX }) => (
|
||||
{reactionsIcons?.map(({ sender, emoji, startX }) => (
|
||||
<span
|
||||
// Reactions effects are considered presentation elements. The reaction
|
||||
// is also present on the sender's tile, which assistive technology can
|
||||
|
||||
@@ -98,30 +98,18 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M8.929 15.1a13.6 13.6 0 0 0 4.654 3.066q2.62 1.036 5.492.923h.008l.003-.004.003-.002-.034-3.124-3.52-.483-1.791 1.792-.645-.322a13.5 13.5 0 0 1-3.496-2.52 13.4 13.4 0 0 1-2.52-3.496l-.322-.644 1.792-1.792-.483-3.519-3.123-.034-.003.002-.003.004v.002a13.65 13.65 0 0 0 .932 5.492A13.4 13.4 0 0 0 8.93 15.1m3.92 4.926a15.6 15.6 0 0 1-5.334-3.511 15.4 15.4 0 0 1-3.505-5.346 15.6 15.6 0 0 1-1.069-6.274 1.93 1.93 0 0 1 .589-1.366c.366-.366.84-.589 1.386-.589h.01l3.163.035a1.96 1.96 0 0 1 1.958 1.694v.005l.487 3.545v.003c.043.297.025.605-.076.907a2 2 0 0 1-.485.773l-.762.762a11.4 11.4 0 0 0 3.206 3.54q.457.33.948.614l.762-.761a2 2 0 0 1 .774-.486c.302-.1.61-.118.907-.076l3.553.487a1.96 1.96 0 0 1 1.694 1.958l.034 3.174c0 .546-.223 1.02-.588 1.386-.361.36-.827.582-1.363.588a15.3 15.3 0 0 1-6.29-1.062"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
<path
|
||||
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7"
|
||||
/>
|
||||
<path
|
||||
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Handset Mode
|
||||
Earpiece only mode
|
||||
</h2>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
|
||||
@@ -135,11 +123,8 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Back to Speaker Mode
|
||||
Back to video
|
||||
</button>
|
||||
<div
|
||||
class="spacer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
|
||||
@@ -18,8 +18,8 @@ import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
|
||||
import { MatrixRTCFocusMissingError } from "./utils/errors";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { MatrixRTCFocusMissingError } from "./utils/errors.ts";
|
||||
import { getUrlParams } from "./UrlParams.ts";
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
|
||||
@@ -116,7 +116,6 @@ export async function enterRTCSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
{
|
||||
notificationType: getUrlParams().sendNotificationType,
|
||||
useNewMembershipManager,
|
||||
manageMediaKeys: encryptMedia,
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
|
||||
@@ -98,7 +98,7 @@ export const DeviceSelection: FC<Props> = ({
|
||||
labelText = t("settings.devices.loudspeaker");
|
||||
break;
|
||||
case "earpiece":
|
||||
labelText = t("settings.devices.handset");
|
||||
labelText = t("settings.devices.earpiece");
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
@@ -33,7 +34,6 @@ import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { useSubmitRageshake } from "./submit-rageshake";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
type SettingsTab =
|
||||
| "audio"
|
||||
@@ -112,7 +112,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
// rather than the input section.
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
// If we are on iOS we will show a button to open the native audio device picker.
|
||||
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
||||
const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$);
|
||||
|
||||
const audioTab: Tab<SettingsTab> = {
|
||||
key: "audio",
|
||||
|
||||
@@ -6,11 +6,10 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
export class Setting<T> {
|
||||
public constructor(
|
||||
@@ -39,7 +38,7 @@ export class Setting<T> {
|
||||
private readonly key: string;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
public readonly value$: Behavior<T>;
|
||||
public readonly value$: Observable<T>;
|
||||
|
||||
public readonly setValue = (value: T): void => {
|
||||
this._value$.next(value);
|
||||
@@ -54,7 +53,7 @@ export class Setting<T> {
|
||||
* React hook that returns a settings's current value and a setter.
|
||||
*/
|
||||
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
|
||||
return [useBehavior(setting.value$), setting.setValue];
|
||||
return [useObservableEagerState(setting.value$), setting.setValue];
|
||||
}
|
||||
|
||||
// null = undecided
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
/**
|
||||
* A stateful, read-only reactive value. As an Observable, it is "hot" and
|
||||
* always replays the current value upon subscription.
|
||||
*
|
||||
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
|
||||
* provide a way to imperatively set new values. For more info on the
|
||||
* distinction between Behaviors and Observables, see
|
||||
* https://monoid.dk/post/behaviors-and-streams-why-both/.
|
||||
*/
|
||||
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
|
||||
|
||||
/**
|
||||
* Creates a Behavior which never changes in value.
|
||||
*/
|
||||
export function constant<T>(value: T): Behavior<T> {
|
||||
return new BehaviorSubject(value);
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
skip,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
@@ -75,18 +75,10 @@ import {
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
vi.mock("rxjs", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
// Disable interval Observables for the following tests since the test
|
||||
// scheduler will loop on them forever and never call the test 'done'
|
||||
interval: (): Observable<number> => NEVER,
|
||||
}));
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||
@@ -165,10 +157,9 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
l.spotlight?.media$ ?? of(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight?.map((vm) => vm.id),
|
||||
@@ -187,8 +178,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -222,8 +212,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
}
|
||||
|
||||
function withCallViewModel(
|
||||
remoteParticipants$: Behavior<RemoteParticipant[]>,
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
||||
remoteParticipants$: Observable<RemoteParticipant[]>,
|
||||
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||
connectionState$: Observable<ECConnectionState>,
|
||||
speaking: Map<Participant, Observable<boolean>>,
|
||||
mediaDevices: MediaDevices,
|
||||
@@ -301,7 +291,7 @@ function withCallViewModel(
|
||||
}
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
const participantInputMarbles = "a-ba";
|
||||
// Start switching focus on frame 1 and reconnect on frame 3
|
||||
@@ -310,12 +300,12 @@ test("participants are retained during a focus switch", () => {
|
||||
const expectedLayoutMarbles = " a";
|
||||
|
||||
withCallViewModel(
|
||||
behavior(participantInputMarbles, {
|
||||
hot(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
behavior(connectionInputMarbles, {
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
hot(connectionInputMarbles, {
|
||||
c: ConnectionState.Connected,
|
||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
@@ -338,7 +328,7 @@ test("participants are retained during a focus switch", () => {
|
||||
});
|
||||
|
||||
test("screen sharing activates spotlight layout", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||
// then return to no screen shares, then have just Alice share for a bit
|
||||
const participantInputMarbles = " abcda-ba";
|
||||
@@ -351,13 +341,13 @@ test("screen sharing activates spotlight layout", () => {
|
||||
const expectedLayoutMarbles = " abcdaefeg";
|
||||
const expectedShowSpeakingMarbles = "y----nyny";
|
||||
withCallViewModel(
|
||||
behavior(participantInputMarbles, {
|
||||
hot(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceSharingScreen, bobParticipant],
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
d: [aliceParticipant, bobSharingScreen],
|
||||
}),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -423,7 +413,7 @@ test("screen sharing activates spotlight layout", () => {
|
||||
});
|
||||
|
||||
test("participants stay in the same order unless to appear/disappear", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
const visibilityInputMarbles = "a";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
|
||||
@@ -436,22 +426,13 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -491,7 +472,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
});
|
||||
|
||||
test("participants adjust order when space becomes constrained", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
// Start with all tiles on screen then shrink to 3
|
||||
const visibilityInputMarbles = "a-b";
|
||||
// Bob and Dave speak
|
||||
@@ -503,18 +484,12 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
const expectedLayoutMarbles = " a-b";
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -548,7 +523,7 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
});
|
||||
|
||||
test("spotlight speakers swap places", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
// Go immediately into spotlight mode for the test
|
||||
const modeInputMarbles = " s";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
@@ -562,22 +537,13 @@ test("spotlight speakers swap places", () => {
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -621,8 +587,8 @@ test("layout enters picture-in-picture mode when requested", () => {
|
||||
const expectedLayoutMarbles = " aba";
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -663,8 +629,8 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
const expectedLayoutMarbles = "abcbada";
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -712,7 +678,7 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
});
|
||||
|
||||
test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
// iterate through a number of combinations of participants and MatrixRTC memberships
|
||||
// Bob never has an MatrixRTC membership
|
||||
const scenarioInputMarbles = " abcdec";
|
||||
@@ -720,14 +686,14 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
const expectedLayoutMarbles = "a-bc-b";
|
||||
|
||||
withCallViewModel(
|
||||
behavior(scenarioInputMarbles, {
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
d: [aliceParticipant, daveParticipant, bobParticipant],
|
||||
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
||||
}),
|
||||
behavior(scenarioInputMarbles, {
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [],
|
||||
c: [aliceRtcMember],
|
||||
@@ -768,17 +734,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
try {
|
||||
// enable the setting:
|
||||
showNonMemberTiles.setValue(true);
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
const scenarioInputMarbles = " abc";
|
||||
const expectedLayoutMarbles = "abc";
|
||||
|
||||
withCallViewModel(
|
||||
behavior(scenarioInputMarbles, {
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
}),
|
||||
constant([]), // No one joins the MatrixRTC session
|
||||
of([]), // No one joins the MatrixRTC session
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -813,15 +779,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
});
|
||||
|
||||
it("should show at least one tile per MatrixRTCSession", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
// iterate through some combinations of MatrixRTC memberships
|
||||
const scenarioInputMarbles = " abcd";
|
||||
// There should always be one tile for each MatrixRTCSession
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, daveRtcMember],
|
||||
@@ -863,13 +829,13 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
test("should disambiguate users with the same displayname", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
const scenarioInputMarbles = "abcde";
|
||||
const expectedLayoutMarbles = "abcde";
|
||||
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
@@ -880,46 +846,50 @@ test("should disambiguate users with the same displayname", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, alice.rawDisplayName],
|
||||
]),
|
||||
// The second alice joins.
|
||||
c: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
]),
|
||||
// Bob also joins
|
||||
d: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
// Alice leaves, and the displayname should reset.
|
||||
e: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceDoppelgangerId, "Alice"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
});
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, alice.rawDisplayName],
|
||||
]),
|
||||
// The second alice joins.
|
||||
c: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
]),
|
||||
// Bob also joins
|
||||
d: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
// Alice leaves, and the displayname should reset.
|
||||
e: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceDoppelgangerId, "Alice"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should disambiguate users with invisible characters", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
const scenarioInputMarbles = "ab";
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
}),
|
||||
@@ -927,32 +897,36 @@ test("should disambiguate users with invisible characters", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Bobs join, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[bobId, `Bob (${bob.userId})`],
|
||||
[
|
||||
bobZeroWidthSpaceId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
});
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Bobs join, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[bobId, `Bob (${bob.userId})`],
|
||||
[
|
||||
bobZeroWidthSpaceId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should strip RTL characters from displayname", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
const scenarioInputMarbles = "ab";
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [daveRtcMember, daveRTLRtcMember],
|
||||
}),
|
||||
@@ -960,18 +934,22 @@ test("should strip RTL characters from displayname", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
// Not disambiguated
|
||||
[daveId, "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
[daveRTLId, `evaD (${daveRTL.userId})`],
|
||||
]),
|
||||
});
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
// Not disambiguated
|
||||
[daveId, "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
[daveRTLId, `evaD (${daveRTL.userId})`],
|
||||
]),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -983,8 +961,8 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -1048,7 +1026,7 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
|
||||
window.controls.setAvailableAudioDevices([
|
||||
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||
{ id: "earpiece", name: "Handset", isEarpiece: true },
|
||||
{ id: "earpiece", name: "Earpiece", isEarpiece: true },
|
||||
{ id: "headphones", name: "Headphones" },
|
||||
]);
|
||||
window.controls.setAudioDevice("headphones");
|
||||
@@ -1058,8 +1036,8 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
const expectedTargetStateMarbles = " sese";
|
||||
|
||||
withCallViewModel(
|
||||
constant([]),
|
||||
constant([]),
|
||||
of([]),
|
||||
of([]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
devices,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
type Observable,
|
||||
} from "rxjs";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
audioInput as audioInputSetting,
|
||||
@@ -33,11 +34,11 @@ import {
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { switchWhen } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
const logger = rootLogger.getChild("[MediaDevices]");
|
||||
|
||||
export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
@@ -73,11 +74,11 @@ export interface MediaDevice<Label, Selected> {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available$: Behavior<Map<string, Label>>;
|
||||
available$: Observable<Map<string, Label>>;
|
||||
/**
|
||||
* The selected device.
|
||||
*/
|
||||
selected$: Behavior<Selected | undefined>;
|
||||
selected$: Observable<Selected | undefined>;
|
||||
/**
|
||||
* Selects a new device.
|
||||
*/
|
||||
@@ -93,37 +94,35 @@ export interface MediaDevice<Label, Selected> {
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ =
|
||||
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
|
||||
function availableRawDevices$(
|
||||
kind: MediaDeviceKind,
|
||||
usingNames$: Behavior<boolean>,
|
||||
usingNames$: Observable<boolean>,
|
||||
scope: ObservableScope,
|
||||
logger: Logger,
|
||||
): Behavior<MediaDeviceInfo[]> {
|
||||
): Observable<MediaDeviceInfo[]> {
|
||||
const logError = (e: Error): void =>
|
||||
logger.error("Error creating MediaDeviceObserver", e);
|
||||
const devices$ = createMediaDeviceObserver(kind, logError, false);
|
||||
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
|
||||
|
||||
return scope.behavior(
|
||||
usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
),
|
||||
return usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
),
|
||||
[],
|
||||
startWith([]),
|
||||
scope.state(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,40 +161,34 @@ function selectDevice$<Label>(
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
|
||||
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
|
||||
availableRawDevices$("audioinput", this.usingNames$, this.scope);
|
||||
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
availableRawDevices$(
|
||||
"audioinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
this.availableRaw$.pipe(map(buildDeviceMap)),
|
||||
public readonly available$ = this.availableRaw$.pipe(
|
||||
map(buildDeviceMap),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, audioInputSetting.value$).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map(
|
||||
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
|
||||
),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
@@ -203,11 +196,11 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
this.logger.info("[audio-input] available devices:", available);
|
||||
logger.info("[audio-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -215,61 +208,55 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
class AudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioOutput]");
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
|
||||
if (isSafari) {
|
||||
// set to empty map if we are on Safari, because it does not support setSinkId
|
||||
available = new Map();
|
||||
}
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, audioOutputSetting.value$).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioOutputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
audioOutputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
this.logger.info("[audio-output] available devices:", available);
|
||||
logger.info("[audio-output] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -277,43 +264,30 @@ class AudioOutput
|
||||
class ControlledAudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
|
||||
// We need to subscribe to the raw devices so that the OS does update the input
|
||||
// back to what it was before. otherwise we will switch back to the default
|
||||
// whenever we allocate a new stream.
|
||||
public readonly availableRaw$ = availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
);
|
||||
public readonly available$ = combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
|
||||
return available;
|
||||
},
|
||||
),
|
||||
);
|
||||
return available;
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
|
||||
private readonly deviceSelection$ = new Subject<string>();
|
||||
|
||||
@@ -321,82 +295,67 @@ class ControlledAudioOutput
|
||||
this.deviceSelection$.next(id);
|
||||
}
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
),
|
||||
);
|
||||
public readonly selected$ = combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
public constructor(private readonly scope: ObservableScope) {
|
||||
this.selected$.subscribe((device) => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
// This information is probably only of interest if the earpiece mode has
|
||||
// been selected - for example, Element X iOS listens to this to determine
|
||||
// whether it should enable the proximity sensor.
|
||||
if (device !== undefined) {
|
||||
this.logger.info(
|
||||
"[controlled-output] onAudioDeviceSelect called:",
|
||||
device,
|
||||
);
|
||||
logger.info("[controlled-output] setAudioDeviceSelect called:", device);
|
||||
window.controls.onAudioDeviceSelect?.(device.id);
|
||||
// Also invoke the deprecated callback for backward compatibility
|
||||
window.controls.onOutputDeviceSelect?.(device.id);
|
||||
}
|
||||
});
|
||||
this.available$.subscribe((available) => {
|
||||
this.logger.info("[controlled-output] available devices:", available);
|
||||
});
|
||||
this.availableRaw$.subscribe((availableRaw) => {
|
||||
this.logger.info(
|
||||
"[controlled-output] available raw devices:",
|
||||
availableRaw,
|
||||
);
|
||||
logger.info("[controlled-output] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(map(buildDeviceMap));
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
).pipe(map(buildDeviceMap)),
|
||||
);
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, videoInputSetting.value$).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
),
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
videoInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
videoInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
// This also has the purpose of subscribing to the available devices
|
||||
this.available$.subscribe((available) => {
|
||||
this.logger.info("[video-input] available devices:", available);
|
||||
logger.info("[video-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -419,10 +378,12 @@ export class MediaDevices {
|
||||
// you to do to receive device names in lieu of a more explicit permissions
|
||||
// API. This flag never resets to false, because once permissions are granted
|
||||
// the first time, the user won't be prompted again until reload of the page.
|
||||
private readonly usingNames$ = this.scope.behavior(
|
||||
this.deviceNamesRequest$.pipe(map(() => true)),
|
||||
false,
|
||||
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
@@ -432,7 +393,7 @@ export class MediaDevices {
|
||||
AudioOutputDeviceLabel,
|
||||
SelectedAudioOutputDevice
|
||||
> = getUrlParams().controlledAudioDevices
|
||||
? new ControlledAudioOutput(this.usingNames$, this.scope)
|
||||
? new ControlledAudioOutput(this.scope)
|
||||
: new AudioOutput(this.usingNames$, this.scope);
|
||||
|
||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||
|
||||
@@ -51,19 +51,26 @@ import { accumulate } from "../utils/observable";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
participant$: Observable<Participant | undefined>,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReferenceOrPlaceholder> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
||||
return participant$.pipe(
|
||||
switchMap((p) => {
|
||||
if (p) {
|
||||
return observeParticipantMedia(p).pipe(
|
||||
map(() => ({
|
||||
participant: p,
|
||||
publication: p.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
);
|
||||
} else {
|
||||
return of(undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +82,7 @@ export function observeRtpStreamStats$(
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
observeTrackReference$(of(participant), source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
@@ -216,31 +223,19 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
|
||||
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning$: Behavior<boolean>;
|
||||
public readonly unencryptedWarning$: Observable<boolean>;
|
||||
|
||||
public readonly encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
public readonly encryptionStatus$: Observable<EncryptionStatus>;
|
||||
|
||||
/**
|
||||
* Whether this media corresponds to the local participant.
|
||||
*/
|
||||
public abstract readonly local: boolean;
|
||||
|
||||
private observeTrackReference$(
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReferenceOrPlaceholder | undefined> {
|
||||
return this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === undefined ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
@@ -262,85 +257,84 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
livekitRoom: LivekitRoom,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
public readonly displayname$: Observable<string>,
|
||||
) {
|
||||
super();
|
||||
|
||||
const audio$ = this.observeTrackReference$(audioSource);
|
||||
this.video$ = this.observeTrackReference$(videoSource);
|
||||
|
||||
this.unencryptedWarning$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
),
|
||||
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
|
||||
this.scope.state(),
|
||||
);
|
||||
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
|
||||
this.scope.state(),
|
||||
);
|
||||
this.unencryptedWarning$ = combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
).pipe(this.scope.state());
|
||||
|
||||
this.encryptionStatus$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
this.encryptionStatus$ = this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -360,33 +354,31 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
public readonly speaking$ = this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
|
||||
map((p) => p.isSpeaking),
|
||||
)
|
||||
: of(false),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
public readonly audioEnabled$: Observable<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
public readonly videoEnabled$: Behavior<boolean>;
|
||||
public readonly videoEnabled$: Observable<boolean>;
|
||||
|
||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||
/**
|
||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||
*/
|
||||
public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
|
||||
public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
@@ -394,9 +386,9 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayName$: Behavior<string>,
|
||||
public readonly handRaised$: Behavior<Date | null>,
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
displayname$: Observable<string>,
|
||||
public readonly handRaised$: Observable<Date | null>,
|
||||
public readonly reaction$: Observable<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -406,19 +398,18 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom,
|
||||
displayName$,
|
||||
displayname$,
|
||||
);
|
||||
|
||||
const media$ = this.scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
const media$ = participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
this.scope.state(),
|
||||
);
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
this.audioEnabled$ = media$.pipe(
|
||||
map((m) => m?.microphoneTrack?.isMuted === false),
|
||||
);
|
||||
this.videoEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
this.videoEnabled$ = media$.pipe(
|
||||
map((m) => m?.cameraTrack?.isMuted === false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -445,19 +436,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
);
|
||||
}),
|
||||
),
|
||||
public readonly mirror$ = this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
);
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -470,12 +460,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant$: Behavior<LocalParticipant | undefined>,
|
||||
participant$: Observable<LocalParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayName$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -483,7 +473,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
displayName$,
|
||||
displayname$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
@@ -522,42 +512,42 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* The volume to which this participant's audio is set, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume$ = this.scope.behavior<number>(
|
||||
merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
public readonly localVolume$: Observable<number> = merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant's audio is disabled.
|
||||
*/
|
||||
public readonly locallyMuted$ = this.scope.behavior<boolean>(
|
||||
this.localVolume$.pipe(map((volume) => volume === 0)),
|
||||
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
|
||||
map((volume) => volume === 0),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
@@ -566,9 +556,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -631,7 +621,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Behavior<string>,
|
||||
displayname$: Observable<string>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
|
||||
@@ -26,9 +26,11 @@ test("muteAllAudio$", () => {
|
||||
|
||||
muteAllAudio.unsubscribe();
|
||||
|
||||
expect(valueMock).toHaveBeenCalledTimes(4);
|
||||
expect(valueMock).toHaveBeenCalledTimes(6);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, true); // muteAllAudioSetting.setValue(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
|
||||
});
|
||||
|
||||
@@ -9,14 +9,11 @@ import { combineLatest, startWith } from "rxjs";
|
||||
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
|
||||
import { globalScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* This can transition into sth more complete: `GroupCallViewModel.ts`
|
||||
*/
|
||||
export const muteAllAudio$ = globalScope.behavior(
|
||||
combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
),
|
||||
export const muteAllAudio$ = combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
);
|
||||
|
||||
@@ -6,19 +6,15 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
type Observable,
|
||||
shareReplay,
|
||||
Subject,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
/**
|
||||
* A scope which limits the execution lifetime of its bound Observables.
|
||||
*/
|
||||
@@ -35,31 +31,20 @@ export class ObservableScope {
|
||||
return this.bindImpl;
|
||||
}
|
||||
|
||||
private readonly stateImpl: MonoTypeOperator = (o$) =>
|
||||
o$.pipe(
|
||||
this.bind(),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Converts an Observable to a Behavior. If no initial value is specified, the
|
||||
* Observable must synchronously emit an initial value.
|
||||
* Transforms an Observable into a hot state Observable which replays its
|
||||
* latest value upon subscription, skips updates with identical values, and
|
||||
* is bound to this scope.
|
||||
*/
|
||||
public behavior<T>(
|
||||
setValue$: Observable<T>,
|
||||
initialValue: T | typeof nothing = nothing,
|
||||
): Behavior<T> {
|
||||
const subject$ = new BehaviorSubject(initialValue);
|
||||
// Push values from the Observable into the BehaviorSubject.
|
||||
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
||||
// they will no longer re-emit their current value upon subscription. We want
|
||||
// to support Observables that complete (for example `of({})`), so we have to
|
||||
// take care to not propagate the completion event.
|
||||
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
|
||||
next(value) {
|
||||
subject$.next(value);
|
||||
},
|
||||
error(err: unknown) {
|
||||
subject$.error(err);
|
||||
},
|
||||
});
|
||||
if (subject$.value === nothing)
|
||||
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||
return subject$ as Behavior<T>;
|
||||
public state(): MonoTypeOperator {
|
||||
return this.stateImpl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,8 +55,3 @@ export class ObservableScope {
|
||||
this.ended$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global scope, a scope which never ends.
|
||||
*/
|
||||
export const globalScope = new ObservableScope();
|
||||
|
||||
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
@@ -17,15 +18,15 @@ function createId(): string {
|
||||
export class GridTileViewModel extends ViewModel {
|
||||
public readonly id = createId();
|
||||
|
||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
|
||||
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class SpotlightTileViewModel extends ViewModel {
|
||||
public constructor(
|
||||
public readonly media$: Behavior<MediaViewModel[]>,
|
||||
public readonly maximised$: Behavior<boolean>,
|
||||
public readonly media$: Observable<MediaViewModel[]>,
|
||||
public readonly maximised$: Observable<boolean>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type RemoteTrackPublication } from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { of } from "rxjs";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
@@ -16,7 +17,6 @@ import { mockRtcMembership, withRemoteMedia } from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import type { CallViewModel } from "../state/CallViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -53,13 +53,13 @@ test("GridTile is accessible", async () => {
|
||||
memberships: [],
|
||||
} as unknown as MatrixRTCSession;
|
||||
const cVm = {
|
||||
reactions$: constant({}),
|
||||
handsRaised$: constant({}),
|
||||
reactions$: of({}),
|
||||
handsRaised$: of({}),
|
||||
} as Partial<CallViewModel> as CallViewModel;
|
||||
const { container } = render(
|
||||
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={new GridTileViewModel(constant(vm))}
|
||||
vm={new GridTileViewModel(of(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
ToggleMenuItem,
|
||||
Menu,
|
||||
} from "@vector-im/compound-web";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { useObservableEagerState, useObservableState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import {
|
||||
@@ -49,7 +49,6 @@ import { useLatest } from "../useLatest";
|
||||
import { type GridTileViewModel } from "../state/TileViewModel";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -82,19 +81,19 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
}) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
const { t } = useTranslation();
|
||||
const video = useBehavior(vm.video$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const speaking = useObservableEagerState(vm.speaking$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
@@ -102,8 +101,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const handRaised = useBehavior(vm.handRaised$);
|
||||
const reaction = useBehavior(vm.reaction$);
|
||||
const handRaised = useObservableState(vm.handRaised$);
|
||||
const reaction = useObservableState(vm.reaction$);
|
||||
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
@@ -206,8 +205,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useBehavior(vm.mirror$);
|
||||
const alwaysShow = useBehavior(vm.alwaysShow$);
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
@@ -257,8 +256,8 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||
const localVolume = useBehavior(vm.localVolume$);
|
||||
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
|
||||
const localVolume = useObservableEagerState(vm.localVolume$);
|
||||
const onSelectMute = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
@@ -329,8 +328,8 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useBehavior(vm.media$);
|
||||
const displayName = useBehavior(media.displayName$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const displayName = useObservableEagerState(media.displayname$);
|
||||
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { test, expect, vi } from "vitest";
|
||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
withRemoteMedia,
|
||||
} from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -44,12 +44,7 @@ test("SpotlightTile is accessible", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm1, vm2]),
|
||||
constant(false),
|
||||
)
|
||||
}
|
||||
vm={new SpotlightTileViewModel(of([vm1, vm2]), of(false))}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type Observable, map } from "rxjs";
|
||||
import { useObservableRef } from "observable-hooks";
|
||||
import { useObservableEagerState, useObservableRef } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
@@ -43,7 +43,6 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -74,7 +73,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const mirror = useBehavior(vm.mirror$);
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
return <MediaView mirror={mirror} {...props} />;
|
||||
};
|
||||
|
||||
@@ -88,8 +87,8 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
@@ -131,10 +130,10 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useBehavior(vm.displayName$);
|
||||
const video = useBehavior(vm.video$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
const displayName = useObservableEagerState(vm.displayname$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
@@ -201,8 +200,8 @@ export const SpotlightTile: FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useBehavior(vm.maximised$);
|
||||
const media = useBehavior(vm.media$);
|
||||
const maximised = useObservableEagerState(vm.maximised$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
|
||||
@@ -10,12 +10,12 @@ import { type FC } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { useAudioContext } from "./useAudioContext";
|
||||
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
|
||||
import { mockMediaDevices } from "./utils/test";
|
||||
import { constant } from "./state/Behavior";
|
||||
|
||||
const staticSounds = Promise.resolve({
|
||||
aSound: new ArrayBuffer(0),
|
||||
@@ -128,8 +128,8 @@ test("will use the correct device", () => {
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(new Map<never, never>()),
|
||||
selected$: constant({ id: "chosen-device", virtualEarpiece: false }),
|
||||
available$: of(new Map<never, never>()),
|
||||
selected$: of({ id: "chosen-device", virtualEarpiece: false }),
|
||||
select: () => {},
|
||||
},
|
||||
})}
|
||||
@@ -161,8 +161,8 @@ test("will use the pan if earpiece is selected", async () => {
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(new Map<never, never>()),
|
||||
selected$: constant({ id: "chosen-device", virtualEarpiece: true }),
|
||||
available$: of(new Map<never, never>()),
|
||||
selected$: of({ id: "chosen-device", virtualEarpiece: true }),
|
||||
select: () => {},
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import { type Behavior } from "./state/Behavior";
|
||||
|
||||
/**
|
||||
* React hook which reactively reads the value of a behavior.
|
||||
*/
|
||||
export function useBehavior<T>(behavior: Behavior<T>): T {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
const s = behavior.subscribe(onChange);
|
||||
return (): void => s.unsubscribe();
|
||||
},
|
||||
[behavior],
|
||||
);
|
||||
const getValue = useCallback(() => behavior.value, [behavior]);
|
||||
return useSyncExternalStore(subscribe, getValue);
|
||||
}
|
||||
@@ -47,8 +47,6 @@ import {
|
||||
} from "../config/ConfigOptions";
|
||||
import { Config } from "../config/Config";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
export function withFakeTimers(continuation: () => void): void {
|
||||
vi.useFakeTimers();
|
||||
@@ -69,11 +67,6 @@ export interface OurRunHelpers extends RunHelpers {
|
||||
* diagram.
|
||||
*/
|
||||
schedule: (marbles: string, actions: Record<string, () => void>) => void;
|
||||
behavior<T = string>(
|
||||
marbles: string,
|
||||
values?: { [marble: string]: T },
|
||||
error?: unknown,
|
||||
): Behavior<T>;
|
||||
}
|
||||
|
||||
interface TestRunnerGlobal {
|
||||
@@ -89,7 +82,6 @@ export function withTestScheduler(
|
||||
const scheduler = new TestScheduler((actual, expected) => {
|
||||
expect(actual).deep.equals(expected);
|
||||
});
|
||||
const scope = new ObservableScope();
|
||||
// we set the test scheduler as a global so that you can watch it in a debugger
|
||||
// and get the frame number. e.g. `rxjsTestScheduler?.now()`
|
||||
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
|
||||
@@ -106,36 +98,8 @@ export function withTestScheduler(
|
||||
// Run the actions and verify that none of them error
|
||||
helpers.expectObservable(actionsObservable$).toBe(marbles, results);
|
||||
},
|
||||
behavior<T>(
|
||||
marbles: string,
|
||||
values?: { [marble: string]: T },
|
||||
error?: unknown,
|
||||
) {
|
||||
// Generate a hot Observable with helpers.hot and use it as a Behavior.
|
||||
// To do this, we need to ensure that the initial value emits
|
||||
// synchronously upon subscription. The issue is that helpers.hot emits
|
||||
// frame 0 of the marble diagram *asynchronously*, only once we return
|
||||
// from the continuation, so we need to splice out the initial marble
|
||||
// and turn it into a proper initial value.
|
||||
const initialMarbleIndex = marbles.search(/[^ ]/);
|
||||
if (initialMarbleIndex === -1)
|
||||
throw new Error("Behavior must have an initial value");
|
||||
const initialMarble = marbles[initialMarbleIndex];
|
||||
const initialValue =
|
||||
values === undefined ? (initialMarble as T) : values[initialMarble];
|
||||
// The remainder of the marble diagram should start on frame 1
|
||||
return scope.behavior(
|
||||
helpers.hot(
|
||||
`-${marbles.slice(initialMarbleIndex + 1)}`,
|
||||
values,
|
||||
error,
|
||||
),
|
||||
initialValue,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
scope.end();
|
||||
}
|
||||
|
||||
interface EmitterMock<T> {
|
||||
@@ -247,14 +211,14 @@ export async function withLocalMedia(
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"local",
|
||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||
constant(localParticipant),
|
||||
of(localParticipant),
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({ localParticipant }),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
of(null),
|
||||
of(null),
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
@@ -291,9 +255,9 @@ export async function withRemoteMedia(
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
of(null),
|
||||
of(null),
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
@@ -335,7 +299,7 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
public withMemberships(
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
||||
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||
): MockRTCSession {
|
||||
rtcMembers$.subscribe((m) => {
|
||||
const old = this.memberships;
|
||||
|
||||
@@ -106,10 +106,6 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
if (!baseUrl) throw new Error("Base URL must be supplied");
|
||||
|
||||
// These are all the event types the app uses
|
||||
const sendEvent = [
|
||||
EventType.CallNotify, // Sent as a deprecated fallback
|
||||
EventType.RTCNotification,
|
||||
];
|
||||
const sendRecvEvent = [
|
||||
"org.matrix.rageshake_request",
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
@@ -133,7 +129,6 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
{ eventType: EventType.RoomEncryption },
|
||||
{ eventType: EventType.GroupCallMemberPrefix },
|
||||
];
|
||||
|
||||
const sendRecvToDevice = [
|
||||
EventType.CallInvite,
|
||||
EventType.CallCandidates,
|
||||
@@ -151,7 +146,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
const client = createRoomWidgetClient(
|
||||
api,
|
||||
{
|
||||
sendEvent: [...sendEvent, ...sendRecvEvent],
|
||||
sendEvent: sendRecvEvent,
|
||||
receiveEvent: sendRecvEvent,
|
||||
sendState,
|
||||
receiveState,
|
||||
|
||||
Reference in New Issue
Block a user