mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Drop ErrorView's widget prop in favour of the host bridge
ErrorView needed a widget only to decide whether to offer a close button or a link home. That prop was threaded down through ErrorPage, RichError and GroupCallErrorBoundary from seven call sites, to answer the single question of whether the host can dismiss Element Call — which the host bridge now answers directly through the presence of close(). Read the bridge from context in ErrorView and remove the prop, along with the plumbing that carried it. No functional change: the rendered output is unchanged, as the existing snapshot confirms. Move GroupCallView onto the host bridge GroupCallView asked the widget API to keep it on screen, to close it, and to tell it when a preloaded call should join. Route all three through the host bridge and drop the widget prop.
This commit is contained in:
+28
-15
@@ -33,6 +33,12 @@ import {
|
|||||||
import { AppBar } from "./AppBar";
|
import { AppBar } from "./AppBar";
|
||||||
import { i18n } from "./utils/i18n";
|
import { i18n } from "./utils/i18n";
|
||||||
import { useRootElement } from "./RootElementContext";
|
import { useRootElement } from "./RootElementContext";
|
||||||
|
import {
|
||||||
|
createWidgetHostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "./HostBridge";
|
||||||
|
import { useInitial } from "./useInitial";
|
||||||
|
|
||||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||||
|
|
||||||
@@ -78,13 +84,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const App: FC<Props> = ({ vm }) => {
|
export const App: FC<Props> = ({ vm }) => {
|
||||||
|
// The standalone build has no host; the widget build's host is the client it
|
||||||
|
// is a widget of.
|
||||||
|
const hostBridge = useInitial(() =>
|
||||||
|
widget === null ? nullHostBridge : createWidgetHostBridge(widget),
|
||||||
|
);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Initializer.init()
|
Initializer.init()
|
||||||
?.then(async () => {
|
?.then(async () => {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
await widget?.api.sendContentLoaded();
|
await hostBridge.contentLoaded();
|
||||||
})
|
})
|
||||||
.catch(logger.error);
|
.catch(logger.error);
|
||||||
});
|
});
|
||||||
@@ -94,7 +105,7 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
<MediaDevicesContext value={vm.mediaDevices}>
|
<MediaDevicesContext value={vm.mediaDevices}>
|
||||||
<ProcessorProvider>
|
<ProcessorProvider>
|
||||||
<Sentry.ErrorBoundary
|
<Sentry.ErrorBoundary
|
||||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
fallback={(error) => <ErrorPage error={error} />}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<SentryRoute path="/" element={<HomePage />} />
|
<SentryRoute path="/" element={<HomePage />} />
|
||||||
@@ -112,19 +123,21 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<BrowserRouter>
|
<HostBridgeProvider value={hostBridge}>
|
||||||
<LocationUrlParamsProvider>
|
<BrowserRouter>
|
||||||
<BackgroundProvider>
|
<LocationUrlParamsProvider>
|
||||||
<ThemeProvider>
|
<BackgroundProvider>
|
||||||
<TooltipProvider>
|
<ThemeProvider>
|
||||||
<Suspense fallback={null}>
|
<TooltipProvider>
|
||||||
<MaybeAppBar>{content}</MaybeAppBar>
|
<Suspense fallback={null}>
|
||||||
</Suspense>
|
<MaybeAppBar>{content}</MaybeAppBar>
|
||||||
</TooltipProvider>
|
</Suspense>
|
||||||
</ThemeProvider>
|
</TooltipProvider>
|
||||||
</BackgroundProvider>
|
</ThemeProvider>
|
||||||
</LocationUrlParamsProvider>
|
</BackgroundProvider>
|
||||||
</BrowserRouter>
|
</LocationUrlParamsProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</HostBridgeProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-49
@@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { type FC, type PropsWithChildren } from "react";
|
import { type FC, type PropsWithChildren } from "react";
|
||||||
import { type WidgetApi } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { ClientContextProvider } from "./ClientContext";
|
import { ClientContextProvider } from "./ClientContext";
|
||||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||||
import { widget } from "./widget";
|
import {
|
||||||
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "./HostBridge";
|
||||||
|
|
||||||
const TestComponent: FC<
|
const TestComponent: FC<
|
||||||
PropsWithChildren<{
|
PropsWithChildren<{
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
|
hostBridge?: HostBridge;
|
||||||
}>
|
}>
|
||||||
> = ({ client, children }) => {
|
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||||
return (
|
return (
|
||||||
<ClientContextProvider
|
<ClientContextProvider
|
||||||
value={{
|
value={{
|
||||||
@@ -38,17 +42,11 @@ const TestComponent: FC<
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||||
</ClientContextProvider>
|
</ClientContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("./widget", () => ({
|
|
||||||
widget: {
|
|
||||||
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should attempt to use widget API if running as a widget", async () => {
|
test("should download media through the host when it offers to", async () => {
|
||||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||||
const expectedObjectURL = "my-object-url";
|
const expectedObjectURL = "my-object-url";
|
||||||
const theBlob = new Blob([]);
|
const theBlob = new Blob([]);
|
||||||
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
|
|||||||
getAccessToken: () => undefined,
|
getAccessToken: () => undefined,
|
||||||
} as unknown as MatrixClient);
|
} as unknown as MatrixClient);
|
||||||
|
|
||||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
|
||||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
|
||||||
const member = mockMatrixRoomMember(
|
const member = mockMatrixRoomMember(
|
||||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||||
{
|
{
|
||||||
@@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => {
|
|||||||
);
|
);
|
||||||
const displayName = "Alice";
|
const displayName = "Alice";
|
||||||
render(
|
render(
|
||||||
<TestComponent client={client}>
|
<TestComponent client={client} hostBridge={hostBridge}>
|
||||||
<Avatar
|
<Avatar
|
||||||
id={member.userId}
|
id={member.userId}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
|
|||||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
|
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
|
||||||
});
|
|
||||||
|
|
||||||
test("Supports download files as base64", async () => {
|
|
||||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
|
||||||
const expectedBase64 =
|
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
|
|
||||||
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
|
|
||||||
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
|
|
||||||
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
|
|
||||||
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
|
|
||||||
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
|
|
||||||
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
|
|
||||||
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
|
|
||||||
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
|
|
||||||
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
|
|
||||||
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
|
|
||||||
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
|
|
||||||
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
|
|
||||||
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
|
|
||||||
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
|
|
||||||
"YII=";
|
|
||||||
const mockWidgetAPI = {
|
|
||||||
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
|
|
||||||
if (contentUri !== expectedMXCUrl) {
|
|
||||||
return Promise.reject(new Error("Unexpected content URI"));
|
|
||||||
}
|
|
||||||
return { file: expectedBase64 };
|
|
||||||
}),
|
|
||||||
} as unknown as WidgetApi;
|
|
||||||
|
|
||||||
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
|
|
||||||
|
|
||||||
expect(blob).toBeInstanceOf(Blob);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-27
@@ -14,10 +14,9 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { type WidgetApi } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { useClientState } from "./ClientContext";
|
import { useClientState } from "./ClientContext";
|
||||||
import { widget } from "./widget";
|
import { useHostBridge } from "./HostBridge";
|
||||||
|
|
||||||
export enum Size {
|
export enum Size {
|
||||||
XS = "xs",
|
XS = "xs",
|
||||||
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const clientState = useClientState();
|
const clientState = useClientState();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
|
||||||
const sizePx = useMemo(
|
const sizePx = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
|
|||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
|
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||||
|
// downloads, but in practice they should be stable long before this code runs.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
setAvatarUrl(undefined);
|
setAvatarUrl(undefined);
|
||||||
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
|
|||||||
|
|
||||||
let blob: Promise<Blob>;
|
let blob: Promise<Blob>;
|
||||||
|
|
||||||
if (widget?.api) {
|
if (hostBridge.downloadMedia) {
|
||||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
blob = hostBridge.downloadMedia(src);
|
||||||
} else if (
|
} else if (
|
||||||
clientState?.state === "valid" &&
|
clientState?.state === "valid" &&
|
||||||
clientState.authenticated?.client &&
|
clientState.authenticated?.client &&
|
||||||
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
|||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [clientState, src, sizePx]);
|
}, [clientState, hostBridge, src, sizePx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CompoundAvatar
|
<CompoundAvatar
|
||||||
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
|
|||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
// export for testing
|
|
||||||
export async function getAvatarFromWidgetAPI(
|
|
||||||
api: WidgetApi,
|
|
||||||
src: string,
|
|
||||||
): Promise<Blob> {
|
|
||||||
const response = await api.downloadFile(src);
|
|
||||||
const file = response.file;
|
|
||||||
|
|
||||||
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
|
|
||||||
if (file instanceof Blob) {
|
|
||||||
return file;
|
|
||||||
} else if (typeof file === "string") {
|
|
||||||
// it is a base64 string
|
|
||||||
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
|
|
||||||
return new Blob([bytes]);
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
"Downloaded file format is not supported: " + typeof file + "",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
}, [initClientState, onSync]);
|
}, [initClientState, onSync]);
|
||||||
|
|
||||||
if (alreadyOpenedErr) {
|
if (alreadyOpenedErr) {
|
||||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
return <ErrorPage error={alreadyOpenedErr} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ClientContext value={state}>{children}</ClientContext>;
|
return <ClientContext value={state}>{children}</ClientContext>;
|
||||||
|
|||||||
+13
-19
@@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton";
|
|||||||
import styles from "./ErrorView.module.css";
|
import styles from "./ErrorView.module.css";
|
||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import { LinkButton } from "./button";
|
import { LinkButton } from "./button";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
import { useHostBridge } from "./HostBridge.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||||
@@ -38,7 +38,6 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
fatal?: boolean;
|
fatal?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorView: FC<Props> = ({
|
export const ErrorView: FC<Props> = ({
|
||||||
@@ -47,32 +46,27 @@ export const ErrorView: FC<Props> = ({
|
|||||||
rageshake,
|
rageshake,
|
||||||
fatal,
|
fatal,
|
||||||
children,
|
children,
|
||||||
widget,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { confineToRoom } = useUrlParams();
|
const { confineToRoom } = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
|
||||||
const onReload = useCallback(() => {
|
const onReload = useCallback(() => {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
const CloseButton: FC<{ close: () => Promise<void> }> = ({
|
||||||
widget,
|
close,
|
||||||
}): ReactElement => {
|
}): ReactElement => {
|
||||||
// in widget mode we don't want to show the return home button but a close button
|
// When the host can dismiss us, offer that instead of a link home
|
||||||
const closeWidget = (): void => {
|
const onClose = (): void => {
|
||||||
widget.api.transport
|
close().catch((e) => {
|
||||||
.send(ElementWidgetActions.Close, {})
|
// What to do here?
|
||||||
.catch((e) => {
|
logger.error("Failed to ask the host to close Element Call", e);
|
||||||
// What to do here?
|
});
|
||||||
logger.error("Failed to send close action", e);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
widget.api.transport.stop();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Button kind="primary" onClick={closeWidget}>
|
<Button kind="primary" onClick={onClose}>
|
||||||
{t("action.close")}
|
{t("action.close")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -108,8 +102,8 @@ export const ErrorView: FC<Props> = ({
|
|||||||
{rageshake && (
|
{rageshake && (
|
||||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||||
)}
|
)}
|
||||||
{widget ? (
|
{hostBridge.close ? (
|
||||||
<CloseWidgetButton widget={widget} />
|
<CloseButton close={hostBridge.close} />
|
||||||
) : (
|
) : (
|
||||||
!confineToRoom && <ReturnToHomeButton />
|
!confineToRoom && <ReturnToHomeButton />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
|
|||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import { RichError } from "./RichError";
|
import { RichError } from "./RichError";
|
||||||
import { ErrorView } from "./ErrorView";
|
import { ErrorView } from "./ErrorView";
|
||||||
import { type WidgetHelpers } from "./widget.ts";
|
|
||||||
|
|
||||||
interface FullScreenViewProps {
|
interface FullScreenViewProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
|||||||
|
|
||||||
interface ErrorPageProps {
|
interface ErrorPageProps {
|
||||||
error: unknown;
|
error: unknown;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due to this component being used as the crash fallback for Sentry, which has
|
// Due to this component being used as the crash fallback for Sentry, which has
|
||||||
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
|
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
|
||||||
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
|||||||
error.richMessage
|
error.richMessage
|
||||||
) : (
|
) : (
|
||||||
<ErrorView
|
<ErrorView
|
||||||
widget={widget}
|
|
||||||
Icon={ErrorSolidIcon}
|
Icon={ErrorSolidIcon}
|
||||||
title={t("error.generic")}
|
title={t("error.generic")}
|
||||||
rageshake
|
rageshake
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { type WidgetApi } from "matrix-widget-api";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
|
||||||
|
import { createWidgetHostBridge, nullHostBridge } from "./HostBridge";
|
||||||
|
import { type WidgetHelpers } from "./widget";
|
||||||
|
|
||||||
|
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
|
||||||
|
return {
|
||||||
|
api: api as WidgetApi,
|
||||||
|
lazyActions: new EventEmitter(),
|
||||||
|
client: Promise.resolve(),
|
||||||
|
} as unknown as WidgetHelpers;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createWidgetHostBridge", () => {
|
||||||
|
describe("downloadMedia", () => {
|
||||||
|
const mxcUri = "mxc://example.org/alice-avatar";
|
||||||
|
|
||||||
|
test("passes a Blob through unchanged", async () => {
|
||||||
|
const file = new Blob([]);
|
||||||
|
const bridge = createWidgetHostBridge(
|
||||||
|
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes a base64 string into a Blob", async () => {
|
||||||
|
const bridge = createWidgetHostBridge(
|
||||||
|
mockWidget({
|
||||||
|
// "hello" in base64
|
||||||
|
downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await bridge.downloadMedia!(mxcUri);
|
||||||
|
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
// The five decoded bytes, rather than the eight characters of base64 —
|
||||||
|
// which is what we'd get if the string were stored verbatim.
|
||||||
|
expect(blob.size).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a file format it does not understand", async () => {
|
||||||
|
const bridge = createWidgetHostBridge(
|
||||||
|
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow(
|
||||||
|
"Downloaded file format is not supported",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supportsReactions", () => {
|
||||||
|
const capabilities = [
|
||||||
|
"org.matrix.msc2762.send.event:m.reaction",
|
||||||
|
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||||
|
"org.matrix.msc2762.receive.event:m.reaction",
|
||||||
|
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||||
|
];
|
||||||
|
|
||||||
|
test("is true when the host grants every reaction capability", () => {
|
||||||
|
const bridge = createWidgetHostBridge(
|
||||||
|
mockWidget({ hasCapability: () => true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bridge.supportsReactions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(capabilities)("is false without %s", (missing) => {
|
||||||
|
const bridge = createWidgetHostBridge(
|
||||||
|
mockWidget({ hasCapability: (c) => c !== missing }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bridge.supportsReactions).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nullHostBridge", () => {
|
||||||
|
test("offers no way to close, so the interface falls back to navigation", () => {
|
||||||
|
expect(nullHostBridge.close).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers no media download, so Element Call uses its own client", () => {
|
||||||
|
expect(nullHostBridge.downloadMedia).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports reactions, since nothing is mediating its homeserver access", () => {
|
||||||
|
expect(nullHostBridge.supportsReactions).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, use } from "react";
|
||||||
|
import { fromEvent, map, NEVER, type Observable } from "rxjs";
|
||||||
|
import {
|
||||||
|
type IWidgetApiRequest,
|
||||||
|
type IWidgetApiRequestData,
|
||||||
|
WidgetApiToWidgetAction,
|
||||||
|
} from "matrix-widget-api";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElementWidgetActions,
|
||||||
|
type JoinCallData,
|
||||||
|
type WidgetHelpers,
|
||||||
|
} from "./widget";
|
||||||
|
|
||||||
|
// Note: these are type aliases rather than interfaces so that they satisfy the
|
||||||
|
// widget API's index-signature payload types.
|
||||||
|
|
||||||
|
/** The mute state Element Call and its host exchange. */
|
||||||
|
export type DeviceMuteState = {
|
||||||
|
audio_enabled: boolean;
|
||||||
|
video_enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mute state change requested by the host. An absent field means "leave this
|
||||||
|
* one as it is".
|
||||||
|
*/
|
||||||
|
export type DeviceMuteRequest = {
|
||||||
|
audio_enabled?: boolean;
|
||||||
|
video_enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something the host has asked of Element Call, which it is expected to
|
||||||
|
* acknowledge.
|
||||||
|
*/
|
||||||
|
export interface HostRequest<Data, Reply = void> {
|
||||||
|
data: Data;
|
||||||
|
/** Acknowledges the request. Should be called exactly once. */
|
||||||
|
reply(reply: Reply): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element Call's view of the application hosting it.
|
||||||
|
*
|
||||||
|
* Element Call can run as its own page, as a widget inside a Matrix client, or
|
||||||
|
* embedded directly into one. Only the last two give it a host, and each of
|
||||||
|
* them reaches it by a different route — so everything Element Call needs from
|
||||||
|
* whatever is hosting it goes through this interface, rather than being
|
||||||
|
* expressed in terms of the widget API.
|
||||||
|
*
|
||||||
|
* This covers the interactions Element Call has with its host while running.
|
||||||
|
* The Matrix client it talks to is supplied separately, at startup.
|
||||||
|
*/
|
||||||
|
export interface HostBridge {
|
||||||
|
// What Element Call tells the host.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the host to keep Element Call on screen (or stop doing so), so that a
|
||||||
|
* call in progress is not torn down when the user navigates elsewhere.
|
||||||
|
*/
|
||||||
|
setAlwaysOnScreen(alwaysOnScreen: boolean): Promise<void>;
|
||||||
|
/** Tells the host that Element Call has finished loading. */
|
||||||
|
contentLoaded(): Promise<void>;
|
||||||
|
/** Tells the host that the user has joined the call. */
|
||||||
|
notifyJoined(): Promise<void>;
|
||||||
|
/** Tells the host that the user has hung up. */
|
||||||
|
notifyHungUp(): Promise<void>;
|
||||||
|
/** Tells the host the user's current audio and video mute state. */
|
||||||
|
notifyDeviceMute(state: DeviceMuteState): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Asks the host to close Element Call, and stops communicating with it. No
|
||||||
|
* further calls should be made on this bridge afterwards.
|
||||||
|
*
|
||||||
|
* Absent when the host has no way to dismiss Element Call — standalone, the
|
||||||
|
* user navigates away instead — so its presence is what tells the interface
|
||||||
|
* whether to offer a close affordance.
|
||||||
|
*/
|
||||||
|
close?(): Promise<void>;
|
||||||
|
|
||||||
|
// What the host asks of Element Call.
|
||||||
|
|
||||||
|
/** The host has changed the theme Element Call should use. */
|
||||||
|
themeChange$: Observable<HostRequest<{ name?: string }>>;
|
||||||
|
/** The host wants a preloaded Element Call to join the call now. */
|
||||||
|
join$: Observable<HostRequest<JoinCallData>>;
|
||||||
|
/** The host wants Element Call to leave the call. */
|
||||||
|
hangUp$: Observable<HostRequest<Record<string, never>>>;
|
||||||
|
/** The host wants to change, or read back, the device mute state. */
|
||||||
|
deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>;
|
||||||
|
|
||||||
|
// What the host is capable of.
|
||||||
|
|
||||||
|
/** Whether the host permits Element Call to send and receive reactions. */
|
||||||
|
readonly supportsReactions: boolean;
|
||||||
|
/**
|
||||||
|
* Fetches media on Element Call's behalf, for hosts that do not give it
|
||||||
|
* direct access to the homeserver. Absent when Element Call should fetch
|
||||||
|
* media itself using its own client.
|
||||||
|
*/
|
||||||
|
downloadMedia?(mxcUri: string): Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bridge to nowhere, for when Element Call has no host — that is, when it is
|
||||||
|
* running as its own page and talks to the homeserver directly.
|
||||||
|
*/
|
||||||
|
export const nullHostBridge: HostBridge = {
|
||||||
|
setAlwaysOnScreen: async () => {},
|
||||||
|
contentLoaded: async () => {},
|
||||||
|
notifyJoined: async () => {},
|
||||||
|
notifyHungUp: async () => {},
|
||||||
|
notifyDeviceMute: async () => {},
|
||||||
|
themeChange$: NEVER,
|
||||||
|
join$: NEVER,
|
||||||
|
hangUp$: NEVER,
|
||||||
|
deviceMute$: NEVER,
|
||||||
|
// Standalone Element Call reaches the homeserver itself, so nothing is
|
||||||
|
// withholding these from it.
|
||||||
|
supportsReactions: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bridges to a host that Element Call is a widget of. */
|
||||||
|
export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
|
||||||
|
const requests = <Data, Reply>(
|
||||||
|
action: string,
|
||||||
|
): Observable<HostRequest<Data, Reply>> =>
|
||||||
|
(
|
||||||
|
fromEvent(widget.lazyActions, action) as Observable<
|
||||||
|
CustomEvent<IWidgetApiRequest>
|
||||||
|
>
|
||||||
|
).pipe(
|
||||||
|
map((ev) => ({
|
||||||
|
data: ev.detail.data as Data,
|
||||||
|
// The widget API requires a reply for every request, and carries the
|
||||||
|
// payload as a plain object, so an empty reply becomes {}.
|
||||||
|
reply: (reply: Reply): void =>
|
||||||
|
widget.api.transport.reply(ev.detail, reply ?? {}),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = async (
|
||||||
|
action: ElementWidgetActions,
|
||||||
|
data: IWidgetApiRequestData = {},
|
||||||
|
): Promise<void> => {
|
||||||
|
await widget.api.transport.send(action, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||||
|
await widget.api.setAlwaysOnScreen(alwaysOnScreen);
|
||||||
|
},
|
||||||
|
contentLoaded: async () => widget.api.sendContentLoaded(),
|
||||||
|
notifyJoined: async () => send(ElementWidgetActions.JoinCall),
|
||||||
|
notifyHungUp: async () => send(ElementWidgetActions.HangupCall),
|
||||||
|
notifyDeviceMute: async (state) =>
|
||||||
|
send(ElementWidgetActions.DeviceMute, state),
|
||||||
|
close: async () => {
|
||||||
|
await send(ElementWidgetActions.Close);
|
||||||
|
widget.api.transport.stop();
|
||||||
|
},
|
||||||
|
themeChange$: requests(WidgetApiToWidgetAction.ThemeChange),
|
||||||
|
join$: requests(ElementWidgetActions.JoinCall),
|
||||||
|
hangUp$: requests(ElementWidgetActions.HangupCall),
|
||||||
|
deviceMute$: requests(ElementWidgetActions.DeviceMute),
|
||||||
|
// Element Call needs the host's permission to send reactions on its behalf.
|
||||||
|
// Read on access rather than up front: the widget API negotiates its
|
||||||
|
// capabilities asynchronously, and the bridge is built before that settles.
|
||||||
|
get supportsReactions(): boolean {
|
||||||
|
return (
|
||||||
|
widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") &&
|
||||||
|
widget.api.hasCapability(
|
||||||
|
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||||
|
) &&
|
||||||
|
widget.api.hasCapability(
|
||||||
|
"org.matrix.msc2762.receive.event:m.reaction",
|
||||||
|
) &&
|
||||||
|
widget.api.hasCapability(
|
||||||
|
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
downloadMedia: async (mxcUri) => {
|
||||||
|
const { file } = await widget.api.downloadFile(mxcUri);
|
||||||
|
if (file instanceof Blob) return file;
|
||||||
|
if (typeof file === "string")
|
||||||
|
// it is a base64 string
|
||||||
|
return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]);
|
||||||
|
throw new Error(
|
||||||
|
`Downloaded file format is not supported: ${typeof file}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const HostBridgeContext = createContext<HostBridge | null>(null);
|
||||||
|
|
||||||
|
export const HostBridgeProvider = HostBridgeContext.Provider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application hosting Element Call.
|
||||||
|
*
|
||||||
|
* Defaults to {@link nullHostBridge}, so that tests and stories, which have no
|
||||||
|
* host, need no provider.
|
||||||
|
*/
|
||||||
|
export const useHostBridge = (): HostBridge =>
|
||||||
|
use(HostBridgeContext) ?? nullHostBridge;
|
||||||
+1
-6
@@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|||||||
|
|
||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { ErrorView } from "./ErrorView";
|
import { ErrorView } from "./ErrorView";
|
||||||
import { widget } from "./widget.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An error consisting of a terse message to be logged to the console and a
|
* An error consisting of a terse message to be logged to the console and a
|
||||||
@@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorView
|
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||||
widget={widget}
|
|
||||||
Icon={PopOutIcon}
|
|
||||||
title={t("error.open_elsewhere")}
|
|
||||||
>
|
|
||||||
<p>
|
<p>
|
||||||
{t("error.open_elsewhere_description", {
|
{t("error.open_elsewhere_description", {
|
||||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
|||||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||||
import { RegisteredView } from "./RegisteredView";
|
import { RegisteredView } from "./RegisteredView";
|
||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
import { widget } from "../widget.ts";
|
|
||||||
|
|
||||||
export const HomePage: FC = () => {
|
export const HomePage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
|
|||||||
if (!clientState) {
|
if (!clientState) {
|
||||||
return <LoadingPage />;
|
return <LoadingPage />;
|
||||||
} else if (clientState.state === "error") {
|
} else if (clientState.state === "error") {
|
||||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
return <ErrorPage error={clientState.error} />;
|
||||||
} else {
|
} else {
|
||||||
return clientState.authenticated ? (
|
return clientState.authenticated ? (
|
||||||
<RegisteredView client={clientState.authenticated.client} />
|
<RegisteredView client={clientState.authenticated.client} />
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ import {
|
|||||||
UnknownCallError,
|
UnknownCallError,
|
||||||
} from "../utils/errors.ts";
|
} from "../utils/errors.ts";
|
||||||
import { mockConfig } from "../utils/test.ts";
|
import { mockConfig } from "../utils/test.ts";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
import {
|
||||||
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "../HostBridge.ts";
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
@@ -79,7 +83,6 @@ test.each([
|
|||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={onErrorMock}
|
onError={onErrorMock}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
@@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => {
|
|||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={onErrorMock}
|
onError={onErrorMock}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
@@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
|
||||||
recoveryActionHandler={reconnectCallback}
|
|
||||||
widget={null}
|
|
||||||
>
|
|
||||||
<TestComponent fail={failState} />
|
<TestComponent fail={failState} />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -199,7 +198,6 @@ describe("Rageshake button", () => {
|
|||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={vi.fn()}
|
onError={vi.fn()}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
@@ -224,29 +222,27 @@ describe("Rageshake button", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have a close button in widget mode", async () => {
|
test("should have a close button when the host can dismiss us", async () => {
|
||||||
const error = new MatrixRTCTransportMissingError("example.com");
|
const error = new MatrixRTCTransportMissingError("example.com");
|
||||||
const TestComponent = (): ReactNode => {
|
const TestComponent = (): ReactNode => {
|
||||||
throw error;
|
throw error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWidget = {
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
api: {
|
const hostBridge: HostBridge = { ...nullHostBridge, close };
|
||||||
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
|
|
||||||
},
|
|
||||||
} as unknown as WidgetHelpers;
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onErrorMock = vi.fn();
|
const onErrorMock = vi.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GroupCallErrorBoundary
|
<HostBridgeProvider value={hostBridge}>
|
||||||
widget={mockWidget}
|
<GroupCallErrorBoundary
|
||||||
onError={onErrorMock}
|
onError={onErrorMock}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
|
</HostBridgeProvider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
|
||||||
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
|
expect(close).toHaveBeenCalled();
|
||||||
ElementWidgetActions.Close,
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show technical details when error has a matrixError cause", async () => {
|
test("should show technical details when error has a matrixError cause", async () => {
|
||||||
@@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async (
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||||
onError={vi.fn()}
|
|
||||||
recoveryActionHandler={vi.fn()}
|
|
||||||
widget={null}
|
|
||||||
>
|
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
@@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||||
onError={vi.fn()}
|
|
||||||
recoveryActionHandler={vi.fn()}
|
|
||||||
widget={null}
|
|
||||||
>
|
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
@@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => {
|
|||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={vi.fn()}
|
onError={vi.fn()}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
@@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => {
|
|||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={vi.fn()}
|
onError={vi.fn()}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
} from "../utils/errors.ts";
|
} from "../utils/errors.ts";
|
||||||
import { FullScreenView } from "../FullScreenView.tsx";
|
import { FullScreenView } from "../FullScreenView.tsx";
|
||||||
import { ErrorView } from "../ErrorView.tsx";
|
import { ErrorView } from "../ErrorView.tsx";
|
||||||
import { type WidgetHelpers } from "../widget.ts";
|
|
||||||
import styles from "../ErrorView.module.css";
|
import styles from "../ErrorView.module.css";
|
||||||
|
|
||||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||||
@@ -47,13 +46,11 @@ interface ErrorPageProps {
|
|||||||
error: ElementCallError;
|
error: ElementCallError;
|
||||||
recoveryActionHandler: RecoveryActionHandler;
|
recoveryActionHandler: RecoveryActionHandler;
|
||||||
resetError: () => void;
|
resetError: () => void;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrorPage: FC<ErrorPageProps> = ({
|
const ErrorPage: FC<ErrorPageProps> = ({
|
||||||
error,
|
error,
|
||||||
recoveryActionHandler,
|
recoveryActionHandler,
|
||||||
widget,
|
|
||||||
}: ErrorPageProps): ReactElement => {
|
}: ErrorPageProps): ReactElement => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
logger.error("Error boundary caught:", error);
|
logger.error("Error boundary caught:", error);
|
||||||
@@ -89,7 +86,6 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
|||||||
Icon={icon}
|
Icon={icon}
|
||||||
title={error.localisedTitle}
|
title={error.localisedTitle}
|
||||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||||
widget={widget}
|
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{error.localisedMessageKey ? (
|
{error.localisedMessageKey ? (
|
||||||
@@ -148,14 +144,12 @@ interface BoundaryProps {
|
|||||||
children: ReactNode | (() => ReactNode);
|
children: ReactNode | (() => ReactNode);
|
||||||
recoveryActionHandler: RecoveryActionHandler;
|
recoveryActionHandler: RecoveryActionHandler;
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown) => void;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupCallErrorBoundary = ({
|
export const GroupCallErrorBoundary = ({
|
||||||
recoveryActionHandler,
|
recoveryActionHandler,
|
||||||
onError,
|
onError,
|
||||||
children,
|
children,
|
||||||
widget,
|
|
||||||
}: BoundaryProps): ReactElement => {
|
}: BoundaryProps): ReactElement => {
|
||||||
const fallbackRenderer: FallbackRender = useCallback(
|
const fallbackRenderer: FallbackRender = useCallback(
|
||||||
({ error, resetError }): ReactElement => {
|
({ error, resetError }): ReactElement => {
|
||||||
@@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({
|
|||||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
widget={widget ?? null}
|
|
||||||
error={callError}
|
error={callError}
|
||||||
resetError={resetError}
|
resetError={resetError}
|
||||||
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
||||||
@@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[recoveryActionHandler, widget],
|
[recoveryActionHandler],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import userEvent, {
|
|||||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { type ITransport } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { prefetchSounds } from "../soundUtils";
|
import { prefetchSounds } from "../soundUtils";
|
||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
@@ -52,8 +51,11 @@ import {
|
|||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { GroupCallView } from "./GroupCallView";
|
import { GroupCallView } from "./GroupCallView";
|
||||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
import {
|
||||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "../HostBridge";
|
||||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
@@ -134,7 +136,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function createGroupCallView(
|
function createGroupCallView(
|
||||||
widget: WidgetHelpers | null,
|
hostBridge: HostBridge,
|
||||||
joined = true,
|
joined = true,
|
||||||
options: {
|
options: {
|
||||||
withErrorBoundary?: boolean;
|
withErrorBoundary?: boolean;
|
||||||
@@ -184,7 +186,6 @@ function createGroupCallView(
|
|||||||
skipLobby={false}
|
skipLobby={false}
|
||||||
rtcSession={rtcSession.asMockedSession()}
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
muteStates={muteState}
|
muteStates={muteState}
|
||||||
widget={widget}
|
|
||||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||||
joined={true}
|
joined={true}
|
||||||
setJoined={function (value: boolean): void {}}
|
setJoined={function (value: boolean): void {}}
|
||||||
@@ -192,22 +193,21 @@ function createGroupCallView(
|
|||||||
);
|
);
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<TooltipProvider>
|
<HostBridgeProvider value={hostBridge}>
|
||||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
<TooltipProvider>
|
||||||
<ProcessorProvider>
|
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||||
{options.withErrorBoundary ? (
|
<ProcessorProvider>
|
||||||
<GroupCallErrorBoundary
|
{options.withErrorBoundary ? (
|
||||||
recoveryActionHandler={vi.fn()}
|
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||||
widget={null}
|
{groupCallView}
|
||||||
>
|
</GroupCallErrorBoundary>
|
||||||
{groupCallView}
|
) : (
|
||||||
</GroupCallErrorBoundary>
|
groupCallView
|
||||||
) : (
|
)}
|
||||||
groupCallView
|
</ProcessorProvider>
|
||||||
)}
|
</MediaDevicesContext>
|
||||||
</ProcessorProvider>
|
</TooltipProvider>
|
||||||
</MediaDevicesContext>
|
</HostBridgeProvider>
|
||||||
</TooltipProvider>
|
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -218,7 +218,7 @@ function createGroupCallView(
|
|||||||
|
|
||||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { getByText, rtcSession } = createGroupCallView(null);
|
const { getByText, rtcSession } = createGroupCallView(nullHostBridge);
|
||||||
const leaveButton = getByText("Leave");
|
const leaveButton = getByText("Leave");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
expect(playSound).toHaveBeenCalledWith("left");
|
expect(playSound).toHaveBeenCalledWith("left");
|
||||||
@@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
|
|||||||
|
|
||||||
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const widget = {
|
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
||||||
api: {
|
|
||||||
setAlwaysOnScreen: async () => Promise.resolve(true),
|
|
||||||
} as Partial<WidgetHelpers["api"]>,
|
|
||||||
lazyActions: new LazyEventEmitter(),
|
|
||||||
};
|
|
||||||
let resolvePlaySound: () => void;
|
let resolvePlaySound: () => void;
|
||||||
playSound = vi
|
playSound = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
|||||||
soundDuration: {},
|
soundDuration: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText, rtcSession } = createGroupCallView(
|
const { getByText, rtcSession } = createGroupCallView(hostBridge);
|
||||||
widget as WidgetHelpers,
|
|
||||||
);
|
|
||||||
const leaveButton = getByText("Leave");
|
const leaveButton = getByText("Leave");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
|||||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should close widget when all other left and play a sound", async () => {
|
test("Should ask the host to close when all other left and play a sound", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let widgetClosedCalled = false;
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
const hostBridge: HostBridge = {
|
||||||
Promise.withResolvers<void>();
|
...nullHostBridge,
|
||||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
|
||||||
if (action === ElementWidgetActions.Close) {
|
close,
|
||||||
widgetClosedCalled = true;
|
|
||||||
widgetClosedResolver();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const widget = {
|
|
||||||
api: {
|
|
||||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
|
||||||
transport: {
|
|
||||||
send: widgetSendMock,
|
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
|
||||||
stop: widgetStopMock,
|
|
||||||
} as unknown as ITransport,
|
|
||||||
} as Partial<WidgetHelpers["api"]>,
|
|
||||||
lazyActions: new LazyEventEmitter(),
|
|
||||||
};
|
};
|
||||||
const resolvePlaySound = Promise.withResolvers<void>();
|
const resolvePlaySound = Promise.withResolvers<void>();
|
||||||
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
||||||
@@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => {
|
|||||||
soundDuration: {},
|
soundDuration: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
const { getByText } = createGroupCallView(hostBridge);
|
||||||
const leaveButton = getByText("SimulateOtherLeft");
|
const leaveButton = getByText("SimulateOtherLeft");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(widgetClosedCalled).toBeFalsy();
|
expect(close).not.toHaveBeenCalled();
|
||||||
resolvePlaySound.resolve();
|
resolvePlaySound.resolve();
|
||||||
|
|
||||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||||
await widgetClosedPromise;
|
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||||
await flushPromises();
|
|
||||||
expect(widgetClosedCalled).toBeTruthy();
|
|
||||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
|
||||||
}, 80000);
|
}, 80000);
|
||||||
|
|
||||||
test("Should not close widget when auto leave due to error", async () => {
|
test("Should not ask the host to close when auto leave due to error", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
const widgetSendMock = vi.fn().mockResolvedValue(undefined);
|
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
|
||||||
const widget = {
|
const hostBridge: HostBridge = {
|
||||||
api: {
|
...nullHostBridge,
|
||||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
setAlwaysOnScreen,
|
||||||
transport: {
|
close,
|
||||||
send: widgetSendMock,
|
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
|
||||||
stop: widgetStopMock,
|
|
||||||
} as unknown as ITransport,
|
|
||||||
} as Partial<WidgetHelpers["api"]>,
|
|
||||||
lazyActions: new LazyEventEmitter(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
|
const { getByText } = createGroupCallView(hostBridge);
|
||||||
|
|
||||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
|
||||||
const leaveButton = getByText("SimulateErrorLeft");
|
const leaveButton = getByText("SimulateErrorLeft");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
// When onLeft is called, we first set always on screen to false
|
// When onLeft is called, we first set always on screen to false
|
||||||
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
|
await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// But then we do not close the widget automatically
|
// But then we do not ask to be closed automatically
|
||||||
expect(widgetStopMock).not.toHaveBeenCalledOnce();
|
expect(close).not.toHaveBeenCalled();
|
||||||
expect(widgetSendMock).not.toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||||
@@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rtcSession } = createGroupCallView(null);
|
const { rtcSession } = createGroupCallView(nullHostBridge);
|
||||||
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
||||||
screen.getByText("Something went wrong");
|
screen.getByText("Something went wrong");
|
||||||
expect(leaveRTCSession).toHaveBeenCalledWith(
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||||
@@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
|
|||||||
onTestFinished(() => {
|
onTestFinished(() => {
|
||||||
enterRTCSession.mockReset();
|
enterRTCSession.mockReset();
|
||||||
});
|
});
|
||||||
createGroupCallView(null, false);
|
createGroupCallView(nullHostBridge, false);
|
||||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||||
screen.getByText("Call is not supported");
|
screen.getByText("Call is not supported");
|
||||||
});
|
});
|
||||||
@@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
|||||||
{ cause: stickyError },
|
{ cause: stickyError },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { rtcSession } = createGroupCallView(null, true, {
|
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||||
withErrorBoundary: true,
|
withErrorBoundary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||||
const { rtcSession } = createGroupCallView(null, true, {
|
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||||
withErrorBoundary: true,
|
withErrorBoundary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro
|
|||||||
|
|
||||||
test("user can reconnect after a membership manager error", async () => {
|
test("user can reconnect after a membership manager error", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rtcSession } = createGroupCallView(null, true);
|
const { rtcSession } = createGroupCallView(nullHostBridge, true);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||||
);
|
);
|
||||||
|
|||||||
+56
-64
@@ -30,12 +30,7 @@ import {
|
|||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
import { type JoinCallData } from "../widget";
|
||||||
import {
|
|
||||||
ElementWidgetActions,
|
|
||||||
type JoinCallData,
|
|
||||||
type WidgetHelpers,
|
|
||||||
} from "../widget";
|
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { type MatrixInfo } from "./VideoPreview";
|
import { type MatrixInfo } from "./VideoPreview";
|
||||||
import { CallEndedView } from "./CallEndedView";
|
import { CallEndedView } from "./CallEndedView";
|
||||||
@@ -76,6 +71,7 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
|||||||
import { useAppBarTitle } from "../AppBar.tsx";
|
import { useAppBarTitle } from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
import { useRootElement } from "../RootElementContext.ts";
|
import { useRootElement } from "../RootElementContext.ts";
|
||||||
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -99,7 +95,6 @@ interface Props {
|
|||||||
joined: boolean;
|
joined: boolean;
|
||||||
setJoined: (value: boolean) => void;
|
setJoined: (value: boolean) => void;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupCallView: FC<Props> = ({
|
export const GroupCallView: FC<Props> = ({
|
||||||
@@ -112,7 +107,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
joined,
|
joined,
|
||||||
setJoined,
|
setJoined,
|
||||||
muteStates,
|
muteStates,
|
||||||
widget,
|
|
||||||
}) => {
|
}) => {
|
||||||
// Used to thread through any errors that occur outside the error boundary
|
// Used to thread through any errors that occur outside the error boundary
|
||||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||||
@@ -120,6 +114,14 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
const rootElement = useRootElement();
|
const rootElement = useRootElement();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
// A host that can close us is a host that decides when we stop existing, so
|
||||||
|
// we neither show our own post-call screens nor assume we have time to
|
||||||
|
// finish what we are doing.
|
||||||
|
// TODO: this reads a capability as a proxy for who owns our lifetime. Worth
|
||||||
|
// finding a more direct way to express it — see the guidance in UrlParams.ts
|
||||||
|
// on naming behaviours rather than situations.
|
||||||
|
const hostControlsLifetime = hostBridge.close !== undefined;
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
const leaveSoundContext = useLatest(
|
const leaveSoundContext = useLatest(
|
||||||
@@ -294,28 +296,26 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (skipLobby) {
|
if (skipLobby) {
|
||||||
if (widget && preload) {
|
// `preload` is only ever set when we have a host to be preloaded by.
|
||||||
|
if (preload) {
|
||||||
// In preload mode without lobby we wait for a join action before entering
|
// In preload mode without lobby we wait for a join action before entering
|
||||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
await defaultDeviceSetup(data);
|
||||||
setJoined(true);
|
setJoined(true);
|
||||||
widget.api.transport.reply(ev.detail, {});
|
reply();
|
||||||
})().catch((e) => {
|
})().catch((e) => {
|
||||||
logger.error("Error joining RTC session on preload", e);
|
logger.error("Error joining RTC session on preload", e);
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
return (): void => subscription.unsubscribe();
|
||||||
return (): void => {
|
|
||||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
// No lobby and no preload: we enter the rtc session right away
|
// No lobby and no preload: we enter the rtc session right away
|
||||||
setJoined(true);
|
setJoined(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
widget,
|
hostBridge,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
preload,
|
preload,
|
||||||
skipLobby,
|
skipLobby,
|
||||||
@@ -341,7 +341,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
||||||
// already plays the "left" sound when the remote participant's media
|
// already plays the "left" sound when the remote participant's media
|
||||||
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
||||||
// (used to destory the widget)
|
// (which is what delays asking the host to close us)
|
||||||
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
||||||
break;
|
break;
|
||||||
case "timeout":
|
case "timeout":
|
||||||
@@ -356,12 +356,12 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
setLeft(true);
|
setLeft(true);
|
||||||
|
|
||||||
// We need to wait until the callEnded event is tracked on PostHog,
|
// We need to wait until the callEnded event is tracked on PostHog,
|
||||||
// otherwise the iframe may get killed first.
|
// otherwise we may be torn down first.
|
||||||
const posthogRequest = new Promise((resolve) => {
|
const posthogRequest = new Promise((resolve) => {
|
||||||
// To increase the likelihood of the PostHog event being sent out in
|
// To increase the likelihood of the PostHog event being sent out
|
||||||
// widget mode before the iframe is killed, we ask it to skip the
|
// before the host disposes of us, we ask it to skip the usual
|
||||||
// usual queuing/batching of requests.
|
// queuing/batching of requests.
|
||||||
const sendInstantly = widget !== null;
|
const sendInstantly = hostControlsLifetime;
|
||||||
PosthogAnalytics.instance.eventCallEnded.track(
|
PosthogAnalytics.instance.eventCallEnded.track(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
rtcSession.memberships.length,
|
rtcSession.memberships.length,
|
||||||
@@ -369,8 +369,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
rtcSession,
|
rtcSession,
|
||||||
);
|
);
|
||||||
// Unfortunately the PostHog library provides no way to await the
|
// Unfortunately the PostHog library provides no way to await the
|
||||||
// tracking of an event, but we don't really want it to hold up the
|
// tracking of an event, but we don't really want it to hold up our
|
||||||
// closing of the widget that long anyway, so giving it 10 ms will do.
|
// disposal that long anyway, so giving it 10 ms will do.
|
||||||
window.setTimeout(resolve, 10);
|
window.setTimeout(resolve, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,25 +389,19 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
)
|
)
|
||||||
void navigate("/");
|
void navigate("/");
|
||||||
|
|
||||||
if (widget) {
|
// After this point the host could dispose of us at any moment!
|
||||||
// After this point the iframe could die at any moment!
|
try {
|
||||||
|
await hostBridge.setAlwaysOnScreen(false);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to set `alwaysOnScreen` to false", e);
|
||||||
|
}
|
||||||
|
// On a normal user hangup we can shut down and ask to be closed. But
|
||||||
|
// if an error occurs we should stay open until the user reads it.
|
||||||
|
if (reason != "error" && !returnToLobby) {
|
||||||
try {
|
try {
|
||||||
await widget.api.setAlwaysOnScreen(false);
|
await hostBridge.close?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error("Failed to ask the host to close Element Call", e);
|
||||||
"Failed to set call widget `alwaysOnScreen` to false",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// On a normal user hangup we can shut down and close the widget. But if an
|
|
||||||
// error occurs we should keep the widget open until the user reads it.
|
|
||||||
if (reason != "error" && !returnToLobby) {
|
|
||||||
try {
|
|
||||||
await widget.api.transport.send(ElementWidgetActions.Close, {});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to send close action", e);
|
|
||||||
}
|
|
||||||
widget.api.transport.stop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -415,7 +409,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
[
|
[
|
||||||
setJoined,
|
setJoined,
|
||||||
leaveSoundContext,
|
leaveSoundContext,
|
||||||
widget,
|
hostBridge,
|
||||||
|
hostControlsLifetime,
|
||||||
room.roomId,
|
room.roomId,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
@@ -426,12 +421,12 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget && joined)
|
if (joined)
|
||||||
// set widget to sticky once joined.
|
// ask to be kept on screen once joined.
|
||||||
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
hostBridge.setAlwaysOnScreen(true).catch((e) => {
|
||||||
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
||||||
});
|
});
|
||||||
}, [widget, joined, rtcSession]);
|
}, [hostBridge, joined, rtcSession]);
|
||||||
|
|
||||||
const joinRule = useJoinRule(room);
|
const joinRule = useJoinRule(room);
|
||||||
|
|
||||||
@@ -501,19 +496,16 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (left && widget === null) {
|
} else if (left && !hostControlsLifetime) {
|
||||||
// Left in SPA mode:
|
// Left, and it is up to us what to show next:
|
||||||
|
|
||||||
// The call ended view is shown for two reasons: prompting guests to create
|
// The call ended view is shown for two reasons: prompting guests to create
|
||||||
// an account, and prompting users that have opted into analytics to provide
|
// an account, and prompting users that have opted into analytics to provide
|
||||||
// feedback. We don't show a feedback prompt to widget users however (at
|
// feedback. We don't show a feedback prompt when a host owns our lifetime
|
||||||
// least for now), because we don't yet have designs that would allow widget
|
// however (at least for now), because we don't yet have designs that would
|
||||||
// users to dismiss the feedback prompt and close the call window without
|
// allow those users to dismiss the feedback prompt and close the call
|
||||||
// submitting anything.
|
// window without submitting anything.
|
||||||
if (
|
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
|
||||||
isPasswordlessUser ||
|
|
||||||
(PosthogAnalytics.instance.isEnabled() && widget === null)
|
|
||||||
) {
|
|
||||||
body = (
|
body = (
|
||||||
<CallEndedView
|
<CallEndedView
|
||||||
endedCallId={rtcSession.room.roomId}
|
endedCallId={rtcSession.room.roomId}
|
||||||
@@ -529,8 +521,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
// LobbyView again which would open capture devices again.
|
// LobbyView again which would open capture devices again.
|
||||||
body = null;
|
body = null;
|
||||||
}
|
}
|
||||||
} else if (left && widget !== null) {
|
} else if (left && hostControlsLifetime) {
|
||||||
// Left in widget mode:
|
// Left, and the host decides what happens next:
|
||||||
body = returnToLobby ? lobbyView : null;
|
body = returnToLobby ? lobbyView : null;
|
||||||
} else if (preload || skipLobby) {
|
} else if (preload || skipLobby) {
|
||||||
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
||||||
@@ -541,7 +533,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
widget={widget}
|
|
||||||
recoveryActionHandler={async (action) => {
|
recoveryActionHandler={async (action) => {
|
||||||
setExternalError(null);
|
setExternalError(null);
|
||||||
if (action == "reconnect") {
|
if (action == "reconnect") {
|
||||||
@@ -553,9 +544,10 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
onError={(_error) => {
|
onError={(_error) => {
|
||||||
if (rtcSession.isJoined()) onLeft("error");
|
if (rtcSession.isJoined()) onLeft("error");
|
||||||
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
|
// If there is an error we need to be dismissible again. This is done in
|
||||||
// We need it here explicitly in case rtcSession.isJoined is false.
|
// `onLeft` as well; we need it here explicitly in case
|
||||||
void widget?.api.setAlwaysOnScreen(false);
|
// rtcSession.isJoined is false.
|
||||||
|
void hostBridge.setAlwaysOnScreen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
return (
|
return (
|
||||||
muteStates && (
|
muteStates && (
|
||||||
<GroupCallView
|
<GroupCallView
|
||||||
widget={widget}
|
|
||||||
client={client!}
|
client={client!}
|
||||||
rtcSession={groupCallState.rtcSession}
|
rtcSession={groupCallState.rtcSession}
|
||||||
joined={joined}
|
joined={joined}
|
||||||
@@ -198,7 +197,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
<ErrorView
|
<ErrorView
|
||||||
Icon={UnknownSolidIcon}
|
Icon={UnknownSolidIcon}
|
||||||
title={t("error.call_not_found")}
|
title={t("error.call_not_found")}
|
||||||
widget={widget}
|
|
||||||
>
|
>
|
||||||
<Trans i18nKey="error.call_not_found_description">
|
<Trans i18nKey="error.call_not_found_description">
|
||||||
<p>
|
<p>
|
||||||
@@ -216,7 +214,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
<ErrorView
|
<ErrorView
|
||||||
Icon={groupCallState.error.icon}
|
Icon={groupCallState.error.icon}
|
||||||
title={groupCallState.error.message}
|
title={groupCallState.error.message}
|
||||||
widget={widget}
|
|
||||||
>
|
>
|
||||||
<p>{groupCallState.error.messageBody}</p>
|
<p>{groupCallState.error.messageBody}</p>
|
||||||
{groupCallState.error.reason && (
|
{groupCallState.error.reason && (
|
||||||
@@ -230,7 +227,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <ErrorPage widget={widget} error={groupCallState.error} />;
|
return <ErrorPage error={groupCallState.error} />;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return <> </>;
|
return <> </>;
|
||||||
@@ -238,7 +235,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading || isRegistering) return <LoadingPage />;
|
if (loading || isRegistering) return <LoadingPage />;
|
||||||
if (error) return <ErrorPage widget={widget} error={error} />;
|
if (error) return <ErrorPage error={error} />;
|
||||||
if (!client) return <RoomAuthView />;
|
if (!client) return <RoomAuthView />;
|
||||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||||
if (!roomIdOrAlias) return <HomePage />;
|
if (!roomIdOrAlias) return <HomePage />;
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
|||||||
</DocumentFragment>
|
</DocumentFragment>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`should have a close button in widget mode 1`] = `
|
exports[`should have a close button when the host can dismiss us 1`] = `
|
||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
<div
|
<div
|
||||||
class="_page_4be5c0"
|
class="_page_4be5c0"
|
||||||
|
|||||||
+22
-21
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { createElement, type FC, type PropsWithChildren } from "react";
|
||||||
|
import { Subject } from "rxjs";
|
||||||
import {
|
import {
|
||||||
afterEach,
|
afterEach,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
@@ -15,24 +17,28 @@ import {
|
|||||||
test,
|
test,
|
||||||
vi,
|
vi,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import EventEmitter from "events";
|
|
||||||
import { WidgetApiToWidgetAction } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { useTheme } from "./useTheme";
|
import { useTheme } from "./useTheme";
|
||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import { widget } from "./widget";
|
import {
|
||||||
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
type HostRequest,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "./HostBridge";
|
||||||
|
|
||||||
vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() }));
|
vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() }));
|
||||||
vi.mock("./widget", () => ({
|
|
||||||
widget: {
|
|
||||||
api: { transport: { reply: vi.fn() } },
|
|
||||||
lazyActions: new EventEmitter(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("useTheme", () => {
|
describe("useTheme", () => {
|
||||||
let originalClassList: DOMTokenList;
|
let originalClassList: DOMTokenList;
|
||||||
|
let themeChange$: Subject<HostRequest<{ name?: string }>>;
|
||||||
|
let wrapper: FC<PropsWithChildren>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
themeChange$ = new Subject();
|
||||||
|
const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ };
|
||||||
|
wrapper = ({ children }) =>
|
||||||
|
createElement(HostBridgeProvider, { value: hostBridge }, children);
|
||||||
// Save the original classList to setup spies
|
// Save the original classList to setup spies
|
||||||
originalClassList = document.body.classList;
|
originalClassList = document.body.classList;
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ describe("useTheme", () => {
|
|||||||
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
|
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
|
||||||
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
|
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
|
||||||
|
|
||||||
renderHook(() => useTheme());
|
renderHook(() => useTheme(), { wrapper });
|
||||||
|
|
||||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||||
"cpd-theme-light",
|
"cpd-theme-light",
|
||||||
@@ -71,7 +77,7 @@ describe("useTheme", () => {
|
|||||||
// Simulate a previous theme
|
// Simulate a previous theme
|
||||||
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
|
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
|
||||||
|
|
||||||
renderHook(() => useTheme());
|
renderHook(() => useTheme(), { wrapper });
|
||||||
|
|
||||||
expect(document.body.classList.add).not.toHaveBeenCalledWith(
|
expect(document.body.classList.add).not.toHaveBeenCalledWith(
|
||||||
"cpd-theme-dark",
|
"cpd-theme-dark",
|
||||||
@@ -82,18 +88,13 @@ describe("useTheme", () => {
|
|||||||
expect(originalClassList.add).not.toHaveBeenCalled();
|
expect(originalClassList.add).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("theme changes in response to widget actions", async () => {
|
test("theme changes in response to host requests", () => {
|
||||||
renderHook(() => useTheme());
|
renderHook(() => useTheme(), { wrapper });
|
||||||
|
|
||||||
expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark");
|
expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark");
|
||||||
await act(() =>
|
const reply = vi.fn();
|
||||||
widget!.lazyActions.emit(
|
act(() => themeChange$.next({ data: { name: "light" }, reply }));
|
||||||
WidgetApiToWidgetAction.ThemeChange,
|
expect(reply).toHaveBeenCalledOnce();
|
||||||
new CustomEvent(WidgetApiToWidgetAction.ThemeChange, {
|
|
||||||
detail: { data: { name: "light" } },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||||
"cpd-theme-light",
|
"cpd-theme-light",
|
||||||
"cpd-theme-dark",
|
"cpd-theme-dark",
|
||||||
|
|||||||
+10
-22
@@ -6,39 +6,27 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { WidgetApiToWidgetAction } from "matrix-widget-api";
|
|
||||||
import { type IThemeChangeActionRequest } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import { widget } from "./widget";
|
|
||||||
import { useRootElement } from "./RootElementContext";
|
import { useRootElement } from "./RootElementContext";
|
||||||
|
import { useHostBridge } from "./HostBridge";
|
||||||
|
|
||||||
export const useTheme = (): void => {
|
export const useTheme = (): void => {
|
||||||
const rootElement = useRootElement();
|
const rootElement = useRootElement();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
const { theme } = useUrlParams();
|
const { theme } = useUrlParams();
|
||||||
const [requestedTheme, setRequestedTheme] = useState(theme);
|
const [requestedTheme, setRequestedTheme] = useState(theme);
|
||||||
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
|
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget) {
|
const subscription = hostBridge.themeChange$.subscribe(
|
||||||
const onThemeChange = (
|
({ data, reply }) => {
|
||||||
ev: CustomEvent<IThemeChangeActionRequest>,
|
if (typeof data.name === "string") setRequestedTheme(data.name);
|
||||||
): void => {
|
reply();
|
||||||
ev.preventDefault();
|
},
|
||||||
if ("name" in ev.detail.data && typeof ev.detail.data.name === "string")
|
);
|
||||||
setRequestedTheme(ev.detail.data.name);
|
return (): void => subscription.unsubscribe();
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
}, [hostBridge]);
|
||||||
};
|
|
||||||
|
|
||||||
widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange);
|
|
||||||
return (): void => {
|
|
||||||
widget!.lazyActions.off(
|
|
||||||
WidgetApiToWidgetAction.ThemeChange,
|
|
||||||
onThemeChange,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// If no theme has been explicitly requested we default to dark
|
// If no theme has been explicitly requested we default to dark
|
||||||
|
|||||||
Reference in New Issue
Block a user