Merge branch 'voip-team/rebased-multiSFU' into valere/multi-sfu/connection_states

This commit is contained in:
Valere
2025-10-10 15:01:49 +02:00
17 changed files with 118 additions and 89 deletions

View File

@@ -20,6 +20,7 @@ import {
MatrixRTCSessionManagerEvents,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { getKeyForRoom } from "../e2ee/sharedKeyManagement";
@@ -139,22 +140,24 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
.filter(roomHasCallMembershipEvents)
.filter(roomIsJoinable);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
const session = client.matrixRTC.getRoomSession(room);
return {
roomAlias: room.getCanonicalAlias() ?? undefined,
roomName: room.name,
avatarUrl: room.getMxcAvatarUrl()!,
room,
session,
participants: session.memberships
.filter((m) => m.sender)
.map((m) => room.getMember(m.sender!))
.filter((m) => m) as RoomMember[],
};
});
setRooms(items);
Promise.all(
sortedRooms.map(async (room) => {
const session = await client.matrixRTC.getRoomSession(room);
return {
roomAlias: room.getCanonicalAlias() ?? undefined,
roomName: room.name,
avatarUrl: room.getMxcAvatarUrl()!,
room,
session,
participants: session.memberships
.filter((m) => m.sender)
.map((m) => room.getMember(m.sender!))
.filter((m) => m) as RoomMember[],
};
}),
)
.then((items) => setRooms(items))
.catch(logger.error);
}
updateRooms();

View File

@@ -436,7 +436,10 @@ export const GroupCallView: FC<Props> = ({
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => setJoined(true)}
onEnter={async () => {
setJoined(true);
return Promise.resolve();
}}
confineToRoom={confineToRoom}
hideHeader={header === HeaderStyle.None}
participantCount={participantCount}

View File

@@ -57,7 +57,7 @@ interface Props {
client: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
onEnter: () => Promise<void>;
enterLabel?: JSX.Element | string;
confineToRoom: boolean;
hideHeader: boolean;
@@ -183,6 +183,14 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack);
const [waitingToEnter, setWaitingToEnter] = useState(false);
const onEnterCall = useCallback(() => {
setWaitingToEnter(true);
void onEnter().finally(() => setWaitingToEnter(false));
}, [onEnter]);
const waiting = waitingForInvite || waitingToEnter;
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -212,11 +220,12 @@ export const LobbyView: FC<Props> = ({
>
<Button
className={classNames(styles.join, {
[styles.wait]: waitingForInvite,
[styles.wait]: waiting,
})}
size={waitingForInvite ? "sm" : "lg"}
size={waiting ? "sm" : "lg"}
disabled={waiting}
onClick={() => {
if (!waitingForInvite) onEnter();
if (!waiting) onEnterCall();
}}
data-testid="lobby_joinCall"
>

View File

@@ -150,35 +150,34 @@ export const RoomPage: FC = () => {
</>
);
return (
muteStates && (
<LobbyView
client={client!}
matrixInfo={{
userId: client!.getUserId() ?? "",
displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "",
roomAlias: null,
roomId: groupCallState.roomSummary.room_id,
roomName: groupCallState.roomSummary.name ?? "",
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
e2eeSystem: {
kind: groupCallState.roomSummary[
"im.nheko.summary.encryption"
]
? E2eeType.PER_PARTICIPANT
: E2eeType.NONE,
},
}}
onEnter={(): void => knock?.()}
enterLabel={label}
waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom}
hideHeader={header !== "standard"}
participantCount={null}
muteStates={muteStates}
onShareClick={null}
/>
)
muteStates && <LobbyView
client={client!}
matrixInfo={{
userId: client!.getUserId() ?? "",
displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "",
roomAlias: null,
roomId: groupCallState.roomSummary.room_id,
roomName: groupCallState.roomSummary.name ?? "",
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
e2eeSystem: {
kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
? E2eeType.PER_PARTICIPANT
: E2eeType.NONE,
},
}}
onEnter={async (): Promise<void> => {
knock?.();
return Promise.resolve();
}}
enterLabel={label}
waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom}
hideHeader={header !== "standard"}
participantCount={null}
muteStates={muteStates}
onShareClick={null}
/>
);
}
case "loading":

View File

@@ -528,10 +528,12 @@ export class CallViewModel extends ViewModel {
multiSfu.value$,
],
(preferred, memberships, multiSfu) => {
const oldestMembership =
this.matrixRTCSession.getOldestMembership();
const remote = memberships.flatMap((m) => {
if (m.sender === this.userId && m.deviceId === this.deviceId)
return [];
const t = this.matrixRTCSession.resolveActiveFocus(m);
const t = m.getTransport(oldestMembership ?? m);
return t && isLivekitTransport(t)
? [{ membership: m, transport: t }]
: [];
@@ -633,10 +635,11 @@ export class CallViewModel extends ViewModel {
// Until the local transport becomes ready we have no idea which
// transports will actually need a dedicated remote connection
if (transports?.local.state === "ready") {
const oldestMembership = this.matrixRTCSession.getOldestMembership();
const localServiceUrl = transports.local.value.livekit_service_url;
const remoteServiceUrls = new Set(
transports.remote.flatMap(({ membership, transport }) => {
const t = this.matrixRTCSession.resolveActiveFocus(membership);
const t = membership.getTransport(oldestMembership ?? membership);
return t &&
isLivekitTransport(t) &&
t.livekit_service_url !== localServiceUrl

View File

@@ -99,7 +99,19 @@ export class ObservableScope {
.subscribe(callback);
}
// TODO-MULTI-SFU Dear Future Robin, please document this. Love, Past Robin.
/**
* For the duration of the scope, sync some external state with the value of
* the provided Behavior by way of an async function which attempts to update
* (reconcile) the external state. The reconciliation function may return a
* clean-up callback which will be called and awaited before the next change
* in value (or the end of the scope).
*
* All calls to the function and its clean-up callbacks are serialized. If the
* value changes faster than the handlers can keep up with, intermediate
* values may be skipped.
*
* Basically, this is like React's useEffect but async and for Behaviors.
*/
public reconcile<T>(
value$: Behavior<T>,
callback: (value: T) => Promise<(() => Promise<void>) | undefined>,
@@ -107,27 +119,27 @@ export class ObservableScope {
let latestValue: T | typeof nothing = nothing;
let reconciledValue: T | typeof nothing = nothing;
let cleanUp: (() => Promise<void>) | undefined = undefined;
let callbackPromise: Promise<(() => Promise<void>) | undefined>;
value$
.pipe(
catchError(() => EMPTY),
this.bind(),
endWith(nothing),
catchError(() => EMPTY), // Ignore errors
this.bind(), // Limit to the duration of the scope
endWith(nothing), // Clean up when the scope ends
)
.subscribe((value) => {
void (async (): Promise<void> => {
if (latestValue === nothing) {
latestValue = value;
while (latestValue !== reconciledValue) {
await cleanUp?.();
await cleanUp?.(); // Call the previous value's clean-up handler
reconciledValue = latestValue;
if (latestValue !== nothing) {
callbackPromise = callback(latestValue);
cleanUp = await callbackPromise;
}
if (latestValue !== nothing)
cleanUp = await callback(latestValue); // Sync current value
}
// Reset to signal that reconciliation is done for now
latestValue = nothing;
} else {
// There's already an instance of the above 'while' loop running
// concurrently. Just update the latest value and let it be handled.
latestValue = value;
}
})();