From 546ab06d60cfd9db1e04bed81a84a9afb54c9f80 Mon Sep 17 00:00:00 2001 From: Robert Long Date: Wed, 5 Jan 2022 17:19:03 -0800 Subject: [PATCH] Refactor matrix hooks --- src/App.jsx | 2 +- src/ClientContext.jsx | 204 +++++++++++ src/ConferenceCallManagerHooks.jsx | 486 ------------------------- src/Facepile.jsx | 2 +- src/ProfileModal.jsx | 2 +- src/UserMenuContainer.jsx | 3 +- src/auth/LoginPage.jsx | 5 +- src/auth/RegisterPage.jsx | 6 +- src/auth/useInteractiveLogin.js | 7 +- src/auth/useInteractiveRegistration.js | 7 +- src/home/CallList.jsx | 2 +- src/home/HomePage.jsx | 2 +- src/home/RegisteredView.jsx | 7 +- src/home/UnauthenticatedView.jsx | 5 +- src/home/useGroupCallRooms.js | 87 +++++ src/matrix-utils.js | 108 ++++++ src/room/CallEndedView.jsx | 2 +- src/room/InCallView.jsx | 2 +- src/room/InviteModal.jsx | 2 +- src/room/LobbyView.jsx | 2 +- src/room/RoomPage.jsx | 2 +- src/room/RoomRedirect.jsx | 2 +- src/useProfile.js | 91 +++++ 23 files changed, 513 insertions(+), 525 deletions(-) create mode 100644 src/ClientContext.jsx delete mode 100644 src/ConferenceCallManagerHooks.jsx create mode 100644 src/home/useGroupCallRooms.js create mode 100644 src/matrix-utils.js create mode 100644 src/useProfile.js diff --git a/src/App.jsx b/src/App.jsx index 4808a67..5dea73e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,7 +23,7 @@ import { LoginPage } from "./auth/LoginPage"; import { RegisterPage } from "./auth/RegisterPage"; import { RoomPage } from "./room/RoomPage"; import { RoomRedirect } from "./room/RoomRedirect"; -import { ClientProvider } from "./ConferenceCallManagerHooks"; +import { ClientProvider } from "./ClientContext"; import { usePageFocusStyle } from "./usePageFocusStyle"; const SentryRoute = Sentry.withSentryRouting(Route); diff --git a/src/ClientContext.jsx b/src/ClientContext.jsx new file mode 100644 index 0000000..7023aa4 --- /dev/null +++ b/src/ClientContext.jsx @@ -0,0 +1,204 @@ +/* +Copyright 2021 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { + useCallback, + useEffect, + useState, + createContext, + useMemo, + useContext, +} from "react"; +import { useHistory } from "react-router-dom"; +import { initClient } from "./matrix-utils"; + +const ClientContext = createContext(); + +export function ClientProvider({ children }) { + const history = useHistory(); + const [ + { loading, isAuthenticated, isPasswordlessUser, client, userName }, + setState, + ] = useState({ + loading: true, + isAuthenticated: false, + isPasswordlessUser: false, + client: undefined, + userName: null, + }); + + useEffect(() => { + async function restore() { + try { + const authStore = localStorage.getItem("matrix-auth-store"); + + if (authStore) { + const { + user_id, + device_id, + access_token, + passwordlessUser, + tempPassword, + } = JSON.parse(authStore); + + const client = await initClient({ + baseUrl: defaultHomeserver, + accessToken: access_token, + userId: user_id, + deviceId: device_id, + }); + + localStorage.setItem( + "matrix-auth-store", + JSON.stringify({ + user_id, + device_id, + access_token, + + passwordlessUser, + tempPassword, + }) + ); + + return { client, passwordlessUser }; + } + + return { client: undefined }; + } catch (err) { + localStorage.removeItem("matrix-auth-store"); + throw err; + } + } + + restore() + .then(({ client, passwordlessUser }) => { + setState({ + client, + loading: false, + isAuthenticated: !!client, + isPasswordlessUser: !!passwordlessUser, + userName: client?.getUserIdLocalpart(), + }); + }) + .catch(() => { + setState({ + client: undefined, + loading: false, + isAuthenticated: false, + isPasswordlessUser: false, + userName: null, + }); + }); + }, []); + + const changePassword = useCallback( + async (password) => { + const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse( + localStorage.getItem("matrix-auth-store") + ); + + await client.setPassword( + { + type: "m.login.password", + identifier: { + type: "m.id.user", + user: existingSession.user_id, + }, + user: existingSession.user_id, + password: tempPassword, + }, + password + ); + + localStorage.setItem( + "matrix-auth-store", + JSON.stringify({ + ...existingSession, + passwordlessUser: false, + }) + ); + + setState({ + client, + loading: false, + isAuthenticated: true, + isPasswordlessUser: false, + userName: client.getUserIdLocalpart(), + }); + }, + [client] + ); + + const setClient = useCallback((client, session) => { + if (client) { + localStorage.setItem("matrix-auth-store", JSON.stringify(session)); + + setState({ + client, + loading: false, + isAuthenticated: true, + isPasswordlessUser: !!session.passwordlessUser, + userName: client.getUserIdLocalpart(), + }); + } else { + localStorage.removeItem("matrix-auth-store"); + + setState({ + client: undefined, + loading: false, + isAuthenticated: false, + isPasswordlessUser: false, + userName: null, + }); + } + }, []); + + const logout = useCallback(() => { + localStorage.removeItem("matrix-auth-store"); + window.location = "/"; + }, [history]); + + const context = useMemo( + () => ({ + loading, + isAuthenticated, + isPasswordlessUser, + client, + changePassword, + logout, + userName, + setClient, + }), + [ + loading, + isAuthenticated, + isPasswordlessUser, + client, + changePassword, + logout, + userName, + setClient, + ] + ); + + return ( + {children} + ); +} + +export function useClient() { + return useContext(ClientContext); +} diff --git a/src/ConferenceCallManagerHooks.jsx b/src/ConferenceCallManagerHooks.jsx deleted file mode 100644 index 3af8725..0000000 --- a/src/ConferenceCallManagerHooks.jsx +++ /dev/null @@ -1,486 +0,0 @@ -/* -Copyright 2021 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { - useCallback, - useEffect, - useState, - createContext, - useMemo, - useContext, -} from "react"; -import matrix from "matrix-js-sdk/src/browser-index"; -import { - GroupCallIntent, - GroupCallType, -} from "matrix-js-sdk/src/browser-index"; -import { useHistory } from "react-router-dom"; - -export const defaultHomeserver = - import.meta.env.VITE_DEFAULT_HOMESERVER || - `${window.location.protocol}//${window.location.host}`; - -export const defaultHomeserverHost = new URL(defaultHomeserver).host; - -const ClientContext = createContext(); - -function waitForSync(client) { - return new Promise((resolve, reject) => { - const onSync = (state, _old, data) => { - if (state === "PREPARED") { - resolve(); - client.removeListener("sync", onSync); - } else if (state === "ERROR") { - reject(data?.error); - client.removeListener("sync", onSync); - } - }; - client.on("sync", onSync); - }); -} - -export async function initClient(clientOptions) { - const client = matrix.createClient(clientOptions); - - await client.startClient({ - // dirty hack to reduce chance of gappy syncs - // should be fixed by spotting gaps and backpaginating - initialSyncLimit: 50, - }); - - await waitForSync(client); - - return client; -} - -export function ClientProvider({ children }) { - const history = useHistory(); - const [ - { loading, isAuthenticated, isPasswordlessUser, client, userName }, - setState, - ] = useState({ - loading: true, - isAuthenticated: false, - isPasswordlessUser: false, - client: undefined, - userName: null, - }); - - useEffect(() => { - async function restore() { - try { - const authStore = localStorage.getItem("matrix-auth-store"); - - if (authStore) { - const { - user_id, - device_id, - access_token, - passwordlessUser, - tempPassword, - } = JSON.parse(authStore); - - const client = await initClient({ - baseUrl: defaultHomeserver, - accessToken: access_token, - userId: user_id, - deviceId: device_id, - }); - - localStorage.setItem( - "matrix-auth-store", - JSON.stringify({ - user_id, - device_id, - access_token, - - passwordlessUser, - tempPassword, - }) - ); - - return { client, passwordlessUser }; - } - - return { client: undefined }; - } catch (err) { - localStorage.removeItem("matrix-auth-store"); - throw err; - } - } - - restore() - .then(({ client, passwordlessUser }) => { - setState({ - client, - loading: false, - isAuthenticated: !!client, - isPasswordlessUser: !!passwordlessUser, - userName: client?.getUserIdLocalpart(), - }); - }) - .catch(() => { - setState({ - client: undefined, - loading: false, - isAuthenticated: false, - isPasswordlessUser: false, - userName: null, - }); - }); - }, []); - - const changePassword = useCallback( - async (password) => { - const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse( - localStorage.getItem("matrix-auth-store") - ); - - await client.setPassword( - { - type: "m.login.password", - identifier: { - type: "m.id.user", - user: existingSession.user_id, - }, - user: existingSession.user_id, - password: tempPassword, - }, - password - ); - - localStorage.setItem( - "matrix-auth-store", - JSON.stringify({ - ...existingSession, - passwordlessUser: false, - }) - ); - - setState({ - client, - loading: false, - isAuthenticated: true, - isPasswordlessUser: false, - userName: client.getUserIdLocalpart(), - }); - }, - [client] - ); - - const setClient = useCallback((client, session) => { - if (client) { - localStorage.setItem("matrix-auth-store", JSON.stringify(session)); - - setState({ - client, - loading: false, - isAuthenticated: true, - isPasswordlessUser: !!session.passwordlessUser, - userName: client.getUserIdLocalpart(), - }); - } else { - localStorage.removeItem("matrix-auth-store"); - - setState({ - client: undefined, - loading: false, - isAuthenticated: false, - isPasswordlessUser: false, - userName: null, - }); - } - }, []); - - const logout = useCallback(() => { - localStorage.removeItem("matrix-auth-store"); - window.location = "/"; - }, [history]); - - const context = useMemo( - () => ({ - loading, - isAuthenticated, - isPasswordlessUser, - client, - changePassword, - logout, - userName, - setClient, - }), - [ - loading, - isAuthenticated, - isPasswordlessUser, - client, - changePassword, - logout, - userName, - setClient, - ] - ); - - return ( - {children} - ); -} - -export function useClient() { - return useContext(ClientContext); -} - -export function roomAliasFromRoomName(roomName) { - return roomName - .trim() - .replace(/\s/g, "-") - .replace(/[^\w-]/g, "") - .toLowerCase(); -} - -export async function createRoom(client, name) { - const { room_id, room_alias } = await client.createRoom({ - visibility: "private", - preset: "public_chat", - name, - room_alias_name: roomAliasFromRoomName(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, - }, - }, - }); - - await client.createGroupCall( - room_id, - GroupCallType.Video, - GroupCallIntent.Prompt - ); - - return room_alias || room_id; -} - -const tsCache = {}; - -function getLastTs(client, r) { - if (tsCache[r.roomId]) { - return tsCache[r.roomId]; - } - - if (!r || !r.timeline) { - const ts = Number.MAX_SAFE_INTEGER; - tsCache[r.roomId] = ts; - return ts; - } - - const myUserId = client.getUserId(); - - if (r.getMyMembership() !== "join") { - const membershipEvent = r.currentState.getStateEvents( - "m.room.member", - myUserId - ); - - if (membershipEvent && !Array.isArray(membershipEvent)) { - const ts = membershipEvent.getTs(); - tsCache[r.roomId] = ts; - return ts; - } - } - - for (let i = r.timeline.length - 1; i >= 0; --i) { - const ev = r.timeline[i]; - const ts = ev.getTs(); - - if (ts) { - tsCache[r.roomId] = ts; - return ts; - } - } - - const ts = Number.MAX_SAFE_INTEGER; - tsCache[r.roomId] = ts; - return ts; -} - -function sortRooms(client, rooms) { - return rooms.sort((a, b) => { - return getLastTs(client, b) - getLastTs(client, a); - }); -} - -export function useGroupCallRooms(client) { - const [rooms, setRooms] = useState([]); - - useEffect(() => { - function updateRooms() { - const groupCalls = client.groupCallEventHandler.groupCalls.values(); - const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room); - const sortedRooms = sortRooms(client, rooms); - const items = sortedRooms.map((room) => { - const groupCall = client.getGroupCallForRoom(room.roomId); - - return { - roomId: room.getCanonicalAlias() || room.roomId, - roomName: room.name, - avatarUrl: null, - room, - groupCall, - participants: [...groupCall.participants], - }; - }); - setRooms(items); - } - - updateRooms(); - - client.on("GroupCall.incoming", updateRooms); - client.on("GroupCall.participants", updateRooms); - - return () => { - client.removeListener("GroupCall.incoming", updateRooms); - client.removeListener("GroupCall.participants", updateRooms); - }; - }, []); - - return rooms; -} - -export function getRoomUrl(roomId) { - if (roomId.startsWith("#")) { - const [localPart, host] = roomId.replace("#", "").split(":"); - - if (host !== defaultHomeserverHost) { - return `${window.location.host}/room/${roomId}`; - } else { - return `${window.location.host}/${localPart}`; - } - } else { - return `${window.location.host}/room/${roomId}`; - } -} - -export function getAvatarUrl(client, mxcUrl, avatarSize = 96) { - const width = Math.floor(avatarSize * window.devicePixelRatio); - const height = Math.floor(avatarSize * window.devicePixelRatio); - return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop"); -} - -export function useProfile(client) { - const [{ loading, displayName, avatarUrl, error, success }, setState] = - useState(() => { - const user = client?.getUser(client.getUserId()); - - return { - success: false, - loading: false, - displayName: user?.displayName, - avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl), - error: null, - }; - }); - - useEffect(() => { - const onChangeUser = (_event, { displayName, avatarUrl }) => { - setState({ - success: false, - loading: false, - displayName, - avatarUrl: getAvatarUrl(client, avatarUrl), - error: null, - }); - }; - - let user; - - if (client) { - const userId = client.getUserId(); - user = client.getUser(userId); - user.on("User.displayName", onChangeUser); - user.on("User.avatarUrl", onChangeUser); - } - - return () => { - if (user) { - user.removeListener("User.displayName", onChangeUser); - user.removeListener("User.avatarUrl", onChangeUser); - } - }; - }, [client]); - - const saveProfile = useCallback( - async ({ displayName, avatar }) => { - if (client) { - setState((prev) => ({ - ...prev, - loading: true, - error: null, - success: false, - })); - - try { - await client.setDisplayName(displayName); - - let mxcAvatarUrl; - - if (avatar) { - mxcAvatarUrl = await client.uploadContent(avatar); - await client.setAvatarUrl(mxcAvatarUrl); - } - - setState((prev) => ({ - ...prev, - displayName, - avatarUrl: mxcAvatarUrl - ? getAvatarUrl(client, mxcAvatarUrl) - : prev.avatarUrl, - loading: false, - success: true, - })); - } catch (error) { - setState((prev) => ({ - ...prev, - loading: false, - error, - success: false, - })); - } - } else { - console.error("Client not initialized before calling saveProfile"); - } - }, - [client] - ); - - return { loading, error, displayName, avatarUrl, saveProfile, success }; -} diff --git a/src/Facepile.jsx b/src/Facepile.jsx index bc084e4..53b0b94 100644 --- a/src/Facepile.jsx +++ b/src/Facepile.jsx @@ -2,7 +2,7 @@ import React from "react"; import styles from "./Facepile.module.css"; import classNames from "classnames"; import { Avatar } from "./Avatar"; -import { getAvatarUrl } from "./ConferenceCallManagerHooks"; +import { getAvatarUrl } from "./matrix-utils"; export function Facepile({ className, client, participants, ...rest }) { return ( diff --git a/src/ProfileModal.jsx b/src/ProfileModal.jsx index 6378f59..5b7e47f 100644 --- a/src/ProfileModal.jsx +++ b/src/ProfileModal.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { Button } from "./button"; -import { useProfile } from "./ConferenceCallManagerHooks"; +import { useProfile } from "./useProfile"; import { FieldRow, InputField, ErrorMessage } from "./Input"; import { Modal, ModalContent } from "./Modal"; diff --git a/src/UserMenuContainer.jsx b/src/UserMenuContainer.jsx index d70cb25..4229f69 100644 --- a/src/UserMenuContainer.jsx +++ b/src/UserMenuContainer.jsx @@ -1,6 +1,7 @@ import React, { useCallback } from "react"; import { useHistory, useLocation } from "react-router-dom"; -import { useClient, useProfile } from "./ConferenceCallManagerHooks"; +import { useClient } from "./ClientContext"; +import { useProfile } from "./useProfile"; import { useModalTriggerState } from "./Modal"; import { ProfileModal } from "./ProfileModal"; import { UserMenu } from "./UserMenu"; diff --git a/src/auth/LoginPage.jsx b/src/auth/LoginPage.jsx index e1df8dc..5a8a4d2 100644 --- a/src/auth/LoginPage.jsx +++ b/src/auth/LoginPage.jsx @@ -19,10 +19,7 @@ import { useHistory, useLocation, Link } from "react-router-dom"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; import { FieldRow, InputField, ErrorMessage } from "../Input"; import { Button } from "../button"; -import { - defaultHomeserver, - defaultHomeserverHost, -} from "../ConferenceCallManagerHooks"; +import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils"; import styles from "./LoginPage.module.css"; import { useInteractiveLogin } from "./useInteractiveLogin"; diff --git a/src/auth/RegisterPage.jsx b/src/auth/RegisterPage.jsx index f584bc2..cd05b8b 100644 --- a/src/auth/RegisterPage.jsx +++ b/src/auth/RegisterPage.jsx @@ -18,10 +18,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useHistory, useLocation } from "react-router-dom"; import { FieldRow, InputField, ErrorMessage } from "../Input"; import { Button } from "../button"; -import { - useClient, - defaultHomeserverHost, -} from "../ConferenceCallManagerHooks"; +import { useClient } from "../ClientContext"; +import { defaultHomeserverHost } from "../matrix-utils"; import { useInteractiveRegistration } from "./useInteractiveRegistration"; import styles from "./LoginPage.module.css"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; diff --git a/src/auth/useInteractiveLogin.js b/src/auth/useInteractiveLogin.js index 64e4229..4b1bc54 100644 --- a/src/auth/useInteractiveLogin.js +++ b/src/auth/useInteractiveLogin.js @@ -1,10 +1,7 @@ import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; import { useState, useCallback } from "react"; -import { - useClient, - initClient, - defaultHomeserver, -} from "../ConferenceCallManagerHooks"; +import { useClient } from "../ClientContext"; +import { initClient, defaultHomeserver } from "../matrix-utils"; export function useInteractiveLogin() { const { setClient } = useClient(); diff --git a/src/auth/useInteractiveRegistration.js b/src/auth/useInteractiveRegistration.js index 350f2fa..57e2178 100644 --- a/src/auth/useInteractiveRegistration.js +++ b/src/auth/useInteractiveRegistration.js @@ -1,10 +1,7 @@ import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index"; import { useState, useEffect, useCallback, useRef } from "react"; -import { - useClient, - initClient, - defaultHomeserver, -} from "../ConferenceCallManagerHooks"; +import { useClient } from "../ClientContext"; +import { initClient, defaultHomeserver } from "../matrix-utils"; export function useInteractiveRegistration() { const { setClient } = useClient(); diff --git a/src/home/CallList.jsx b/src/home/CallList.jsx index de8e4c0..35b4da6 100644 --- a/src/home/CallList.jsx +++ b/src/home/CallList.jsx @@ -5,7 +5,7 @@ import { Facepile } from "../Facepile"; import { Avatar } from "../Avatar"; import { ReactComponent as VideoIcon } from "../icons/Video.svg"; import styles from "./CallList.module.css"; -import { getRoomUrl } from "../ConferenceCallManagerHooks"; +import { getRoomUrl } from "../matrix-utils"; import { Body, Caption } from "../typography/Typography"; export function CallList({ rooms, client }) { diff --git a/src/home/HomePage.jsx b/src/home/HomePage.jsx index 0c5a811..daed470 100644 --- a/src/home/HomePage.jsx +++ b/src/home/HomePage.jsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { useClient } from "../ConferenceCallManagerHooks"; +import { useClient } from "../ClientContext"; import { ErrorView, LoadingView } from "../FullScreenView"; import { UnauthenticatedView } from "./UnauthenticatedView"; import { RegisteredView } from "./RegisteredView"; diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx index aad1a82..1c73177 100644 --- a/src/home/RegisteredView.jsx +++ b/src/home/RegisteredView.jsx @@ -1,9 +1,6 @@ import React, { useState, useCallback } from "react"; -import { - createRoom, - useGroupCallRooms, - roomAliasFromRoomName, -} from "../ConferenceCallManagerHooks"; +import { createRoom, roomAliasFromRoomName } from "../matrix-utils"; +import { useGroupCallRooms } from "./useGroupCallRooms"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import commonStyles from "./common.module.css"; import styles from "./RegisteredView.module.css"; diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx index 608d37d..c4eeca7 100644 --- a/src/home/UnauthenticatedView.jsx +++ b/src/home/UnauthenticatedView.jsx @@ -5,10 +5,7 @@ import { useHistory } from "react-router-dom"; import { FieldRow, InputField, ErrorMessage } from "../Input"; import { Button } from "../button"; import { randomString } from "matrix-js-sdk/src/randomstring"; -import { - createRoom, - roomAliasFromRoomName, -} from "../ConferenceCallManagerHooks"; +import { createRoom, roomAliasFromRoomName } from "../matrix-utils"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { useModalTriggerState } from "../Modal"; import { JoinExistingCallModal } from "./JoinExistingCallModal"; diff --git a/src/home/useGroupCallRooms.js b/src/home/useGroupCallRooms.js new file mode 100644 index 0000000..ea075f8 --- /dev/null +++ b/src/home/useGroupCallRooms.js @@ -0,0 +1,87 @@ +import { useState, useEffect } from "react"; + +const tsCache = {}; + +function getLastTs(client, r) { + if (tsCache[r.roomId]) { + return tsCache[r.roomId]; + } + + if (!r || !r.timeline) { + const ts = Number.MAX_SAFE_INTEGER; + tsCache[r.roomId] = ts; + return ts; + } + + const myUserId = client.getUserId(); + + if (r.getMyMembership() !== "join") { + const membershipEvent = r.currentState.getStateEvents( + "m.room.member", + myUserId + ); + + if (membershipEvent && !Array.isArray(membershipEvent)) { + const ts = membershipEvent.getTs(); + tsCache[r.roomId] = ts; + return ts; + } + } + + for (let i = r.timeline.length - 1; i >= 0; --i) { + const ev = r.timeline[i]; + const ts = ev.getTs(); + + if (ts) { + tsCache[r.roomId] = ts; + return ts; + } + } + + const ts = Number.MAX_SAFE_INTEGER; + tsCache[r.roomId] = ts; + return ts; +} + +function sortRooms(client, rooms) { + return rooms.sort((a, b) => { + return getLastTs(client, b) - getLastTs(client, a); + }); +} + +export function useGroupCallRooms(client) { + const [rooms, setRooms] = useState([]); + + useEffect(() => { + function updateRooms() { + const groupCalls = client.groupCallEventHandler.groupCalls.values(); + const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room); + const sortedRooms = sortRooms(client, rooms); + const items = sortedRooms.map((room) => { + const groupCall = client.getGroupCallForRoom(room.roomId); + + return { + roomId: room.getCanonicalAlias() || room.roomId, + roomName: room.name, + avatarUrl: null, + room, + groupCall, + participants: [...groupCall.participants], + }; + }); + setRooms(items); + } + + updateRooms(); + + client.on("GroupCall.incoming", updateRooms); + client.on("GroupCall.participants", updateRooms); + + return () => { + client.removeListener("GroupCall.incoming", updateRooms); + client.removeListener("GroupCall.participants", updateRooms); + }; + }, []); + + return rooms; +} diff --git a/src/matrix-utils.js b/src/matrix-utils.js new file mode 100644 index 0000000..f2f4ec0 --- /dev/null +++ b/src/matrix-utils.js @@ -0,0 +1,108 @@ +import matrix from "matrix-js-sdk/src/browser-index"; +import { + GroupCallIntent, + GroupCallType, +} from "matrix-js-sdk/src/browser-index"; + +export const defaultHomeserver = + import.meta.env.VITE_DEFAULT_HOMESERVER || + `${window.location.protocol}//${window.location.host}`; + +export const defaultHomeserverHost = new URL(defaultHomeserver).host; + +function waitForSync(client) { + return new Promise((resolve, reject) => { + const onSync = (state, _old, data) => { + if (state === "PREPARED") { + resolve(); + client.removeListener("sync", onSync); + } else if (state === "ERROR") { + reject(data?.error); + client.removeListener("sync", onSync); + } + }; + client.on("sync", onSync); + }); +} + +export async function initClient(clientOptions) { + const client = matrix.createClient(clientOptions); + + await client.startClient({ + // dirty hack to reduce chance of gappy syncs + // should be fixed by spotting gaps and backpaginating + initialSyncLimit: 50, + }); + + await waitForSync(client); + + return client; +} + +export function roomAliasFromRoomName(roomName) { + return roomName + .trim() + .replace(/\s/g, "-") + .replace(/[^\w-]/g, "") + .toLowerCase(); +} + +export async function createRoom(client, name) { + const { room_id, room_alias } = await client.createRoom({ + visibility: "private", + preset: "public_chat", + name, + room_alias_name: roomAliasFromRoomName(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, + }, + }, + }); + + await client.createGroupCall( + room_id, + GroupCallType.Video, + GroupCallIntent.Prompt + ); + + return room_alias || room_id; +} + +export function getRoomUrl(roomId) { + if (roomId.startsWith("#")) { + const [localPart, host] = roomId.replace("#", "").split(":"); + + if (host !== defaultHomeserverHost) { + return `${window.location.host}/room/${roomId}`; + } else { + return `${window.location.host}/${localPart}`; + } + } else { + return `${window.location.host}/room/${roomId}`; + } +} + +export function getAvatarUrl(client, mxcUrl, avatarSize = 96) { + const width = Math.floor(avatarSize * window.devicePixelRatio); + const height = Math.floor(avatarSize * window.devicePixelRatio); + return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop"); +} diff --git a/src/room/CallEndedView.jsx b/src/room/CallEndedView.jsx index 77d085d..54f7c91 100644 --- a/src/room/CallEndedView.jsx +++ b/src/room/CallEndedView.jsx @@ -1,7 +1,7 @@ import React from "react"; import styles from "./CallEndedView.module.css"; import { LinkButton } from "../button"; -import { useProfile } from "../ConferenceCallManagerHooks"; +import { useProfile } from "../useProfile"; import { Subtitle, Body, Link, Headline } from "../typography/Typography"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; diff --git a/src/room/InCallView.jsx b/src/room/InCallView.jsx index e3b7db1..54246e8 100644 --- a/src/room/InCallView.jsx +++ b/src/room/InCallView.jsx @@ -12,7 +12,7 @@ import VideoGrid, { } from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid"; import SimpleVideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/SimpleVideoGrid"; import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss"; -import { getAvatarUrl } from "../ConferenceCallManagerHooks"; +import { getAvatarUrl } from "../matrix-utils"; import { GroupCallInspector } from "./GroupCallInspector"; import { OverflowMenu } from "./OverflowMenu"; import { GridLayoutMenu } from "./GridLayoutMenu"; diff --git a/src/room/InviteModal.jsx b/src/room/InviteModal.jsx index a1c73a6..1036f51 100644 --- a/src/room/InviteModal.jsx +++ b/src/room/InviteModal.jsx @@ -1,7 +1,7 @@ import React from "react"; import { Modal, ModalContent } from "../Modal"; import { CopyButton } from "../button"; -import { getRoomUrl } from "../ConferenceCallManagerHooks"; +import { getRoomUrl } from "../matrix-utils"; import styles from "./InviteModal.module.css"; export function InviteModal({ roomId, ...rest }) { diff --git a/src/room/LobbyView.jsx b/src/room/LobbyView.jsx index f13c975..714c209 100644 --- a/src/room/LobbyView.jsx +++ b/src/room/LobbyView.jsx @@ -5,7 +5,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed"; import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream"; -import { getRoomUrl } from "../ConferenceCallManagerHooks"; +import { getRoomUrl } from "../matrix-utils"; import { OverflowMenu } from "./OverflowMenu"; import { UserMenuContainer } from "../UserMenuContainer"; import { Body, Link } from "../typography/Typography"; diff --git a/src/room/RoomPage.jsx b/src/room/RoomPage.jsx index ec76e9a..30bdc4c 100644 --- a/src/room/RoomPage.jsx +++ b/src/room/RoomPage.jsx @@ -16,7 +16,7 @@ limitations under the License. import React, { useMemo } from "react"; import { useLocation, useParams } from "react-router-dom"; -import { useClient } from "../ConferenceCallManagerHooks"; +import { useClient } from "../ClientContext"; import { ErrorView, LoadingView } from "../FullScreenView"; import { RoomAuthView } from "./RoomAuthView"; import { GroupCallLoader } from "./GroupCallLoader"; diff --git a/src/room/RoomRedirect.jsx b/src/room/RoomRedirect.jsx index 1725a74..3c733fc 100644 --- a/src/room/RoomRedirect.jsx +++ b/src/room/RoomRedirect.jsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; import { useLocation, useHistory } from "react-router-dom"; -import { defaultHomeserverHost } from "../ConferenceCallManagerHooks"; +import { defaultHomeserverHost } from "../matrix-utils"; import { LoadingView } from "../FullScreenView"; export function RoomRedirect() { diff --git a/src/useProfile.js b/src/useProfile.js new file mode 100644 index 0000000..0f164f7 --- /dev/null +++ b/src/useProfile.js @@ -0,0 +1,91 @@ +import { useState, useCallback, useEffect } from "react"; +import { getAvatarUrl } from "./matrix-utils"; + +export function useProfile(client) { + const [{ loading, displayName, avatarUrl, error, success }, setState] = + useState(() => { + const user = client?.getUser(client.getUserId()); + + return { + success: false, + loading: false, + displayName: user?.displayName, + avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl), + error: null, + }; + }); + + useEffect(() => { + const onChangeUser = (_event, { displayName, avatarUrl }) => { + setState({ + success: false, + loading: false, + displayName, + avatarUrl: getAvatarUrl(client, avatarUrl), + error: null, + }); + }; + + let user; + + if (client) { + const userId = client.getUserId(); + user = client.getUser(userId); + user.on("User.displayName", onChangeUser); + user.on("User.avatarUrl", onChangeUser); + } + + return () => { + if (user) { + user.removeListener("User.displayName", onChangeUser); + user.removeListener("User.avatarUrl", onChangeUser); + } + }; + }, [client]); + + const saveProfile = useCallback( + async ({ displayName, avatar }) => { + if (client) { + setState((prev) => ({ + ...prev, + loading: true, + error: null, + success: false, + })); + + try { + await client.setDisplayName(displayName); + + let mxcAvatarUrl; + + if (avatar) { + mxcAvatarUrl = await client.uploadContent(avatar); + await client.setAvatarUrl(mxcAvatarUrl); + } + + setState((prev) => ({ + ...prev, + displayName, + avatarUrl: mxcAvatarUrl + ? getAvatarUrl(client, mxcAvatarUrl) + : prev.avatarUrl, + loading: false, + success: true, + })); + } catch (error) { + setState((prev) => ({ + ...prev, + loading: false, + error, + success: false, + })); + } + } else { + console.error("Client not initialized before calling saveProfile"); + } + }, + [client] + ); + + return { loading, error, displayName, avatarUrl, saveProfile, success }; +}