diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index f4107103..c7803415 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -1,6 +1,16 @@
module.exports = {
plugins: ["matrix-org"],
- extends: ["plugin:matrix-org/react", "plugin:matrix-org/a11y", "prettier"],
+ extends: [
+ "prettier",
+ "plugin:matrix-org/react",
+ "plugin:matrix-org/a11y",
+ "plugin:matrix-org/typescript",
+ ],
+ parserOptions: {
+ ecmaVersion: 2018,
+ sourceType: "module",
+ project: ["./tsconfig.json"],
+ },
env: {
browser: true,
node: true,
diff --git a/package.json b/package.json
index af5e240b..a5f5e979 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,9 @@
"build-storybook": "build-storybook",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
- "lint": "yarn lint:types && yarn lint:js",
- "lint:js": "eslint --max-warnings 0 src",
+ "lint": "yarn lint:types && yarn lint:eslint",
+ "lint:eslint": "eslint --max-warnings 0 src",
+ "lint:eslint-fix": "eslint --max-warnings 0 src --fix",
"lint:types": "tsc",
"i18n": "node_modules/i18next-parser/bin/cli.js",
"i18n:check": "node_modules/i18next-parser/bin/cli.js --fail-on-warnings --fail-on-update",
@@ -46,6 +47,7 @@
"@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
+ "@types/react-router-dom": "^5.3.3",
"@types/sdp-transform": "^2.4.5",
"@use-gesture/react": "^10.2.11",
"@vitejs/plugin-react": "^4.0.1",
@@ -69,7 +71,6 @@
"react-dom": "18",
"react-i18next": "^11.18.6",
"react-json-view": "^1.21.3",
- "react-router": "6",
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
diff --git a/src/App.tsx b/src/App.tsx
index d020655d..0ef5bcd6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,6 +18,7 @@ import { Suspense, useEffect, useState } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
+import { History } from "history";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
@@ -51,6 +52,8 @@ export default function App({ history }: AppProps) {
const errorPage = ;
return (
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
{loaded ? (
diff --git a/src/Avatar.tsx b/src/Avatar.tsx
index 9549cd48..1e1b8dbe 100644
--- a/src/Avatar.tsx
+++ b/src/Avatar.tsx
@@ -99,10 +99,10 @@ export const Avatar: FC = ({
[size]
);
- const resolvedSrc = useMemo(
- () => resolveAvatarSrc(client, src, sizePx),
- [client, src, sizePx]
- );
+ const resolvedSrc = useMemo(() => {
+ if (!client || !src || !sizePx) return undefined;
+ return resolveAvatarSrc(client, src, sizePx);
+ }, [client, src, sizePx]);
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(
diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx
index ba1b217b..a440e46a 100644
--- a/src/ClientContext.tsx
+++ b/src/ClientContext.tsx
@@ -20,7 +20,6 @@ import {
useEffect,
useState,
createContext,
- useMemo,
useContext,
useRef,
} from "react";
@@ -31,9 +30,9 @@ import { useTranslation } from "react-i18next";
import { ErrorView } from "./FullScreenView";
import {
- initClient,
CryptoStoreIntegrityError,
fallbackICEServerAllowed,
+ initClient,
} from "./matrix-utils";
import { widget } from "./widget";
import {
@@ -47,184 +46,141 @@ import { Config } from "./config/Config";
declare global {
interface Window {
matrixclient: MatrixClient;
- isPasswordlessUser: boolean;
+ passwordlessUser: boolean;
}
}
-export interface Session {
- user_id: string;
- device_id: string;
- access_token: string;
+export type ClientState = ValidClientState | ErrorState;
+
+export type ValidClientState = {
+ state: "valid";
+ authenticated?: AuthenticatedClient;
+ setClient: (params?: SetClientParams) => void;
+};
+
+export type AuthenticatedClient = {
+ client: MatrixClient;
+ isPasswordlessUser: boolean;
+ changePassword: (password: string) => Promise;
+ logout: () => void;
+};
+
+export type ErrorState = {
+ state: "error";
+ error: Error;
+};
+
+export type SetClientParams = {
+ client: MatrixClient;
+ session: Session;
+};
+
+const ClientContext = createContext(undefined);
+
+export const useClientState = () => useContext(ClientContext);
+
+export function useClient(): {
+ client?: MatrixClient;
+ setClient?: (params?: SetClientParams) => void;
+} {
+ let client;
+ let setClient;
+
+ const clientState = useClientState();
+ if (clientState?.state === "valid") {
+ client = clientState.authenticated?.client;
+ setClient = clientState.setClient;
+ }
+
+ return { client, setClient };
+}
+
+// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
+export function useClientLegacy(): {
+ client?: MatrixClient;
+ setClient?: (params?: SetClientParams) => void;
passwordlessUser: boolean;
- tempPassword?: string;
+ loading: boolean;
+ authenticated: boolean;
+ logout?: () => void;
+ error?: Error;
+} {
+ const clientState = useClientState();
+
+ let client;
+ let setClient;
+ let passwordlessUser = false;
+ let loading = true;
+ let error;
+ let authenticated = false;
+ let logout;
+
+ if (clientState?.state === "valid") {
+ client = clientState.authenticated?.client;
+ setClient = clientState.setClient;
+ passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
+ loading = false;
+ authenticated = client !== undefined;
+ logout = clientState.authenticated?.logout;
+ } else if (clientState?.state === "error") {
+ error = clientState.error;
+ loading = false;
+ }
+
+ return {
+ client,
+ setClient,
+ passwordlessUser,
+ loading,
+ authenticated,
+ logout,
+ error,
+ };
}
const loadChannel =
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
-const loadSession = (): Session => {
- const data = localStorage.getItem("matrix-auth-store");
- if (data) return JSON.parse(data);
- return null;
-};
-const saveSession = (session: Session) =>
- localStorage.setItem("matrix-auth-store", JSON.stringify(session));
-const clearSession = () => localStorage.removeItem("matrix-auth-store");
-
-interface ClientState {
- loading: boolean;
- isAuthenticated: boolean;
- isPasswordlessUser: boolean;
- client: MatrixClient;
- userName: string;
- changePassword: (password: string) => Promise;
- logout: () => void;
- setClient: (client: MatrixClient, session: Session) => void;
- error?: Error;
-}
-
-const ClientContext = createContext(null);
-
-type ClientProviderState = Omit<
- ClientState,
- "changePassword" | "logout" | "setClient"
-> & { error?: Error };
-
interface Props {
children: JSX.Element;
}
export const ClientProvider: FC = ({ children }) => {
const history = useHistory();
- const initializing = useRef(false);
- const [
- { loading, isAuthenticated, isPasswordlessUser, client, userName, error },
- setState,
- ] = useState({
- loading: true,
- isAuthenticated: false,
- isPasswordlessUser: false,
- client: undefined,
- userName: null,
- error: undefined,
- });
+ const [initClientState, setInitClientState] = useState<
+ InitResult | undefined
+ >(undefined);
+
+ const initializing = useRef(false);
useEffect(() => {
// In case the component is mounted, unmounted, and remounted quickly (as
// React does in strict mode), we need to make sure not to doubly initialize
- // the client
+ // the client.
if (initializing.current) return;
initializing.current = true;
- const init = async (): Promise<
- Pick
- > => {
- if (widget) {
- // We're inside a widget, so let's engage *matryoshka mode*
- logger.log("Using a matryoshka client");
- return {
- client: await widget.client,
- isPasswordlessUser: false,
- };
- } else {
- // We're running as a standalone application
- try {
- const session = loadSession();
- if (!session) return { client: undefined, isPasswordlessUser: false };
-
- logger.log("Using a standalone client");
-
- /* eslint-disable camelcase */
- const { user_id, device_id, access_token, passwordlessUser } =
- session;
-
- const livekit = Config.get().livekit;
- const foci = livekit
- ? [
- {
- livekitServiceUrl: livekit.livekit_service_url,
- },
- ]
- : undefined;
-
- try {
- return {
- client: await initClient(
- {
- baseUrl: Config.defaultHomeserverUrl(),
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- fallbackICEServerAllowed: fallbackICEServerAllowed,
- foci,
- },
- true
- ),
- isPasswordlessUser: passwordlessUser,
- };
- } catch (err) {
- if (err instanceof CryptoStoreIntegrityError) {
- // We can't use this session anymore, so let's log it out
- try {
- const client = await initClient(
- {
- baseUrl: Config.defaultHomeserverUrl(),
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- fallbackICEServerAllowed: fallbackICEServerAllowed,
- foci,
- },
- false // Don't need the crypto store just to log out
- );
- await client.logout(true);
- } catch (err_) {
- logger.warn(
- "The previous session was lost, and we couldn't log it out, " +
- "either"
- );
- }
- }
- throw err;
- }
- /* eslint-enable camelcase */
- } catch (err) {
- clearSession();
- throw err;
+ loadClient()
+ .then((maybeClient) => {
+ if (!maybeClient) {
+ logger.error("Failed to initialize client");
+ return;
}
- }
- };
- init()
- .then(({ client, isPasswordlessUser }) => {
- setState({
- client,
- loading: false,
- isAuthenticated: Boolean(client),
- isPasswordlessUser,
- userName: client?.getUserIdLocalpart(),
- error: undefined,
- });
- })
- .catch((err) => {
- logger.error(err);
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- userName: null,
- error: undefined,
- });
+ setInitClientState(maybeClient);
})
+ .catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, []);
const changePassword = useCallback(
async (password: string) => {
- const { tempPassword, ...session } = loadSession();
+ const session = loadSession();
+ if (!initClientState?.client || !session) {
+ return;
+ }
- await client.setPassword(
+ await initClientState.client.setPassword(
{
type: "m.login.password",
identifier: {
@@ -232,73 +188,56 @@ export const ClientProvider: FC = ({ children }) => {
user: session.user_id,
},
user: session.user_id,
- password: tempPassword,
+ password: session.tempPassword,
},
password
);
saveSession({ ...session, passwordlessUser: false });
- setState({
- client,
- loading: false,
- isAuthenticated: true,
- isPasswordlessUser: false,
- userName: client.getUserIdLocalpart(),
- error: undefined,
+ setInitClientState({
+ client: initClientState.client,
+ passwordlessUser: false,
});
},
- [client]
+ [initClientState?.client]
);
const setClient = useCallback(
- (newClient: MatrixClient, session: Session) => {
- if (client && client !== newClient) {
- client.stopClient();
+ (clientParams?: SetClientParams) => {
+ const oldClient = initClientState?.client;
+ const newClient = clientParams?.client;
+ if (oldClient && oldClient !== newClient) {
+ oldClient.stopClient();
}
- if (newClient) {
- saveSession(session);
-
- setState({
- client: newClient,
- loading: false,
- isAuthenticated: true,
- isPasswordlessUser: session.passwordlessUser,
- userName: newClient.getUserIdLocalpart(),
- error: undefined,
+ if (clientParams) {
+ saveSession(clientParams.session);
+ setInitClientState({
+ client: clientParams.client,
+ passwordlessUser: clientParams.session.passwordlessUser,
});
} else {
clearSession();
-
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- userName: null,
- error: undefined,
- });
+ setInitClientState(undefined);
}
},
- [client]
+ [initClientState?.client]
);
const logout = useCallback(async () => {
+ const client = initClientState?.client;
+ if (!client) {
+ return;
+ }
+
await client.logout(true);
await client.clearStores();
clearSession();
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: true,
- userName: "",
- error: undefined,
- });
+ setInitClientState(undefined);
history.push("/");
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
- }, [history, client]);
+ }, [history, initClientState?.client]);
const { t } = useTranslation();
@@ -310,61 +249,146 @@ export const ClientProvider: FC = ({ children }) => {
if (!widget) loadChannel?.postMessage({});
}, []);
+ const [alreadyOpenedErr, setAlreadyOpenedErr] = useState(
+ undefined
+ );
useEventTarget(
loadChannel,
"message",
useCallback(() => {
- client?.stopClient();
-
- setState((prev) => ({
- ...prev,
- error: translatedError(
- "This application has been opened in another tab.",
- t
- ),
- }));
- }, [client, setState, t])
+ initClientState?.client.stopClient();
+ setAlreadyOpenedErr(
+ translatedError("This application has been opened in another tab.", t)
+ );
+ }, [initClientState?.client, setAlreadyOpenedErr, t])
);
- const context = useMemo(
- () => ({
- loading,
- isAuthenticated,
- isPasswordlessUser,
- client,
- changePassword,
- logout,
- userName,
- setClient,
- error: undefined,
- }),
- [
- loading,
- isAuthenticated,
- isPasswordlessUser,
- client,
- changePassword,
- logout,
- userName,
- setClient,
- ]
- );
+ const [state, setState] = useState(undefined);
+ useEffect(() => {
+ if (alreadyOpenedErr) {
+ setState({ state: "error", error: alreadyOpenedErr });
+ return;
+ }
+
+ let authenticated = undefined;
+ if (initClientState) {
+ authenticated = {
+ client: initClientState.client,
+ isPasswordlessUser: initClientState.passwordlessUser,
+ changePassword,
+ logout,
+ };
+ }
+
+ setState({ state: "valid", authenticated, setClient });
+ }, [alreadyOpenedErr, changePassword, initClientState, logout, setClient]);
useEffect(() => {
- window.matrixclient = client;
- window.isPasswordlessUser = isPasswordlessUser;
+ if (!initClientState) {
+ return;
+ }
+
+ window.matrixclient = initClientState.client;
+ window.passwordlessUser = initClientState.passwordlessUser;
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
- }, [client, isPasswordlessUser]);
+ }, [initClientState]);
- if (error) {
- return ;
+ if (alreadyOpenedErr) {
+ return ;
}
return (
- {children}
+ {children}
);
};
-export const useClient = () => useContext(ClientContext);
+type InitResult = {
+ client: MatrixClient;
+ passwordlessUser: boolean;
+};
+
+async function loadClient(): Promise {
+ if (widget) {
+ // We're inside a widget, so let's engage *matryoshka mode*
+ logger.log("Using a matryoshka client");
+ const client = await widget.client;
+ return {
+ client,
+ passwordlessUser: false,
+ };
+ } else {
+ // We're running as a standalone application
+ try {
+ const session = loadSession();
+ if (!session) {
+ throw new Error("No session stored");
+ }
+
+ logger.log("Using a standalone client");
+
+ const foci = Config.get().livekit
+ ? [{ livekitServiceUrl: Config.get().livekit!.livekit_service_url }]
+ : undefined;
+
+ /* eslint-disable camelcase */
+ const { user_id, device_id, access_token, passwordlessUser } = session;
+ const initClientParams = {
+ baseUrl: Config.defaultHomeserverUrl()!,
+ accessToken: access_token,
+ userId: user_id,
+ deviceId: device_id,
+ fallbackICEServerAllowed: fallbackICEServerAllowed,
+ foci,
+ };
+
+ try {
+ const client = await initClient(initClientParams, true);
+ return {
+ client,
+ passwordlessUser,
+ };
+ } catch (err) {
+ if (err instanceof CryptoStoreIntegrityError) {
+ // We can't use this session anymore, so let's log it out
+ try {
+ const client = await initClient(initClientParams, false); // Don't need the crypto store just to log out)
+ await client.logout(true);
+ } catch (err) {
+ logger.warn(
+ "The previous session was lost, and we couldn't log it out, " +
+ err +
+ "either"
+ );
+ }
+ }
+ throw err;
+ }
+ /* eslint-enable camelcase */
+ } catch (err) {
+ clearSession();
+ throw err;
+ }
+ }
+}
+
+export interface Session {
+ user_id: string;
+ device_id: string;
+ access_token: string;
+ passwordlessUser: boolean;
+ tempPassword?: string;
+}
+
+const clearSession = () => localStorage.removeItem("matrix-auth-store");
+const saveSession = (s: Session) =>
+ localStorage.setItem("matrix-auth-store", JSON.stringify(s));
+const loadSession = (): Session | undefined => {
+ const data = localStorage.getItem("matrix-auth-store");
+ if (!data) {
+ return undefined;
+ }
+
+ return JSON.parse(data);
+};
diff --git a/src/Facepile.tsx b/src/Facepile.tsx
index f5ffff62..0c9ec239 100644
--- a/src/Facepile.tsx
+++ b/src/Facepile.tsx
@@ -47,8 +47,8 @@ export function Facepile({
}: Props) {
const { t } = useTranslation();
- const _size = sizes.get(size);
- const _overlap = overlapMap[size];
+ const _size = sizes.get(size)!;
+ const _overlap = overlapMap[size]!;
const title = useMemo(() => {
return members.reduce(
diff --git a/src/ListBox.tsx b/src/ListBox.tsx
index 0ee2542a..b7ec7c72 100644
--- a/src/ListBox.tsx
+++ b/src/ListBox.tsx
@@ -36,15 +36,16 @@ export function ListBox({
listBoxRef,
...rest
}: ListBoxProps) {
- const ref = useRef();
- if (!listBoxRef) listBoxRef = ref;
+ const ref = useRef(null);
- const { listBoxProps } = useListBox(rest, state, listBoxRef);
+ const listRef = listBoxRef ?? ref;
+
+ const { listBoxProps } = useListBox(rest, state, listRef);
return (
{[...state.collection].map((item) => (
@@ -66,7 +67,7 @@ interface OptionProps {
}
function Option({ item, state, className }: OptionProps) {
- const ref = useRef();
+ const ref = useRef(null);
const { optionProps, isSelected, isFocused, isDisabled } = useOption(
{ key: item.key },
state,
@@ -83,7 +84,11 @@ function Option({ item, state, className }: OptionProps) {
const origPointerUp = optionProps.onPointerUp;
delete optionProps.onPointerUp;
optionProps.onClick = useCallback(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
(e) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
origPointerUp(e as unknown as PointerEvent);
},
[origPointerUp]
diff --git a/src/Menu.tsx b/src/Menu.tsx
index 1e158413..d7982df7 100644
--- a/src/Menu.tsx
+++ b/src/Menu.tsx
@@ -26,7 +26,7 @@ import styles from "./Menu.module.css";
interface MenuProps extends AriaMenuOptions {
className?: String;
- onClose?: () => void;
+ onClose: () => void;
onAction: (value: Key) => void;
label?: string;
}
@@ -39,7 +39,7 @@ export function Menu({
...rest
}: MenuProps) {
const state = useTreeState({ ...rest, selectionMode: "none" });
- const menuRef = useRef();
+ const menuRef = useRef(null);
const { menuProps } = useMenu(rest, state, menuRef);
return (
@@ -69,7 +69,7 @@ interface MenuItemProps {
}
function MenuItem({ item, state, onAction, onClose }: MenuItemProps) {
- const ref = useRef();
+ const ref = useRef(null);
const { menuItemProps } = useMenuItem(
{
key: item.key,
diff --git a/src/Modal.tsx b/src/Modal.tsx
index 07217539..56db481e 100644
--- a/src/Modal.tsx
+++ b/src/Modal.tsx
@@ -55,7 +55,7 @@ export function Modal({
...rest
}: ModalProps) {
const { t } = useTranslation();
- const modalRef = useRef();
+ const modalRef = useRef(null);
const { overlayProps, underlayProps } = useOverlay(
{ ...rest, onClose },
modalRef
@@ -63,7 +63,7 @@ export function Modal({
usePreventScroll();
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog(rest, modalRef);
- const closeButtonRef = useRef();
+ const closeButtonRef = useRef(null);
const { buttonProps: closeButtonProps } = useButton(
{
onPress: () => onClose(),
diff --git a/src/SequenceDiagramViewerPage.tsx b/src/SequenceDiagramViewerPage.tsx
index 208352fe..9fb66f94 100644
--- a/src/SequenceDiagramViewerPage.tsx
+++ b/src/SequenceDiagramViewerPage.tsx
@@ -36,6 +36,9 @@ export function SequenceDiagramViewerPage() {
const [debugLog, setDebugLog] = useState();
const [selectedUserId, setSelectedUserId] = useState();
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
const onChangeDebugLog = useCallback((e) => {
if (e.target.files && e.target.files.length > 0) {
e.target.files[0].text().then((text: string) => {
@@ -55,7 +58,7 @@ export function SequenceDiagramViewerPage() {
onChange={onChangeDebugLog}
/>
- {debugLog && (
+ {debugLog && selectedUserId && (
(
const tooltipTriggerProps = { delay: 250, ...rest };
const tooltipState = useTooltipTriggerState(tooltipTriggerProps);
const triggerRef = useObjectRef(ref);
- const overlayRef = useRef();
+ const overlayRef = useRef(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
tooltipTriggerProps,
tooltipState,
diff --git a/src/UserMenu.tsx b/src/UserMenu.tsx
index 5648edd9..92e297e4 100644
--- a/src/UserMenu.tsx
+++ b/src/UserMenu.tsx
@@ -119,7 +119,7 @@ export function UserMenu({
)}
- {(props) => (
+ {(props: any) => (