mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-30 00:50:48 +08:00
Prototyping for to-device key distribution
Show participant ID and some encryption status message Allow encryption system to be chosen at point of room creation Send cryptoVersion platform data to Posthog Send key distribution stats to posthog Send encryption type for CallStarted and CallEnded events Update js-sdk
This commit is contained in:
parent
040288790c
commit
497b38b609
@ -73,6 +73,7 @@ interface PlatformProperties {
|
||||
appVersion: string;
|
||||
matrixBackend: "embedded" | "jssdk";
|
||||
callBackend: "livekit" | "full-mesh";
|
||||
cryptoVersion?: string;
|
||||
}
|
||||
|
||||
interface PosthogSettings {
|
||||
@ -193,6 +194,9 @@ export class PosthogAnalytics {
|
||||
appVersion,
|
||||
matrixBackend: widget ? "embedded" : "jssdk",
|
||||
callBackend: "livekit",
|
||||
cryptoVersion: widget
|
||||
? undefined
|
||||
: window.matrixclient.getCrypto()?.getVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -16,19 +16,40 @@ limitations under the License.
|
||||
|
||||
import { DisconnectReason } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
||||
|
||||
import {
|
||||
IPosthogEvent,
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "./PosthogAnalytics";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
type EncryptionScheme = "none" | "shared" | "per_sender";
|
||||
|
||||
function mapE2eeType(type: E2eeType): EncryptionScheme {
|
||||
switch (type) {
|
||||
case E2eeType.NONE:
|
||||
return "none";
|
||||
case E2eeType.SHARED_KEY:
|
||||
return "shared";
|
||||
case E2eeType.PER_PARTICIPANT:
|
||||
return "per_sender";
|
||||
}
|
||||
}
|
||||
interface CallEnded extends IPosthogEvent {
|
||||
eventName: "CallEnded";
|
||||
callId: string;
|
||||
callParticipantsOnLeave: number;
|
||||
callParticipantsMax: number;
|
||||
callDuration: number;
|
||||
encryption: EncryptionScheme;
|
||||
toDeviceEncryptionKeysSent: number;
|
||||
toDeviceEncryptionKeysReceived: number;
|
||||
toDeviceEncryptionKeysReceivedAverageAge: number;
|
||||
roomEventEncryptionKeysSent: number;
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
roomEventEncryptionKeysReceivedAverageAge: number;
|
||||
}
|
||||
|
||||
export class CallEndedTracker {
|
||||
@ -51,6 +72,8 @@ export class CallEndedTracker {
|
||||
public track(
|
||||
callId: string,
|
||||
callParticipantsNow: number,
|
||||
e2eeType: E2eeType,
|
||||
rtcSession: MatrixRTCSession,
|
||||
sendInstantly: boolean,
|
||||
): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
@ -60,6 +83,27 @@ export class CallEndedTracker {
|
||||
callParticipantsMax: this.cache.maxParticipantsCount,
|
||||
callParticipantsOnLeave: callParticipantsNow,
|
||||
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
|
||||
encryption: mapE2eeType(e2eeType),
|
||||
toDeviceEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.toDeviceEncryptionKeysSent,
|
||||
toDeviceEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.toDeviceEncryptionKeysReceived,
|
||||
toDeviceEncryptionKeysReceivedAverageAge:
|
||||
rtcSession.statistics.counters.toDeviceEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.toDeviceEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.toDeviceEncryptionKeysReceived
|
||||
: 0,
|
||||
roomEventEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
|
||||
roomEventEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
|
||||
roomEventEncryptionKeysReceivedAverageAge:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
},
|
||||
{ send_instantly: sendInstantly },
|
||||
);
|
||||
@ -69,13 +113,15 @@ export class CallEndedTracker {
|
||||
interface CallStarted extends IPosthogEvent {
|
||||
eventName: "CallStarted";
|
||||
callId: string;
|
||||
encryption: EncryptionScheme;
|
||||
}
|
||||
|
||||
export class CallStartedTracker {
|
||||
public track(callId: string): void {
|
||||
public track(callId: string, e2eeType: E2eeType): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallStarted>({
|
||||
eventName: "CallStarted",
|
||||
callId: callId,
|
||||
encryption: mapE2eeType(e2eeType),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ import { useState, useCallback, FormEvent, FormEventHandler, FC } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
import { Dropdown, Heading } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
@ -45,6 +45,17 @@ import { useOptInAnalytics } from "../settings/settings";
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
}
|
||||
const encryptionOptions = {
|
||||
shared: {
|
||||
label: "Shared key",
|
||||
e2eeType: E2eeType.SHARED_KEY,
|
||||
},
|
||||
sender: {
|
||||
label: "Per-participant key",
|
||||
e2eeType: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
none: { label: "None", e2eeType: E2eeType.NONE },
|
||||
};
|
||||
|
||||
export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -59,6 +70,9 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
[setJoinExistingCallModalOpen],
|
||||
);
|
||||
|
||||
const [encryption, setEncryption] =
|
||||
useState<keyof typeof encryptionOptions>("shared");
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -73,21 +87,13 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
|
||||
const createRoomResult = await createRoom(
|
||||
const { roomId, encryptionSystem } = await createRoom(
|
||||
client,
|
||||
roomName,
|
||||
E2eeType.SHARED_KEY,
|
||||
encryptionOptions[encryption].e2eeType,
|
||||
);
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
history.push(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||
roomName,
|
||||
),
|
||||
);
|
||||
history.push(getRelativeRoomUrl(roomId, encryptionSystem, roomName));
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
@ -103,7 +109,7 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
}
|
||||
});
|
||||
},
|
||||
[client, history, setJoinExistingCallModalOpen],
|
||||
[client, history, setJoinExistingCallModalOpen, encryption],
|
||||
);
|
||||
|
||||
const recentRooms = useGroupCallRooms(client);
|
||||
@ -142,6 +148,19 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
data-testid="home_callName"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
label="Encryption"
|
||||
defaultValue={encryption}
|
||||
onValueChange={(x) =>
|
||||
setEncryption(x as keyof typeof encryptionOptions)
|
||||
}
|
||||
values={Object.keys(encryptionOptions).map((value) => [
|
||||
value,
|
||||
encryptionOptions[value as keyof typeof encryptionOptions]
|
||||
.label,
|
||||
])}
|
||||
placeholder=""
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
@ -18,7 +18,7 @@ import { FC, useCallback, useState, FormEventHandler } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button, Heading } from "@vector-im/compound-web";
|
||||
import { Button, Dropdown, Heading } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
@ -44,6 +44,18 @@ import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
|
||||
const encryptionOptions = {
|
||||
shared: {
|
||||
label: "Shared key",
|
||||
e2eeType: E2eeType.SHARED_KEY,
|
||||
},
|
||||
sender: {
|
||||
label: "Per-participant key",
|
||||
e2eeType: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
none: { label: "None", e2eeType: E2eeType.NONE },
|
||||
};
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -52,6 +64,9 @@ export const UnauthenticatedView: FC = () => {
|
||||
const { recaptchaKey, register } = useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const [encryption, setEncryption] =
|
||||
useState<keyof typeof encryptionOptions>("shared");
|
||||
|
||||
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
||||
useState(false);
|
||||
const onDismissJoinExistingCallModal = useCallback(
|
||||
@ -82,13 +97,16 @@ export const UnauthenticatedView: FC = () => {
|
||||
true,
|
||||
);
|
||||
|
||||
let createRoomResult;
|
||||
let roomId;
|
||||
let encryptionSystem;
|
||||
try {
|
||||
createRoomResult = await createRoom(
|
||||
const res = await createRoom(
|
||||
client,
|
||||
roomName,
|
||||
E2eeType.SHARED_KEY,
|
||||
encryptionOptions[encryption].e2eeType,
|
||||
);
|
||||
roomId = res.roomId;
|
||||
encryptionSystem = res.encryptionSystem;
|
||||
} catch (error) {
|
||||
if (!setClient) {
|
||||
throw error;
|
||||
@ -115,17 +133,11 @@ export const UnauthenticatedView: FC = () => {
|
||||
if (!setClient) {
|
||||
throw new Error("setClient is undefined");
|
||||
}
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
// if (!createRoomResult.password)
|
||||
// throw new Error("Failed to create room with shared secret");
|
||||
|
||||
setClient({ client, session });
|
||||
history.push(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
{ kind: E2eeType.SHARED_KEY, secret: createRoomResult.password },
|
||||
roomName,
|
||||
),
|
||||
);
|
||||
history.push(getRelativeRoomUrl(roomId, encryptionSystem, roomName));
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
@ -142,6 +154,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
history,
|
||||
setJoinExistingCallModalOpen,
|
||||
setClient,
|
||||
encryption,
|
||||
],
|
||||
);
|
||||
|
||||
@ -204,6 +217,20 @@ export const UnauthenticatedView: FC = () => {
|
||||
<ErrorMessage error={error} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<Dropdown
|
||||
label="Encryption"
|
||||
defaultValue={encryption}
|
||||
onValueChange={(x) =>
|
||||
setEncryption(x as keyof typeof encryptionOptions)
|
||||
}
|
||||
values={Object.keys(encryptionOptions).map((value) => [
|
||||
value,
|
||||
encryptionOptions[value as keyof typeof encryptionOptions]
|
||||
.label,
|
||||
])}
|
||||
placeholder=""
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
@ -97,7 +97,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const roomName = useRoomName(rtcSession.room);
|
||||
const roomAvatar = useRoomAvatar(rtcSession.room);
|
||||
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
||||
const { returnToLobby } = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
@ -191,7 +191,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
ev: CustomEvent<IWidgetApiRequest>,
|
||||
): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
await enterRTCSession(rtcSession, e2eeSystem.kind);
|
||||
await widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
@ -201,12 +201,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
const join = async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
await enterRTCSession(rtcSession, e2eeSystem.kind);
|
||||
};
|
||||
// No lobby and no preload: we enter the RTC Session right away.
|
||||
join();
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
}, [rtcSession, preload, skipLobby, e2eeSystem]);
|
||||
|
||||
const [left, setLeft] = useState(false);
|
||||
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
|
||||
@ -223,6 +223,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
PosthogAnalytics.instance.eventCallEnded.track(
|
||||
rtcSession.room.roomId,
|
||||
rtcSession.memberships.length,
|
||||
matrixInfo.e2eeSystem.kind,
|
||||
rtcSession,
|
||||
sendInstantly,
|
||||
);
|
||||
|
||||
@ -237,7 +239,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
history.push("/");
|
||||
}
|
||||
},
|
||||
[rtcSession, isPasswordlessUser, confineToRoom, history],
|
||||
[rtcSession, isPasswordlessUser, confineToRoom, history, matrixInfo],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -262,8 +264,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
const onReconnect = useCallback(() => {
|
||||
setLeft(false);
|
||||
setLeaveError(undefined);
|
||||
enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
}, [rtcSession, perParticipantE2EE]);
|
||||
enterRTCSession(rtcSession, e2eeSystem.kind);
|
||||
}, [rtcSession, e2eeSystem]);
|
||||
|
||||
const joinRule = useJoinRule(rtcSession.room);
|
||||
|
||||
@ -316,7 +318,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
|
||||
onEnter={() => void enterRTCSession(rtcSession, e2eeSystem.kind)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={participantCount}
|
||||
|
@ -19,6 +19,7 @@ import { expect, test, vi } from "vitest";
|
||||
|
||||
import { enterRTCSession } from "../src/rtcSessionHelpers";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
|
||||
test("It joins the correct Session", async () => {
|
||||
const focusFromOlderMembership = {
|
||||
@ -60,7 +61,7 @@ test("It joins the correct Session", async () => {
|
||||
}),
|
||||
joinRoomSession: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
await enterRTCSession(mockedSession, false);
|
||||
await enterRTCSession(mockedSession, E2eeType.NONE);
|
||||
|
||||
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
|
||||
[
|
||||
|
@ -26,6 +26,7 @@ import {
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementWidgetActions, WidgetHelpers, widget } from "./widget";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
|
||||
@ -97,10 +98,13 @@ async function makePreferredLivekitFoci(
|
||||
|
||||
export async function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
encryptMedia: boolean,
|
||||
e2eeType: E2eeType,
|
||||
): Promise<void> {
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||
PosthogAnalytics.instance.eventCallStarted.track(
|
||||
rtcSession.room.roomId,
|
||||
e2eeType,
|
||||
);
|
||||
|
||||
// This must be called before we start trying to join the call, as we need to
|
||||
// have started tracking by the time calls start getting created.
|
||||
@ -114,7 +118,7 @@ export async function enterRTCSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
{
|
||||
manageMediaKeys: encryptMedia,
|
||||
manageMediaKeys: e2eeType === E2eeType.PER_PARTICIPANT,
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
|
@ -173,11 +173,24 @@ class UserMedia {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
this.vm =
|
||||
participant instanceof LocalParticipant
|
||||
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
||||
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
||||
? new LocalUserMediaViewModel(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
callEncrypted,
|
||||
livekitRoom,
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
callEncrypted,
|
||||
livekitRoom,
|
||||
);
|
||||
|
||||
this.speaker = this.vm.speaking.pipe(
|
||||
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
||||
@ -219,8 +232,15 @@ class ScreenShare {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
liveKitRoom: LivekitRoom,
|
||||
) {
|
||||
this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted);
|
||||
this.vm = new ScreenShareViewModel(
|
||||
id,
|
||||
member,
|
||||
participant,
|
||||
callEncrypted,
|
||||
liveKitRoom,
|
||||
);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
@ -353,7 +373,13 @@ export class CallViewModel extends ViewModel {
|
||||
yield [
|
||||
userMediaId,
|
||||
prevItems.get(userMediaId) ??
|
||||
new UserMedia(userMediaId, member, p, this.encrypted),
|
||||
new UserMedia(
|
||||
userMediaId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
this.livekitRoom,
|
||||
),
|
||||
];
|
||||
|
||||
if (p.isScreenShareEnabled) {
|
||||
@ -361,7 +387,13 @@ export class CallViewModel extends ViewModel {
|
||||
yield [
|
||||
screenShareId,
|
||||
prevItems.get(screenShareId) ??
|
||||
new ScreenShare(screenShareId, member, p, this.encrypted),
|
||||
new ScreenShare(
|
||||
screenShareId,
|
||||
member,
|
||||
p,
|
||||
this.encrypted,
|
||||
this.livekitRoom,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
VideoSource,
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
roomEventSelector,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
LocalParticipant,
|
||||
@ -30,18 +31,25 @@ import {
|
||||
Track,
|
||||
TrackEvent,
|
||||
facingModeFromLocalTrack,
|
||||
Room as LivekitRoom,
|
||||
RoomEvent as LivekitRoomEvent,
|
||||
} from "livekit-client";
|
||||
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
distinctUntilKeyChanged,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
} from "rxjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@ -85,6 +93,35 @@ function observeTrackReference(
|
||||
);
|
||||
}
|
||||
|
||||
function encryptionErrorObservable(
|
||||
room: LivekitRoom,
|
||||
participant: Participant,
|
||||
criteria: string,
|
||||
): Observable<boolean> {
|
||||
const roomEvents = roomEventSelector(
|
||||
room,
|
||||
LivekitRoomEvent.EncryptionError,
|
||||
).pipe(
|
||||
map((e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [err, participantIdentity] = e as any; // FIXME: fudge for type error during build
|
||||
return (
|
||||
(participantIdentity == participant.identity &&
|
||||
err?.message.includes(criteria)) ??
|
||||
false
|
||||
);
|
||||
}),
|
||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||
);
|
||||
|
||||
return merge(
|
||||
roomEvents,
|
||||
roomEvents.pipe(
|
||||
debounceTime(3000), // Wait 3 seconds before clearing the error, toast style
|
||||
map(() => false),
|
||||
),
|
||||
);
|
||||
}
|
||||
abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* Whether the media belongs to the local user.
|
||||
@ -99,6 +136,10 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
*/
|
||||
public readonly unencryptedWarning: Observable<boolean>;
|
||||
|
||||
public readonly encryptionKeyMissing: Observable<boolean>;
|
||||
|
||||
public readonly encryptionKeyInvalid: Observable<boolean>;
|
||||
|
||||
public constructor(
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
@ -110,10 +151,11 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
// TODO: Fully separate the data layer from the UI layer by keeping the
|
||||
// member object internal
|
||||
public readonly member: RoomMember | undefined,
|
||||
protected readonly participant: LocalParticipant | RemoteParticipant,
|
||||
public readonly participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
super();
|
||||
const audio = observeTrackReference(participant, audioSource).pipe(
|
||||
@ -128,7 +170,17 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
callEncrypted &&
|
||||
(a.publication?.isEncrypted === false ||
|
||||
v.publication?.isEncrypted === false),
|
||||
).pipe(this.scope.state());
|
||||
).pipe(distinctUntilChanged(), shareReplay(1));
|
||||
this.encryptionKeyMissing = encryptionErrorObservable(
|
||||
livekitRoom,
|
||||
participant,
|
||||
"MissingKey",
|
||||
).pipe(startWith(false));
|
||||
this.encryptionKeyInvalid = encryptionErrorObservable(
|
||||
livekitRoom,
|
||||
participant,
|
||||
"InvalidKey",
|
||||
).pipe(startWith(false));
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,6 +227,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@ -183,6 +236,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
callEncrypted,
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom,
|
||||
);
|
||||
|
||||
const media = observeParticipantMedia(participant).pipe(this.scope.state());
|
||||
@ -232,8 +286,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant,
|
||||
callEncrypted: boolean,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
super(id, member, participant, callEncrypted);
|
||||
super(id, member, participant, callEncrypted, livekitRoom);
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,8 +314,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
member: RoomMember | undefined,
|
||||
participant: RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
super(id, member, participant, callEncrypted);
|
||||
super(id, member, participant, callEncrypted, livekitRoom);
|
||||
|
||||
// Sync the local mute state and volume with LiveKit
|
||||
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
|
||||
@ -290,6 +346,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
livekitRoom: LivekitRoom,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@ -298,6 +355,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
callEncrypted,
|
||||
Track.Source.ScreenShareAudio,
|
||||
Track.Source.ScreenShare,
|
||||
livekitRoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
@ -88,6 +89,12 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
const encryptionKeyMissing = useObservableEagerState(
|
||||
vm.encryptionKeyMissing,
|
||||
);
|
||||
const encryptionKeyInvalid = useObservableEagerState(
|
||||
vm.encryptionKeyInvalid,
|
||||
);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||
const speaking = useObservableEagerState(vm.speaking);
|
||||
@ -100,6 +107,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
[vm],
|
||||
);
|
||||
|
||||
const participantId = useMemo(() => vm.participant.identity, [vm]);
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@ -122,6 +131,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionKeyMissing={encryptionKeyMissing}
|
||||
encryptionKeyInvalid={encryptionKeyInvalid}
|
||||
participantId={participantId}
|
||||
videoEnabled={videoEnabled && showVideo}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
|
@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: ". ." "nameTag button";
|
||||
grid-template-areas: "status status" "nameTag button";
|
||||
gap: var(--cpd-space-1x);
|
||||
place-items: start;
|
||||
}
|
||||
@ -115,6 +115,24 @@ unconditionally select the container so we can use cqmin units */
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
justify-self: center;
|
||||
align-self: start;
|
||||
padding: var(--cpd-space-1x);
|
||||
padding-block: var(--cpd-space-1x);
|
||||
color: var(--cpd-color-text-primary);
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
box-sizing: border-box;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.nameTag > svg,
|
||||
.nameTag > span {
|
||||
flex-shrink: 0;
|
||||
|
@ -38,8 +38,11 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
member: RoomMember | undefined;
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionKeyMissing: boolean;
|
||||
encryptionKeyInvalid: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
displayName: string;
|
||||
participantId: string;
|
||||
primaryButton?: ReactNode;
|
||||
}
|
||||
|
||||
@ -59,6 +62,9 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
encryptionKeyMissing,
|
||||
encryptionKeyInvalid,
|
||||
participantId,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@ -69,7 +75,8 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
[styles.mirror]: mirror,
|
||||
[styles.videoMuted]: !videoEnabled,
|
||||
[styles.videoMuted]:
|
||||
!videoEnabled || encryptionKeyInvalid || encryptionKeyMissing,
|
||||
})}
|
||||
style={style}
|
||||
ref={ref}
|
||||
@ -95,10 +102,24 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.nameTag}>
|
||||
{encryptionKeyMissing && (
|
||||
<div className={styles.status}>
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
Encryption key missing
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{encryptionKeyInvalid && (
|
||||
<div className={styles.status}>
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
Encryption key invalid
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.nameTag} title={participantId}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{displayName}
|
||||
{displayName} ({participantId})
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@ -60,7 +61,10 @@ interface SpotlightItemBaseProps {
|
||||
video: TrackReferenceOrPlaceholder;
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionKeyMissing: boolean;
|
||||
encryptionKeyInvalid: boolean;
|
||||
displayName: string;
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
@ -127,6 +131,17 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
const displayName = useDisplayName(vm);
|
||||
const video = useObservableEagerState(vm.video);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||
const encryptionKeyMissing = useObservableEagerState(
|
||||
vm.encryptionKeyMissing,
|
||||
);
|
||||
const encryptionKeyInvalid = useObservableEagerState(
|
||||
vm.encryptionKeyInvalid,
|
||||
);
|
||||
|
||||
const participantId = useMemo(
|
||||
() => vm.participant.identity,
|
||||
[vm.participant],
|
||||
);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
@ -153,6 +168,9 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
encryptionKeyMissing,
|
||||
encryptionKeyInvalid,
|
||||
participantId,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
|
@ -18,6 +18,7 @@ import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
|
||||
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
||||
import {
|
||||
createClient,
|
||||
EventType,
|
||||
ICreateClientOpts,
|
||||
Preset,
|
||||
Visibility,
|
||||
@ -217,7 +218,7 @@ export function sanitiseRoomNameInput(input: string): string {
|
||||
interface CreateRoomResult {
|
||||
roomId: string;
|
||||
alias?: string;
|
||||
password?: string;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -264,6 +265,18 @@ export async function createRoom(
|
||||
[client.getUserId()!]: 100,
|
||||
},
|
||||
},
|
||||
initial_state:
|
||||
e2ee === E2eeType.PER_PARTICIPANT
|
||||
? [
|
||||
{
|
||||
type: EventType.RoomEncryption,
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: "m.megolm.v1.aes-sha2",
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Wait for the room to arrive
|
||||
@ -287,16 +300,22 @@ export async function createRoom(
|
||||
|
||||
const result = await createPromise;
|
||||
|
||||
let password;
|
||||
let encryptionSystem: EncryptionSystem;
|
||||
if (e2ee == E2eeType.SHARED_KEY) {
|
||||
password = secureRandomBase64Url(16);
|
||||
const password = secureRandomBase64Url(16);
|
||||
saveKeyForRoom(result.room_id, password);
|
||||
encryptionSystem = {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: password,
|
||||
};
|
||||
} else {
|
||||
encryptionSystem = { kind: e2ee };
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: result.room_id,
|
||||
alias: e2ee ? undefined : fullAliasFromRoomName(name, client),
|
||||
password,
|
||||
encryptionSystem,
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user