From 005b965fba58556f4a637e2829a219937883f9f2 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Sat, 7 Mar 2026 19:44:00 -0600 Subject: [PATCH] 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