mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(media): clarify failed external image embeds
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
export { default } from './comment-media';
|
||||
export { default, MediaLoadFailureInfo } from './comment-media';
|
||||
|
||||
Reference in New Issue
Block a user