Download Avatar from relevent source

Instead of relying on failures directly use the
available method to download the avatar.
This commit is contained in:
JephDiel
2026-03-09 22:25:54 -05:00
parent 005b965fba
commit 699e31f59a
4 changed files with 47 additions and 76 deletions

View File

@@ -17,8 +17,15 @@ import EventEmitter from "events";
import { widget } from "./widget"; import { widget } from "./widget";
const TestComponent: FC< const TestComponent: FC<
PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }> PropsWithChildren<{
> = ({ client, children, supportsThumbnails }) => { client: MatrixClient;
supportsAuthenticatedMedia?: boolean;
}>
> = ({
client,
children,
supportsAuthenticatedMedia: supportsAuthenticatedMedia,
}) => {
return ( return (
<ClientContextProvider <ClientContextProvider
value={{ value={{
@@ -26,7 +33,7 @@ const TestComponent: FC<
disconnected: false, disconnected: false,
supportedFeatures: { supportedFeatures: {
reactions: true, reactions: true,
thumbnails: supportsThumbnails ?? true, authenticatedMedia: supportsAuthenticatedMedia ?? true,
}, },
setClient: vi.fn(), setClient: vi.fn(),
authenticated: { authenticated: {
@@ -81,36 +88,7 @@ test("should just render a placeholder when the user has no avatar", () => {
expect(client.mxcUrlToHttp).toBeCalledTimes(0); expect(client.mxcUrlToHttp).toBeCalledTimes(0);
}); });
test("should just render a placeholder when thumbnails are not supported", () => { test("should attempt to fetch authenticated media if supported", async () => {
const client = vi.mocked<MatrixClient>({
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(
<TestComponent client={client} supportsThumbnails={false}>
<Avatar
id={member.userId}
name={displayName}
size={96}
src={member.getMxcAvatarUrl()}
/>
</TestComponent>,
);
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 () => {
const expectedAuthUrl = "http://example.org/media/alice-avatar"; const expectedAuthUrl = "http://example.org/media/alice-avatar";
const expectedObjectURL = "my-object-url"; const expectedObjectURL = "my-object-url";
const accessToken = "my-access-token"; const accessToken = "my-access-token";
@@ -142,7 +120,7 @@ test("should attempt to fetch authenticated media", async () => {
); );
const displayName = "Alice"; const displayName = "Alice";
render( render(
<TestComponent client={client}> <TestComponent client={client} supportsAuthenticatedMedia={true}>
<Avatar <Avatar
id={member.userId} id={member.userId}
name={displayName} name={displayName}
@@ -163,7 +141,7 @@ test("should attempt to fetch authenticated media", async () => {
}); });
}); });
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 expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedObjectURL = "my-object-url"; const expectedObjectURL = "my-object-url";
const theBlob = new Blob([]); const theBlob = new Blob([]);
@@ -188,7 +166,7 @@ test("should use widget API when unable to authenticate media", async () => {
); );
const displayName = "Alice"; const displayName = "Alice";
render( render(
<TestComponent client={client}> <TestComponent client={client} supportsAuthenticatedMedia={false}>
<Avatar <Avatar
id={member.userId} id={member.userId}
name={displayName} name={displayName}

View File

@@ -15,7 +15,7 @@ import {
import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { ClientState, useClientState } from "./ClientContext"; import { useClientState } from "./ClientContext";
import { widget } from "./widget"; import { widget } from "./widget";
import { WidgetApi } from "matrix-widget-api"; import { WidgetApi } from "matrix-widget-api";
@@ -80,7 +80,7 @@ export const Avatar: FC<Props> = ({
const sizePx = useMemo( const sizePx = useMemo(
() => () =>
Object.values(Size).includes(size as Size) Object.values(Size).includes(size as Size)
? sizes.get(size as Size) ? sizes.get(size as Size)!
: (size as number), : (size as number),
[size], [size],
); );
@@ -88,17 +88,29 @@ export const Avatar: FC<Props> = ({
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined); const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!src) { if (!src || clientState?.state !== "valid") {
setAvatarUrl(undefined);
return;
}
const { authenticated, supportedFeatures } = clientState;
let blob: Promise<Blob>;
if (
supportedFeatures.authenticatedMedia &&
authenticated?.client &&
sizePx
) {
blob = getAvatarFromServer(authenticated.client, src, sizePx);
} else if (widget?.api) {
blob = getAvatarFromWidgetAPI(widget.api, src);
} else {
setAvatarUrl(undefined); setAvatarUrl(undefined);
return; return;
} }
let objectUrl: string | undefined; let objectUrl: string | undefined;
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) => { .then((blob) => {
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setAvatarUrl(objectUrl); setAvatarUrl(objectUrl);
@@ -128,26 +140,10 @@ export const Avatar: FC<Props> = ({
}; };
async function getAvatarFromServer( async function getAvatarFromServer(
clientState: ClientState | undefined, client: MatrixClient,
src: string, src: string,
sizePx: number | undefined, sizePx: number,
): Promise<Blob> { ): Promise<Blob> {
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); const httpSrc = getAvatarUrl(client, src, sizePx);
if (!httpSrc) { if (!httpSrc) {
throw new Error("Failed to get http avatar URL"); throw new Error("Failed to get http avatar URL");
@@ -169,14 +165,10 @@ async function getAvatarFromServer(
return blob; return blob;
} }
async function getAvatarFromWidget( async function getAvatarFromWidgetAPI(
api: WidgetApi | undefined, api: WidgetApi,
src: string, src: string,
): Promise<Blob> { ): Promise<Blob> {
if (!api) {
throw new Error("No widget api given");
}
const response = await api.downloadFile(src); const response = await api.downloadFile(src);
const file = response.file; const file = response.file;

View File

@@ -48,7 +48,7 @@ export type ValidClientState = {
disconnected: boolean; disconnected: boolean;
supportedFeatures: { supportedFeatures: {
reactions: boolean; reactions: boolean;
thumbnails: boolean; authenticatedMedia: boolean;
}; };
setClient: (client: MatrixClient, session: Session) => void; setClient: (client: MatrixClient, session: Session) => void;
}; };
@@ -249,7 +249,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
const [isDisconnected, setIsDisconnected] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false);
const [supportsThumbnails, setSupportsThumbnails] = useState(false); const [supportsAuthenticatedMedia, setSupportsAuthenticatedMedia] =
useState(false);
const state: ClientState | undefined = useMemo(() => { const state: ClientState | undefined = useMemo(() => {
if (alreadyOpenedErr) { if (alreadyOpenedErr) {
@@ -275,7 +276,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
disconnected: isDisconnected, disconnected: isDisconnected,
supportedFeatures: { supportedFeatures: {
reactions: supportsReactions, reactions: supportsReactions,
thumbnails: supportsThumbnails, authenticatedMedia: supportsAuthenticatedMedia,
}, },
}; };
}, [ }, [
@@ -286,7 +287,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
setClient, setClient,
isDisconnected, isDisconnected,
supportsReactions, supportsReactions,
supportsThumbnails, supportsAuthenticatedMedia,
]); ]);
const onSync = useCallback( const onSync = useCallback(
@@ -312,8 +313,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
} }
if (initClientState.widgetApi) { if (initClientState.widgetApi) {
// There is currently no widget API for authenticated media thumbnails. // There is currently no way for widgets to request authenticated media directly from the server.
setSupportsThumbnails(false); setSupportsAuthenticatedMedia(false);
const reactSend = initClientState.widgetApi.hasCapability( const reactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.reaction", "org.matrix.msc2762.send.event:m.reaction",
); );
@@ -335,7 +336,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
} }
} else { } else {
setSupportsReactions(true); setSupportsReactions(true);
setSupportsThumbnails(true); setSupportsAuthenticatedMedia(true);
} }
return (): void => { return (): void => {

View File

@@ -78,7 +78,7 @@ function renderWithMockClient(
disconnected: false, disconnected: false,
supportedFeatures: { supportedFeatures: {
reactions: true, reactions: true,
thumbnails: true, authenticatedMedia: true,
}, },
setClient: vi.fn(), setClient: vi.fn(),
authenticated: { authenticated: {