Compare commits

..

1 Commits

Author SHA1 Message Date
Timo
f6ae395e66 Increase opacity of the EarpieceOverlay background for better visibility 2025-06-27 10:11:37 +02:00
52 changed files with 1658 additions and 2084 deletions

View File

@@ -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
View File

@@ -9,7 +9,6 @@ dist-ssr
public/config.json
backend/synapse_tmp/*
/coverage
config.json
# Yarn
yarn-error.log

View File

@@ -21,5 +21,3 @@ turn:
external_tls: true
keys:
devkey: secret
room:
auto_create: false

View File

@@ -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

View File

@@ -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 users 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

View File

@@ -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" }

View File

@@ -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

View File

@@ -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",

View File

@@ -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}}",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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,
};
};

View File

@@ -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>

View File

@@ -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.

View File

@@ -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;

View File

@@ -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] =

View File

@@ -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],

View File

@@ -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 }) =>

View File

@@ -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

View File

@@ -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],
);

View File

@@ -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]);

View File

@@ -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

View File

@@ -61,7 +61,3 @@
.overlay > p {
text-align: center;
}
.spacer {
min-height: var(--cpd-space-32x);
}

View File

@@ -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>
);
};

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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$,
);

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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 && {

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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> =

View File

@@ -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(

View File

@@ -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);
});

View File

@@ -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,
);

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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);

View File

@@ -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: () => {},
},
})}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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,

1178
yarn.lock

File diff suppressed because it is too large Load Diff