From 005b965fba58556f4a637e2829a219937883f9f2 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Sat, 7 Mar 2026 19:44:00 -0600 Subject: [PATCH 1/7] Download avatars using the Widget API If we can't authenticate media because we're running as a widget, use the MC4039 widget API instead of a direct fetch to download the avatar. --- src/Avatar.test.tsx | 51 +++++++++++++++++++++++++ src/Avatar.tsx | 93 ++++++++++++++++++++++++++++++++++----------- src/widget.ts | 1 + 3 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index a02963e0..1ad0f4be 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -13,6 +13,8 @@ import { type FC, type PropsWithChildren } from "react"; import { ClientContextProvider } from "./ClientContext"; import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; +import EventEmitter from "events"; +import { widget } from "./widget"; const TestComponent: FC< PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }> @@ -40,6 +42,12 @@ const TestComponent: FC< ); }; +vi.mock("./widget", () => ({ + widget: { + api: { downloadFile: vi.fn() }, + }, +})); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -154,3 +162,46 @@ test("should attempt to fetch authenticated media", async () => { headers: { Authorization: `Bearer ${accessToken}` }, }); }); + +test("should use widget API when unable to authenticate media", async () => { + const expectedMXCUrl = "mxc://example.org/alice-avatar"; + const expectedObjectURL = "my-object-url"; + const theBlob = new Blob([]); + + // vitest doesn't have a implementation of create/revokeObjectURL, so we need + // to delete the property. It's a bit odd, but it works. + Reflect.deleteProperty(global.window.URL, "createObjectURL"); + globalThis.URL.createObjectURL = vi.fn().mockReturnValue(expectedObjectURL); + Reflect.deleteProperty(global.window.URL, "revokeObjectURL"); + globalThis.URL.revokeObjectURL = vi.fn(); + + const client = vi.mocked({ + getAccessToken: () => undefined, + } as unknown as MatrixClient); + + vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); + const member = mockMatrixRoomMember( + mockRtcMembership("@alice:example.org", "AAAA"), + { + getMxcAvatarUrl: () => expectedMXCUrl, + }, + ); + const displayName = "Alice"; + render( + + + , + ); + + // Fetch is asynchronous, so wait for this to resolve. + await vi.waitUntil(() => + document.querySelector(`img[src='${expectedObjectURL}']`), + ); + + expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl); +}); diff --git a/src/Avatar.tsx b/src/Avatar.tsx index d862dbb1..7eb941b0 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -15,7 +15,9 @@ import { import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; -import { useClientState } from "./ClientContext"; +import { ClientState, useClientState } from "./ClientContext"; +import { widget } from "./widget"; +import { WidgetApi } from "matrix-widget-api"; export enum Size { XS = "xs", @@ -86,33 +88,17 @@ export const Avatar: FC = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); useEffect(() => { - if (clientState?.state !== "valid") { - return; - } - const { authenticated, supportedFeatures } = clientState; - const client = authenticated?.client; - - if (!client || !src || !sizePx || !supportedFeatures.thumbnails) { - return; - } - - const token = client.getAccessToken(); - if (!token) { - return; - } - const resolveSrc = getAvatarUrl(client, src, sizePx); - if (!resolveSrc) { + if (!src) { setAvatarUrl(undefined); return; } let objectUrl: string | undefined; - fetch(resolveSrc, { - headers: { - Authorization: `Bearer ${token}`, - }, - }) - .then(async (req) => req.blob()) + + getAvatarFromServer(clientState, src, sizePx) // Try to download directly from the mxc:// + .catch((ex) => { + return getAvatarFromWidget(widget?.api, src); // Fallback to trying to use the MSC4039 Widget API + }) .then((blob) => { objectUrl = URL.createObjectURL(blob); setAvatarUrl(objectUrl); @@ -140,3 +126,64 @@ export const Avatar: FC = ({ /> ); }; + +async function getAvatarFromServer( + clientState: ClientState | undefined, + src: string, + sizePx: number | undefined, +): Promise { + if (clientState?.state !== "valid") { + throw new Error("Client state must be valid"); + } + if (!sizePx) { + throw new Error("size must be supplied"); + } + + const { authenticated, supportedFeatures } = clientState; + const client = authenticated?.client; + if (!client) { + throw new Error("Client must be supplied"); + } + if (!supportedFeatures.thumbnails) { + throw new Error("Thumbnails are not supported"); + } + + const httpSrc = getAvatarUrl(client, src, sizePx); + if (!httpSrc) { + throw new Error("Failed to get http avatar URL"); + } + + const token = client.getAccessToken(); + if (!token) { + throw new Error("Failed to get access token"); + } + + const request = await fetch(httpSrc, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const blob = await request.blob(); + + return blob; +} + +async function getAvatarFromWidget( + api: WidgetApi | undefined, + src: string, +): Promise { + if (!api) { + throw new Error("No widget api given"); + } + + 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)) { + throw new Error("Downloaded file is not a Blob"); + } + + return file; +} diff --git a/src/widget.ts b/src/widget.ts index 321727f6..2ec76e15 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -93,6 +93,7 @@ export const initializeWidget = ( logger.info("Widget API is available"); const api = new WidgetApi(widgetId, parentOrigin); api.requestCapability(MatrixCapabilities.AlwaysOnScreen); + api.requestCapability(MatrixCapabilities.MSC4039DownloadFile); // Set up the lazy action emitter, but only for select actions that we // intend for the app to handle From 699e31f59a00c358f0a9fe6b68ac2fa19c909a45 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Mon, 9 Mar 2026 22:25:54 -0500 Subject: [PATCH 2/7] Download Avatar from relevent source Instead of relying on failures directly use the available method to download the avatar. --- src/Avatar.test.tsx | 50 ++++++--------------- src/Avatar.tsx | 56 ++++++++++-------------- src/ClientContext.tsx | 15 ++++--- src/settings/useSubmitRageshake.test.tsx | 2 +- 4 files changed, 47 insertions(+), 76 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 1ad0f4be..3d75d4c1 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -17,8 +17,15 @@ import EventEmitter from "events"; import { widget } from "./widget"; const TestComponent: FC< - PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }> -> = ({ client, children, supportsThumbnails }) => { + PropsWithChildren<{ + client: MatrixClient; + supportsAuthenticatedMedia?: boolean; + }> +> = ({ + client, + children, + supportsAuthenticatedMedia: supportsAuthenticatedMedia, +}) => { return ( { expect(client.mxcUrlToHttp).toBeCalledTimes(0); }); -test("should just render a placeholder when thumbnails are not supported", () => { - const client = vi.mocked({ - getAccessToken: () => "my-access-token", - mxcUrlToHttp: () => vi.fn(), - } as unknown as MatrixClient); - - vi.spyOn(client, "mxcUrlToHttp"); - const member = mockMatrixRoomMember( - mockRtcMembership("@alice:example.org", "AAAA"), - { - getMxcAvatarUrl: () => "mxc://example.org/alice-avatar", - }, - ); - const displayName = "Alice"; - render( - - - , - ); - const element = screen.getByRole("img", { name: "@alice:example.org" }); - expect(element.tagName).toEqual("SPAN"); - expect(client.mxcUrlToHttp).toBeCalledTimes(0); -}); - -test("should attempt to fetch authenticated media", async () => { +test("should attempt to fetch authenticated media if supported", async () => { const expectedAuthUrl = "http://example.org/media/alice-avatar"; const expectedObjectURL = "my-object-url"; const accessToken = "my-access-token"; @@ -142,7 +120,7 @@ test("should attempt to fetch authenticated media", async () => { ); const displayName = "Alice"; render( - + { }); }); -test("should use widget API when unable to authenticate media", async () => { +test("should attempt to use widget API if authenticate media is not supported", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -188,7 +166,7 @@ test("should use widget API when unable to authenticate media", async () => { ); const displayName = "Alice"; render( - + = ({ const sizePx = useMemo( () => Object.values(Size).includes(size as Size) - ? sizes.get(size as Size) + ? sizes.get(size as Size)! : (size as number), [size], ); @@ -88,17 +88,29 @@ export const Avatar: FC = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); useEffect(() => { - if (!src) { + if (!src || clientState?.state !== "valid") { + setAvatarUrl(undefined); + return; + } + + const { authenticated, supportedFeatures } = clientState; + let blob: Promise; + + if ( + supportedFeatures.authenticatedMedia && + authenticated?.client && + sizePx + ) { + blob = getAvatarFromServer(authenticated.client, src, sizePx); + } else if (widget?.api) { + blob = getAvatarFromWidgetAPI(widget.api, src); + } else { setAvatarUrl(undefined); return; } let objectUrl: string | undefined; - - getAvatarFromServer(clientState, src, sizePx) // Try to download directly from the mxc:// - .catch((ex) => { - return getAvatarFromWidget(widget?.api, src); // Fallback to trying to use the MSC4039 Widget API - }) + blob .then((blob) => { objectUrl = URL.createObjectURL(blob); setAvatarUrl(objectUrl); @@ -128,26 +140,10 @@ export const Avatar: FC = ({ }; async function getAvatarFromServer( - clientState: ClientState | undefined, + client: MatrixClient, src: string, - sizePx: number | undefined, + sizePx: number, ): Promise { - if (clientState?.state !== "valid") { - throw new Error("Client state must be valid"); - } - if (!sizePx) { - throw new Error("size must be supplied"); - } - - const { authenticated, supportedFeatures } = clientState; - const client = authenticated?.client; - if (!client) { - throw new Error("Client must be supplied"); - } - if (!supportedFeatures.thumbnails) { - throw new Error("Thumbnails are not supported"); - } - const httpSrc = getAvatarUrl(client, src, sizePx); if (!httpSrc) { throw new Error("Failed to get http avatar URL"); @@ -169,14 +165,10 @@ async function getAvatarFromServer( return blob; } -async function getAvatarFromWidget( - api: WidgetApi | undefined, +async function getAvatarFromWidgetAPI( + api: WidgetApi, src: string, ): Promise { - if (!api) { - throw new Error("No widget api given"); - } - const response = await api.downloadFile(src); const file = response.file; diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 1488965a..7059cd69 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -48,7 +48,7 @@ export type ValidClientState = { disconnected: boolean; supportedFeatures: { reactions: boolean; - thumbnails: boolean; + authenticatedMedia: boolean; }; setClient: (client: MatrixClient, session: Session) => void; }; @@ -249,7 +249,8 @@ export const ClientProvider: FC = ({ children }) => { const [isDisconnected, setIsDisconnected] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false); - const [supportsThumbnails, setSupportsThumbnails] = useState(false); + const [supportsAuthenticatedMedia, setSupportsAuthenticatedMedia] = + useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -275,7 +276,7 @@ export const ClientProvider: FC = ({ children }) => { disconnected: isDisconnected, supportedFeatures: { reactions: supportsReactions, - thumbnails: supportsThumbnails, + authenticatedMedia: supportsAuthenticatedMedia, }, }; }, [ @@ -286,7 +287,7 @@ export const ClientProvider: FC = ({ children }) => { setClient, isDisconnected, supportsReactions, - supportsThumbnails, + supportsAuthenticatedMedia, ]); const onSync = useCallback( @@ -312,8 +313,8 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - // There is currently no widget API for authenticated media thumbnails. - setSupportsThumbnails(false); + // There is currently no way for widgets to request authenticated media directly from the server. + setSupportsAuthenticatedMedia(false); const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -335,7 +336,7 @@ export const ClientProvider: FC = ({ children }) => { } } else { setSupportsReactions(true); - setSupportsThumbnails(true); + setSupportsAuthenticatedMedia(true); } return (): void => { diff --git a/src/settings/useSubmitRageshake.test.tsx b/src/settings/useSubmitRageshake.test.tsx index b278d4b1..d69255e0 100644 --- a/src/settings/useSubmitRageshake.test.tsx +++ b/src/settings/useSubmitRageshake.test.tsx @@ -78,7 +78,7 @@ function renderWithMockClient( disconnected: false, supportedFeatures: { reactions: true, - thumbnails: true, + authenticatedMedia: true, }, setClient: vi.fn(), authenticated: { From 8ecb1b3dbf2dacc95ea20a731b6367ab81e510aa Mon Sep 17 00:00:00 2001 From: JephDiel Date: Thu, 12 Mar 2026 22:18:38 -0500 Subject: [PATCH 3/7] Simplify widget detection Use the exists check for the widget API directly instead of a feature flag. --- src/Avatar.test.tsx | 21 ++++++++------------- src/Avatar.tsx | 16 ++++++++-------- src/ClientContext.tsx | 8 -------- src/settings/useSubmitRageshake.test.tsx | 1 - 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 3d75d4c1..debff26a 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -13,19 +13,14 @@ import { type FC, type PropsWithChildren } from "react"; import { ClientContextProvider } from "./ClientContext"; import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import EventEmitter from "events"; import { widget } from "./widget"; +import { WidgetApi } from "matrix-widget-api"; const TestComponent: FC< PropsWithChildren<{ client: MatrixClient; - supportsAuthenticatedMedia?: boolean; }> -> = ({ - client, - children, - supportsAuthenticatedMedia: supportsAuthenticatedMedia, -}) => { +> = ({ client, children }) => { return ( ({ widget: { - api: { downloadFile: vi.fn() }, + 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 }, })); @@ -88,7 +82,7 @@ test("should just render a placeholder when the user has no avatar", () => { expect(client.mxcUrlToHttp).toBeCalledTimes(0); }); -test("should attempt to fetch authenticated media if supported", async () => { +test("should attempt to fetch authenticated media from the server", async () => { const expectedAuthUrl = "http://example.org/media/alice-avatar"; const expectedObjectURL = "my-object-url"; const accessToken = "my-access-token"; @@ -120,7 +114,7 @@ test("should attempt to fetch authenticated media if supported", async () => { ); const displayName = "Alice"; render( - + { }); }); -test("should attempt to use widget API if authenticate media is not supported", async () => { +test("should attempt to use widget API if running as a widget", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -157,6 +151,7 @@ test("should attempt to use widget API if authenticate media is not supported", getAccessToken: () => undefined, } as unknown as MatrixClient); + widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi; vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); const member = mockMatrixRoomMember( mockRtcMembership("@alice:example.org", "AAAA"), @@ -166,7 +161,7 @@ test("should attempt to use widget API if authenticate media is not supported", ); const displayName = "Alice"; render( - + = ({ const [avatarUrl, setAvatarUrl] = useState(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. useEffect(() => { - if (!src || clientState?.state !== "valid") { + if (!src) { setAvatarUrl(undefined); return; } - const { authenticated, supportedFeatures } = clientState; let blob: Promise; - if ( - supportedFeatures.authenticatedMedia && - authenticated?.client && + if (widget?.api) { + blob = getAvatarFromWidgetAPI(widget.api, src); + } else if ( + clientState?.state === "valid" && + clientState.authenticated?.client && sizePx ) { - blob = getAvatarFromServer(authenticated.client, src, sizePx); - } else if (widget?.api) { - blob = getAvatarFromWidgetAPI(widget.api, src); + blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx); } else { setAvatarUrl(undefined); return; diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 7059cd69..f2ff3dd4 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -48,7 +48,6 @@ export type ValidClientState = { disconnected: boolean; supportedFeatures: { reactions: boolean; - authenticatedMedia: boolean; }; setClient: (client: MatrixClient, session: Session) => void; }; @@ -249,8 +248,6 @@ export const ClientProvider: FC = ({ children }) => { const [isDisconnected, setIsDisconnected] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false); - const [supportsAuthenticatedMedia, setSupportsAuthenticatedMedia] = - useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -276,7 +273,6 @@ export const ClientProvider: FC = ({ children }) => { disconnected: isDisconnected, supportedFeatures: { reactions: supportsReactions, - authenticatedMedia: supportsAuthenticatedMedia, }, }; }, [ @@ -287,7 +283,6 @@ export const ClientProvider: FC = ({ children }) => { setClient, isDisconnected, supportsReactions, - supportsAuthenticatedMedia, ]); const onSync = useCallback( @@ -313,8 +308,6 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - // There is currently no way for widgets to request authenticated media directly from the server. - setSupportsAuthenticatedMedia(false); const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -336,7 +329,6 @@ export const ClientProvider: FC = ({ children }) => { } } else { setSupportsReactions(true); - setSupportsAuthenticatedMedia(true); } return (): void => { diff --git a/src/settings/useSubmitRageshake.test.tsx b/src/settings/useSubmitRageshake.test.tsx index d69255e0..b5d07553 100644 --- a/src/settings/useSubmitRageshake.test.tsx +++ b/src/settings/useSubmitRageshake.test.tsx @@ -78,7 +78,6 @@ function renderWithMockClient( disconnected: false, supportedFeatures: { reactions: true, - authenticatedMedia: true, }, setClient: vi.fn(), authenticated: { From b1987219690ff9b36bb9c213e1e71e44fd5c66fa Mon Sep 17 00:00:00 2001 From: JephDiel Date: Thu, 12 Mar 2026 22:20:19 -0500 Subject: [PATCH 4/7] Ignore stale downloads If src or sizePx changes while we're downloading, discard the now-stale fetch result so we don't override the fresh one. --- src/Avatar.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Avatar.tsx b/src/Avatar.tsx index 8d89865e..d7768d6f 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -110,16 +110,24 @@ export const Avatar: FC = ({ } let objectUrl: string | undefined; + let stale = false; blob .then((blob) => { + if (stale) { + return; + } objectUrl = URL.createObjectURL(blob); setAvatarUrl(objectUrl); }) .catch((ex) => { + if (stale) { + return; + } setAvatarUrl(undefined); }); return (): void => { + stale = true; if (objectUrl) { URL.revokeObjectURL(objectUrl); } From af807489f94c21a957480b8b18d492438c9e388f Mon Sep 17 00:00:00 2001 From: JephDiel Date: Fri, 13 Mar 2026 11:31:55 -0500 Subject: [PATCH 5/7] Import order lint fixes Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> --- src/Avatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avatar.tsx b/src/Avatar.tsx index d7768d6f..d0cb243c 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,10 +14,10 @@ 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 { WidgetApi } from "matrix-widget-api"; export enum Size { XS = "xs", From fa74aaa81e663ccf355c9d5e3d8a09aaf3689788 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Fri, 13 Mar 2026 11:33:07 -0500 Subject: [PATCH 6/7] Import order lint fixes Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> --- src/Avatar.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index debff26a..672aff3b 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -14,7 +14,13 @@ import { ClientContextProvider } from "./ClientContext"; import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; import { widget } from "./widget"; -import { WidgetApi } from "matrix-widget-api"; +import { type FC, type PropsWithChildren } from "react"; +import { type WidgetApi } from "matrix-widget-api"; + +import { ClientContextProvider } from "./ClientContext"; +import { Avatar } from "./Avatar"; +import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; +import { widget } from "./widget"; const TestComponent: FC< PropsWithChildren<{ From 997cc9bb2717cf7254fbf31bf771f1af780d8f48 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Fri, 13 Mar 2026 11:41:58 -0500 Subject: [PATCH 7/7] Lint fixes v2 --- src/Avatar.test.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 672aff3b..25d2c42b 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -9,12 +9,6 @@ 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 { ClientContextProvider } from "./ClientContext"; -import { Avatar } from "./Avatar"; -import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import { widget } from "./widget"; -import { type FC, type PropsWithChildren } from "react"; import { type WidgetApi } from "matrix-widget-api"; import { ClientContextProvider } from "./ClientContext";