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
@@ -185,7 +185,7 @@ const renderContent = async (comment: TestComment) => {
});
};
const renderContentWithProps = async (props: { comment: TestComment; prependContent?: React.ReactNode }) => {
const renderContentWithProps = async (props: { appendContent?: React.ReactNode; comment: TestComment; prependContent?: React.ReactNode }) => {
await act(async () => {
root.render(createElement(CommentContent, props as any));
});
@@ -268,6 +268,26 @@ describe('CommentContent', () => {
expect(blockquote?.querySelectorAll('br')).toHaveLength(1);
});
it('renders appended content after markdown with a two-line break separator', async () => {
await renderContentWithProps({
appendContent: createElement('span', { 'data-testid': 'append' }, 'media failed'),
comment: {
cid: 'post-1',
content: 'body',
postCid: 'post-1',
},
});
const blockquote = container.querySelector('blockquote');
const markdown = container.querySelector('[data-testid="markdown"]');
const append = container.querySelector('[data-testid="append"]');
expect(markdown?.textContent).toBe('body');
expect(append?.textContent).toBe('media failed');
expect(blockquote?.lastChild).toBe(append);
expect(blockquote?.querySelectorAll('br')).toHaveLength(2);
});
it('truncates long comments outside the post view and expands them on demand', async () => {
const longComment = 'x'.repeat(1105);
@@ -70,7 +70,7 @@ const getFailedCommentError = (comment: Comment | undefined): unknown => {
return Array.isArray(comment.errors) ? comment.errors.find(Boolean) : undefined;
};
const CommentContent = ({ comment: post, prependContent }: { comment: Comment; prependContent?: ReactNode }) => {
const CommentContent = ({ appendContent, comment: post, prependContent }: { appendContent?: ReactNode; comment: Comment; prependContent?: ReactNode }) => {
const { t } = useTranslation();
const params = useParams();
const location = useLocation();
@@ -306,6 +306,13 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p
{loadingString}
</>
)}
{appendContent && (
<>
<br />
<br />
{appendContent}
</>
)}
</blockquote>
);
};
@@ -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';
+37 -7
View File
@@ -25,7 +25,7 @@ import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import { useCurrentTime } from '../../hooks/use-current-time';
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
import CommentContent from '../comment-content';
import CommentMedia from '../comment-media';
import CommentMedia, { MediaLoadFailureInfo } from '../comment-media';
import FailedPublishNotice from '../failed-publish-notice';
import LoadingEllipsis from '../loading-ellipsis';
import PostMenuMobile from './post-menu-mobile';
@@ -70,7 +70,14 @@ const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingSt
// Store scroll position for replies virtuoso across navigations
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, postsByAuthorInThread }: PostProps & { postsByAuthorInThread?: Map<string, number> }) => {
const PostInfoAndMedia = ({
onMediaLoadFailureChange,
post,
postReplyCount = 0,
roles,
threadNumber,
postsByAuthorInThread,
}: PostProps & { onMediaLoadFailureChange?: (url: string | undefined) => void; postsByAuthorInThread?: Map<string, number> }) => {
const { t } = useTranslation();
const directories = useDirectories();
const resolvedPost = withResolvedCommentCommunityAddress(post);
@@ -441,12 +448,22 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
</span>
</span>
</div>
{(hasThumbnail || link) && !(deleted || removed || purged) && <PostMediaContent key={cid} post={resolvedPost} link={link} />}
{(hasThumbnail || link) && !(deleted || removed || purged) && (
<PostMediaContent key={cid} onMediaLoadFailureChange={onMediaLoadFailureChange} post={resolvedPost} link={link} />
)}
</>
);
};
const PostMediaContent = ({ post, link }: { post: Comment | undefined; link: string }) => {
const PostMediaContent = ({
onMediaLoadFailureChange,
post,
link,
}: {
onMediaLoadFailureChange?: (url: string | undefined) => void;
post: Comment | undefined;
link: string;
}) => {
const [showThumbnail, setShowThumbnail] = useState(true);
const { thumbnailUrl, linkWidth, linkHeight, spoiler, deleted, removed, parentCid } = post || {};
const purged = post?.commentModeration?.purged;
@@ -465,6 +482,7 @@ const PostMediaContent = ({ post, link }: { post: Comment | undefined; link: str
setShowThumbnail={setShowThumbnail}
parentCid={parentCid}
spoiler={spoiler}
onMediaLoadFailureChange={onMediaLoadFailureChange}
/>
)
);
@@ -536,6 +554,8 @@ const Reply = ({
onRetry={canRetryFailedPost ? onRetryFailedPost : undefined}
/>
) : undefined;
const [failedMediaUrl, setFailedMediaUrl] = useState<string | undefined>();
const mediaLoadFailureInfo = failedMediaUrl && failedMediaUrl === post?.link ? <MediaLoadFailureInfo url={failedMediaUrl} /> : undefined;
return (
<div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
@@ -546,9 +566,16 @@ const Reply = ({
data-author-address={author?.shortAddress}
data-post-cid={postCid}
>
<PostInfoAndMedia post={post} postReplyCount={postReplyCount} postsByAuthorInThread={postsByAuthorInThread} roles={roles} threadNumber={threadNumber} />
<PostInfoAndMedia
onMediaLoadFailureChange={setFailedMediaUrl}
post={post}
postReplyCount={postReplyCount}
postsByAuthorInThread={postsByAuthorInThread}
roles={roles}
threadNumber={threadNumber}
/>
{post && !hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && (
<CommentContent comment={post} prependContent={failedPublishNotice} />
<CommentContent appendContent={mediaLoadFailureInfo} comment={post} prependContent={failedPublishNotice} />
)}
{post && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
</div>
@@ -681,6 +708,8 @@ const PostMobile = ({
onRetry={canRetryFailedPost ? onRetryFailedPost : undefined}
/>
) : undefined;
const [failedMediaUrl, setFailedMediaUrl] = useState<string | undefined>();
const mediaLoadFailureInfo = failedMediaUrl && failedMediaUrl === resolvedPost?.link ? <MediaLoadFailureInfo url={failedMediaUrl} /> : undefined;
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
@@ -837,13 +866,14 @@ const PostMobile = ({
>
{shouldShowSnow() && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
<PostInfoAndMedia
onMediaLoadFailureChange={setFailedMediaUrl}
post={resolvedPost}
postReplyCount={replyCount}
postsByAuthorInThread={postsByAuthorInThread}
roles={roles}
threadNumber={resolvedPost?.number}
/>
{resolvedPost && <CommentContent comment={resolvedPost} prependContent={failedPublishNotice} />}
{resolvedPost && <CommentContent appendContent={mediaLoadFailureInfo} comment={resolvedPost} prependContent={failedPublishNotice} />}
{resolvedPost && <ReplyBacklinks post={resolvedPost} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
</div>
{!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (