fix(media): clarify failed external image embeds

This commit is contained in:
Tommaso Casaburi
2026-05-14 16:36:20 +07:00
parent e38d888b74
commit fe87808a90
42 changed files with 551 additions and 55 deletions
@@ -20,7 +20,7 @@ const testState = vi.hoisted(() => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string, options?: { host?: string }) => (options?.host ? `${key}:${options.host}` : key),
}),
}));
@@ -151,24 +151,73 @@ describe('CommentMedia', () => {
expect(container.querySelector('[data-expanded-media="true"]')).toBeTruthy();
});
it('falls back to the deleted-file placeholder when an image fails to load', async () => {
it('shows a media-load warning when an image fails to load', async () => {
testState.hostname = 'files.catbox.moe';
const onMediaLoadFailureChange = vi.fn();
await renderMedia({
commentMediaInfo: {
type: 'image',
url: 'https://cdn.example.com/missing.jpg',
url: 'https://files.catbox.moe/missing.jpg',
},
onMediaLoadFailureChange,
setShowThumbnail: setShowThumbnailMock,
showThumbnail: true,
});
const image = container.querySelector('img[src="https://cdn.example.com/missing.jpg"]');
const image = container.querySelector('img[src="https://files.catbox.moe/missing.jpg"]');
expect(image).toBeTruthy();
await act(async () => {
image?.dispatchEvent(new Event('error', { bubbles: true }));
});
expect(container.querySelector('img[alt="File deleted"]')).toBeTruthy();
expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy();
expect(onMediaLoadFailureChange).toHaveBeenCalledWith('https://files.catbox.moe/missing.jpg');
const status = container.querySelector('[role="status"]');
expect(status?.textContent).toContain('media_failed_to_load_inline:files.catbox.moe');
expect(status?.textContent).toContain('media_failed_to_load_open_source');
expect(status?.textContent).not.toContain('media_failed_to_load_hint');
expect(status?.getAttribute('aria-label')).toBe(
'media_failed_to_load. media_failed_to_load_source:files.catbox.moe. media_failed_to_load_hint. media_failed_to_load_open_source.',
);
expect(status?.getAttribute('title')).toBe(
'media_failed_to_load. media_failed_to_load_source:files.catbox.moe. media_failed_to_load_hint. media_failed_to_load_open_source.',
);
expect(container.querySelector<HTMLAnchorElement>('a[href="https://files.catbox.moe/missing.jpg"]')?.textContent).toBe('media_failed_to_load_open_source');
});
it('moves media-load guidance below failed image thumbnails on mobile', async () => {
testState.hostname = 'files.catbox.moe';
testState.isMobile = true;
const onMediaLoadFailureChange = vi.fn();
await renderMedia({
commentMediaInfo: {
type: 'image',
url: 'https://files.catbox.moe/missing.jpg',
},
onMediaLoadFailureChange,
setShowThumbnail: setShowThumbnailMock,
showThumbnail: true,
});
const image = container.querySelector('img[src="https://files.catbox.moe/missing.jpg"]');
expect(image).toBeTruthy();
await act(async () => {
image?.dispatchEvent(new Event('error', { bubbles: true }));
});
const status = container.querySelector('[role="status"]');
expect(status?.textContent).toBe('');
expect(status?.getAttribute('aria-label')).toBe(
'media_failed_to_load. media_failed_to_load_source:files.catbox.moe. media_failed_to_load_hint. media_failed_to_load_open_source.',
);
expect(onMediaLoadFailureChange).toHaveBeenCalledWith('https://files.catbox.moe/missing.jpg');
expect(container.textContent).not.toContain('media_failed_to_load_inline:files.catbox.moe');
expect(container.textContent).not.toContain('media_failed_to_load_open_source');
expect(container.textContent).toContain('image');
});
it('renders GIF thumbnail states and toggles the media view from the placeholder', async () => {
@@ -169,6 +169,69 @@
image-rendering: pixelated;
}
.mediaLoadFailure {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
min-width: 0;
padding: 8px;
box-sizing: border-box;
line-height: 1.2;
font-size: 10pt;
text-align: center;
overflow-wrap: anywhere;
}
.mediaLoadFailureSource {
max-width: 30ch;
font-size: 8pt;
}
.mediaLoadFailureStatus {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.mediaLoadFailureLink {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
font-size: 9pt;
}
.mediaLoadFailureLink:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
.mediaLoadFailureInfo {
display: block;
clear: both;
color: var(--post-mobile-file-info-text-color);
font-size: 9pt;
line-height: 1.2;
}
.mediaLoadFailureInfo a {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.mediaLoadFailureInfo a:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-link-text-decoration-hover);
}
@media (min-width: 640px) {
.thumbnail,
.content {
+87 -5
View File
@@ -25,10 +25,68 @@ interface MediaProps {
spoiler?: boolean;
showThumbnail?: boolean;
setShowThumbnail: (showThumbnail: boolean) => void;
onMediaLoadFailureChange?: (url: string | undefined) => void;
}
type GifFrameState = ReturnType<typeof useFetchGifFirstFrame>;
const getMediaLoadFailureLabels = (url: string | undefined, t: ReturnType<typeof useTranslation>['t']) => {
const hostname = url ? getHostname(url) : undefined;
const label = t('media_failed_to_load');
const source = hostname ? t('media_failed_to_load_source', { host: hostname }) : t('media_failed_to_load_source_unknown');
const hint = t('media_failed_to_load_hint');
const openSourceLabel = t('media_failed_to_load_open_source');
const inline = hostname ? t('media_failed_to_load_inline', { host: hostname }) : t('media_failed_to_load_inline_unknown');
const statusLabel = url ? `${label}. ${source}. ${hint}. ${openSourceLabel}.` : `${label}. ${source}. ${hint}.`;
return { inline, openSourceLabel, statusLabel };
};
const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: string }) => {
const { t } = useTranslation();
const { inline, openSourceLabel, statusLabel } = getMediaLoadFailureLabels(url, t);
if (compact) {
return (
<>
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
<span className={styles.mediaLoadFailureStatus} role='status' aria-label={statusLabel} title={statusLabel} />
</>
);
}
return (
<span className={styles.mediaLoadFailure} role='status' aria-label={statusLabel} title={statusLabel}>
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
<span className={styles.mediaLoadFailureSource}>{inline}</span>
{url && (
<a className={styles.mediaLoadFailureLink} href={url} target='_blank' rel='noopener noreferrer'>
{openSourceLabel}
</a>
)}
</span>
);
};
export const MediaLoadFailureInfo = ({ url }: { url?: string }) => {
const { t } = useTranslation();
const { inline, openSourceLabel, statusLabel } = getMediaLoadFailureLabels(url, t);
return (
<div className={styles.mediaLoadFailureInfo} title={statusLabel}>
{inline}
{url && (
<>
{' '}
<a href={url} target='_blank' rel='noopener noreferrer'>
{openSourceLabel}
</a>
</>
)}
</div>
);
};
const Thumbnail = ({
commentMediaInfo,
deleted,
@@ -290,11 +348,22 @@ interface ImageProps {
displayWidth: string;
initialExpanded?: boolean;
isOutOfFeed: boolean;
onMediaLoadFailureChange?: (url: string | undefined) => void;
parentCid?: string;
spoiler?: boolean;
}
const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, displayWidth, initialExpanded = false, isOutOfFeed, parentCid, spoiler }: ImageProps) => {
const Image = ({
commentMediaInfo,
disableToggle = false,
displayHeight,
displayWidth,
initialExpanded = false,
isOutOfFeed,
onMediaLoadFailureChange,
parentCid,
spoiler,
}: ImageProps) => {
const { t } = useTranslation();
const { type, url } = commentMediaInfo || {};
const isReply = parentCid;
@@ -310,8 +379,16 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
const expandedMediaAttribute = isImageExpanded ? 'true' : undefined;
const imageMediaStyle = isImageExpanded ? undefined : thumbnailDimensions;
const [hasError, setHasError] = useState(false);
const handleError = () => setHasError(true);
const [failedUrl, setFailedUrl] = useState<string | undefined>();
const hasError = failedUrl === url;
const handleError = () => {
setFailedUrl(url);
onMediaLoadFailureChange?.(url);
};
const handleLoad = () => {
setFailedUrl((currentFailedUrl) => (currentFailedUrl === url ? undefined : currentFailedUrl));
onMediaLoadFailureChange?.(undefined);
};
if (spoiler && !isImageExpanded) {
const spoilerDimensions = { '--width': '150px', '--height': '150px' } as React.CSSProperties;
@@ -346,11 +423,12 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
style={imageMediaStyle}
>
{hasError ? (
<img src='assets/filedeleted-res.gif' alt='File deleted' />
<MediaLoadFailure compact url={url} />
) : (
<img
src={url}
onError={handleError}
onLoad={handleLoad}
alt=''
role={disableToggle ? undefined : 'button'}
tabIndex={disableToggle ? undefined : 0}
@@ -386,11 +464,12 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
style={imageMediaStyle}
>
{hasError ? (
<img src='assets/filedeleted-res.gif' alt='File deleted' />
<MediaLoadFailure url={url} />
) : (
<img
src={url}
onError={handleError}
onLoad={handleLoad}
alt=''
role='button'
tabIndex={0}
@@ -424,6 +503,7 @@ const CommentMedia = ({
showThumbnail,
setShowThumbnail,
spoiler,
onMediaLoadFailureChange,
}: MediaProps) => {
const isReply = parentCid;
const { t } = useTranslation();
@@ -476,6 +556,7 @@ const CommentMedia = ({
displayWidth={displayWidth}
initialExpanded={showThumbnail === false}
isOutOfFeed={isOutOfFeed}
onMediaLoadFailureChange={onMediaLoadFailureChange}
parentCid={parentCid}
spoiler={spoiler}
/>
@@ -516,6 +597,7 @@ export default memo(CommentMedia, (prev, next) => {
prev.isReply === next.isReply &&
prev.linkHeight === next.linkHeight &&
prev.linkWidth === next.linkWidth &&
prev.onMediaLoadFailureChange === next.onMediaLoadFailureChange &&
prev.parentCid === next.parentCid &&
prev.purged === next.purged &&
prev.removed === next.removed &&
+1 -1
View File
@@ -1 +1 @@
export { default } from './comment-media';
export { default, MediaLoadFailureInfo } from './comment-media';