Fix authenticated media loading for Tauri/WebKit environments
- Add useAuthenticatedMediaUrl hook that fetches media with auth headers - Add AuthenticatedMedia, AuthenticatedAvatarImage components - Update downloadMedia and downloadEncryptedMedia to accept access token - Update all media components to pass access token for auth media - Fix 401 errors on Matrix servers requiring authenticated media (v1.11+) This fixes media loading in Tauri desktop apps where service workers may not work reliably for cross-origin authenticated requests.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import React, { ComponentProps, forwardRef } from 'react';
|
||||
import { AvatarImage } from 'folds';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
export type AuthenticatedAvatarImageProps = ComponentProps<typeof AvatarImage>;
|
||||
|
||||
/**
|
||||
* AvatarImage component that handles Matrix authenticated media.
|
||||
* Automatically fetches authenticated media URLs with the proper Authorization header
|
||||
* and renders them as blob URLs.
|
||||
*/
|
||||
export const AuthenticatedAvatarImage = forwardRef<HTMLImageElement, AuthenticatedAvatarImageProps>(
|
||||
({ src, ...props }, ref) => {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
return <AvatarImage ref={ref} {...props} src={authenticatedSrc} />;
|
||||
}
|
||||
);
|
||||
|
||||
AuthenticatedAvatarImage.displayName = 'AuthenticatedAvatarImage';
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { ImgHTMLAttributes, forwardRef, useEffect, useState } from 'react';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
export interface AuthenticatedMediaProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
/** The source URL of the media (may be an authenticated media URL) */
|
||||
src?: string;
|
||||
/** Override for authentication - if not provided, uses useMediaAuthentication hook */
|
||||
useAuthentication?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image component that handles Matrix authenticated media.
|
||||
* Automatically fetches authenticated media URLs with the proper Authorization header
|
||||
* and renders them as blob URLs.
|
||||
*/
|
||||
export const AuthenticatedMedia = forwardRef<HTMLImageElement, AuthenticatedMediaProps>(
|
||||
({ src, useAuthentication: useAuthOverride, ...props }, ref) => {
|
||||
const useAuthFromServer = useMediaAuthentication();
|
||||
const useAuthentication = useAuthOverride ?? useAuthFromServer;
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
return <img ref={ref} {...props} src={authenticatedSrc} />;
|
||||
}
|
||||
);
|
||||
|
||||
AuthenticatedMedia.displayName = 'AuthenticatedMedia';
|
||||
|
||||
/**
|
||||
* A simpler authenticated img component for use in places like custom HTML parsers.
|
||||
* This component handles its own authentication state.
|
||||
*/
|
||||
export interface AuthenticatedImgProps extends ImgHTMLAttributes<HTMLImageElement> {}
|
||||
|
||||
export const AuthenticatedImg = forwardRef<HTMLImageElement, AuthenticatedImgProps>(
|
||||
({ src, ...props }, ref) => {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
return <img ref={ref} {...props} src={authenticatedSrc} />;
|
||||
}
|
||||
);
|
||||
|
||||
AuthenticatedImg.displayName = 'AuthenticatedImg';
|
||||
4
src/app/components/authenticated-media/index.ts
Normal file
4
src/app/components/authenticated-media/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AuthenticatedMedia, AuthenticatedImg } from './AuthenticatedMedia';
|
||||
export type { AuthenticatedMediaProps, AuthenticatedImgProps } from './AuthenticatedMedia';
|
||||
export { AuthenticatedAvatarImage } from './AuthenticatedAvatarImage';
|
||||
export type { AuthenticatedAvatarImageProps } from './AuthenticatedAvatarImage';
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Box,
|
||||
Text,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
Icon,
|
||||
@@ -25,6 +24,7 @@ import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { PackMetaReader } from '../../plugins/custom-emoji';
|
||||
import { AuthenticatedAvatarImage } from '../authenticated-media';
|
||||
|
||||
type ImagePackAvatarProps = {
|
||||
url?: string;
|
||||
@@ -34,7 +34,7 @@ function ImagePackAvatar({ url, name }: ImagePackAvatarProps) {
|
||||
return (
|
||||
<Avatar size="500" className={ContainerColor({ variant: 'Secondary' })}>
|
||||
{url ? (
|
||||
<AvatarImage src={url} alt={name ?? 'Unknown'} />
|
||||
<AuthenticatedAvatarImage src={url} alt={name ?? 'Unknown'} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Text size="H2">{nameInitials(name ?? 'Unknown')}</Text>
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
@@ -16,11 +17,12 @@ export type ImageViewerProps = {
|
||||
|
||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const fileContent = await downloadMedia(src);
|
||||
const fileContent = await downloadMedia(src, mx.getAccessToken());
|
||||
FileSaver.saveAs(fileContent, alt);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,9 +28,10 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, filename);
|
||||
|
||||
@@ -55,9 +55,10 @@ export function AudioContent({
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
@@ -87,9 +87,10 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
||||
const [textState, loadText] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const text = fileContent.text();
|
||||
setTextViewer(true);
|
||||
@@ -177,9 +178,10 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
||||
const [pdfState, loadPdf] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
setPdfViewer(true);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
@@ -254,9 +256,10 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
||||
const [downloadState, download] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
|
||||
: await downloadMedia(mediaUrl);
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
|
||||
const fileURL = URL.createObjectURL(fileContent);
|
||||
FileSaver.saveAs(fileURL, body);
|
||||
|
||||
@@ -32,6 +32,32 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication headers and returns a blob URL.
|
||||
* This is needed because service workers don't work reliably in Tauri/WebKit.
|
||||
*/
|
||||
const fetchAuthenticatedMedia = async (
|
||||
url: string,
|
||||
accessToken: string | null
|
||||
): Promise<string> => {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a URL is an authenticated media endpoint.
|
||||
*/
|
||||
const isAuthenticatedMediaUrl = (url: string): boolean =>
|
||||
url.includes('/_matrix/client/v1/media/download') ||
|
||||
url.includes('/_matrix/client/v1/media/thumbnail');
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
@@ -88,12 +114,19 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
if (encInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) => decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
// For authenticated media, fetch with auth header and return blob URL
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
return mediaUrl;
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
@@ -2,10 +2,28 @@ import { ReactNode, useCallback, useEffect } from 'react';
|
||||
import { IThumbnailContent } from '../../../../types/matrix/common';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { decryptFile, downloadEncryptedMedia, isAuthenticatedMediaUrl, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication headers and returns a blob URL.
|
||||
*/
|
||||
const fetchAuthenticatedMedia = async (
|
||||
url: string,
|
||||
accessToken: string | null
|
||||
): Promise<string> => {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
export type ThumbnailContentProps = {
|
||||
info: IThumbnailContent;
|
||||
renderImage: (src: string) => ReactNode;
|
||||
@@ -24,13 +42,21 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
|
||||
}
|
||||
|
||||
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication) ?? thumbMxcUrl;
|
||||
const accessToken = mx.getAccessToken();
|
||||
if (encInfo) {
|
||||
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
|
||||
const fileContent = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) => decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo),
|
||||
accessToken
|
||||
);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}
|
||||
|
||||
// For authenticated media, fetch with auth header and return blob URL
|
||||
if (useAuthentication && isAuthenticatedMediaUrl(mediaUrl)) {
|
||||
return fetchAuthenticatedMedia(mediaUrl, accessToken);
|
||||
}
|
||||
|
||||
return mediaUrl;
|
||||
}, [mx, info, useAuthentication])
|
||||
);
|
||||
|
||||
@@ -82,11 +82,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
|
||||
const accessToken = mx.getAccessToken();
|
||||
const fileContent = encInfo
|
||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||
decryptFile(encBuf, mimeType, encInfo)
|
||||
decryptFile(encBuf, mimeType, encInfo), accessToken
|
||||
)
|
||||
: await downloadMedia(mediaUrl);
|
||||
: await downloadMedia(mediaUrl, accessToken);
|
||||
return URL.createObjectURL(fileContent);
|
||||
}, [mx, url, useAuthentication, mimeType, encInfo])
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useSta
|
||||
import * as css from './RoomAvatar.css';
|
||||
import { joinRuleToIconSrc } from '../../utils/room';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
type RoomAvatarProps = {
|
||||
roomId: string;
|
||||
@@ -13,12 +15,14 @@ type RoomAvatarProps = {
|
||||
};
|
||||
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||
};
|
||||
|
||||
if (!src || error) {
|
||||
if (!authenticatedSrc || error) {
|
||||
return (
|
||||
<AvatarFallback
|
||||
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
|
||||
@@ -32,7 +36,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
||||
return (
|
||||
<AvatarImage
|
||||
className={css.RoomAvatar}
|
||||
src={src}
|
||||
src={authenticatedSrc}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
|
||||
@@ -3,6 +3,8 @@ import React, { ReactEventHandler, ReactNode, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './UserAvatar.css';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
||||
type UserAvatarProps = {
|
||||
className?: string;
|
||||
@@ -13,12 +15,14 @@ type UserAvatarProps = {
|
||||
};
|
||||
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||
|
||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||
};
|
||||
|
||||
if (!src || error) {
|
||||
if (!authenticatedSrc || error) {
|
||||
return (
|
||||
<AvatarFallback
|
||||
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
|
||||
@@ -32,7 +36,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
return (
|
||||
<AvatarImage
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
src={src}
|
||||
src={authenticatedSrc}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Icon,
|
||||
Icons,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
toRem,
|
||||
config,
|
||||
@@ -33,6 +32,7 @@ import { SequenceCardStyle } from '../styles.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { AuthenticatedAvatarImage } from '../../../components/authenticated-media';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { suffixRename } from '../../../utils/common';
|
||||
@@ -236,7 +236,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
||||
))}
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Icons,
|
||||
IconButton,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
config,
|
||||
Spinner,
|
||||
@@ -31,6 +30,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AuthenticatedAvatarImage } from '../../../components/authenticated-media';
|
||||
import {
|
||||
EmoteRoomsContent,
|
||||
ImagePack,
|
||||
@@ -190,7 +190,7 @@ function GlobalPackSelector({
|
||||
<Box alignItems="Center" gap="300">
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
@@ -389,7 +389,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
)}
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage, Box, Button, Icon, Icons, Text } from 'folds';
|
||||
import { Avatar, AvatarFallback, Box, Button, Icon, Icons, Text } from 'folds';
|
||||
import { useUserImagePack } from '../../../hooks/useImagePacks';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -8,6 +8,7 @@ import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { AuthenticatedAvatarImage } from '../../../components/authenticated-media';
|
||||
|
||||
type UserPackProps = {
|
||||
onViewPack: (imagePack: ImagePack) => void;
|
||||
@@ -44,7 +45,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
|
||||
before={
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
<AuthenticatedAvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
|
||||
123
src/app/hooks/useAuthenticatedMediaUrl.ts
Normal file
123
src/app/hooks/useAuthenticatedMediaUrl.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
/**
|
||||
* Fetches media with authentication and returns a blob URL.
|
||||
* This is needed because service workers may not work reliably in Tauri/WebKit environments,
|
||||
* and cross-origin image requests cannot include Authorization headers.
|
||||
*/
|
||||
export const useAuthenticatedMediaUrl = (
|
||||
src: string | undefined,
|
||||
useAuthentication: boolean
|
||||
): string | undefined => {
|
||||
const mx = useMatrixClient();
|
||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setBlobUrl(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not using authentication, just return the original URL
|
||||
if (!useAuthentication) {
|
||||
setBlobUrl(src);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an authenticated media URL
|
||||
const isAuthenticatedMediaUrl =
|
||||
src.includes('/_matrix/client/v1/media/download') ||
|
||||
src.includes('/_matrix/client/v1/media/thumbnail');
|
||||
|
||||
if (!isAuthenticatedMediaUrl) {
|
||||
setBlobUrl(src);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | undefined;
|
||||
|
||||
const fetchMedia = async () => {
|
||||
try {
|
||||
const accessToken = mx.getAccessToken();
|
||||
const response = await fetch(src, {
|
||||
method: 'GET',
|
||||
headers: accessToken
|
||||
? { Authorization: `Bearer ${accessToken}` }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch authenticated media: ${response.status}`);
|
||||
// Fall back to original URL in case server doesn't require auth
|
||||
if (!cancelled) setBlobUrl(src);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (!cancelled) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error fetching authenticated media:', error);
|
||||
// Fall back to original URL
|
||||
if (!cancelled) setBlobUrl(src);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMedia();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src, useAuthentication, mx]);
|
||||
|
||||
return blobUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an authenticated fetch function for media URLs.
|
||||
* Useful for components that need to load multiple images.
|
||||
*/
|
||||
export const useAuthenticatedMediaFetch = () => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return useCallback(
|
||||
async (src: string): Promise<string> => {
|
||||
const isAuthenticatedMediaUrl =
|
||||
src.includes('/_matrix/client/v1/media/download') ||
|
||||
src.includes('/_matrix/client/v1/media/thumbnail');
|
||||
|
||||
if (!isAuthenticatedMediaUrl) {
|
||||
return src;
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = mx.getAccessToken();
|
||||
const response = await fetch(src, {
|
||||
method: 'GET',
|
||||
headers: accessToken
|
||||
? { Authorization: `Bearer ${accessToken}` }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch authenticated media: ${response.status}`);
|
||||
return src;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn('Error fetching authenticated media:', error);
|
||||
return src;
|
||||
}
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { onEnterOrSpace } from '../utils/keyboard';
|
||||
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||
import { AuthenticatedImg } from '../components/authenticated-media';
|
||||
|
||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||
|
||||
@@ -485,12 +486,12 @@ export const getReactCustomHtmlParser = (
|
||||
return (
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()}>
|
||||
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
|
||||
<AuthenticatedImg {...props} className={css.EmoticonImg} src={htmlSrc} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
|
||||
if (htmlSrc) return <AuthenticatedImg {...props} className={css.Img} src={htmlSrc} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,18 +296,39 @@ export const mxcUrlToHttp = (
|
||||
useAuthentication
|
||||
);
|
||||
|
||||
export const downloadMedia = async (src: string): Promise<Blob> => {
|
||||
// this request is authenticated by service worker
|
||||
const res = await fetch(src, { method: 'GET' });
|
||||
/**
|
||||
* Check if a URL is an authenticated media endpoint.
|
||||
*/
|
||||
export const isAuthenticatedMediaUrl = (url: string): boolean =>
|
||||
url.includes('/_matrix/client/v1/media/download') ||
|
||||
url.includes('/_matrix/client/v1/media/thumbnail');
|
||||
|
||||
/**
|
||||
* Downloads media with optional authentication.
|
||||
* For authenticated media URLs, the access token is required.
|
||||
*/
|
||||
export const downloadMedia = async (src: string, accessToken?: string | null): Promise<Blob> => {
|
||||
const needsAuth = isAuthenticatedMediaUrl(src);
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (needsAuth && accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(src, { method: 'GET', headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download media: ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
return blob;
|
||||
};
|
||||
|
||||
export const downloadEncryptedMedia = async (
|
||||
src: string,
|
||||
decryptContent: (buf: ArrayBuffer) => Promise<Blob>
|
||||
decryptContent: (buf: ArrayBuffer) => Promise<Blob>,
|
||||
accessToken?: string | null
|
||||
): Promise<Blob> => {
|
||||
const encryptedContent = await downloadMedia(src);
|
||||
const encryptedContent = await downloadMedia(src, accessToken);
|
||||
const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer());
|
||||
|
||||
return decryptedContent;
|
||||
|
||||
Reference in New Issue
Block a user