merge: upstream Element Call v0.28.0 (cinny #201) #61
+28
-15
@@ -33,6 +33,12 @@ import {
|
||||
import { AppBar } from "./AppBar";
|
||||
import { i18n } from "./utils/i18n";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { useInitial } from "./useInitial";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
@@ -78,13 +84,18 @@ interface Props {
|
||||
}
|
||||
|
||||
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);
|
||||
useEffect(() => {
|
||||
Initializer.init()
|
||||
?.then(async () => {
|
||||
if (loaded) return;
|
||||
setLoaded(true);
|
||||
await widget?.api.sendContentLoaded();
|
||||
await hostBridge.contentLoaded();
|
||||
})
|
||||
.catch(logger.error);
|
||||
});
|
||||
@@ -94,7 +105,7 @@ export const App: FC<Props> = ({ vm }) => {
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
@@ -112,19 +123,21 @@ export const App: FC<Props> = ({ vm }) => {
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BrowserRouter>
|
||||
<LocationUrlParamsProvider>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
<MaybeAppBar>{content}</MaybeAppBar>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</LocationUrlParamsProvider>
|
||||
</BrowserRouter>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<BrowserRouter>
|
||||
<LocationUrlParamsProvider>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
<MaybeAppBar>{content}</MaybeAppBar>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</LocationUrlParamsProvider>
|
||||
</BrowserRouter>
|
||||
</HostBridgeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+14
-49
@@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import { widget } from "./widget";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
|
||||
const TestComponent: FC<
|
||||
PropsWithChildren<{
|
||||
client: MatrixClient;
|
||||
hostBridge?: HostBridge;
|
||||
}>
|
||||
> = ({ client, children }) => {
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
@@ -38,17 +42,11 @@ const TestComponent: FC<
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</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(() => {
|
||||
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 expectedObjectURL = "my-object-url";
|
||||
const theBlob = new Blob([]);
|
||||
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
getAccessToken: () => undefined,
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
||||
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
|
||||
const member = mockMatrixRoomMember(
|
||||
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";
|
||||
render(
|
||||
<TestComponent client={client}>
|
||||
<TestComponent client={client} hostBridge={hostBridge}>
|
||||
<Avatar
|
||||
id={member.userId}
|
||||
name={displayName}
|
||||
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||
);
|
||||
|
||||
expect(widget!.api.downloadFile).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);
|
||||
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
+7
-27
@@ -14,10 +14,9 @@ import {
|
||||
} from "react";
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
XS = "xs",
|
||||
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const clientState = useClientState();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
() =>
|
||||
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
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(() => {
|
||||
if (!src) {
|
||||
setAvatarUrl(undefined);
|
||||
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
let blob: Promise<Blob>;
|
||||
|
||||
if (widget?.api) {
|
||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
|
||||
|
||||
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]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
|
||||
+13
-19
@@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton";
|
||||
import styles from "./ErrorView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { LinkButton } from "./button";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
||||
import { useHostBridge } from "./HostBridge.ts";
|
||||
|
||||
interface Props {
|
||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
@@ -38,7 +38,6 @@ interface Props {
|
||||
*/
|
||||
fatal?: boolean;
|
||||
children: ReactNode;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const ErrorView: FC<Props> = ({
|
||||
@@ -47,32 +46,27 @@ export const ErrorView: FC<Props> = ({
|
||||
rageshake,
|
||||
fatal,
|
||||
children,
|
||||
widget,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
||||
widget,
|
||||
const CloseButton: FC<{ close: () => Promise<void> }> = ({
|
||||
close,
|
||||
}): ReactElement => {
|
||||
// in widget mode we don't want to show the return home button but a close button
|
||||
const closeWidget = (): void => {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to send close action", e);
|
||||
})
|
||||
.finally(() => {
|
||||
widget.api.transport.stop();
|
||||
});
|
||||
// When the host can dismiss us, offer that instead of a link home
|
||||
const onClose = (): void => {
|
||||
close().catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button kind="primary" onClick={closeWidget}>
|
||||
<Button kind="primary" onClick={onClose}>
|
||||
{t("action.close")}
|
||||
</Button>
|
||||
);
|
||||
@@ -108,8 +102,8 @@ export const ErrorView: FC<Props> = ({
|
||||
{rageshake && (
|
||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||
)}
|
||||
{widget ? (
|
||||
<CloseWidgetButton widget={widget} />
|
||||
{hostBridge.close ? (
|
||||
<CloseButton close={hostBridge.close} />
|
||||
) : (
|
||||
!confineToRoom && <ReturnToHomeButton />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { RichError } from "./RichError";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { type WidgetHelpers } from "./widget.ts";
|
||||
|
||||
interface FullScreenViewProps {
|
||||
className?: string;
|
||||
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: unknown;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
// 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>
|
||||
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
logger.error(error);
|
||||
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
error.richMessage
|
||||
) : (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={ErrorSolidIcon}
|
||||
title={t("error.generic")}
|
||||
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 { ErrorView } from "./ErrorView";
|
||||
import { widget } from "./widget.ts";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={PopOutIcon}
|
||||
title={t("error.open_elsewhere")}
|
||||
>
|
||||
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||
<p>
|
||||
{t("error.open_elsewhere_description", {
|
||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { widget } from "../widget.ts";
|
||||
|
||||
export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge.ts";
|
||||
|
||||
test.each([
|
||||
{
|
||||
@@ -79,7 +83,6 @@ test.each([
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () =
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={reconnectCallback}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
|
||||
<TestComponent fail={failState} />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>
|
||||
@@ -199,7 +198,6 @@ describe("Rageshake button", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</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 TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const mockWidget = {
|
||||
api: {
|
||||
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
|
||||
},
|
||||
} as unknown as WidgetHelpers;
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close };
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
widget={mockWidget}
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</HostBridgeProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
@@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
@@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "../utils/errors.ts";
|
||||
import { FullScreenView } from "../FullScreenView.tsx";
|
||||
import { ErrorView } from "../ErrorView.tsx";
|
||||
import { type WidgetHelpers } from "../widget.ts";
|
||||
import styles from "../ErrorView.module.css";
|
||||
|
||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||
@@ -47,13 +46,11 @@ interface ErrorPageProps {
|
||||
error: ElementCallError;
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
resetError: () => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
const ErrorPage: FC<ErrorPageProps> = ({
|
||||
error,
|
||||
recoveryActionHandler,
|
||||
widget,
|
||||
}: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
logger.error("Error boundary caught:", error);
|
||||
@@ -89,7 +86,6 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
Icon={icon}
|
||||
title={error.localisedTitle}
|
||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||
widget={widget}
|
||||
>
|
||||
<p>
|
||||
{error.localisedMessageKey ? (
|
||||
@@ -148,14 +144,12 @@ interface BoundaryProps {
|
||||
children: ReactNode | (() => ReactNode);
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
onError?: (error: unknown) => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallErrorBoundary = ({
|
||||
recoveryActionHandler,
|
||||
onError,
|
||||
children,
|
||||
widget,
|
||||
}: BoundaryProps): ReactElement => {
|
||||
const fallbackRenderer: FallbackRender = useCallback(
|
||||
({ error, resetError }): ReactElement => {
|
||||
@@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
error={callError}
|
||||
resetError={resetError}
|
||||
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
||||
@@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[recoveryActionHandler, widget],
|
||||
[recoveryActionHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,7 +36,6 @@ import userEvent, {
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type ITransport } from "matrix-widget-api";
|
||||
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
@@ -52,8 +51,11 @@ import {
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
@@ -134,7 +136,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
hostBridge: HostBridge,
|
||||
joined = true,
|
||||
options: {
|
||||
withErrorBoundary?: boolean;
|
||||
@@ -184,7 +186,6 @@ function createGroupCallView(
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
@@ -192,22 +193,21 @@ function createGroupCallView(
|
||||
);
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
</HostBridgeProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
@@ -218,7 +218,7 @@ function createGroupCallView(
|
||||
|
||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText, rtcSession } = createGroupCallView(null);
|
||||
const { getByText, rtcSession } = createGroupCallView(nullHostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: async () => Promise.resolve(true),
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
||||
let resolvePlaySound: () => void;
|
||||
playSound = vi
|
||||
.fn()
|
||||
@@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText, rtcSession } = createGroupCallView(
|
||||
widget as WidgetHelpers,
|
||||
);
|
||||
const { getByText, rtcSession } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
@@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
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();
|
||||
let widgetClosedCalled = false;
|
||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
||||
Promise.withResolvers<void>();
|
||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
||||
if (action === ElementWidgetActions.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 close = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = {
|
||||
...nullHostBridge,
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
|
||||
close,
|
||||
};
|
||||
const resolvePlaySound = Promise.withResolvers<void>();
|
||||
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
||||
@@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => {
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateOtherLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeFalsy();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
resolvePlaySound.resolve();
|
||||
|
||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||
await widgetClosedPromise;
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeTruthy();
|
||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
}, 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 widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widgetSendMock = 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 close = vi.fn().mockResolvedValue(undefined);
|
||||
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = {
|
||||
...nullHostBridge,
|
||||
setAlwaysOnScreen,
|
||||
close,
|
||||
};
|
||||
|
||||
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateErrorLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
|
||||
// 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();
|
||||
// But then we do not close the widget automatically
|
||||
expect(widgetStopMock).not.toHaveBeenCalledOnce();
|
||||
expect(widgetSendMock).not.toHaveBeenCalledOnce();
|
||||
// But then we do not ask to be closed automatically
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 { rtcSession } = createGroupCallView(null);
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge);
|
||||
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
||||
screen.getByText("Something went wrong");
|
||||
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||
@@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
onTestFinished(() => {
|
||||
enterRTCSession.mockReset();
|
||||
});
|
||||
createGroupCallView(null, false);
|
||||
createGroupCallView(nullHostBridge, false);
|
||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
@@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
{ cause: stickyError },
|
||||
);
|
||||
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, 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 () => {
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, 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 () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null, true);
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true);
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||
);
|
||||
|
||||
+56
-64
@@ -30,12 +30,7 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "../widget";
|
||||
import { type JoinCallData } from "../widget";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
@@ -76,6 +71,7 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { useRootElement } from "../RootElementContext.ts";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@@ -99,7 +95,6 @@ interface Props {
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
muteStates: MuteStates;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
@@ -112,7 +107,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
joined,
|
||||
setJoined,
|
||||
muteStates,
|
||||
widget,
|
||||
}) => {
|
||||
// Used to thread through any errors that occur outside the error boundary
|
||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||
@@ -120,6 +114,14 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
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 leaveSoundContext = useLatest(
|
||||
@@ -294,28 +296,26 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
|
||||
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
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await defaultDeviceSetup(data);
|
||||
setJoined(true);
|
||||
widget.api.transport.reply(ev.detail, {});
|
||||
reply();
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session on preload", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
setJoined(true);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
widget,
|
||||
hostBridge,
|
||||
rtcSession,
|
||||
preload,
|
||||
skipLobby,
|
||||
@@ -341,7 +341,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
||||
// 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.
|
||||
// (used to destory the widget)
|
||||
// (which is what delays asking the host to close us)
|
||||
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
||||
break;
|
||||
case "timeout":
|
||||
@@ -356,12 +356,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
setLeft(true);
|
||||
|
||||
// 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) => {
|
||||
// To increase the likelihood of the PostHog event being sent out in
|
||||
// widget mode before the iframe is killed, we ask it to skip the
|
||||
// usual queuing/batching of requests.
|
||||
const sendInstantly = widget !== null;
|
||||
// To increase the likelihood of the PostHog event being sent out
|
||||
// before the host disposes of us, we ask it to skip the usual
|
||||
// queuing/batching of requests.
|
||||
const sendInstantly = hostControlsLifetime;
|
||||
PosthogAnalytics.instance.eventCallEnded.track(
|
||||
room.roomId,
|
||||
rtcSession.memberships.length,
|
||||
@@ -369,8 +369,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession,
|
||||
);
|
||||
// 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
|
||||
// closing of the widget that long anyway, so giving it 10 ms will do.
|
||||
// tracking of an event, but we don't really want it to hold up our
|
||||
// disposal that long anyway, so giving it 10 ms will do.
|
||||
window.setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
@@ -389,25 +389,19 @@ export const GroupCallView: FC<Props> = ({
|
||||
)
|
||||
void navigate("/");
|
||||
|
||||
if (widget) {
|
||||
// After this point the iframe could die at any moment!
|
||||
// After this point the host could dispose of us 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 {
|
||||
await widget.api.setAlwaysOnScreen(false);
|
||||
await hostBridge.close?.();
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
"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();
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -415,7 +409,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
[
|
||||
setJoined,
|
||||
leaveSoundContext,
|
||||
widget,
|
||||
hostBridge,
|
||||
hostControlsLifetime,
|
||||
room.roomId,
|
||||
rtcSession,
|
||||
isPasswordlessUser,
|
||||
@@ -426,12 +421,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget && joined)
|
||||
// set widget to sticky once joined.
|
||||
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
||||
if (joined)
|
||||
// ask to be kept on screen once joined.
|
||||
hostBridge.setAlwaysOnScreen(true).catch((e) => {
|
||||
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
||||
});
|
||||
}, [widget, joined, rtcSession]);
|
||||
}, [hostBridge, joined, rtcSession]);
|
||||
|
||||
const joinRule = useJoinRule(room);
|
||||
|
||||
@@ -501,19 +496,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (left && widget === null) {
|
||||
// Left in SPA mode:
|
||||
} else if (left && !hostControlsLifetime) {
|
||||
// Left, and it is up to us what to show next:
|
||||
|
||||
// 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
|
||||
// feedback. We don't show a feedback prompt to widget users however (at
|
||||
// least for now), because we don't yet have designs that would allow widget
|
||||
// users to dismiss the feedback prompt and close the call window without
|
||||
// submitting anything.
|
||||
if (
|
||||
isPasswordlessUser ||
|
||||
(PosthogAnalytics.instance.isEnabled() && widget === null)
|
||||
) {
|
||||
// feedback. We don't show a feedback prompt when a host owns our lifetime
|
||||
// however (at least for now), because we don't yet have designs that would
|
||||
// allow those users to dismiss the feedback prompt and close the call
|
||||
// window without submitting anything.
|
||||
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
|
||||
body = (
|
||||
<CallEndedView
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
@@ -529,8 +521,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
// LobbyView again which would open capture devices again.
|
||||
body = null;
|
||||
}
|
||||
} else if (left && widget !== null) {
|
||||
// Left in widget mode:
|
||||
} else if (left && hostControlsLifetime) {
|
||||
// Left, and the host decides what happens next:
|
||||
body = returnToLobby ? lobbyView : null;
|
||||
} else if (preload || skipLobby) {
|
||||
// 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 (
|
||||
<GroupCallErrorBoundary
|
||||
widget={widget}
|
||||
recoveryActionHandler={async (action) => {
|
||||
setExternalError(null);
|
||||
if (action == "reconnect") {
|
||||
@@ -553,9 +544,10 @@ export const GroupCallView: FC<Props> = ({
|
||||
}}
|
||||
onError={(_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
|
||||
// We need it here explicitly in case rtcSession.isJoined is false.
|
||||
void widget?.api.setAlwaysOnScreen(false);
|
||||
// If there is an error we need to be dismissible again. This is done in
|
||||
// `onLeft` as well; we need it here explicitly in case
|
||||
// rtcSession.isJoined is false.
|
||||
void hostBridge.setAlwaysOnScreen(false);
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -123,7 +123,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
return (
|
||||
muteStates && (
|
||||
<GroupCallView
|
||||
widget={widget}
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
joined={joined}
|
||||
@@ -198,7 +197,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
<ErrorView
|
||||
Icon={UnknownSolidIcon}
|
||||
title={t("error.call_not_found")}
|
||||
widget={widget}
|
||||
>
|
||||
<Trans i18nKey="error.call_not_found_description">
|
||||
<p>
|
||||
@@ -216,7 +214,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
<ErrorView
|
||||
Icon={groupCallState.error.icon}
|
||||
title={groupCallState.error.message}
|
||||
widget={widget}
|
||||
>
|
||||
<p>{groupCallState.error.messageBody}</p>
|
||||
{groupCallState.error.reason && (
|
||||
@@ -230,7 +227,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorPage widget={widget} error={groupCallState.error} />;
|
||||
return <ErrorPage error={groupCallState.error} />;
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
@@ -238,7 +235,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
};
|
||||
|
||||
if (loading || isRegistering) return <LoadingPage />;
|
||||
if (error) return <ErrorPage widget={widget} error={error} />;
|
||||
if (error) return <ErrorPage error={error} />;
|
||||
if (!client) return <RoomAuthView />;
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
if (!roomIdOrAlias) return <HomePage />;
|
||||
|
||||
@@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
||||
</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>
|
||||
<div
|
||||
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 { createElement, type FC, type PropsWithChildren } from "react";
|
||||
import { Subject } from "rxjs";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
@@ -15,24 +17,28 @@ import {
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import EventEmitter from "events";
|
||||
import { WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
|
||||
import { useTheme } from "./useTheme";
|
||||
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("./widget", () => ({
|
||||
widget: {
|
||||
api: { transport: { reply: vi.fn() } },
|
||||
lazyActions: new EventEmitter(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useTheme", () => {
|
||||
let originalClassList: DOMTokenList;
|
||||
let themeChange$: Subject<HostRequest<{ name?: string }>>;
|
||||
let wrapper: FC<PropsWithChildren>;
|
||||
|
||||
beforeEach(() => {
|
||||
themeChange$ = new Subject();
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ };
|
||||
wrapper = ({ children }) =>
|
||||
createElement(HostBridgeProvider, { value: hostBridge }, children);
|
||||
// Save the original classList to setup spies
|
||||
originalClassList = document.body.classList;
|
||||
|
||||
@@ -55,7 +61,7 @@ describe("useTheme", () => {
|
||||
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
|
||||
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
|
||||
|
||||
renderHook(() => useTheme());
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||
"cpd-theme-light",
|
||||
@@ -71,7 +77,7 @@ describe("useTheme", () => {
|
||||
// Simulate a previous theme
|
||||
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
|
||||
|
||||
renderHook(() => useTheme());
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(document.body.classList.add).not.toHaveBeenCalledWith(
|
||||
"cpd-theme-dark",
|
||||
@@ -82,18 +88,13 @@ describe("useTheme", () => {
|
||||
expect(originalClassList.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("theme changes in response to widget actions", async () => {
|
||||
renderHook(() => useTheme());
|
||||
test("theme changes in response to host requests", () => {
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark");
|
||||
await act(() =>
|
||||
widget!.lazyActions.emit(
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
new CustomEvent(WidgetApiToWidgetAction.ThemeChange, {
|
||||
detail: { data: { name: "light" } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const reply = vi.fn();
|
||||
act(() => themeChange$.next({ data: { name: "light" }, reply }));
|
||||
expect(reply).toHaveBeenCalledOnce();
|
||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||
"cpd-theme-light",
|
||||
"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 { WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
import { type IThemeChangeActionRequest } from "matrix-widget-api";
|
||||
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { widget } from "./widget";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export const useTheme = (): void => {
|
||||
const rootElement = useRootElement();
|
||||
const hostBridge = useHostBridge();
|
||||
const { theme } = useUrlParams();
|
||||
const [requestedTheme, setRequestedTheme] = useState(theme);
|
||||
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onThemeChange = (
|
||||
ev: CustomEvent<IThemeChangeActionRequest>,
|
||||
): void => {
|
||||
ev.preventDefault();
|
||||
if ("name" in ev.detail.data && typeof ev.detail.data.name === "string")
|
||||
setRequestedTheme(ev.detail.data.name);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
onThemeChange,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
const subscription = hostBridge.themeChange$.subscribe(
|
||||
({ data, reply }) => {
|
||||
if (typeof data.name === "string") setRequestedTheme(data.name);
|
||||
reply();
|
||||
},
|
||||
);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [hostBridge]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// If no theme has been explicitly requested we default to dark
|
||||
|
||||
Reference in New Issue
Block a user