Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Timo K
2024-04-17 15:08:37 +02:00
parent b663cb48d8
commit a7f95ecfc1
13 changed files with 111 additions and 89 deletions

View File

@@ -61,14 +61,15 @@
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>", "full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>", "full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
"group_call_loader": { "group_call_loader": {
"banned_body": "You got banned from the room.", "banned_body": "You have been banned from the room.",
"banned_heading": "Banned", "banned_heading": "Banned",
"call_ended_body": "You got removed from the call.", "call_ended_body": "You have been removed from the call.",
"call_ended_heading": "Call Ended", "call_ended_heading": "Call ended",
"failed_heading": "Call not found", "failed_heading": "Call not found",
"failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.", "failed_text": "Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key.",
"knock_reject_body": "The room members declined your request to join.", "knock_reject_body": "The room members declined your request to join.",
"knock_reject_heading": "Not allowed to join" "knock_reject_heading": "Not allowed to join",
"reason": "Reason"
}, },
"hangup_button_label": "End call", "hangup_button_label": "End call",
"header_label": "Element Call Home", "header_label": "Element Call Home",
@@ -101,6 +102,7 @@
"mute_microphone_button_label": "Mute microphone", "mute_microphone_button_label": "Mute microphone",
"participant_count_one": "{{count, number}}", "participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}", "participant_count_other": "{{count, number}}",
"participant_count_unkonwn": "unknown",
"rageshake_button_error_caption": "Retry sending logs", "rageshake_button_error_caption": "Retry sending logs",
"rageshake_request_modal": { "rageshake_request_modal": {
"body": "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.", "body": "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.",

View File

@@ -117,7 +117,7 @@ interface RoomHeaderInfoProps {
name: string; name: string;
avatarUrl: string | null; avatarUrl: string | null;
encrypted: boolean; encrypted: boolean;
participantCount: number; participantCount: number | null;
} }
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
@@ -150,7 +150,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
</Heading> </Heading>
<EncryptionLock encrypted={encrypted} /> <EncryptionLock encrypted={encrypted} />
</div> </div>
{participantCount > 0 && ( {(participantCount ?? 0) > 0 && (
<div className={styles.participantsLine}> <div className={styles.participantsLine}>
<UserProfileIcon <UserProfileIcon
width={20} width={20}
@@ -158,7 +158,9 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
aria-label={t("header_participants_label")} aria-label={t("header_participants_label")}
/> />
<Text as="span" size="sm" weight="medium"> <Text as="span" size="sm" weight="medium">
{t("participant_count", { count: participantCount })} {participantCount
? t("participant_count", { count: participantCount })
: t("participant_count_unkonwn")}
</Text> </Text>
</div> </div>
)} )}

View File

@@ -16,12 +16,12 @@ limitations under the License.
import { useMemo } from "react"; import { useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/src/logger";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
export const PASSWORD_STRING = "password=";
export const PER_PARTICIPANT_STRING = "perParticipantE2EE=";
export const VIA_SERVERS_STRING = "viaServers=";
interface RoomIdentifier { interface RoomIdentifier {
roomAlias: string | null; roomAlias: string | null;
roomId: string | null; roomId: string | null;
@@ -329,3 +329,32 @@ export const useRoomIdentifier = (): RoomIdentifier => {
[pathname, search, hash], [pathname, search, hash],
); );
}; };
export function generateUrlSearchParams(
roomId: string,
encryptionSystem: EncryptionSystem,
viaServers?: string[],
): URLSearchParams {
const params = new URLSearchParams();
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
// it in case it came from another client that generated a non url-safe one
switch (encryptionSystem?.kind) {
case E2eeType.SHARED_KEY: {
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
if (encodedPassword !== encryptionSystem.secret) {
logger.info(
"Encoded call password used non URL-safe chars: buggy client?",
);
}
params.set("password", encodedPassword);
break;
}
case E2eeType.PER_PARTICIPANT:
params.set("perParticipantE2EE", "true");
break;
}
params.set("roomId", roomId);
viaServers?.forEach((s) => params.set("viaServers", s));
return params;
}

View File

@@ -79,25 +79,25 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
// (and we still need to take the value it returns because // (and we still need to take the value it returns because
// the effect won't run in time for it to save to localstorage in // the effect won't run in time for it to save to localstorage in
// time for us to read it out again). // time for us to read it out again).
const [urlRoomId, passwordFormUrl] = useKeyFromUrl(); const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
const storedPassword = useInternalRoomSharedKey(roomId); const storedPassword = useInternalRoomSharedKey(roomId);
const room = client?.getRoom(roomId); const room = client?.getRoom(roomId);
const e2eeSystem = useMemo(() => { const e2eeSystem = <EncryptionSystem>useMemo(() => {
if (!room) return { kind: E2eeType.NONE } as Unencrypted; if (!room) return { kind: E2eeType.NONE };
if (storedPassword) if (storedPassword)
return { return {
kind: E2eeType.SHARED_KEY, kind: E2eeType.SHARED_KEY,
secret: storedPassword, secret: storedPassword,
} as SharedSecret; };
if (urlRoomId === roomId) if (urlRoomId === roomId)
return { return {
kind: E2eeType.SHARED_KEY, kind: E2eeType.SHARED_KEY,
secret: passwordFormUrl, secret: passwordFromUrl,
} as SharedSecret; };
if (room.hasEncryptionStateEvent()) { if (room.hasEncryptionStateEvent()) {
return { kind: E2eeType.PER_PARTICIPANT } as PerParticipantE2EE; return { kind: E2eeType.PER_PARTICIPANT };
} }
return { kind: E2eeType.NONE } as EncryptionSystem; return { kind: E2eeType.NONE };
}, [passwordFormUrl, room, roomId, storedPassword, urlRoomId]); }, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
return e2eeSystem; return e2eeSystem;
} }

View File

@@ -93,7 +93,7 @@ const roomIsJoinable = (room: Room): boolean => {
return joinRule === JoinRule.Knock || joinRule === JoinRule.Public; return joinRule === JoinRule.Knock || joinRule === JoinRule.Public;
}; };
const roomIsJoinedWithCall = (room: Room): boolean => { const roomHasCallMembershipEvents = (room: Room): boolean => {
const roomStateEvents = room const roomStateEvents = room
.getLiveTimeline() .getLiveTimeline()
.getState(EventTimeline.FORWARDS)?.events; .getState(EventTimeline.FORWARDS)?.events;
@@ -108,13 +108,13 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
useEffect(() => { useEffect(() => {
function updateRooms(): void { function updateRooms(): void {
// We want to show all rooms that historically had a call and which we are (can become) part of.
const rooms = client const rooms = client
.getRooms() .getRooms()
.filter(roomIsJoinedWithCall) .filter(roomHasCallMembershipEvents)
.filter(roomIsJoinable); .filter(roomIsJoinable);
const sortedRooms = sortRooms(client, rooms); const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => { const items = sortedRooms.map((room) => {
// const groupCall = client.getGroupCallForRoom(room.roomId)!;
const session = client.matrixRTC.getRoomSession(room); const session = client.matrixRTC.getRoomSession(room);
session.memberships; session.memberships;
return { return {

View File

@@ -29,12 +29,7 @@ import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import IndexedDBWorker from "./IndexedDBWorker?worker"; import IndexedDBWorker from "./IndexedDBWorker?worker";
import { import { generateUrlSearchParams, getUrlParams } from "./UrlParams";
getUrlParams,
PASSWORD_STRING,
PER_PARTICIPANT_STRING,
VIA_SERVERS_STRING,
} from "./UrlParams";
import { loadOlm } from "./olm"; import { loadOlm } from "./olm";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { E2eeType } from "./e2ee/e2eeType"; import { E2eeType } from "./e2ee/e2eeType";
@@ -339,8 +334,6 @@ export async function createRoom(
const result = await createPromise; const result = await createPromise;
logger.log(`Creating group call in ${result.room_id}`);
let password; let password;
if (e2ee == E2eeType.SHARED_KEY) { if (e2ee == E2eeType.SHARED_KEY) {
password = secureRandomBase64Url(16); password = secureRandomBase64Url(16);
@@ -383,33 +376,10 @@ export function getRelativeRoomUrl(
roomName?: string, roomName?: string,
viaServers?: string[], viaServers?: string[],
): string { ): string {
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode const roomPart = roomName
// it in case it came from another client that generated a non url-safe one ? "/" + roomAliasLocalpartFromRoomName(roomName)
const encryptionPart = ((): string => {
switch (encryptionSystem?.kind) {
case E2eeType.SHARED_KEY: {
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
if (encodedPassword !== encryptionSystem.secret) {
logger.info(
"Encoded call password used non URL-safe chars: buggy client?",
);
}
return "&" + PASSWORD_STRING + encodedPassword;
}
case E2eeType.PER_PARTICIPANT:
return "&" + PER_PARTICIPANT_STRING + "true";
case E2eeType.NONE:
return "";
}
})();
const roomIdPart = `roomId=${roomId}`;
const viaServersPart = viaServers
? viaServers.map((s) => "&" + VIA_SERVERS_STRING + s).join("")
: ""; : "";
return `/room/#${ return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
roomName ? "/" + roomAliasLocalpartFromRoomName(roomName) : ""
}?${roomIdPart}${encryptionPart}${viaServersPart}`;
} }
export function getAvatarUrl( export function getAvatarUrl(

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ReactNode, useCallback } from "react"; import { useCallback } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MatrixError } from "matrix-js-sdk"; import { MatrixError } from "matrix-js-sdk";
@@ -24,7 +24,7 @@ import { Heading, Link, Text } from "@vector-im/compound-web";
import { import {
useLoadGroupCall, useLoadGroupCall,
GroupCallStatus, GroupCallStatus,
CustomMessage, CallTerminatedMessage,
} from "./useLoadGroupCall"; } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView"; import { ErrorView, FullScreenView } from "../FullScreenView";
@@ -32,7 +32,7 @@ interface Props {
client: MatrixClient; client: MatrixClient;
roomIdOrAlias: string; roomIdOrAlias: string;
viaServers: string[]; viaServers: string[];
children: (groupCallState: GroupCallStatus) => ReactNode; children: (groupCallState: GroupCallStatus) => JSX.Element;
} }
export function GroupCallLoader({ export function GroupCallLoader({
@@ -57,7 +57,7 @@ export function GroupCallLoader({
case "loaded": case "loaded":
case "waitForInvite": case "waitForInvite":
case "canKnock": case "canKnock":
return <>{children(groupCallState)}</>; return children(groupCallState);
case "loading": case "loading":
return ( return (
<FullScreenView> <FullScreenView>
@@ -77,14 +77,14 @@ export function GroupCallLoader({
</Link> </Link>
</FullScreenView> </FullScreenView>
); );
} else if (groupCallState.error instanceof CustomMessage) { } else if (groupCallState.error instanceof CallTerminatedMessage) {
return ( return (
<FullScreenView> <FullScreenView>
<Heading>{groupCallState.error.message}</Heading> <Heading>{groupCallState.error.message}</Heading>
<Text>{groupCallState.error.messageBody}</Text> <Text>{groupCallState.error.messageBody}</Text>
{groupCallState.error.reason && ( {groupCallState.error.reason && (
<> <>
Reason: {t("group_call_loader.reason")}:
<Text size="sm">"{groupCallState.error.reason}"</Text> <Text size="sm">"{groupCallState.error.reason}"</Text>
</> </>
)} )}

View File

@@ -83,12 +83,12 @@ export const GroupCallView: FC<Props> = ({
// The mute state reactively gets updated once the participant count reaches the threshold. // The mute state reactively gets updated once the participant count reaches the threshold.
// The user then still is able to unmute again. // The user then still is able to unmute again.
// The more common case is that the user is muted from the start (participant count is already over the threshold). // The more common case is that the user is muted from the start (participant count is already over the threshold).
const [autoMuteHappened, setAutoMuteHappened] = useState(false); const autoMuteHappened = useRef(false);
useEffect(() => { useEffect(() => {
if (autoMuteHappened) return; if (autoMuteHappened.current) return;
if (memberships.length >= MUTE_PARTICIPANT_COUNT) { if (memberships.length >= MUTE_PARTICIPANT_COUNT) {
muteStates.audio.setEnabled?.(false); muteStates.audio.setEnabled?.(false);
setAutoMuteHappened(true); autoMuteHappened.current = true;
} }
}, [autoMuteHappened, memberships, muteStates.audio]); }, [autoMuteHappened, memberships, muteStates.audio]);
@@ -293,7 +293,7 @@ export const GroupCallView: FC<Props> = ({
if (e2eeSystem.kind === E2eeType.NONE && !widget) { if (e2eeSystem.kind === E2eeType.NONE && !widget) {
// the url wants encryption, but we don't have a encryption system. (e.g. when joining a call without password) // the url wants encryption, but we don't have a encryption system. (e.g. when joining a call without password)
// TODO: we need to figure out what we do with encryption goning forward. // TODO: we need to figure out what we do with encryption going forward.
// In the SPA a room is unencrypted but uses a shared secret for the call (since we distribute via url that works) // In the SPA a room is unencrypted but uses a shared secret for the call (since we distribute via url that works)
// In embedded mode unencrypted rooms would make most sense to also call unencrypted. // In embedded mode unencrypted rooms would make most sense to also call unencrypted.
// A call where one person joins with the spa and another person with the widget would be a problem. // A call where one person joins with the spa and another person with the widget would be a problem.
@@ -301,7 +301,7 @@ export const GroupCallView: FC<Props> = ({
<ErrorView <ErrorView
error={ error={
new Error( new Error(
"No E2EE key or other encryption system provided: Element call calls are always encrypted, please make sure the URL you're using to join this call has been retrieved using the in-app button.", `No E2EE key or other encryption system provided: ${import.meta.env.VITE_PRODUCT_NAME || "Element Call"} calls are always encrypted. Please make sure the URL you're using to join this call has been retrieved using the in-app button.`,
) )
} }
/> />

View File

@@ -24,9 +24,17 @@ limitations under the License.
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
} }
.wait { .wait {
color: black !important; color: var(--cpd-color-text-primary) !important;
background-color: rgba(255, 255, 255, 0.67) !important; background-color: var(--cpd-color-bg-canvas-default) !important;
/* relative colors are only supported on chromium based browsers */
background-color: rgb(
from var(--cpd-color-bg-canvas-default) r g b / 0.5
) !important;
}
.wait > svg {
color: var(--cpd-color-theme-primary) !important;
} }
@media (max-width: 500px) { @media (max-width: 500px) {

View File

@@ -43,10 +43,10 @@ interface Props {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; muteStates: MuteStates;
onEnter: () => void; onEnter: () => void;
enterLabel?: JSX.Element; enterLabel?: JSX.Element | string;
confineToRoom: boolean; confineToRoom: boolean;
hideHeader: boolean; hideHeader: boolean;
participantCount: number; participantCount: number | null;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
waitingForInvite?: boolean; waitingForInvite?: boolean;
} }
@@ -121,11 +121,9 @@ export const LobbyView: FC<Props> = ({
<div className={styles.content}> <div className={styles.content}>
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}> <VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
<Button <Button
className={classNames( className={classNames(styles.join, {
"cpd-theme-dark", [styles.wait]: waitingForInvite,
styles.join, })}
waitingForInvite ? styles.wait : "",
)}
size={waitingForInvite ? "sm" : "lg"} size={waitingForInvite ? "sm" : "lg"}
onClick={() => { onClick={() => {
if (!waitingForInvite) onEnter(); if (!waitingForInvite) onEnter();

View File

@@ -23,7 +23,6 @@ import { useReactiveState } from "../useReactiveState";
* If there already are this many participants in the call, we automatically mute * If there already are this many participants in the call, we automatically mute
* the user. * the user.
*/ */
// TODO set back to 8. for demo purposes we use 1 here.
export const MUTE_PARTICIPANT_COUNT = 8; export const MUTE_PARTICIPANT_COUNT = 8;
interface DeviceAvailable { interface DeviceAvailable {

View File

@@ -87,7 +87,7 @@ export const RoomPage: FC = () => {
}, [optInAnalytics, setOptInAnalytics]); }, [optInAnalytics, setOptInAnalytics]);
const groupCallView = useCallback( const groupCallView = useCallback(
(groupCallState: GroupCallStatus) => { (groupCallState: GroupCallStatus): JSX.Element => {
switch (groupCallState.kind) { switch (groupCallState.kind) {
case "loaded": case "loaded":
return ( return (
@@ -106,9 +106,9 @@ export const RoomPage: FC = () => {
case "canKnock": { case "canKnock": {
const knock = const knock =
groupCallState.kind === "canKnock" ? groupCallState.knock : null; groupCallState.kind === "canKnock" ? groupCallState.knock : null;
const label = const label: string | JSX.Element =
groupCallState.kind === "canKnock" ? ( groupCallState.kind === "canKnock" ? (
<>{t("lobby.ask_to_join")}</> t("lobby.ask_to_join")
) : ( ) : (
<> <>
{t("lobby.waiting_for_invite")} {t("lobby.waiting_for_invite")}
@@ -139,12 +139,14 @@ export const RoomPage: FC = () => {
waitingForInvite={groupCallState.kind === "waitForInvite"} waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={hideHeader}
participantCount={0} participantCount={null}
muteStates={muteStates} muteStates={muteStates}
onShareClick={null} onShareClick={null}
/> />
); );
} }
default:
return <> </>;
} }
}, },
[ [

View File

@@ -63,9 +63,21 @@ export type GroupCallStatus =
| GroupCallWaitForInvite | GroupCallWaitForInvite
| GroupCallCanKnock; | GroupCallCanKnock;
export class CustomMessage extends Error { export class CallTerminatedMessage extends Error {
/**
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
*/
public messageBody: string; public messageBody: string;
/**
* @param reason The user provided reason for the termination (kick/ban)
*/
public reason?: string; public reason?: string;
/**
*
* @param messageTitle The title of the call ended screen message (translated)
* @param messageBody The message explaining the kind of termination (kick, ban, knock reject, etc.) (translated)
* @param reason The user provided reason for the termination (kick/ban)
*/
public constructor( public constructor(
messageTitle: string, messageTitle: string,
messageBody: string, messageBody: string,
@@ -87,8 +99,8 @@ export const useLoadGroupCall = (
const { t } = useTranslation(); const { t } = useTranslation();
const bannedError = useCallback( const bannedError = useCallback(
(): CustomMessage => (): CallTerminatedMessage =>
new CustomMessage( new CallTerminatedMessage(
t("group_call_loader.banned_heading"), t("group_call_loader.banned_heading"),
t("group_call_loader.banned_body"), t("group_call_loader.banned_body"),
leaveReason(), leaveReason(),
@@ -96,8 +108,8 @@ export const useLoadGroupCall = (
[t], [t],
); );
const knockRejectError = useCallback( const knockRejectError = useCallback(
(): CustomMessage => (): CallTerminatedMessage =>
new CustomMessage( new CallTerminatedMessage(
t("group_call_loader.knock_reject_heading"), t("group_call_loader.knock_reject_heading"),
t("group_call_loader.knock_reject_body"), t("group_call_loader.knock_reject_body"),
leaveReason(), leaveReason(),
@@ -105,8 +117,8 @@ export const useLoadGroupCall = (
[t], [t],
); );
const removeNoticeError = useCallback( const removeNoticeError = useCallback(
(): CustomMessage => (): CallTerminatedMessage =>
new CustomMessage( new CallTerminatedMessage(
t("group_call_loader.call_ended_heading"), t("group_call_loader.call_ended_heading"),
t("group_call_loader.call_ended_body"), t("group_call_loader.call_ended_body"),
leaveReason(), leaveReason(),
@@ -148,7 +160,7 @@ export const useLoadGroupCall = (
onKnockSent: () => void, onKnockSent: () => void,
): Promise<Room> => { ): Promise<Room> => {
let joinedRoom: Room | null = null; let joinedRoom: Room | null = null;
await client.knockRoom(roomId); await client.knockRoom(roomId, { viaServers });
onKnockSent(); onKnockSent();
const invitePromise = new Promise<void>((resolve, reject) => { const invitePromise = new Promise<void>((resolve, reject) => {
client.on( client.on(