Use finnish notation for observables (#2905)

To help make our usage of the observables more readable/intuitive.
This commit is contained in:
Hugh Nimmo-Smith
2024-12-17 04:01:56 +00:00
committed by GitHub
parent e4bd9d7cf9
commit 79c40f198c
30 changed files with 491 additions and 490 deletions

View File

@@ -44,6 +44,7 @@ module.exports = {
], ],
// To encourage good usage of RxJS: // To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error", "rxjs/no-exposed-subjects": "error",
"rxjs/finnish": "error",
}, },
settings: { settings: {
react: { react: {

View File

@@ -415,7 +415,7 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device // * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes) // won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
optInAnalytics.value.subscribe((optIn) => { optInAnalytics.value$.subscribe((optIn) => {
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled); this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser().catch(() => this.maybeIdentifyUser().catch(() =>
logger.log("Could not identify user"), logger.log("Could not identify user"),

View File

@@ -13,18 +13,18 @@ export interface Controls {
disablePip: () => void; disablePip: () => void;
} }
export const setPipEnabled = new Subject<boolean>(); export const setPipEnabled$ = new Subject<boolean>();
window.controls = { window.controls = {
canEnterPip(): boolean { canEnterPip(): boolean {
return setPipEnabled.observed; return setPipEnabled$.observed;
}, },
enablePip(): void { enablePip(): void {
if (!setPipEnabled.observed) throw new Error("No call is running"); if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled.next(true); setPipEnabled$.next(true);
}, },
disablePip(): void { disablePip(): void {
if (!setPipEnabled.observed) throw new Error("No call is running"); if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled.next(false); setPipEnabled$.next(false);
}, },
}; };

View File

@@ -31,15 +31,15 @@ export interface CallLayoutInputs {
/** /**
* The minimum bounds of the layout area. * The minimum bounds of the layout area.
*/ */
minBounds: Observable<Bounds>; minBounds$: Observable<Bounds>;
/** /**
* The alignment of the floating spotlight tile, if present. * The alignment of the floating spotlight tile, if present.
*/ */
spotlightAlignment: BehaviorSubject<Alignment>; spotlightAlignment$: BehaviorSubject<Alignment>;
/** /**
* The alignment of the small picture-in-picture tile, if present. * The alignment of the small picture-in-picture tile, if present.
*/ */
pipAlignment: BehaviorSubject<Alignment>; pipAlignment$: BehaviorSubject<Alignment>;
} }
export interface CallLayoutOutputs<Model> { export interface CallLayoutOutputs<Model> {

View File

@@ -156,7 +156,7 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
); );
} }
const windowHeightObservable = fromEvent(window, "resize").pipe( const windowHeightObservable$ = fromEvent(window, "resize").pipe(
startWith(null), startWith(null),
map(() => window.innerHeight), map(() => window.innerHeight),
); );
@@ -262,7 +262,7 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null); const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2); const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const windowHeight = useObservableEagerState(windowHeightObservable); const windowHeight = useObservableEagerState(windowHeightObservable$);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] = const [visibleTilesCallback, setVisibleTilesCallback] =

View File

@@ -26,8 +26,8 @@ interface GridCSSProperties extends CSSProperties {
* together in a scrolling grid. * together in a scrolling grid.
*/ */
export const makeGridLayout: CallLayout<GridLayoutModel> = ({ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds, minBounds$,
spotlightAlignment, spotlightAlignment$,
}) => ({ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
@@ -37,7 +37,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
useUpdateLayout(); useUpdateLayout();
const alignment = useObservableEagerState( const alignment = useObservableEagerState(
useInitial(() => useInitial(() =>
spotlightAlignment.pipe( spotlightAlignment$.pipe(
distinctUntilChanged( distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline, (a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
), ),
@@ -47,7 +47,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
const onDragSpotlight: DragCallback = useCallback( const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
spotlightAlignment.next({ spotlightAlignment$.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end",
}), }),
@@ -74,7 +74,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds); const { width, height: minHeight } = useObservableEagerState(minBounds$);
const { gap, tileWidth, tileHeight } = useMemo( const { gap, tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, minHeight, model.grid.length), () => arrangeTiles(width, minHeight, model.grid.length),
[width, minHeight, model.grid.length], [width, minHeight, model.grid.length],

View File

@@ -19,8 +19,8 @@ import { type DragCallback, useUpdateLayout } from "./Grid";
* is shown at maximum size, overlaid by a small view of the local participant. * is shown at maximum size, overlaid by a small view of the local participant.
*/ */
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds, minBounds$,
pipAlignment, pipAlignment$,
}) => ({ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
@@ -31,8 +31,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
useUpdateLayout(); useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds); const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment); const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const { tileWidth, tileHeight } = useMemo( const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1), () => arrangeTiles(width, height, 1),
[width, height], [width, height],
@@ -40,7 +40,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
const onDragLocalTile: DragCallback = useCallback( const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
pipAlignment.next({ pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end",
}), }),

View File

@@ -19,7 +19,7 @@ import styles from "./SpotlightExpandedLayout.module.css";
*/ */
export const makeSpotlightExpandedLayout: CallLayout< export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel SpotlightExpandedLayoutModel
> = ({ pipAlignment }) => ({ > = ({ pipAlignment$ }) => ({
scrollingOnTop: true, scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed( fixed: forwardRef(function SpotlightExpandedLayoutFixed(
@@ -44,11 +44,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
ref, ref,
) { ) {
useUpdateLayout(); useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment); const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const onDragPip: DragCallback = useCallback( const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
pipAlignment.next({ pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end", block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end",
}), }),

View File

@@ -21,7 +21,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid";
*/ */
export const makeSpotlightLandscapeLayout: CallLayout< export const makeSpotlightLandscapeLayout: CallLayout<
SpotlightLandscapeLayoutModel SpotlightLandscapeLayoutModel
> = ({ minBounds }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed( fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
@@ -29,7 +29,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
ref, ref,
) { ) {
useUpdateLayout(); useUpdateLayout();
useObservableEagerState(minBounds); useObservableEagerState(minBounds$);
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>
@@ -51,9 +51,9 @@ export const makeSpotlightLandscapeLayout: CallLayout<
) { ) {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds); useObservableEagerState(minBounds$);
const withIndicators = const withIndicators =
useObservableEagerState(model.spotlight.media).length > 1; useObservableEagerState(model.spotlight.media$).length > 1;
return ( return (
<div ref={ref} className={styles.layer}> <div ref={ref} className={styles.layer}>

View File

@@ -27,7 +27,7 @@ interface GridCSSProperties extends CSSProperties {
*/ */
export const makeSpotlightPortraitLayout: CallLayout< export const makeSpotlightPortraitLayout: CallLayout<
SpotlightPortraitLayoutModel SpotlightPortraitLayoutModel
> = ({ minBounds }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed( fixed: forwardRef(function SpotlightPortraitLayoutFixed(
@@ -55,7 +55,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
) { ) {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds); const { width } = useObservableEagerState(minBounds$);
const { gap, tileWidth, tileHeight } = arrangeTiles( const { gap, tileWidth, tileHeight } = arrangeTiles(
width, width,
// TODO: We pretend that the minimum height is the width, because the // TODO: We pretend that the minimum height is the width, because the
@@ -64,7 +64,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
model.grid.length, model.grid.length,
); );
const withIndicators = const withIndicators =
useObservableEagerState(model.spotlight.media).length > 1; useObservableEagerState(model.spotlight.media$).length > 1;
return ( return (
<div <div

View File

@@ -74,7 +74,7 @@ function useMediaDevice(
// useMediaDevices provides no way to request device names. // useMediaDevices provides no way to request device names.
// Tragically, the only way to get device names out of LiveKit is to specify a // Tragically, the only way to get device names out of LiveKit is to specify a
// kind, which then results in multiple permissions requests. // kind, which then results in multiple permissions requests.
const deviceObserver = useMemo( const deviceObserver$ = useMemo(
() => () =>
createMediaDeviceObserver( createMediaDeviceObserver(
kind, kind,
@@ -86,7 +86,7 @@ function useMediaDevice(
const available = useObservableEagerState( const available = useObservableEagerState(
useMemo( useMemo(
() => () =>
deviceObserver.pipe( deviceObserver$.pipe(
map((availableRaw) => { map((availableRaw) => {
// Sometimes browsers (particularly Firefox) can return multiple device // Sometimes browsers (particularly Firefox) can return multiple device
// entries for the exact same device ID; using a map deduplicates them // entries for the exact same device ID; using a map deduplicates them
@@ -117,7 +117,7 @@ function useMediaDevice(
return available; return available;
}), }),
), ),
[kind, deviceObserver], [kind, deviceObserver$],
), ),
); );
@@ -140,13 +140,13 @@ function useMediaDevice(
const selectedGroupId = useObservableEagerState( const selectedGroupId = useObservableEagerState(
useMemo( useMemo(
() => () =>
deviceObserver.pipe( deviceObserver$.pipe(
map( map(
(availableRaw) => (availableRaw) =>
availableRaw.find((d) => d.deviceId === selectedId)?.groupId, availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
), ),
), ),
[deviceObserver, selectedId], [deviceObserver$, selectedId],
), ),
); );

View File

@@ -100,13 +100,13 @@ function getMockEnv(
): { ): {
vm: CallViewModel; vm: CallViewModel;
session: MockRTCSession; session: MockRTCSession;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>; remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
} { } {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]); const remoteParticipants$ = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom( const liveKitRoom = mockLivekitRoom(
{ localParticipant }, { localParticipant },
{ remoteParticipants }, { remoteParticipants$ },
); );
const matrixRoom = mockMatrixRoom({ const matrixRoom = mockMatrixRoom({
client: { client: {
@@ -118,14 +118,14 @@ function getMockEnv(
getMember: (userId) => matrixRoomMembers.get(userId) ?? null, getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
}); });
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>( const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships, initialRemoteRtcMemberships,
); );
const session = new MockRTCSession( const session = new MockRTCSession(
matrixRoom, matrixRoom,
localRtcMember, localRtcMember,
).withMemberships(remoteRtcMemberships); ).withMemberships(remoteRtcMemberships$);
const vm = new CallViewModel( const vm = new CallViewModel(
session as unknown as MatrixRTCSession, session as unknown as MatrixRTCSession,
@@ -135,7 +135,7 @@ function getMockEnv(
}, },
of(ConnectionState.Connected), of(ConnectionState.Connected),
); );
return { vm, session, remoteRtcMemberships }; return { vm, session, remoteRtcMemberships$ };
} }
/** /**
@@ -146,33 +146,33 @@ function getMockEnv(
* a noise every time. * a noise every time.
*/ */
test("plays one sound when entering a call", () => { test("plays one sound when entering a call", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />); render(<TestComponent rtcSession={session} vm={vm} />);
// Joining a call usually means remote participants are added later. // Joining a call usually means remote participants are added later.
act(() => { act(() => {
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
}); });
expect(playSound).toHaveBeenCalledOnce(); expect(playSound).toHaveBeenCalledOnce();
}); });
// TODO: Same test? // TODO: Same test?
test("plays a sound when a user joins", () => { test("plays a sound when a user joins", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />); render(<TestComponent rtcSession={session} vm={vm} />);
act(() => { act(() => {
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
}); });
// Play a sound when joining a call. // Play a sound when joining a call.
expect(playSound).toBeCalledWith("join"); expect(playSound).toBeCalledWith("join");
}); });
test("plays a sound when a user leaves", () => { test("plays a sound when a user leaves", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); const { session, vm, remoteRtcMemberships$ } = getMockEnv([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />); render(<TestComponent rtcSession={session} vm={vm} />);
act(() => { act(() => {
remoteRtcMemberships.next([]); remoteRtcMemberships$.next([]);
}); });
expect(playSound).toBeCalledWith("left"); expect(playSound).toBeCalledWith("left");
}); });
@@ -185,7 +185,7 @@ test("plays no sound when the participant list is more than the maximum size", (
); );
} }
const { session, vm, remoteRtcMemberships } = getMockEnv( const { session, vm, remoteRtcMemberships$ } = getMockEnv(
[local, alice], [local, alice],
mockRtcMemberships, mockRtcMemberships,
); );
@@ -193,7 +193,7 @@ test("plays no sound when the participant list is more than the maximum size", (
render(<TestComponent rtcSession={session} vm={vm} />); render(<TestComponent rtcSession={session} vm={vm} />);
expect(playSound).not.toBeCalled(); expect(playSound).not.toBeCalled();
act(() => { act(() => {
remoteRtcMemberships.next( remoteRtcMemberships$.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1), mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
); );
}); });

View File

@@ -65,7 +65,7 @@ export function CallEventAudioRenderer({
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]); }, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
useEffect(() => { useEffect(() => {
const joinSub = vm.memberChanges const joinSub = vm.memberChanges$
.pipe( .pipe(
filter( filter(
({ joined, ids }) => ({ joined, ids }) =>
@@ -77,7 +77,7 @@ export function CallEventAudioRenderer({
void audioEngineRef.current?.playSound("join"); void audioEngineRef.current?.playSound("join");
}); });
const leftSub = vm.memberChanges const leftSub = vm.memberChanges$
.pipe( .pipe(
filter( filter(
({ ids, left }) => ({ ids, left }) =>

View File

@@ -110,8 +110,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
sfuConfig, sfuConfig,
props.e2eeSystem, props.e2eeSystem,
); );
const connStateObservable = useObservable( const connStateObservable$ = useObservable(
(inputs) => inputs.pipe(map(([connState]) => connState)), (inputs$) => inputs$.pipe(map(([connState]) => connState)),
[connState], [connState],
); );
const [vm, setVm] = useState<CallViewModel | null>(null); const [vm, setVm] = useState<CallViewModel | null>(null);
@@ -131,12 +131,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession, props.rtcSession,
livekitRoom, livekitRoom,
props.e2eeSystem, props.e2eeSystem,
connStateObservable, connStateObservable$,
); );
setVm(vm); setVm(vm);
return (): void => vm.destroy(); return (): void => vm.destroy();
} }
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]); }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
if (livekitRoom === undefined || vm === null) return null; if (livekitRoom === undefined || vm === null) return null;
@@ -225,14 +225,14 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const windowMode = useObservableEagerState(vm.windowMode); const windowMode = useObservableEagerState(vm.windowMode$);
const layout = useObservableEagerState(vm.layout); const layout = useObservableEagerState(vm.layout$);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration); const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting); const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const gridMode = useObservableEagerState(vm.gridMode); const gridMode = useObservableEagerState(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader); const showHeader = useObservableEagerState(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter); const showFooter = useObservableEagerState(vm.showFooter$);
const switchCamera = useSwitchCamera(vm.localVideo); const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking // Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported // that the pointerType of the event is "touch", but this isn't yet supported
@@ -317,15 +317,15 @@ export const InCallView: FC<InCallViewProps> = ({
windowMode, windowMode,
], ],
); );
const gridBoundsObservable = useObservable( const gridBoundsObservable$ = useObservable(
(inputs) => inputs.pipe(map(([gridBounds]) => gridBounds)), (inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
[gridBounds], [gridBounds],
); );
const spotlightAlignment = useInitial( const spotlightAlignment$ = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment), () => new BehaviorSubject(defaultSpotlightAlignment),
); );
const pipAlignment = useInitial( const pipAlignment$ = useInitial(
() => new BehaviorSubject(defaultPipAlignment), () => new BehaviorSubject(defaultPipAlignment),
); );
@@ -383,15 +383,17 @@ export const InCallView: FC<InCallViewProps> = ({
{ className, style, targetWidth, targetHeight, model }, { className, style, targetWidth, targetHeight, model },
ref, ref,
) { ) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded); const spotlightExpanded = useObservableEagerState(
vm.spotlightExpanded$,
);
const onToggleExpanded = useObservableEagerState( const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded, vm.toggleSpotlightExpanded$,
); );
const showSpeakingIndicatorsValue = useObservableEagerState( const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators, vm.showSpeakingIndicators$,
); );
const showSpotlightIndicatorsValue = useObservableEagerState( const showSpotlightIndicatorsValue = useObservableEagerState(
vm.showSpotlightIndicators, vm.showSpotlightIndicators$,
); );
return model instanceof GridTileViewModel ? ( return model instanceof GridTileViewModel ? (
@@ -424,9 +426,9 @@ export const InCallView: FC<InCallViewProps> = ({
const layouts = useMemo(() => { const layouts = useMemo(() => {
const inputs = { const inputs = {
minBounds: gridBoundsObservable, minBounds$: gridBoundsObservable$,
spotlightAlignment, spotlightAlignment$,
pipAlignment, pipAlignment$,
}; };
return { return {
grid: makeGridLayout(inputs), grid: makeGridLayout(inputs),
@@ -435,7 +437,7 @@ export const InCallView: FC<InCallViewProps> = ({
"spotlight-expanded": makeSpotlightExpandedLayout(inputs), "spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs), "one-on-one": makeOneOnOneLayout(inputs),
}; };
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]); }, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]);
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
if (layout.type === "pip") { if (layout.type === "pip") {

View File

@@ -148,7 +148,7 @@ export const LobbyView: FC<Props> = ({
const switchCamera = useSwitchCamera( const switchCamera = useSwitchCamera(
useObservable( useObservable(
(inputs) => inputs.pipe(map(([video]) => video)), (inputs$) => inputs$.pipe(map(([video]) => video)),
[videoTrack], [videoTrack],
), ),
); );

View File

@@ -31,17 +31,17 @@ import { useLatest } from "../useLatest";
* producing a callback if so. * producing a callback if so.
*/ */
export function useSwitchCamera( export function useSwitchCamera(
video: Observable<LocalVideoTrack | null>, video$: Observable<LocalVideoTrack | null>,
): (() => void) | null { ): (() => void) | null {
const mediaDevices = useMediaDevices(); const mediaDevices = useMediaDevices();
const setVideoInput = useLatest(mediaDevices.videoInput.select); const setVideoInput = useLatest(mediaDevices.videoInput.select);
// Produce an observable like the input 'video' observable, except make it // Produce an observable like the input 'video' observable, except make it
// emit whenever the track is muted or the device changes // emit whenever the track is muted or the device changes
const videoTrack: Observable<LocalVideoTrack | null> = useObservable( const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
(inputs) => (inputs$) =>
inputs.pipe( inputs$.pipe(
switchMap(([video]) => video), switchMap(([video$]) => video$),
switchMap((video) => { switchMap((video) => {
if (video === null) return of(null); if (video === null) return of(null);
return merge( return merge(
@@ -53,15 +53,15 @@ export function useSwitchCamera(
); );
}), }),
), ),
[video], [video$],
); );
const switchCamera: Observable<(() => void) | null> = useObservable( const switchCamera$: Observable<(() => void) | null> = useObservable(
(inputs) => (inputs$) =>
platform === "desktop" platform === "desktop"
? of(null) ? of(null)
: inputs.pipe( : inputs$.pipe(
switchMap(([track]) => track), switchMap(([track$]) => track$),
map((track) => { map((track) => {
if (track === null) return null; if (track === null) return null;
const facingMode = facingModeFromLocalTrack(track).facingMode; const facingMode = facingModeFromLocalTrack(track).facingMode;
@@ -86,8 +86,8 @@ export function useSwitchCamera(
); );
}), }),
), ),
[videoTrack], [videoTrack$],
); );
return useObservableEagerState(switchCamera); return useObservableEagerState(switchCamera$);
} }

View File

@@ -31,17 +31,17 @@ export class Setting<T> {
} }
} }
this._value = new BehaviorSubject(initialValue); this._value$ = new BehaviorSubject(initialValue);
this.value = this._value; this.value$ = this._value$;
} }
private readonly key: string; private readonly key: string;
private readonly _value: BehaviorSubject<T>; private readonly _value$: BehaviorSubject<T>;
public readonly value: Observable<T>; public readonly value$: Observable<T>;
public readonly setValue = (value: T): void => { public readonly setValue = (value: T): void => {
this._value.next(value); this._value$.next(value);
localStorage.setItem(this.key, JSON.stringify(value)); localStorage.setItem(this.key, JSON.stringify(value));
}; };
} }
@@ -50,7 +50,7 @@ export class Setting<T> {
* React hook that returns a settings's current value and a setter. * React hook that returns a settings's current value and a setter.
*/ */
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] { export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
return [useObservableEagerState(setting.value), setting.setValue]; return [useObservableEagerState(setting.value$), setting.setValue];
} }
// null = undecided // null = undecided

View File

@@ -124,15 +124,15 @@ export type LayoutSummary =
| OneOnOneLayoutSummary | OneOnOneLayoutSummary
| PipLayoutSummary; | PipLayoutSummary;
function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> { function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return l.pipe( return l$.pipe(
switchMap((l) => { switchMap((l) => {
switch (l.type) { switch (l.type) {
case "grid": case "grid":
return combineLatest( return combineLatest(
[ [
l.spotlight?.media ?? of(undefined), l.spotlight?.media$ ?? of(undefined),
...l.grid.map((vm) => vm.media), ...l.grid.map((vm) => vm.media$),
], ],
(spotlight, ...grid) => ({ (spotlight, ...grid) => ({
type: l.type, type: l.type,
@@ -143,7 +143,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape": case "spotlight-landscape":
case "spotlight-portrait": case "spotlight-portrait":
return combineLatest( return combineLatest(
[l.spotlight.media, ...l.grid.map((vm) => vm.media)], [l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({ (spotlight, ...grid) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),
@@ -152,7 +152,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
); );
case "spotlight-expanded": case "spotlight-expanded":
return combineLatest( return combineLatest(
[l.spotlight.media, l.pip?.media ?? of(undefined)], [l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
(spotlight, pip) => ({ (spotlight, pip) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),
@@ -161,7 +161,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
); );
case "one-on-one": case "one-on-one":
return combineLatest( return combineLatest(
[l.local.media, l.remote.media], [l.local.media$, l.remote.media$],
(local, remote) => ({ (local, remote) => ({
type: l.type, type: l.type,
local: local.id, local: local.id,
@@ -169,7 +169,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
}), }),
); );
case "pip": case "pip":
return l.spotlight.media.pipe( return l.spotlight.media$.pipe(
map((spotlight) => ({ map((spotlight) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),
@@ -186,9 +186,9 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
} }
function withCallViewModel( function withCallViewModel(
remoteParticipants: Observable<RemoteParticipant[]>, remoteParticipants$: Observable<RemoteParticipant[]>,
rtcMembers: Observable<Partial<CallMembership>[]>, rtcMembers$: Observable<Partial<CallMembership>[]>,
connectionState: Observable<ECConnectionState>, connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>, speaking: Map<Participant, Observable<boolean>>,
continuation: (vm: CallViewModel) => void, continuation: (vm: CallViewModel) => void,
): void { ): void {
@@ -203,10 +203,10 @@ function withCallViewModel(
room, room,
localRtcMember, localRtcMember,
[], [],
).withMemberships(rtcMembers); ).withMemberships(rtcMembers$);
const participantsSpy = vi const participantsSpy = vi
.spyOn(ComponentsCore, "connectedParticipantsObserver") .spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants); .mockReturnValue(remoteParticipants$);
const mediaSpy = vi const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia") .spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) => .mockImplementation((p) =>
@@ -232,7 +232,7 @@ function withCallViewModel(
const liveKitRoom = mockLivekitRoom( const liveKitRoom = mockLivekitRoom(
{ localParticipant }, { localParticipant },
{ remoteParticipants }, { remoteParticipants$ },
); );
const vm = new CallViewModel( const vm = new CallViewModel(
@@ -241,7 +241,7 @@ function withCallViewModel(
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
connectionState, connectionState$,
); );
onTestFinished(() => { onTestFinished(() => {
@@ -276,7 +276,7 @@ test("participants are retained during a focus switch", () => {
}), }),
new Map(), new Map(),
(vm) => { (vm) => {
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -320,7 +320,7 @@ test("screen sharing activates spotlight layout", () => {
g: () => vm.setGridMode("grid"), g: () => vm.setGridMode("grid"),
}); });
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -363,7 +363,7 @@ test("screen sharing activates spotlight layout", () => {
}, },
}, },
); );
expectObservable(vm.showSpeakingIndicators).toBe( expectObservable(vm.showSpeakingIndicators$).toBe(
expectedShowSpeakingMarbles, expectedShowSpeakingMarbles,
{ {
y: true, y: true,
@@ -402,13 +402,13 @@ test("participants stay in the same order unless to appear/disappear", () => {
a: () => { a: () => {
// We imagine that only three tiles (the first three) will be visible // We imagine that only three tiles (the first three) will be visible
// on screen at a time // on screen at a time
vm.layout.subscribe((layout) => { vm.layout$.subscribe((layout) => {
if (layout.type === "grid") layout.setVisibleTiles(3); if (layout.type === "grid") layout.setVisibleTiles(3);
}); });
}, },
}); });
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -455,7 +455,7 @@ test("participants adjust order when space becomes constrained", () => {
]), ]),
(vm) => { (vm) => {
let setVisibleTiles: ((value: number) => void) | null = null; let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout.subscribe((layout) => { vm.layout$.subscribe((layout) => {
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles; if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
}); });
schedule(visibilityInputMarbles, { schedule(visibilityInputMarbles, {
@@ -463,7 +463,7 @@ test("participants adjust order when space becomes constrained", () => {
b: () => setVisibleTiles!(3), b: () => setVisibleTiles!(3),
}); });
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -509,7 +509,7 @@ test("spotlight speakers swap places", () => {
(vm) => { (vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -557,7 +557,7 @@ test("layout enters picture-in-picture mode when requested", () => {
d: () => window.controls.disablePip(), d: () => window.controls.disablePip(),
}); });
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -600,12 +600,12 @@ test("spotlight remembers whether it's expanded", () => {
schedule(expandInputMarbles, { schedule(expandInputMarbles, {
a: () => { a: () => {
let toggle: () => void; let toggle: () => void;
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!)); vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!));
toggle!(); toggle!();
}, },
}); });
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -662,7 +662,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
new Map(), new Map(),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -706,7 +706,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
new Map(), new Map(),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {
@@ -753,7 +753,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
new Map(), new Map(),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
{ {
a: { a: {

View File

@@ -62,7 +62,7 @@ import {
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
type MediaViewModel, type MediaViewModel,
observeTrackReference, observeTrackReference$,
RemoteUserMediaViewModel, RemoteUserMediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
type UserMediaViewModel, type UserMediaViewModel,
@@ -71,7 +71,7 @@ import { accumulate, finalizeValue } from "../utils/observable";
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
import { duplicateTiles, showNonMemberTiles } from "../settings/settings"; import { duplicateTiles, showNonMemberTiles } from "../settings/settings";
import { isFirefox } from "../Platform"; import { isFirefox } from "../Platform";
import { setPipEnabled } from "../controls"; import { setPipEnabled$ } from "../controls";
import { import {
type GridTileViewModel, type GridTileViewModel,
type SpotlightTileViewModel, type SpotlightTileViewModel,
@@ -82,7 +82,7 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { oneOnOneLayout } from "./OneOnOneLayout"; import { oneOnOneLayout } from "./OneOnOneLayout";
import { pipLayout } from "./PipLayout"; import { pipLayout } from "./PipLayout";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { observeSpeaker } from "./observeSpeaker"; import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array"; import { shallowEquals } from "../utils/array";
// How long we wait after a focus switch before showing the real participant // How long we wait after a focus switch before showing the real participant
@@ -232,12 +232,12 @@ interface LayoutScanState {
class UserMedia { class UserMedia {
private readonly scope = new ObservableScope(); private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel; public readonly vm: UserMediaViewModel;
private readonly participant: BehaviorSubject< private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant | undefined LocalParticipant | RemoteParticipant | undefined
>; >;
public readonly speaker: Observable<boolean>; public readonly speaker$: Observable<boolean>;
public readonly presenter: Observable<boolean>; public readonly presenter$: Observable<boolean>;
public constructor( public constructor(
public readonly id: string, public readonly id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
@@ -245,13 +245,13 @@ class UserMedia {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
this.participant = new BehaviorSubject(participant); this.participant$ = new BehaviorSubject(participant);
if (participant?.isLocal) { if (participant?.isLocal) {
this.vm = new LocalUserMediaViewModel( this.vm = new LocalUserMediaViewModel(
this.id, this.id,
member, member,
this.participant.asObservable() as Observable<LocalParticipant>, this.participant$.asObservable() as Observable<LocalParticipant>,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
); );
@@ -259,7 +259,7 @@ class UserMedia {
this.vm = new RemoteUserMediaViewModel( this.vm = new RemoteUserMediaViewModel(
id, id,
member, member,
this.participant.asObservable() as Observable< this.participant$.asObservable() as Observable<
RemoteParticipant | undefined RemoteParticipant | undefined
>, >,
encryptionSystem, encryptionSystem,
@@ -267,9 +267,9 @@ class UserMedia {
); );
} }
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state()); this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state());
this.presenter = this.participant.pipe( this.presenter$ = this.participant$.pipe(
switchMap( switchMap(
(p) => (p) =>
(p && (p &&
@@ -289,9 +289,9 @@ class UserMedia {
public updateParticipant( public updateParticipant(
newParticipant: LocalParticipant | RemoteParticipant | undefined, newParticipant: LocalParticipant | RemoteParticipant | undefined,
): void { ): void {
if (this.participant.value !== newParticipant) { if (this.participant$.value !== newParticipant) {
// Update the BehaviourSubject in the UserMedia. // Update the BehaviourSubject in the UserMedia.
this.participant.next(newParticipant); this.participant$.next(newParticipant);
} }
} }
@@ -303,7 +303,7 @@ class UserMedia {
class ScreenShare { class ScreenShare {
public readonly vm: ScreenShareViewModel; public readonly vm: ScreenShareViewModel;
private readonly participant: BehaviorSubject< private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant LocalParticipant | RemoteParticipant
>; >;
@@ -314,12 +314,12 @@ class ScreenShare {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom, liveKitRoom: LivekitRoom,
) { ) {
this.participant = new BehaviorSubject(participant); this.participant$ = new BehaviorSubject(participant);
this.vm = new ScreenShareViewModel( this.vm = new ScreenShareViewModel(
id, id,
member, member,
this.participant.asObservable(), this.participant$.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
participant.isLocal, participant.isLocal,
@@ -357,8 +357,8 @@ function findMatrixRoomMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here // TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel { export class CallViewModel extends ViewModel {
public readonly localVideo: Observable<LocalVideoTrack | null> = public readonly localVideo$: Observable<LocalVideoTrack | null> =
observeTrackReference( observeTrackReference$(
of(this.livekitRoom.localParticipant), of(this.livekitRoom.localParticipant),
Track.Source.Camera, Track.Source.Camera,
).pipe( ).pipe(
@@ -371,16 +371,16 @@ export class CallViewModel extends ViewModel {
/** /**
* The raw list of RemoteParticipants as reported by LiveKit * The raw list of RemoteParticipants as reported by LiveKit
*/ */
private readonly rawRemoteParticipants: Observable<RemoteParticipant[]> = private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> =
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
/** /**
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
* they've left * they've left
*/ */
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> = private readonly remoteParticipantHolds$: Observable<RemoteParticipant[][]> =
this.connectionState.pipe( this.connectionState$.pipe(
withLatestFrom(this.rawRemoteParticipants), withLatestFrom(this.rawRemoteParticipants$),
mergeMap(([s, ps]) => { mergeMap(([s, ps]) => {
// Whenever we switch focuses, we should retain all the previous // Whenever we switch focuses, we should retain all the previous
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
@@ -392,7 +392,7 @@ export class CallViewModel extends ViewModel {
// Wait for time to pass and the connection state to have changed // Wait for time to pass and the connection state to have changed
forkJoin([ forkJoin([
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
this.connectionState.pipe( this.connectionState$.pipe(
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus), filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
take(1), take(1),
), ),
@@ -415,9 +415,9 @@ export class CallViewModel extends ViewModel {
/** /**
* The RemoteParticipants including those that are being "held" on the screen * The RemoteParticipants including those that are being "held" on the screen
*/ */
private readonly remoteParticipants: Observable<RemoteParticipant[]> = private readonly remoteParticipants$: Observable<RemoteParticipant[]> =
combineLatest( combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds], [this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => { (raw, holds) => {
const result = [...raw]; const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity)); const resultIds = new Set(result.map((p) => p.identity));
@@ -439,10 +439,10 @@ export class CallViewModel extends ViewModel {
/** /**
* List of MediaItems that we want to display * List of MediaItems that we want to display
*/ */
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([ private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants, this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value, duplicateTiles.value$,
// Also react to changes in the MatrixRTC session list. // Also react to changes in the MatrixRTC session list.
// The session list will also be update if a room membership changes. // The session list will also be update if a room membership changes.
// No additional RoomState event listener needs to be set up. // No additional RoomState event listener needs to be set up.
@@ -450,7 +450,7 @@ export class CallViewModel extends ViewModel {
this.matrixRTCSession, this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)), ).pipe(startWith(null)),
showNonMemberTiles.value, showNonMemberTiles.value$,
]).pipe( ]).pipe(
scan( scan(
( (
@@ -606,13 +606,13 @@ export class CallViewModel extends ViewModel {
/** /**
* List of MediaItems that we want to display, that are of type UserMedia * List of MediaItems that we want to display, that are of type UserMedia
*/ */
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe( private readonly userMedia$: Observable<UserMedia[]> = this.mediaItems$.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia), mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
), ),
); );
public readonly memberChanges = this.userMedia public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id))) .pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe( .pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>( scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
@@ -628,22 +628,22 @@ export class CallViewModel extends ViewModel {
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */
private readonly screenShares: Observable<ScreenShare[]> = private readonly screenShares$: Observable<ScreenShare[]> =
this.mediaItems.pipe( this.mediaItems$.pipe(
map((mediaItems) => map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
), ),
this.scope.state(), this.scope.state(),
); );
private readonly spotlightSpeaker: Observable<UserMediaViewModel | null> = private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> =
this.userMedia.pipe( this.userMedia$.pipe(
switchMap((mediaItems) => switchMap((mediaItems) =>
mediaItems.length === 0 mediaItems.length === 0
? of([]) ? of([])
: combineLatest( : combineLatest(
mediaItems.map((m) => mediaItems.map((m) =>
m.vm.speaking.pipe(map((s) => [m, s] as const)), m.vm.speaking$.pipe(map((s) => [m, s] as const)),
), ),
), ),
), ),
@@ -672,52 +672,53 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe( private readonly grid$: Observable<UserMediaViewModel[]> =
switchMap((mediaItems) => { this.userMedia$.pipe(
const bins = mediaItems.map((m) => switchMap((mediaItems) => {
combineLatest( const bins = mediaItems.map((m) =>
[ combineLatest(
m.speaker, [
m.presenter, m.speaker$,
m.vm.videoEnabled, m.presenter$,
m.vm instanceof LocalUserMediaViewModel m.vm.videoEnabled$,
? m.vm.alwaysShow m.vm instanceof LocalUserMediaViewModel
: of(false), ? m.vm.alwaysShow$
], : of(false),
(speaker, presenter, video, alwaysShow) => { ],
let bin: SortingBin; (speaker, presenter, video, alwaysShow) => {
if (m.vm.local) let bin: SortingBin;
bin = alwaysShow if (m.vm.local)
? SortingBin.SelfAlwaysShown bin = alwaysShow
: SortingBin.SelfNotAlwaysShown; ? SortingBin.SelfAlwaysShown
else if (presenter) bin = SortingBin.Presenters; : SortingBin.SelfNotAlwaysShown;
else if (speaker) bin = SortingBin.Speakers; else if (presenter) bin = SortingBin.Presenters;
else if (video) bin = SortingBin.Video; else if (speaker) bin = SortingBin.Speakers;
else bin = SortingBin.NoVideo; else if (video) bin = SortingBin.Video;
else bin = SortingBin.NoVideo;
return [m, bin] as const; return [m, bin] as const;
}, },
), ),
); );
// Sort the media by bin order and generate a tile for each one // Sort the media by bin order and generate a tile for each one
return bins.length === 0 return bins.length === 0
? of([]) ? of([])
: combineLatest(bins, (...bins) => : combineLatest(bins, (...bins) =>
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
); );
}), }),
distinctUntilChanged(shallowEquals), distinctUntilChanged(shallowEquals),
this.scope.state(), this.scope.state(),
); );
private readonly spotlight: Observable<MediaViewModel[]> = private readonly spotlight$: Observable<MediaViewModel[]> =
this.screenShares.pipe( this.screenShares$.pipe(
switchMap((screenShares) => { switchMap((screenShares) => {
if (screenShares.length > 0) { if (screenShares.length > 0) {
return of(screenShares.map((m) => m.vm)); return of(screenShares.map((m) => m.vm));
} }
return this.spotlightSpeaker.pipe( return this.spotlightSpeaker$.pipe(
map((speaker) => (speaker ? [speaker] : [])), map((speaker) => (speaker ? [speaker] : [])),
); );
}), }),
@@ -725,14 +726,14 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
private readonly pip: Observable<UserMediaViewModel | null> = combineLatest([ private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares, this.screenShares$,
this.spotlightSpeaker, this.spotlightSpeaker$,
this.mediaItems, this.mediaItems$,
]).pipe( ]).pipe(
switchMap(([screenShares, spotlight, mediaItems]) => { switchMap(([screenShares, spotlight, mediaItems]) => {
if (screenShares.length > 0) { if (screenShares.length > 0) {
return this.spotlightSpeaker; return this.spotlightSpeaker$;
} }
if (!spotlight || spotlight.local) { if (!spotlight || spotlight.local) {
return of(null); return of(null);
@@ -749,7 +750,7 @@ export class CallViewModel extends ViewModel {
if (!localUserMediaViewModel) { if (!localUserMediaViewModel) {
return of(null); return of(null);
} }
return localUserMediaViewModel.alwaysShow.pipe( return localUserMediaViewModel.alwaysShow$.pipe(
map((alwaysShow) => { map((alwaysShow) => {
if (alwaysShow) { if (alwaysShow) {
return localUserMediaViewModel; return localUserMediaViewModel;
@@ -762,19 +763,19 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
private readonly hasRemoteScreenShares: Observable<boolean> = private readonly hasRemoteScreenShares$: Observable<boolean> =
this.spotlight.pipe( this.spotlight$.pipe(
map((spotlight) => map((spotlight) =>
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel), spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
), ),
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe( private readonly pipEnabled$: Observable<boolean> = setPipEnabled$.pipe(
startWith(false), startWith(false),
); );
private readonly naturalWindowMode: Observable<WindowMode> = fromEvent( private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent(
window, window,
"resize", "resize",
).pipe( ).pipe(
@@ -796,30 +797,30 @@ export class CallViewModel extends ViewModel {
/** /**
* The general shape of the window. * The general shape of the window.
*/ */
public readonly windowMode: Observable<WindowMode> = this.pipEnabled.pipe( public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe(
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode)), switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)),
); );
private readonly spotlightExpandedToggle = new Subject<void>(); private readonly spotlightExpandedToggle$ = new Subject<void>();
public readonly spotlightExpanded: Observable<boolean> = public readonly spotlightExpanded$: Observable<boolean> =
this.spotlightExpandedToggle.pipe( this.spotlightExpandedToggle$.pipe(
accumulate(false, (expanded) => !expanded), accumulate(false, (expanded) => !expanded),
this.scope.state(), this.scope.state(),
); );
private readonly gridModeUserSelection = new Subject<GridMode>(); private readonly gridModeUserSelection$ = new Subject<GridMode>();
/** /**
* The layout mode of the media tile grid. * The layout mode of the media tile grid.
*/ */
public readonly gridMode: Observable<GridMode> = public readonly gridMode$: Observable<GridMode> =
// If the user hasn't selected spotlight and somebody starts screen sharing, // If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends // automatically switch to spotlight mode and reset when screen sharing ends
this.gridModeUserSelection.pipe( this.gridModeUserSelection$.pipe(
startWith(null), startWith(null),
switchMap((userSelection) => switchMap((userSelection) =>
(userSelection === "spotlight" (userSelection === "spotlight"
? EMPTY ? EMPTY
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe( : combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe(
skip(userSelection === null ? 0 : 1), skip(userSelection === null ? 0 : 1),
map( map(
([hasScreenShares, windowMode]): GridMode => ([hasScreenShares, windowMode]): GridMode =>
@@ -834,43 +835,41 @@ export class CallViewModel extends ViewModel {
); );
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
this.gridModeUserSelection.next(value); this.gridModeUserSelection$.next(value);
} }
private readonly gridLayoutMedia: Observable<GridLayoutMedia> = combineLatest( private readonly gridLayoutMedia$: Observable<GridLayoutMedia> =
[this.grid, this.spotlight], combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
(grid, spotlight) => ({
type: "grid", type: "grid",
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel) spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? spotlight ? spotlight
: undefined, : undefined,
grid, grid,
}), }));
);
private readonly spotlightLandscapeLayoutMedia: Observable<SpotlightLandscapeLayoutMedia> = private readonly spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape", type: "spotlight-landscape",
spotlight, spotlight,
grid, grid,
})); }));
private readonly spotlightPortraitLayoutMedia: Observable<SpotlightPortraitLayoutMedia> = private readonly spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({ combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait", type: "spotlight-portrait",
spotlight, spotlight,
grid, grid,
})); }));
private readonly spotlightExpandedLayoutMedia: Observable<SpotlightExpandedLayoutMedia> = private readonly spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({ combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({
type: "spotlight-expanded", type: "spotlight-expanded",
spotlight, spotlight,
pip: pip ?? undefined, pip: pip ?? undefined,
})); }));
private readonly oneOnOneLayoutMedia: Observable<OneOnOneLayoutMedia | null> = private readonly oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
this.mediaItems.pipe( this.mediaItems$.pipe(
map((mediaItems) => { map((mediaItems) => {
if (mediaItems.length !== 2) return null; if (mediaItems.length !== 2) return null;
const local = mediaItems.find((vm) => vm.vm.local)?.vm as const local = mediaItems.find((vm) => vm.vm.local)?.vm as
@@ -888,86 +887,91 @@ export class CallViewModel extends ViewModel {
}), }),
); );
private readonly pipLayoutMedia: Observable<LayoutMedia> = private readonly pipLayoutMedia$: Observable<LayoutMedia> =
this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight }))); this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight })));
/** /**
* The media to be used to produce a layout. * The media to be used to produce a layout.
*/ */
private readonly layoutMedia: Observable<LayoutMedia> = this.windowMode.pipe( private readonly layoutMedia$: Observable<LayoutMedia> =
switchMap((windowMode) => { this.windowMode$.pipe(
switch (windowMode) { switchMap((windowMode) => {
case "normal": switch (windowMode) {
return this.gridMode.pipe( case "normal":
switchMap((gridMode) => { return this.gridMode$.pipe(
switch (gridMode) { switchMap((gridMode) => {
case "grid": switch (gridMode) {
return this.oneOnOneLayoutMedia.pipe( case "grid":
switchMap((oneOnOne) => return this.oneOnOneLayoutMedia$.pipe(
oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne), switchMap((oneOnOne) =>
), oneOnOne === null
); ? this.gridLayoutMedia$
case "spotlight": : of(oneOnOne),
return this.spotlightExpanded.pipe( ),
switchMap((expanded) => );
expanded case "spotlight":
? this.spotlightExpandedLayoutMedia return this.spotlightExpanded$.pipe(
: this.spotlightLandscapeLayoutMedia, switchMap((expanded) =>
), expanded
); ? this.spotlightExpandedLayoutMedia$
} : this.spotlightLandscapeLayoutMedia$,
}), ),
); );
case "narrow": }
return this.oneOnOneLayoutMedia.pipe( }),
switchMap((oneOnOne) => );
oneOnOne === null case "narrow":
? combineLatest( return this.oneOnOneLayoutMedia$.pipe(
[this.grid, this.spotlight], switchMap((oneOnOne) =>
(grid, spotlight) => oneOnOne === null
grid.length > smallMobileCallThreshold || ? combineLatest(
spotlight.some((vm) => vm instanceof ScreenShareViewModel) [this.grid$, this.spotlight$],
? this.spotlightPortraitLayoutMedia (grid, spotlight) =>
: this.gridLayoutMedia, grid.length > smallMobileCallThreshold ||
).pipe(switchAll()) spotlight.some(
: // The expanded spotlight layout makes for a better one-on-one (vm) => vm instanceof ScreenShareViewModel,
// experience in narrow windows )
this.spotlightExpandedLayoutMedia, ? this.spotlightPortraitLayoutMedia$
), : this.gridLayoutMedia$,
); ).pipe(switchAll())
case "flat": : // The expanded spotlight layout makes for a better one-on-one
return this.gridMode.pipe( // experience in narrow windows
switchMap((gridMode) => { this.spotlightExpandedLayoutMedia$,
switch (gridMode) { ),
case "grid": );
// Yes, grid mode actually gets you a "spotlight" layout in case "flat":
// this window mode. return this.gridMode$.pipe(
return this.spotlightLandscapeLayoutMedia; switchMap((gridMode) => {
case "spotlight": switch (gridMode) {
return this.spotlightExpandedLayoutMedia; case "grid":
} // Yes, grid mode actually gets you a "spotlight" layout in
}), // this window mode.
); return this.spotlightLandscapeLayoutMedia$;
case "pip": case "spotlight":
return this.pipLayoutMedia; return this.spotlightExpandedLayoutMedia$;
} }
}), }),
this.scope.state(), );
); case "pip":
return this.pipLayoutMedia$;
}
}),
this.scope.state(),
);
// There is a cyclical dependency here: the layout algorithms want to know // There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to // which tiles are on screen, but to know which tiles are on screen we have to
// first render a layout. To deal with this we assume initially that no tiles // first render a layout. To deal with this we assume initially that no tiles
// are visible, and loop the data back into the layouts with a Subject. // are visible, and loop the data back into the layouts with a Subject.
private readonly visibleTiles = new Subject<number>(); private readonly visibleTiles$ = new Subject<number>();
private readonly setVisibleTiles = (value: number): void => private readonly setVisibleTiles = (value: number): void =>
this.visibleTiles.next(value); this.visibleTiles$.next(value);
public readonly layoutInternals: Observable< public readonly layoutInternals$: Observable<
LayoutScanState & { layout: Layout } LayoutScanState & { layout: Layout }
> = combineLatest([ > = combineLatest([
this.layoutMedia, this.layoutMedia$,
this.visibleTiles.pipe(startWith(0), distinctUntilChanged()), this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
]).pipe( ]).pipe(
scan< scan<
[LayoutMedia, number], [LayoutMedia, number],
@@ -1009,7 +1013,7 @@ export class CallViewModel extends ViewModel {
/** /**
* The layout of tiles in the call interface. * The layout of tiles in the call interface.
*/ */
public readonly layout: Observable<Layout> = this.layoutInternals.pipe( public readonly layout$: Observable<Layout> = this.layoutInternals$.pipe(
map(({ layout }) => layout), map(({ layout }) => layout),
this.scope.state(), this.scope.state(),
); );
@@ -1017,18 +1021,18 @@ export class CallViewModel extends ViewModel {
/** /**
* The current generation of the tile store, exposed for debugging purposes. * The current generation of the tile store, exposed for debugging purposes.
*/ */
public readonly tileStoreGeneration: Observable<number> = public readonly tileStoreGeneration$: Observable<number> =
this.layoutInternals.pipe( this.layoutInternals$.pipe(
map(({ tiles }) => tiles.generation), map(({ tiles }) => tiles.generation),
this.scope.state(), this.scope.state(),
); );
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe( public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe(
map((l) => l.type !== "grid"), map((l) => l.type !== "grid"),
this.scope.state(), this.scope.state(),
); );
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe( public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe(
switchMap((l) => { switchMap((l) => {
switch (l.type) { switch (l.type) {
case "spotlight-landscape": case "spotlight-landscape":
@@ -1036,7 +1040,7 @@ export class CallViewModel extends ViewModel {
// If the spotlight is showing the active speaker, we can do without // If the spotlight is showing the active speaker, we can do without
// speaking indicators as they're a redundant visual cue. But if // speaking indicators as they're a redundant visual cue. But if
// screen sharing feeds are in the spotlight we still need them. // screen sharing feeds are in the spotlight we still need them.
return l.spotlight.media.pipe( return l.spotlight.media$.pipe(
map((models: MediaViewModel[]) => map((models: MediaViewModel[]) =>
models.some((m) => m instanceof ScreenShareViewModel), models.some((m) => m instanceof ScreenShareViewModel),
), ),
@@ -1055,11 +1059,11 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> = public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> =
this.windowMode.pipe( this.windowMode$.pipe(
switchMap((mode) => switchMap((mode) =>
mode === "normal" mode === "normal"
? this.layout.pipe( ? this.layout$.pipe(
map( map(
(l) => (l) =>
l.type === "spotlight-landscape" || l.type === "spotlight-landscape" ||
@@ -1070,50 +1074,50 @@ export class CallViewModel extends ViewModel {
), ),
distinctUntilChanged(), distinctUntilChanged(),
map((enabled) => map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle.next() : null, enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
), ),
this.scope.state(), this.scope.state(),
); );
private readonly screenTap = new Subject<void>(); private readonly screenTap$ = new Subject<void>();
private readonly controlsTap = new Subject<void>(); private readonly controlsTap$ = new Subject<void>();
private readonly screenHover = new Subject<void>(); private readonly screenHover$ = new Subject<void>();
private readonly screenUnhover = new Subject<void>(); private readonly screenUnhover$ = new Subject<void>();
/** /**
* Callback for when the user taps the call view. * Callback for when the user taps the call view.
*/ */
public tapScreen(): void { public tapScreen(): void {
this.screenTap.next(); this.screenTap$.next();
} }
/** /**
* Callback for when the user taps the call's controls. * Callback for when the user taps the call's controls.
*/ */
public tapControls(): void { public tapControls(): void {
this.controlsTap.next(); this.controlsTap$.next();
} }
/** /**
* Callback for when the user hovers over the call view. * Callback for when the user hovers over the call view.
*/ */
public hoverScreen(): void { public hoverScreen(): void {
this.screenHover.next(); this.screenHover$.next();
} }
/** /**
* Callback for when the user stops hovering over the call view. * Callback for when the user stops hovering over the call view.
*/ */
public unhoverScreen(): void { public unhoverScreen(): void {
this.screenUnhover.next(); this.screenUnhover$.next();
} }
public readonly showHeader: Observable<boolean> = this.windowMode.pipe( public readonly showHeader$: Observable<boolean> = this.windowMode$.pipe(
map((mode) => mode !== "pip" && mode !== "flat"), map((mode) => mode !== "pip" && mode !== "flat"),
this.scope.state(), this.scope.state(),
); );
public readonly showFooter: Observable<boolean> = this.windowMode.pipe( public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe(
switchMap((mode) => { switchMap((mode) => {
switch (mode) { switch (mode) {
case "pip": case "pip":
@@ -1128,9 +1132,9 @@ export class CallViewModel extends ViewModel {
if (isFirefox()) return of(true); if (isFirefox()) return of(true);
// Show/hide the footer in response to interactions // Show/hide the footer in response to interactions
return merge( return merge(
this.screenTap.pipe(map(() => "tap screen" as const)), this.screenTap$.pipe(map(() => "tap screen" as const)),
this.controlsTap.pipe(map(() => "tap controls" as const)), this.controlsTap$.pipe(map(() => "tap controls" as const)),
this.screenHover.pipe(map(() => "hover" as const)), this.screenHover$.pipe(map(() => "hover" as const)),
).pipe( ).pipe(
switchScan((state, interaction) => { switchScan((state, interaction) => {
switch (interaction) { switch (interaction) {
@@ -1153,7 +1157,7 @@ export class CallViewModel extends ViewModel {
// Show on hover and hide after a timeout // Show on hover and hide after a timeout
return race( return race(
timer(showFooterMs), timer(showFooterMs),
this.screenUnhover.pipe(take(1)), this.screenUnhover$.pipe(take(1)),
).pipe( ).pipe(
map(() => false), map(() => false),
startWith(true), startWith(true),
@@ -1172,7 +1176,7 @@ export class CallViewModel extends ViewModel {
private readonly matrixRTCSession: MatrixRTCSession, private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>, private readonly connectionState$: Observable<ECConnectionState>,
) { ) {
super(); super();
} }

View File

@@ -49,7 +49,7 @@ test("control a participant's volume", async () => {
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
}, },
}); });
expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", { expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
a: 1, a: 1,
b: 0, b: 0,
c: 0.6, c: 0.6,
@@ -69,7 +69,7 @@ test("toggle fit/contain for a participant's video", async () => {
a: () => vm.toggleFitContain(), a: () => vm.toggleFitContain(),
b: () => vm.toggleFitContain(), b: () => vm.toggleFitContain(),
}); });
expectObservable(vm.cropVideo).toBe("abc", { expectObservable(vm.cropVideo$).toBe("abc", {
a: true, a: true,
b: false, b: false,
c: true, c: true,
@@ -82,7 +82,7 @@ test("local media remembers whether it should always be shown", async () => {
await withLocalMedia(rtcMembership, {}, (vm) => await withLocalMedia(rtcMembership, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) }); schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false }); expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
}), }),
); );
// Next local media should start out *not* always shown // Next local media should start out *not* always shown
@@ -93,7 +93,7 @@ test("local media remembers whether it should always be shown", async () => {
(vm) => (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) }); schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true });
}), }),
); );
}); });

View File

@@ -74,11 +74,11 @@ export function useDisplayName(vm: MediaViewModel): string {
return displayName; return displayName;
} }
export function observeTrackReference( export function observeTrackReference$(
participant: Observable<Participant | undefined>, participant$: Observable<Participant | undefined>,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder | undefined> { ): Observable<TrackReferenceOrPlaceholder | undefined> {
return participant.pipe( return participant$.pipe(
switchMap((p) => { switchMap((p) => {
if (p) { if (p) {
return observeParticipantMedia(p).pipe( return observeParticipantMedia(p).pipe(
@@ -96,7 +96,7 @@ export function observeTrackReference(
); );
} }
function observeRemoteTrackReceivingOkay( function observeRemoteTrackReceivingOkay$(
participant: Participant, participant: Participant,
source: Track.Source, source: Track.Source,
): Observable<boolean | undefined> { ): Observable<boolean | undefined> {
@@ -111,7 +111,7 @@ function observeRemoteTrackReceivingOkay(
}; };
return combineLatest([ return combineLatest([
observeTrackReference(of(participant), source), observeTrackReference$(of(participant), source),
interval(1000).pipe(startWith(0)), interval(1000).pipe(startWith(0)),
]).pipe( ]).pipe(
switchMap(async ([trackReference]) => { switchMap(async ([trackReference]) => {
@@ -168,7 +168,7 @@ function observeRemoteTrackReceivingOkay(
); );
} }
function encryptionErrorObservable( function encryptionErrorObservable$(
room: LivekitRoom, room: LivekitRoom,
participant: Participant, participant: Participant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
@@ -209,13 +209,13 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>; public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
public readonly unencryptedWarning: Observable<boolean>; public readonly unencryptedWarning$: Observable<boolean>;
public readonly encryptionStatus: Observable<EncryptionStatus>; public readonly encryptionStatus$: Observable<EncryptionStatus>;
/** /**
* Whether this media corresponds to the local participant. * Whether this media corresponds to the local participant.
@@ -235,7 +235,7 @@ abstract class BaseMediaViewModel extends ViewModel {
public readonly member: RoomMember | undefined, public readonly member: RoomMember | undefined,
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit. // livekit.
protected readonly participant: Observable< protected readonly participant$: Observable<
LocalParticipant | RemoteParticipant | undefined LocalParticipant | RemoteParticipant | undefined
>, >,
@@ -245,21 +245,21 @@ abstract class BaseMediaViewModel extends ViewModel {
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
super(); super();
const audio = observeTrackReference(participant, audioSource).pipe( const audio$ = observeTrackReference$(participant$, audioSource).pipe(
this.scope.state(), this.scope.state(),
); );
this.video = observeTrackReference(participant, videoSource).pipe( this.video$ = observeTrackReference$(participant$, videoSource).pipe(
this.scope.state(), this.scope.state(),
); );
this.unencryptedWarning = combineLatest( this.unencryptedWarning$ = combineLatest(
[audio, this.video], [audio$, this.video$],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false || (a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe(this.scope.state()); ).pipe(this.scope.state());
this.encryptionStatus = this.participant.pipe( this.encryptionStatus$ = this.participant$.pipe(
switchMap((participant): Observable<EncryptionStatus> => { switchMap((participant): Observable<EncryptionStatus> => {
if (!participant) { if (!participant) {
return of(EncryptionStatus.Connecting); return of(EncryptionStatus.Connecting);
@@ -270,20 +270,20 @@ abstract class BaseMediaViewModel extends ViewModel {
return of(EncryptionStatus.Okay); return of(EncryptionStatus.Okay);
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
return combineLatest([ return combineLatest([
encryptionErrorObservable( encryptionErrorObservable$(
livekitRoom, livekitRoom,
participant, participant,
encryptionSystem, encryptionSystem,
"MissingKey", "MissingKey",
), ),
encryptionErrorObservable( encryptionErrorObservable$(
livekitRoom, livekitRoom,
participant, participant,
encryptionSystem, encryptionSystem,
"InvalidKey", "InvalidKey",
), ),
observeRemoteTrackReceivingOkay(participant, audioSource), observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource), observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe( ]).pipe(
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
if (keyMissing) return EncryptionStatus.KeyMissing; if (keyMissing) return EncryptionStatus.KeyMissing;
@@ -296,14 +296,14 @@ abstract class BaseMediaViewModel extends ViewModel {
); );
} else { } else {
return combineLatest([ return combineLatest([
encryptionErrorObservable( encryptionErrorObservable$(
livekitRoom, livekitRoom,
participant, participant,
encryptionSystem, encryptionSystem,
"InvalidKey", "InvalidKey",
), ),
observeRemoteTrackReceivingOkay(participant, audioSource), observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource), observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe( ]).pipe(
map( map(
([keyInvalid, audioOkay, videoOkay]): ([keyInvalid, audioOkay, videoOkay]):
@@ -339,7 +339,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking = this.participant.pipe( public readonly speaking$ = this.participant$.pipe(
switchMap((p) => switchMap((p) =>
p p
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( ? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
@@ -353,49 +353,49 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether this participant is sending audio (i.e. is unmuted on their side). * Whether this participant is sending audio (i.e. is unmuted on their side).
*/ */
public readonly audioEnabled: Observable<boolean>; public readonly audioEnabled$: Observable<boolean>;
/** /**
* Whether this participant is sending video. * Whether this participant is sending video.
*/ */
public readonly videoEnabled: Observable<boolean>; public readonly videoEnabled$: Observable<boolean>;
private readonly _cropVideo = new BehaviorSubject(true); private readonly _cropVideo$ = new BehaviorSubject(true);
/** /**
* Whether the tile video should be contained inside the tile or be cropped to fit. * Whether the tile video should be contained inside the tile or be cropped to fit.
*/ */
public readonly cropVideo: Observable<boolean> = this._cropVideo; public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: Observable<LocalParticipant | RemoteParticipant | undefined>, participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
super( super(
id, id,
member, member,
participant, participant$,
encryptionSystem, encryptionSystem,
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom, livekitRoom,
); );
const media = participant.pipe( const media$ = participant$.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(), this.scope.state(),
); );
this.audioEnabled = media.pipe( this.audioEnabled$ = media$.pipe(
map((m) => m?.microphoneTrack?.isMuted === false), map((m) => m?.microphoneTrack?.isMuted === false),
); );
this.videoEnabled = media.pipe( this.videoEnabled$ = media$.pipe(
map((m) => m?.cameraTrack?.isMuted === false), map((m) => m?.cameraTrack?.isMuted === false),
); );
} }
public toggleFitContain(): void { public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value); this._cropVideo$.next(!this._cropVideo$.value);
} }
public get local(): boolean { public get local(): boolean {
@@ -410,7 +410,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror = this.video.pipe( public readonly mirror$ = this.video$.pipe(
switchMap((v) => { switchMap((v) => {
const track = v?.publication?.track; const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
@@ -428,17 +428,17 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* Whether to show this tile in a highly visible location near the start of * Whether to show this tile in a highly visible location near the start of
* the grid. * the grid.
*/ */
public readonly alwaysShow = alwaysShowSelf.value; public readonly alwaysShow$ = alwaysShowSelf.value$;
public readonly setAlwaysShow = alwaysShowSelf.setValue; public readonly setAlwaysShow = alwaysShowSelf.setValue;
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: Observable<LocalParticipant | undefined>, participant$: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
super(id, member, participant, encryptionSystem, livekitRoom); super(id, member, participant$, encryptionSystem, livekitRoom);
} }
} }
@@ -446,18 +446,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* A remote participant's user media. * A remote participant's user media.
*/ */
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
private readonly locallyMutedToggle = new Subject<void>(); private readonly locallyMutedToggle$ = new Subject<void>();
private readonly localVolumeAdjustment = new Subject<number>(); private readonly localVolumeAdjustment$ = new Subject<number>();
private readonly localVolumeCommit = new Subject<void>(); private readonly localVolumeCommit$ = new Subject<void>();
/** /**
* The volume to which this participant's audio is set, as a scalar * The volume to which this participant's audio is set, as a scalar
* multiplier. * multiplier.
*/ */
public readonly localVolume: Observable<number> = merge( public readonly localVolume$: Observable<number> = merge(
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)), this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment, this.localVolumeAdjustment$,
this.localVolumeCommit.pipe(map(() => "commit" as const)), this.localVolumeCommit$.pipe(map(() => "commit" as const)),
).pipe( ).pipe(
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
switch (event) { switch (event) {
@@ -487,7 +487,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether this participant's audio is disabled. * Whether this participant's audio is disabled.
*/ */
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe( public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
map((volume) => volume === 0), map((volume) => volume === 0),
this.scope.state(), this.scope.state(),
); );
@@ -495,29 +495,29 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: Observable<RemoteParticipant | undefined>, participant$: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
super(id, member, participant, encryptionSystem, livekitRoom); super(id, member, participant$, encryptionSystem, livekitRoom);
// Sync the local volume with LiveKit // Sync the local volume with LiveKit
combineLatest([ combineLatest([
participant, participant$,
this.localVolume.pipe(this.scope.bind()), this.localVolume$.pipe(this.scope.bind()),
]).subscribe(([p, volume]) => p && p.setVolume(volume)); ]).subscribe(([p, volume]) => p && p.setVolume(volume));
} }
public toggleLocallyMuted(): void { public toggleLocallyMuted(): void {
this.locallyMutedToggle.next(); this.locallyMutedToggle$.next();
} }
public setLocalVolume(value: number): void { public setLocalVolume(value: number): void {
this.localVolumeAdjustment.next(value); this.localVolumeAdjustment$.next(value);
} }
public commitLocalVolume(): void { public commitLocalVolume(): void {
this.localVolumeCommit.next(); this.localVolumeCommit$.next();
} }
} }
@@ -528,7 +528,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: Observable<LocalParticipant | RemoteParticipant>, participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
public readonly local: boolean, public readonly local: boolean,
@@ -536,7 +536,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
super( super(
id, id,
member, member,
participant, participant$,
encryptionSystem, encryptionSystem,
Track.Source.ScreenShareAudio, Track.Source.ScreenShareAudio,
Track.Source.ScreenShare, Track.Source.ScreenShare,

View File

@@ -19,9 +19,9 @@ type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
* A scope which limits the execution lifetime of its bound Observables. * A scope which limits the execution lifetime of its bound Observables.
*/ */
export class ObservableScope { export class ObservableScope {
private readonly ended = new Subject<void>(); private readonly ended$ = new Subject<void>();
private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended); private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended$);
/** /**
* Binds an Observable to this scope, so that it completes when the scope * Binds an Observable to this scope, so that it completes when the scope
@@ -31,8 +31,8 @@ export class ObservableScope {
return this.bindImpl; return this.bindImpl;
} }
private readonly stateImpl: MonoTypeOperator = (o) => private readonly stateImpl: MonoTypeOperator = (o$) =>
o.pipe( o$.pipe(
this.bind(), this.bind(),
distinctUntilChanged(), distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }), shareReplay({ bufferSize: 1, refCount: false }),
@@ -51,7 +51,7 @@ export class ObservableScope {
* Ends the scope, causing any bound Observables to complete. * Ends the scope, causing any bound Observables to complete.
*/ */
public end(): void { public end(): void {
this.ended.next(); this.ended$.next();
this.ended.complete(); this.ended$.complete();
} }
} }

View File

@@ -18,31 +18,31 @@ function debugEntries(entries: GridTileData[]): string[] {
} }
let DEBUG_ENABLED = false; let DEBUG_ENABLED = false;
debugTileLayout.value.subscribe((value) => (DEBUG_ENABLED = value)); debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value));
class SpotlightTileData { class SpotlightTileData {
private readonly media_: BehaviorSubject<MediaViewModel[]>; private readonly media$: BehaviorSubject<MediaViewModel[]>;
public get media(): MediaViewModel[] { public get media(): MediaViewModel[] {
return this.media_.value; return this.media$.value;
} }
public set media(value: MediaViewModel[]) { public set media(value: MediaViewModel[]) {
this.media_.next(value); this.media$.next(value);
} }
private readonly maximised_: BehaviorSubject<boolean>; private readonly maximised$: BehaviorSubject<boolean>;
public get maximised(): boolean { public get maximised(): boolean {
return this.maximised_.value; return this.maximised$.value;
} }
public set maximised(value: boolean) { public set maximised(value: boolean) {
this.maximised_.next(value); this.maximised$.next(value);
} }
public readonly vm: SpotlightTileViewModel; public readonly vm: SpotlightTileViewModel;
public constructor(media: MediaViewModel[], maximised: boolean) { public constructor(media: MediaViewModel[], maximised: boolean) {
this.media_ = new BehaviorSubject(media); this.media$ = new BehaviorSubject(media);
this.maximised_ = new BehaviorSubject(maximised); this.maximised$ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media_, this.maximised_); this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
} }
public destroy(): void { public destroy(): void {
@@ -51,19 +51,19 @@ class SpotlightTileData {
} }
class GridTileData { class GridTileData {
private readonly media_: BehaviorSubject<UserMediaViewModel>; private readonly media$: BehaviorSubject<UserMediaViewModel>;
public get media(): UserMediaViewModel { public get media(): UserMediaViewModel {
return this.media_.value; return this.media$.value;
} }
public set media(value: UserMediaViewModel) { public set media(value: UserMediaViewModel) {
this.media_.next(value); this.media$.next(value);
} }
public readonly vm: GridTileViewModel; public readonly vm: GridTileViewModel;
public constructor(media: UserMediaViewModel) { public constructor(media: UserMediaViewModel) {
this.media_ = new BehaviorSubject(media); this.media$ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media_); this.vm = new GridTileViewModel(this.media$);
} }
public destroy(): void { public destroy(): void {
@@ -123,7 +123,10 @@ export class TileStoreBuilder {
"speaking" in this.prevSpotlight.media[0] && "speaking" in this.prevSpotlight.media[0] &&
this.prevSpotlight.media[0]; this.prevSpotlight.media[0];
private readonly prevGridByMedia = new Map( private readonly prevGridByMedia: Map<
MediaViewModel,
[GridTileData, number]
> = new Map(
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const), this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
); );

View File

@@ -18,15 +18,15 @@ function createId(): string {
export class GridTileViewModel extends ViewModel { export class GridTileViewModel extends ViewModel {
public readonly id = createId(); public readonly id = createId();
public constructor(public readonly media: Observable<UserMediaViewModel>) { public constructor(public readonly media$: Observable<UserMediaViewModel>) {
super(); super();
} }
} }
export class SpotlightTileViewModel extends ViewModel { export class SpotlightTileViewModel extends ViewModel {
public constructor( public constructor(
public readonly media: Observable<MediaViewModel[]>, public readonly media$: Observable<MediaViewModel[]>,
public readonly maximised: Observable<boolean>, public readonly maximised$: Observable<boolean>,
) { ) {
super(); super();
} }

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { describe, test } from "vitest"; import { describe, test } from "vitest";
import { withTestScheduler } from "../utils/test"; import { withTestScheduler } from "../utils/test";
import { observeSpeaker } from "./observeSpeaker"; import { observeSpeaker$ } from "./observeSpeaker";
const yesNo = { const yesNo = {
y: true, y: true,
@@ -22,40 +22,36 @@ describe("observeSpeaker", () => {
// should default to false when no input is given // should default to false when no input is given
const speakingInputMarbles = ""; const speakingInputMarbles = "";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
test("after no speaking", () => { test("after no speaking", () => {
const speakingInputMarbles = "n"; const speakingInputMarbles = "n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
test("with speaking for 1ms", () => { test("with speaking for 1ms", () => {
const speakingInputMarbles = "y n"; const speakingInputMarbles = "y n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
test("with speaking for 999ms", () => { test("with speaking for 999ms", () => {
const speakingInputMarbles = "y 999ms n"; const speakingInputMarbles = "y 999ms n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
@@ -63,20 +59,18 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = const speakingInputMarbles =
"y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n"; "y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
test("with consecutive speaking then stops speaking", () => { test("with consecutive speaking then stops speaking", () => {
const speakingInputMarbles = "y y y y y y y y y y n"; const speakingInputMarbles = "y y y y y y y y y y n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
}); });
@@ -87,10 +81,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y"; const speakingInputMarbles = " y";
const expectedOutputMarbles = "n 999ms y"; const expectedOutputMarbles = "n 999ms y";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
@@ -98,10 +91,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y 1s n "; const speakingInputMarbles = " y 1s n ";
const expectedOutputMarbles = "n 999ms y 60s n"; const expectedOutputMarbles = "n 999ms y 60s n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
@@ -109,10 +101,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y 5s n "; const speakingInputMarbles = " y 5s n ";
const expectedOutputMarbles = "n 999ms y 64s n"; const expectedOutputMarbles = "n 999ms y 64s n";
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe( expectObservable(
expectedOutputMarbles, observeSpeaker$(hot(speakingInputMarbles, yesNo)),
yesNo, ).toBe(expectedOutputMarbles, yesNo);
);
}); });
}); });
}); });

View File

@@ -18,16 +18,16 @@ import {
* Require 1 second of continuous speaking to become a speaker, and 60 second of * Require 1 second of continuous speaking to become a speaker, and 60 second of
* continuous silence to stop being considered a speaker * continuous silence to stop being considered a speaker
*/ */
export function observeSpeaker( export function observeSpeaker$(
isSpeakingObservable: Observable<boolean>, isSpeakingObservable$: Observable<boolean>,
): Observable<boolean> { ): Observable<boolean> {
const distinct = isSpeakingObservable.pipe(distinctUntilChanged()); const distinct$ = isSpeakingObservable$.pipe(distinctUntilChanged());
return distinct.pipe( return distinct$.pipe(
// Either change to the new value after the timer or re-emit the same value if it toggles back // Either change to the new value after the timer or re-emit the same value if it toggles back
// (audit will return the latest (toggled back) value) before the timeout. // (audit will return the latest (toggled back) value) before the timeout.
audit((s) => audit((s) =>
merge(timer(s ? 1000 : 60000), distinct.pipe(filter((s1) => s1 !== s))), merge(timer(s ? 1000 : 60000), distinct$.pipe(filter((s1) => s1 !== s))),
), ),
// Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->.. // Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->..
startWith(false), startWith(false),

View File

@@ -83,13 +83,13 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
ref, ref,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
const video = useObservableEagerState(vm.video); const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus); const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioEnabled = useObservableEagerState(vm.audioEnabled); const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled); const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking); const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo); const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback( const onSelectFitContain = useCallback(
(e: Event) => { (e: Event) => {
e.preventDefault(); e.preventDefault();
@@ -198,8 +198,8 @@ interface LocalUserMediaTileProps extends TileProps {
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>( const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, ...props }, ref) => { ({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror); const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow); const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow); const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback( const onSelectAlwaysShow = useCallback(
(e: Event) => { (e: Event) => {
@@ -249,8 +249,8 @@ const RemoteUserMediaTile = forwardRef<
RemoteUserMediaTileProps RemoteUserMediaTileProps
>(({ vm, ...props }, ref) => { >(({ vm, ...props }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted); const locallyMuted = useObservableEagerState(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume); const localVolume = useObservableEagerState(vm.localVolume$);
const onSelectMute = useCallback( const onSelectMute = useCallback(
(e: Event) => { (e: Event) => {
e.preventDefault(); e.preventDefault();
@@ -316,7 +316,7 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, onOpenProfile, ...props }, theirRef) => { ({ vm, onOpenProfile, ...props }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media); const media = useObservableEagerState(vm.media$);
const displayName = useDisplayName(media); const displayName = useDisplayName(media);
if (media instanceof LocalUserMediaViewModel) { if (media instanceof LocalUserMediaViewModel) {

View File

@@ -72,7 +72,7 @@ const SpotlightLocalUserMediaItem = forwardRef<
HTMLDivElement, HTMLDivElement,
SpotlightLocalUserMediaItemProps SpotlightLocalUserMediaItemProps
>(({ vm, ...props }, ref) => { >(({ vm, ...props }, ref) => {
const mirror = useObservableEagerState(vm.mirror); const mirror = useObservableEagerState(vm.mirror$);
return <MediaView ref={ref} mirror={mirror} {...props} />; return <MediaView ref={ref} mirror={mirror} {...props} />;
}); });
@@ -86,8 +86,8 @@ const SpotlightUserMediaItem = forwardRef<
HTMLDivElement, HTMLDivElement,
SpotlightUserMediaItemProps SpotlightUserMediaItemProps
>(({ vm, ...props }, ref) => { >(({ vm, ...props }, ref) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled); const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo); const cropVideo = useObservableEagerState(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps & const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = { RefAttributes<HTMLDivElement> = {
@@ -110,7 +110,7 @@ interface SpotlightItemProps {
vm: MediaViewModel; vm: MediaViewModel;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
intersectionObserver: Observable<IntersectionObserver>; intersectionObserver$: Observable<IntersectionObserver>;
/** /**
* Whether this item should act as a scroll snapping point. * Whether this item should act as a scroll snapping point.
*/ */
@@ -124,7 +124,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
vm, vm,
targetWidth, targetWidth,
targetHeight, targetHeight,
intersectionObserver, intersectionObserver$,
snap, snap,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
}, },
@@ -133,15 +133,15 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const displayName = useDisplayName(vm); const displayName = useDisplayName(vm);
const video = useObservableEagerState(vm.video); const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus); const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
useEffect(() => { useEffect(() => {
const element = ourRef.current!; const element = ourRef.current!;
let prevIo: IntersectionObserver | null = null; let prevIo: IntersectionObserver | null = null;
const subscription = intersectionObserver.subscribe((io) => { const subscription = intersectionObserver$.subscribe((io) => {
prevIo?.unobserve(element); prevIo?.unobserve(element);
io.observe(element); io.observe(element);
prevIo = io; prevIo = io;
@@ -150,7 +150,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
subscription.unsubscribe(); subscription.unsubscribe();
prevIo?.unobserve(element); prevIo?.unobserve(element);
}; };
}, [intersectionObserver]); }, [intersectionObserver$]);
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = { const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref, ref,
@@ -208,10 +208,10 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
theirRef, theirRef,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [ourRef, root] = useObservableRef<HTMLDivElement | null>(null); const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised); const maximised = useObservableEagerState(vm.maximised$);
const media = useObservableEagerState(vm.media); const media = useObservableEagerState(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>( const [visibleId, setVisibleId] = useState<string | undefined>(
media[0]?.id, media[0]?.id,
); );
@@ -225,9 +225,9 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
// hooked up to the root element and the items. Because the items will run // hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an // their effects before their parent does, we need to do this dance with an
// Observable to actually give them the intersection observer. // Observable to actually give them the intersection observer.
const intersectionObserver = useInitial<Observable<IntersectionObserver>>( const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
() => () =>
root.pipe( root$.pipe(
map( map(
(r) => (r) =>
new IntersectionObserver( new IntersectionObserver(
@@ -295,7 +295,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
vm={vm} vm={vm}
targetWidth={targetWidth} targetWidth={targetWidth}
targetHeight={targetHeight} targetHeight={targetHeight}
intersectionObserver={intersectionObserver} intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media // This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily // when the previous/next buttons are clicked: we temporarily
// remove all scroll snap points except for just the one media // remove all scroll snap points except for just the one media

View File

@@ -15,10 +15,10 @@ const nothing = Symbol("nothing");
* callback will not be invoked. * callback will not be invoked.
*/ */
export function finalizeValue<T>(callback: (finalValue: T) => void) { export function finalizeValue<T>(callback: (finalValue: T) => void) {
return (source: Observable<T>): Observable<T> => return (source$: Observable<T>): Observable<T> =>
defer(() => { defer(() => {
let finalValue: T | typeof nothing = nothing; let finalValue: T | typeof nothing = nothing;
return source.pipe( return source$.pipe(
tap((value) => (finalValue = value)), tap((value) => (finalValue = value)),
finalize(() => { finalize(() => {
if (finalValue !== nothing) callback(finalValue); if (finalValue !== nothing) callback(finalValue);
@@ -35,6 +35,6 @@ export function accumulate<State, Event>(
initial: State, initial: State,
update: (state: State, event: Event) => State, update: (state: State, event: Event) => State,
) { ) {
return (events: Observable<Event>): Observable<State> => return (events$: Observable<Event>): Observable<State> =>
events.pipe(scan(update, initial), startWith(initial)); events$.pipe(scan(update, initial), startWith(initial));
} }

View File

@@ -77,14 +77,14 @@ export function withTestScheduler(
continuation({ continuation({
...helpers, ...helpers,
schedule(marbles, actions) { schedule(marbles, actions) {
const actionsObservable = helpers const actionsObservable$ = helpers
.cold(marbles) .cold(marbles)
.pipe(map((value) => actions[value]())); .pipe(map((value) => actions[value]()));
const results = Object.fromEntries( const results = Object.fromEntries(
Object.keys(actions).map((value) => [value, undefined] as const), Object.keys(actions).map((value) => [value, undefined] as const),
); );
// Run the actions and verify that none of them error // Run the actions and verify that none of them error
helpers.expectObservable(actionsObservable).toBe(marbles, results); helpers.expectObservable(actionsObservable$).toBe(marbles, results);
}, },
}), }),
); );
@@ -157,16 +157,16 @@ export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
export function mockLivekitRoom( export function mockLivekitRoom(
room: Partial<LivekitRoom>, room: Partial<LivekitRoom>,
{ {
remoteParticipants, remoteParticipants$,
}: { remoteParticipants?: Observable<RemoteParticipant[]> } = {}, }: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
): LivekitRoom { ): LivekitRoom {
const livekitRoom = { const livekitRoom = {
...mockEmitter(), ...mockEmitter(),
...room, ...room,
} as Partial<LivekitRoom> as LivekitRoom; } as Partial<LivekitRoom> as LivekitRoom;
if (remoteParticipants) { if (remoteParticipants$) {
livekitRoom.remoteParticipants = new Map(); livekitRoom.remoteParticipants = new Map();
remoteParticipants.subscribe((newRemoteParticipants) => { remoteParticipants$.subscribe((newRemoteParticipants) => {
livekitRoom.remoteParticipants.clear(); livekitRoom.remoteParticipants.clear();
newRemoteParticipants.forEach((p) => { newRemoteParticipants.forEach((p) => {
livekitRoom.remoteParticipants.set(p.identity, p); livekitRoom.remoteParticipants.set(p.identity, p);
@@ -238,7 +238,7 @@ export async function withRemoteMedia(
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }), mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
); );
try { try {
await continuation(vm); await continuation(vm);
@@ -277,9 +277,9 @@ export class MockRTCSession extends TypedEventEmitter<
} }
public withMemberships( public withMemberships(
rtcMembers: Observable<Partial<CallMembership>[]>, rtcMembers$: Observable<Partial<CallMembership>[]>,
): MockRTCSession { ): MockRTCSession {
rtcMembers.subscribe((m) => { rtcMembers$.subscribe((m) => {
const old = this.memberships; const old = this.memberships;
// always prepend the local participant // always prepend the local participant
const updated = [this.localMembership, ...(m as CallMembership[])]; const updated = [this.localMembership, ...(m as CallMembership[])];