Human readable URLs

This tries to implement the new scheme for element call URLs, including:
 * All rooms have an alias
 * Legacy unencrypted rooms keep their old alias
 * New encerypted rooms get an autogenerated alias so they have a string that
   can always be used to join them.
 * The room name is hyphenated and included in the URL

However this is buggy as it stands. Firstly, I am gettign initialisation
weirdness where it keeps thinking I'm unregistered, then remembering all
my rooms again when I enter a display name... *realises* oh, this is
because it's looking for config.json in the current directory, ie.
/c/.

Another bug is that it doesn't update the URL bar with the canonical URL
for the room, which it probably ought to.

The main blocker, however, is that it turns out we use the absence of a
canonical alias to tell whether a room is supposed to be an e2ee call room,
so this would need that mechanism replaced.
This commit is contained in:
David Baker
2023-09-19 17:27:07 +01:00
parent caa6f5bcde
commit e17fd4b68e
13 changed files with 334 additions and 179 deletions

View File

@@ -16,10 +16,11 @@ 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";
export const PASSWORD_STRING = "password="; export const PASSWORD_PARAM = "password";
interface RoomIdentifier { interface RoomIdentifier {
roomAlias: string | null; roomAlias: string | null;
@@ -134,10 +135,20 @@ class ParamParser {
// leaking them to the server. However, we also check the normal query // leaking them to the server. However, we also check the normal query
// string for backwards compatibility with versions that only used that. // string for backwards compatibility with versions that only used that.
hasParam(name: string): boolean { hasParam(name: string): boolean {
if (!this.fragmentParams.has(name) && this.queryParams.has(name)) {
logger.warn(
`Parameter ${name} loaded from query param (not hash). This is unsupported and will soon be removed.`
);
}
return this.fragmentParams.has(name) || this.queryParams.has(name); return this.fragmentParams.has(name) || this.queryParams.has(name);
} }
getParam(name: string): string | null { getParam(name: string): string | null {
if (!this.fragmentParams.has(name) && this.queryParams.has(name)) {
logger.warn(
`Parameter ${name} loaded from query param (not hash). This is unsupported and will soon be removed.`
);
}
return this.fragmentParams.get(name) ?? this.queryParams.get(name); return this.fragmentParams.get(name) ?? this.queryParams.get(name);
} }
@@ -168,7 +179,7 @@ export const getUrlParams = (
// what would we do if it were invalid? If the widget API says that's what // what would we do if it were invalid? If the widget API says that's what
// the room ID is, then that's what it is. // the room ID is, then that's what it is.
roomId: parser.getParam("roomId"), roomId: parser.getParam("roomId"),
password: parser.getParam("password"), password: parser.getParam(PASSWORD_PARAM),
isEmbedded: parser.hasParam("embed"), isEmbedded: parser.hasParam("embed"),
preload: parser.hasParam("preload"), preload: parser.hasParam("preload"),
hideHeader: parser.hasParam("hideHeader"), hideHeader: parser.hasParam("hideHeader"),
@@ -200,34 +211,24 @@ export function getRoomIdentifierFromUrl(
search: string, search: string,
hash: string hash: string
): RoomIdentifier { ): RoomIdentifier {
let roomAlias: string | null = null; let baseRoomString: string | undefined;
// Here we handle the beginning of the alias and make sure it starts with a "#"
if (hash === "" || hash.startsWith("#?")) { if (hash === "" || hash.startsWith("#?")) {
roomAlias = pathname.substring(1); // Strip the "/" // if the hash is absent or being used as a query string, the alias is the last
// path component.
// Delete "/room/", if present baseRoomString = pathname.split("/").pop();
if (roomAlias.startsWith("room/")) {
roomAlias = roomAlias.substring("room/".length);
}
// Add "#", if not present
if (!roomAlias.startsWith("#")) {
roomAlias = `#${roomAlias}`;
}
} else { } else {
roomAlias = hash; baseRoomString = hash;
logger.warn(
"Using whole hash as room name: this is deprecated and will be removed soon."
);
} }
// Delete "?" and what comes afterwards let roomAlias: string | null = null;
roomAlias = roomAlias.split("?")[0]; if (baseRoomString !== undefined) {
// ensure exactly one hash on the start
if (roomAlias.length <= 1) { roomAlias = `${baseRoomString.replace(/^#*/, "#")}`;
// Make roomAlias is null, if it only is a "#"
roomAlias = null;
} else {
// Add server part, if not present
if (!roomAlias.includes(":")) { if (!roomAlias.includes(":")) {
roomAlias = `${roomAlias}:${Config.defaultServerName()}`; roomAlias += ":" + Config.defaultServerName();
} }
} }
@@ -241,6 +242,12 @@ export function getRoomIdentifierFromUrl(
roomId = null; roomId = null;
} }
if (roomId) {
logger.warn(
"Room loaded by room ID: this is not supported and will be removed soon."
);
}
return { return {
roomAlias, roomAlias,
roomId, roomId,

View File

@@ -19,7 +19,7 @@ import { useEffect, useMemo } from "react";
import { useEnableE2EE } from "../settings/useSetting"; import { useEnableE2EE } from "../settings/useSetting";
import { useLocalStorage } from "../useLocalStorage"; import { useLocalStorage } from "../useLocalStorage";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { PASSWORD_STRING, useUrlParams } from "../UrlParams"; import { PASSWORD_PARAM, useUrlParams } from "../UrlParams";
export const getRoomSharedKeyLocalStorageKey = (roomId: string): string => export const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
`room-shared-key-${roomId}`; `room-shared-key-${roomId}`;
@@ -50,18 +50,18 @@ export const useManageRoomSharedKey = (roomId: string): string | null => {
setE2EESharedKey(password); setE2EESharedKey(password);
}, [password, e2eeSharedKey, setE2EESharedKey]); }, [password, e2eeSharedKey, setE2EESharedKey]);
useEffect(() => { /*useEffect(() => {
const hash = location.hash; const hash = location.hash;
if (!hash.includes("?")) return; if (!hash.includes("?")) return;
if (!hash.includes(PASSWORD_STRING)) return; if (!hash.includes(`${PASSWORD_PARAM}=`)) return;
if (password !== e2eeSharedKey) return; if (password !== e2eeSharedKey) return;
const [hashStart, passwordStart] = hash.split(PASSWORD_STRING); const [hashStart, passwordStart] = hash.split(`${PASSWORD_PARAM}=`);
const hashEnd = passwordStart.split("&")[1]; const hashEnd = passwordStart.split("&")[1];
location.replace((hashStart ?? "") + (hashEnd ?? "")); location.replace((hashStart ?? "") + (hashEnd ?? ""));
}, [password, e2eeSharedKey]); }, [password, e2eeSharedKey]);*/
return e2eeSharedKey; return e2eeSharedKey;
}; };

View File

@@ -16,12 +16,15 @@ limitations under the License.
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { Room } from "matrix-js-sdk";
import { CopyButton } from "../button"; import { CopyButton } from "../button";
import { Avatar, Size } from "../Avatar"; import { Avatar, Size } from "../Avatar";
import styles from "./CallList.module.css"; import styles from "./CallList.module.css";
import { getRoomUrl } from "../matrix-utils"; import {
getAbsoluteRoomUrlForRoom,
getRelativeRoomUrlForRoom,
} from "../matrix-utils";
import { Body } from "../typography/Typography"; import { Body } from "../typography/Typography";
import { GroupCallRoom } from "./useGroupCallRooms"; import { GroupCallRoom } from "./useGroupCallRooms";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
@@ -34,14 +37,14 @@ export function CallList({ rooms, client }: CallListProps) {
return ( return (
<> <>
<div className={styles.callList}> <div className={styles.callList}>
{rooms.map(({ room, roomAlias, roomName, avatarUrl, participants }) => ( {rooms.map(({ room, roomName, avatarUrl }) => (
<CallTile <CallTile
key={roomAlias} key={room.roomId}
client={client} client={client}
name={roomName} name={roomName}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
roomId={room.roomId} room={room}
participants={participants} //participants={participants}
/> />
))} ))}
{rooms.length > 3 && ( {rooms.length > 3 && (
@@ -57,17 +60,20 @@ export function CallList({ rooms, client }: CallListProps) {
interface CallTileProps { interface CallTileProps {
name: string; name: string;
avatarUrl: string; avatarUrl: string;
roomId: string; room: Room;
participants: RoomMember[]; //participants: RoomMember[];
client: MatrixClient; client: MatrixClient;
} }
function CallTile({ name, avatarUrl, roomId }: CallTileProps) { function CallTile({ name, avatarUrl, room }: CallTileProps) {
const roomSharedKey = useRoomSharedKey(roomId); const roomSharedKey = useRoomSharedKey(room.roomId) ?? undefined;
return ( return (
<div className={styles.callTile}> <div className={styles.callTile}>
<Link to={`/room/#?roomId=${roomId}`} className={styles.callTileLink}> <Link
<Avatar id={roomId} name={name} size={Size.LG} src={avatarUrl} /> to={getRelativeRoomUrlForRoom(room, roomSharedKey)}
className={styles.callTileLink}
>
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
<div className={styles.callInfo}> <div className={styles.callInfo}>
<Body overflowEllipsis fontWeight="semiBold"> <Body overflowEllipsis fontWeight="semiBold">
{name} {name}
@@ -78,7 +84,7 @@ function CallTile({ name, avatarUrl, roomId }: CallTileProps) {
<CopyButton <CopyButton
className={styles.copyButton} className={styles.copyButton}
variant="icon" variant="icon"
value={getRoomUrl(roomId, roomSharedKey ?? undefined)} value={getAbsoluteRoomUrlForRoom(room, roomSharedKey)}
/> />
</div> </div>
); );

View File

@@ -23,6 +23,7 @@ import { Heading } from "@vector-im/compound-web";
import { import {
createRoom, createRoom,
getRelativeRoomUrl,
roomAliasLocalpartFromRoomName, roomAliasLocalpartFromRoomName,
sanitiseRoomNameInput, sanitiseRoomNameInput,
} from "../matrix-utils"; } from "../matrix-utils";
@@ -75,18 +76,25 @@ export function RegisteredView({ client }: Props) {
setError(undefined); setError(undefined);
setLoading(true); setLoading(true);
const roomId = ( const createResult = await createRoom(
await createRoom(client, roomName, e2eeEnabled ?? false) client,
)[1]; roomName,
e2eeEnabled ?? false
);
if (e2eeEnabled) { if (e2eeEnabled) {
setLocalStorageItem( setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(roomId), getRoomSharedKeyLocalStorageKey(createResult.roomId),
randomString(32) randomString(32)
); );
} }
history.push(`/room/#?roomId=${roomId}`); history.push(
getRelativeRoomUrl(
createResult.roomId,
roomName,
createResult.roomAlias
)
);
} }
submit().catch((error) => { submit().catch((error) => {

View File

@@ -26,7 +26,9 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { import {
CreateRoomResult,
createRoom, createRoom,
getRelativeRoomUrl,
roomAliasLocalpartFromRoomName, roomAliasLocalpartFromRoomName,
sanitiseRoomNameInput, sanitiseRoomNameInput,
} from "../matrix-utils"; } from "../matrix-utils";
@@ -85,15 +87,17 @@ export const UnauthenticatedView: FC = () => {
true true
); );
let roomId: string; let createResult: CreateRoomResult;
try { try {
roomId = ( createResult = await createRoom(
await createRoom(client, roomName, e2eeEnabled ?? false) client,
)[1]; roomName,
e2eeEnabled ?? false
);
if (e2eeEnabled) { if (e2eeEnabled) {
setLocalStorageItem( setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(roomId), getRoomSharedKeyLocalStorageKey(createResult.roomId),
randomString(32) randomString(32)
); );
} }
@@ -125,7 +129,13 @@ export const UnauthenticatedView: FC = () => {
} }
setClient({ client, session }); setClient({ client, session });
history.push(`/room/#?roomId=${roomId}`); history.push(
getRelativeRoomUrl(
createResult.roomId,
roomName,
createResult.roomAlias
)
);
} }
submit().catch((error) => { submit().catch((error) => {

View File

@@ -15,19 +15,16 @@ limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export interface GroupCallRoom { export interface GroupCallRoom {
roomAlias: string; roomAlias?: string;
roomName: string; roomName: string;
avatarUrl: string; avatarUrl: string;
room: Room; room: Room;
groupCall: GroupCall; //participants: RoomMember[];
participants: RoomMember[];
} }
const tsCache: { [index: string]: number } = {}; const tsCache: { [index: string]: number } = {};
@@ -87,23 +84,22 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
return; return;
} }
// XXX: This should use client.matrixRTC.getActiveRoomSessions() not the old
// group call stuff!
const groupCalls = client.groupCallEventHandler.groupCalls.values(); const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room); const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
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)!;
return { return {
roomAlias: room.getCanonicalAlias(), roomAlias: room.getCanonicalAlias() ?? undefined,
roomName: room.name, roomName: room.name,
avatarUrl: room.getMxcAvatarUrl()!, avatarUrl: room.getMxcAvatarUrl()!,
room, room,
groupCall, //participants: [...groupCall!.participants.keys()],
participants: [...groupCall!.participants.keys()],
}; };
}); });
setRooms(items as GroupCallRoom[]); setRooms(items);
} }
updateRooms(); updateRooms();

View File

@@ -19,7 +19,11 @@ import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store"; import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store"; import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix"; import {
createClient,
ICreateClientOpts,
MatrixError,
} from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client"; import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials"; import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
@@ -28,11 +32,12 @@ import {
GroupCallIntent, GroupCallIntent,
GroupCallType, GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall"; } from "matrix-js-sdk/src/webrtc/groupCall";
import { randomLowercaseString } 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 { getUrlParams, PASSWORD_STRING } from "./UrlParams"; import { getUrlParams, PASSWORD_PARAM } from "./UrlParams";
import { loadOlm } from "./olm"; import { loadOlm } from "./olm";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
@@ -208,10 +213,6 @@ export function roomAliasLocalpartFromRoomName(roomName: string): string {
.toLowerCase(); .toLowerCase();
} }
function fullAliasFromRoomName(roomName: string, client: MatrixClient): string {
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
}
/** /**
* Applies some basic sanitisation to a room name that the user * Applies some basic sanitisation to a room name that the user
* has given us * has given us
@@ -269,46 +270,79 @@ export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
return parts[1] === client.getDomain(); return parts[1] === client.getDomain();
} }
export interface CreateRoomResult {
roomAlias: string;
roomId: string;
}
async function createRoomWrapper(
client: MatrixClient,
name: string,
e2ee: boolean
): Promise<CreateRoomResult> {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const aliasLocalpart = e2ee
? randomLowercaseString(16)
: roomAliasLocalpartFromRoomName(name);
const result = await client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
room_alias_name: aliasLocalpart,
power_level_content_override: {
invite: 100,
kick: 100,
ban: 100,
redact: 50,
state_default: 0,
events_default: 0,
users_default: 0,
events: {
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.tombstone": 100,
"m.room.encryption": 100,
"m.room.name": 50,
"m.room.message": 0,
"m.room.encrypted": 50,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()!]: 100,
},
},
});
return {
roomAlias: `#${aliasLocalpart}:${client.getDomain()}`,
roomId: result.room_id,
};
} catch (e) {
if (e2ee && (e as MatrixError).errcode === "M_ROOM_IN_USE") {
logger.info("Alias is taken, trying a different alias");
continue;
} else {
throw e;
}
}
}
}
export async function createRoom( export async function createRoom(
client: MatrixClient, client: MatrixClient,
name: string, name: string,
e2ee: boolean e2ee: boolean
): Promise<[string, string]> { ): Promise<CreateRoomResult> {
logger.log(`Creating room for group call`); logger.log(`Creating room for group call`);
const createPromise = client.createRoom({ const createPromise = createRoomWrapper(client, name, e2ee);
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
room_alias_name: e2ee ? undefined : roomAliasLocalpartFromRoomName(name),
power_level_content_override: {
invite: 100,
kick: 100,
ban: 100,
redact: 50,
state_default: 0,
events_default: 0,
users_default: 0,
events: {
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.tombstone": 100,
"m.room.encryption": 100,
"m.room.name": 50,
"m.room.message": 0,
"m.room.encrypted": 50,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()!]: 100,
},
},
});
// Wait for the room to arrive // Wait for the room to arrive
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const onRoom = async (room: Room) => { const onRoom = async (room: Room) => {
if (room.roomId === (await createPromise).room_id) { if (room.roomId === (await createPromise).roomId) {
resolve(); resolve();
cleanUp(); cleanUp();
} }
@@ -326,29 +360,108 @@ export async function createRoom(
const result = await createPromise; const result = await createPromise;
logger.log(`Creating group call in ${result.room_id}`); logger.log(`Creating group call in ${result.roomId}`);
await client.createGroupCall( await client.createGroupCall(
result.room_id, result.roomId,
GroupCallType.Video, GroupCallType.Video,
false, false,
GroupCallIntent.Room, GroupCallIntent.Room,
true true
); );
return [fullAliasFromRoomName(name, client), result.room_id]; return result;
} }
/** /**
* Returns a URL to that will load Element Call with the given room * Returns an absolute URL to that will load Element Call with the given room
* @param roomId of the room * @param room The room object
* @param password * @param password The shared key for the room, or undefined if none
* @returns
*/ */
export function getRoomUrl(roomId: string, password?: string): string { export function getAbsoluteRoomUrlForRoom(room: Room, password?: string) {
return getAbsoluteRoomUrl(
room.roomId,
room.name,
room.getCanonicalAlias() ?? undefined,
password
);
}
export function getAbsoluteRoomUrl(
roomId?: string,
roomName?: string,
roomAlias?: string,
password?: string
): string {
return `${window.location.protocol}//${ return `${window.location.protocol}//${
window.location.host window.location.host
}/room/#?roomId=${roomId}${password ? "&" + PASSWORD_STRING + password : ""}`; }${getRelativeRoomUrl(roomId, roomName, roomAlias, password)}`;
}
/**
* Gets a relative URL to a room (convenience wrapper around getRelativeRoomUrl)
* @param room The room object
* @param password The shared key for the room, or undefined if none
*/
export function getRelativeRoomUrlForRoom(
room: Room,
password?: string
): string {
return getRelativeRoomUrl(
room.roomId,
room.name,
room.getCanonicalAlias() ?? undefined,
password
);
}
/**
* Gets a relative URL to a room
* @param roomId The room's ID'
* @param roomName The room's name, if known, otherwise undefined
* @param roomAlias The room alias string to use for the link
* @param password The shared key for the room, or undefined if none
*/
export function getRelativeRoomUrl(
roomId?: string,
roomName?: string,
roomAlias?: string,
password?: string
): string {
let url = "";
let queryAdded = false;
if (roomAlias) {
const aliasParts = roomAlias.substring(1).split(":");
const aliasBase = aliasParts.shift();
const aliasServerPart = aliasParts.join(":");
const compressedRoomName = roomName
? roomAliasLocalpartFromRoomName(roomName)
: undefined;
url += "/c/";
if (compressedRoomName !== undefined && compressedRoomName !== aliasBase) {
url += compressedRoomName + "/";
}
if (aliasServerPart === Config.defaultServerName()) {
url += aliasBase;
} else {
url += `#?${roomAlias.substring(1)}}`;
queryAdded = true;
}
} else {
url += `/c/#?roomId=${roomId}`;
queryAdded = true;
}
if (password) {
url += `${queryAdded ? "&" : "#?"}${PASSWORD_PARAM}=${password}`;
}
return url;
} }
export function getAvatarUrl( export function getAvatarUrl(

View File

@@ -21,15 +21,16 @@ import { ReactComponent as PopOutIcon } from "@vector-im/compound-design-tokens/
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
import { getRoomUrl } from "../matrix-utils"; import { getAbsoluteRoomUrl } from "../matrix-utils";
import styles from "./AppSelectionModal.module.css"; import styles from "./AppSelectionModal.module.css";
import { editFragmentQuery } from "../UrlParams"; import { editFragmentQuery } from "../UrlParams";
interface Props { interface Props {
roomId: string | null; roomId?: string;
roomAlias?: string;
} }
export const AppSelectionModal: FC<Props> = ({ roomId }) => { export const AppSelectionModal: FC<Props> = ({ roomId, roomAlias }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
@@ -46,9 +47,12 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
const appUrl = useMemo(() => { const appUrl = useMemo(() => {
// If the room ID is not known, fall back to the URL of the current page // If the room ID is not known, fall back to the URL of the current page
const url = new URL( const url = new URL(
roomId === null getAbsoluteRoomUrl(
? window.location.href roomId,
: getRoomUrl(roomId, roomSharedKey ?? undefined) undefined,
roomAlias,
roomSharedKey ?? undefined
)
); );
// Edit the URL so that it opens in embedded mode. We do this for two // Edit the URL so that it opens in embedded mode. We do this for two
// reasons: It causes the mobile app to limit the user to only visiting the // reasons: It causes the mobile app to limit the user to only visiting the
@@ -62,7 +66,7 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
const result = new URL("element://call"); const result = new URL("element://call");
result.searchParams.set("url", url.toString()); result.searchParams.set("url", url.toString());
return result.toString(); return result.toString();
}, [roomId, roomSharedKey]); }, [roomId, roomAlias, roomSharedKey]);
return ( return (
<Modal className={styles.modal} title={t("Select app")} open={open}> <Modal className={styles.modal} title={t("Select app")} open={open}>

View File

@@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ReactNode } from "react"; import { ReactNode, 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 { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { MatrixError } from "matrix-js-sdk";
import { useHistory } from "react-router-dom";
import { Link } from "@vector-im/compound-web";
import { useLoadGroupCall } from "./useLoadGroupCall"; import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView"; import { ErrorView, FullScreenView } from "../FullScreenView";
@@ -38,6 +41,9 @@ export function GroupCallLoader({
const { t } = useTranslation(); const { t } = useTranslation();
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
const history = useHistory();
const onHomeClick = useCallback(() => history.push("/"), [history]);
switch (groupCallState.kind) { switch (groupCallState.kind) {
case "loading": case "loading":
return ( return (
@@ -48,6 +54,24 @@ export function GroupCallLoader({
case "loaded": case "loaded":
return <>{children(groupCallState.rtcSession)}</>; return <>{children(groupCallState.rtcSession)}</>;
case "failed": case "failed":
return <ErrorView error={groupCallState.error} />; if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
return (
<FullScreenView>
<h1>{t("Call not found")}</h1>
<p>
{t(
"Element Calls are now end-to-end encrypted and need to be explicitly created. This helps make sure everyone's using the same encryption key."
)}
</p>
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
dupes of this flow, let's make a common component and put it here. */}
<Link href="/" onClick={onHomeClick}>
{t("Home")}
</Link>
</FullScreenView>
);
} else {
return <ErrorView error={groupCallState.error} />;
}
} }
} }

View File

@@ -301,7 +301,7 @@ export function GroupCallView({
const shareModal = ( const shareModal = (
<ShareModal <ShareModal
roomId={rtcSession.room.roomId} room={rtcSession.room}
open={shareModalOpen} open={shareModalOpen}
onDismiss={onDismissShareModal} onDismiss={onDismissShareModal}
/> />

View File

@@ -110,7 +110,10 @@ export const RoomPage: FC = () => {
{/* On mobile, show a prompt to launch the mobile app. If in embedded mode, {/* On mobile, show a prompt to launch the mobile app. If in embedded mode,
that means we *are* in the mobile app and should show no further prompt. */} that means we *are* in the mobile app and should show no further prompt. */}
{(platform === "android" || platform === "ios") && !isEmbedded && ( {(platform === "android" || platform === "ios") && !isEmbedded && (
<AppSelectionModal roomId={roomId} /> <AppSelectionModal
roomId={roomId ?? undefined}
roomAlias={roomAlias ?? undefined}
/>
)} )}
</> </>
); );

View File

@@ -16,29 +16,30 @@ limitations under the License.
import { FC } from "react"; import { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Room } from "matrix-js-sdk";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { CopyButton } from "../button"; import { CopyButton } from "../button";
import { getRoomUrl } from "../matrix-utils"; import { getAbsoluteRoomUrlForRoom } from "../matrix-utils";
import styles from "./ShareModal.module.css"; import styles from "./ShareModal.module.css";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
interface Props { interface Props {
roomId: string; room: Room;
open: boolean; open: boolean;
onDismiss: () => void; onDismiss: () => void;
} }
export const ShareModal: FC<Props> = ({ roomId, open, onDismiss }) => { export const ShareModal: FC<Props> = ({ room, open, onDismiss }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const roomSharedKey = useRoomSharedKey(roomId); const roomSharedKey = useRoomSharedKey(room.roomId) ?? undefined;
return ( return (
<Modal title={t("Share this call")} open={open} onDismiss={onDismiss}> <Modal title={t("Share this call")} open={open} onDismiss={onDismiss}>
<p>{t("Copy and share this call link")}</p> <p>{t("Copy and share this call link")}</p>
<CopyButton <CopyButton
className={styles.copyButton} className={styles.copyButton}
value={getRoomUrl(roomId, roomSharedKey ?? undefined)} value={getAbsoluteRoomUrlForRoom(room, roomSharedKey)}
data-testid="modal_inviteLink" data-testid="modal_inviteLink"
/> />
</Modal> </Modal>

View File

@@ -20,14 +20,11 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync"; import { SyncState } from "matrix-js-sdk/src/sync";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { useHistory } from "react-router-dom";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { setLocalStorageItem } from "../useLocalStorage";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { useEnableE2EE } from "../settings/useSetting"; import { useEnableE2EE } from "../settings/useSetting";
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
export type GroupCallLoaded = { export type GroupCallLoaded = {
kind: "loaded"; kind: "loaded";
@@ -63,60 +60,46 @@ export const useLoadGroupCall = (
const [e2eeEnabled] = useEnableE2EE(); const [e2eeEnabled] = useEnableE2EE();
const history = useHistory();
useEffect(() => { useEffect(() => {
const fetchOrCreateRoom = async (): Promise<Room> => { const fetchOrCreateRoom = async (): Promise<Room> => {
try { let room: Room | null = null;
if (roomIdOrAlias[0] === "#") {
// We lowercase the localpart when we create the room, so we must lowercase // We lowercase the localpart when we create the room, so we must lowercase
// it here too (we just do the whole alias). We can't do the same to room IDs // it here too (we just do the whole alias). We can't do the same to room IDs
// though. // though.
const sanitisedIdOrAlias = // Also, we explicitly look up the room alias here. We previously just tried to
roomIdOrAlias[0] === "#" // join anyway but the js-sdk recreates the room if you pass the alias for a
? roomIdOrAlias.toLowerCase() // room you're already joined to (which it probably ought not to).
: roomIdOrAlias; const lookupResult = await client.getRoomIdForAlias(
roomIdOrAlias.toLowerCase()
const room = await client.joinRoom(sanitisedIdOrAlias, {
viaServers,
});
logger.info(
`Joined ${sanitisedIdOrAlias}, waiting room to be ready for group calls`
); );
await client.waitUntilRoomReadyForGroupCalls(room.roomId); logger.info(`${roomIdOrAlias} resolved to ${lookupResult.room_id}`);
logger.info(`${sanitisedIdOrAlias}, is ready for group calls`); room = client.getRoom(lookupResult.room_id);
return room; if (!room) {
} catch (error) { logger.info(`Room ${lookupResult.room_id} not found, joining.`);
if ( room = await client.joinRoom(lookupResult.room_id, {
isLocalRoomId(roomIdOrAlias, client) && viaServers: lookupResult.servers,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment });
// @ts-ignore
(error.errcode === "M_NOT_FOUND" ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(error.message &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
error.message.indexOf("Failed to fetch alias") !== -1))
) {
// The room doesn't exist, but we can create it
const [, roomId] = await createRoom(
client,
roomNameFromRoomId(roomIdOrAlias),
e2eeEnabled ?? false
);
if (e2eeEnabled) {
setLocalStorageItem(
getRoomSharedKeyLocalStorageKey(roomId),
randomString(32)
);
}
// likewise, wait for the room
await client.waitUntilRoomReadyForGroupCalls(roomId);
return client.getRoom(roomId)!;
} else { } else {
throw error; logger.info(
`Already in room ${lookupResult.room_id}, not rejoining.`
);
} }
} else {
// room IDs we just try to join by their ID, which will not work in the
// general case without providing some servers to join via. We could provide
// our own server, but in practice that is implicit.
room = await client.joinRoom(roomIdOrAlias);
} }
logger.info(
`Joined ${roomIdOrAlias}, waiting room to be ready for group calls`
);
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
logger.info(`${roomIdOrAlias}, is ready for group calls`);
return room;
}; };
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => { const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
@@ -149,7 +132,7 @@ export const useLoadGroupCall = (
.then(fetchOrCreateGroupCall) .then(fetchOrCreateGroupCall)
.then((rtcSession) => setState({ kind: "loaded", rtcSession })) .then((rtcSession) => setState({ kind: "loaded", rtcSession }))
.catch((error) => setState({ kind: "failed", error })); .catch((error) => setState({ kind: "failed", error }));
}, [client, roomIdOrAlias, viaServers, t, e2eeEnabled]); }, [client, roomIdOrAlias, viaServers, t, e2eeEnabled, history]);
return state; return state;
}; };