Files
cinny/src/app/components/message/content/ImageContent.tsx
T
Lotus CIandClaude Opus 5.5 02bd356a89
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m44s
fix(media): retry an image once when it raced the service worker
On a first visit (or right after an update) the service worker only takes
control when it activates. An image requested before then goes out without
the access token, fails, and showed "Retry". Now a failed image waits for the
worker to take control (or a moment) and retries once by itself; "Retry"
appears only if that fails too. Encrypted media is unchanged.

Also the cause of the flaky "#219 gallery lightbox" e2e test (1 in 4 locally,
10/10 after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 19:11:56 -04:00

297 lines
9.9 KiB
TypeScript

import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Box,
Button,
Chip,
Icon,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
Tooltip,
TooltipProvider,
as,
} from 'folds';
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import * as css from './style.css';
import { bytesToSize } from '../../../utils/common';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useNearViewport } from '../../../hooks/useNearViewport';
import { whenMediaAuthReady } from '../../../../sw-session';
type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type RenderImageProps = {
alt: string;
title: string;
src: string;
onLoad: () => void;
onError: () => void;
onClick: () => void;
tabIndex: number;
role: 'button';
'aria-label': string;
onKeyDown: (evt: React.KeyboardEvent<HTMLElement>) => void;
};
export type ImageContentProps = {
body: string;
mimeType?: string;
url: string;
info?: IImageInfo;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
// [Gitea #219] When given, a click opens THIS instead of the built-in
// viewer — the room timeline hands it to the shared media lightbox.
onOpenViewer?: () => void;
};
export const ImageContent = as<'div', ImageContentProps>(
(
{
className,
body,
mimeType,
url,
info,
encInfo,
autoPlay,
markedAsSpoiler,
spoilerReason,
renderViewer,
renderImage,
onOpenViewer,
...props
},
ref,
) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const { t } = useTranslation();
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const sentinelRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(sentinelRef);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo),
);
return URL.createObjectURL(fileContent);
}
return mediaUrl;
}, [mx, url, useAuthentication, mimeType, encInfo]),
);
const handleLoad = () => {
setLoad(true);
};
// An image requested before the service worker controls the page goes out
// without the access token and fails (first visit, just after an update).
// Retry once by itself when that's ready; only then offer "Retry".
const [attempt, setAttempt] = useState(0);
const autoRetried = useRef(false);
const handleError = () => {
setLoad(false);
if (!autoRetried.current && !encInfo) {
autoRetried.current = true;
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
return;
}
setError(true);
};
const handleRetry = () => {
setError(false);
setAttempt((n) => n + 1);
loadSrc();
};
useEffect(() => {
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
}, [autoPlay, nearViewport, loadSrc]);
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
width={32}
height={32}
hash={blurHash}
punch={1}
/>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
fill="Solid"
radii="300"
size="300"
onClick={loadSrc}
before={<Icon size="Inherit" src={Icons.Photo} filled />}
>
<Text size="B300">{t('Organisms.ImageContent.view')}</Text>
</Button>
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({
alt: body,
title: body,
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
tabIndex: 0,
// [Gitea #164] Keyboard users could Tab to the image but not open it.
role: 'button',
'aria-label': `View image: ${body}`,
onKeyDown: (evt) => {
if (evt.key !== 'Enter' && evt.key !== ' ') return;
evt.preventDefault();
if (onOpenViewer) onOpenViewer();
else setViewer(true);
},
})}
</Box>
)}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
typeof spoilerReason === 'string' && (
<Tooltip variant="Secondary">
<Text>{spoilerReason}</Text>
</Tooltip>
)
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Secondary"
radii="Pill"
size="500"
outlined
onClick={() => {
setBlurred(false);
if (srcState.status === AsyncStatus.Idle) {
loadSrc();
}
}}
>
<Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
</Chip>
)}
</TooltipProvider>
</Box>
)}
{(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
!load &&
!blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" />
</Box>
)}
{(error || srcState.status === AsyncStatus.Error) && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<TooltipProvider
tooltip={
<Tooltip variant="Critical">
<Text>Failed to load image!</Text>
</Tooltip>
}
position="Top"
align="Center"
>
{(triggerRef) => (
<Button
ref={triggerRef}
size="300"
variant="Critical"
fill="Soft"
outlined
radii="300"
onClick={handleRetry}
before={<Icon size="Inherit" src={Icons.Warning} filled />}
>
<Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
</Button>
)}
</TooltipProvider>
</Box>
)}
{!load && typeof info?.size === 'number' && (
<Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
<Badge variant="Secondary" fill="Soft">
<Text size="L400">{bytesToSize(info.size)}</Text>
</Badge>
</Box>
)}
</Box>
);
},
);