Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/room/GroupCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export const GroupCallView: FC<Props> = ({
}, [rtcSession]);

// TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
Expand Down Expand Up @@ -313,6 +314,7 @@ export const GroupCallView: FC<Props> = ({

const navigate = useNavigate();

// TODO split this into leave and onDisconnect
const onLeft = useCallback(
(
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
Expand Down
1 change: 1 addition & 0 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
setVm(vm);

vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);

return (): void => {
scope.end();
};
Expand Down
16 changes: 7 additions & 9 deletions src/state/CallViewModel/CallViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,13 @@ import {
type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia,
} from "../layout-types.ts";
import { type ElementCallError } from "../../utils/errors.ts";
import { ElementCallError } from "../../utils/errors.ts";
import { type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
import {
createLocalMembership$,
enterRTCSession,
RTCBackendState,
} from "./localMember/LocalMembership.ts";
} from "./localMember/LocalMember.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
import {
createMemberships$,
Expand Down Expand Up @@ -452,13 +451,13 @@ export function createCallViewModel$(

const localMembership = createLocalMembership$({
scope: scope,
homeserverConnected$: createHomeserverConnected$(
homeserverConnected: createHomeserverConnected$(
scope,
client,
matrixRTCSession,
),
muteStates: muteStates,
joinMatrixRTC: async (transport: LivekitTransport) => {
joinMatrixRTC: (transport: LivekitTransport) => {
return enterRTCSession(
matrixRTCSession,
transport,
Expand Down Expand Up @@ -1455,7 +1454,7 @@ export function createCallViewModel$(
ringOverlay$: ringOverlay$,
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: localMembership.requestConnect,
join: localMembership.requestJoinAndPublish,
toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$,

Expand All @@ -1465,9 +1464,8 @@ export function createCallViewModel$(
unhoverScreen: (): void => screenUnhover$.next(),

fatalError$: scope.behavior(
localMembership.connectionState.livekit$.pipe(
filter((v) => v.state === RTCBackendState.Error),
map((s) => s.error),
localMembership.localMemberState$.pipe(
filter((v) => v instanceof ElementCallError),
),
null,
),
Expand Down
58 changes: 29 additions & 29 deletions src/state/CallViewModel/localMember/HomeserverConnected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,106 +97,106 @@ describe("createHomeserverConnected$", () => {
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay.
it("is false when sync state is not Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
expect(hsConnected$.value).toBe(false);
const hsConnected = createHomeserverConnected$(scope, client, session);
expect(hsConnected.combined$.value).toBe(false);
});

it("remains false while membership status is not Connected even if sync is Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); // membership still disconnected
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
});

it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
// Make sync loop OK
client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);
});

it("becomes true only when all three conditions are satisfied", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); // not yet membership connected
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
// 2. Membership connected
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); // probablyLeft is false
expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
});

it("drops back to false when sync loop leaves Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);

// Sync loop error => should flip false
client.setSyncState(SyncState.Error);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);
});

it("drops back to false when membership status becomes disconnected", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);

session.setMembershipStatus(Status.Disconnected);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);
});

it("drops to false when ProbablyLeft is emitted after being true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);

session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);
});

it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);

session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);

// Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);
});

it("composite sequence reflects each individual failure reason", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session);

// Initially false (sync error + disconnected + not probably left)
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);

// Fix sync only
client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);

// Fix membership
session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);

// Introduce probablyLeft -> false
session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);

// Restore notProbablyLeft -> true again
session.setProbablyLeft(false);
expect(hsConnected$.value).toBe(true);
expect(hsConnected.combined$.value).toBe(true);

// Drop sync -> false
client.setSyncState(SyncState.Error);
expect(hsConnected$.value).toBe(false);
expect(hsConnected.combined$.value).toBe(false);
});
});
38 changes: 22 additions & 16 deletions src/state/CallViewModel/localMember/HomeserverConnected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
*/
const logger = rootLogger.getChild("[HomeserverConnected]");

export interface HomeserverConnected {
combined$: Behavior<boolean>;
rtsSession$: Behavior<Status>;
}

/**
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
* for the purposes of a MatrixRTC session.
Expand All @@ -39,20 +44,23 @@ export function createHomeserverConnected$(
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
): Behavior<boolean> {
): HomeserverConnected {
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
);

const membershipConnected$ = fromEvent(
matrixRTCSession,
MembershipManagerEvent.StatusChanged,
).pipe(
startWith(null),
map(() => matrixRTCSession.membershipStatus === Status.Connected),
const rtsSession$ = scope.behavior<Status>(
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
),
Status.Unknown,
);

const membershipConnected$ = rtsSession$.pipe(
map((status) => status === Status.Connected),
);

// This is basically notProbablyLeft$
Expand All @@ -71,15 +79,13 @@ export function createHomeserverConnected$(
map(() => matrixRTCSession.probablyLeft !== true),
);

const connectedCombined$ = and$(
syncing$,
membershipConnected$,
certainlyConnected$,
).pipe(
tap((connected) => {
logger.info(`Homeserver connected update: ${connected}`);
}),
const combined$ = scope.behavior(
and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
tap((connected) => {
logger.info(`Homeserver connected update: ${connected}`);
}),
),
);

return scope.behavior(connectedCombined$);
return { combined$, rtsSession$ };
}
Loading
Loading