fix(youtube thumbnails): prefer best available image

Prefer the best available YouTube thumbnail, fall back cleanly when high-resolution images are unavailable, and keep the React Doctor PR gate scoped to newly introduced issues.
This commit is contained in:
Tommaso Casaburi
2026-06-13 13:39:22 +07:00
committed by GitHub
parent c3d72e53cc
commit 6cb28b0e4f
16 changed files with 813 additions and 199 deletions
+12 -4
View File
@@ -1,7 +1,7 @@
# React Doctor PR review.
# Reports only issues this PR INTRODUCES (diffed against the merge-base) as inline
# review comments + a sticky summary. It reads doctor.config.jsonc, so the
# React-Compiler rules we don't enforce stay suppressed (see that file and
# Reports only issues this PR INTRODUCES (diffed against the base branch) as
# GitHub annotations. It reads doctor.config.jsonc, so the React-Compiler rules
# we don't enforce stay suppressed (see that file and
# docs/agent-playbooks/known-surprises.md). We do NOT chase the aggregate score.
name: React Doctor
@@ -25,4 +25,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # so React Doctor can diff against the merge-base for new-vs-existing
- uses: millionco/react-doctor@v1
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable
- name: Install dependencies
run: yarn install --immutable
- name: Run React Doctor on changed files
run: yarn doctor --verbose --scope changed --base "origin/${{ github.base_ref }}" --annotations --blocking error
@@ -185,7 +185,7 @@ vi.mock('../../../hooks/use-hide', () => ({
}),
}));
vi.mock('../../post-desktop/post-menu-desktop', () => ({
vi.mock('../../post-desktop/post-menu-desktop/post-menu-desktop', () => ({
default: ({ postMenu }: { postMenu: { cid?: string } }) => createElement('span', { 'data-testid': `post-menu-${postMenu.cid}` }, 'menu'),
}));
+30 -7
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo, type SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useParams } from 'react-router-dom';
@@ -16,10 +16,11 @@ import useEditCommentPrivileges from '../../hooks/use-author-privileges';
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback';
import useHide from '../../hooks/use-hide';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { CATALOG_PREVIEW_MARKDOWN_OPTIONS, removeMarkdown } from '../../lib/utils/post-utils';
import PostMenuDesktop from '../post-desktop/post-menu-desktop';
import PostMenuDesktop from '../post-desktop/post-menu-desktop/post-menu-desktop';
import styles from './catalog-row.module.css';
import capitalize from 'lodash/capitalize';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
@@ -39,11 +40,31 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
void cid;
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const {
handleThumbnailError: handleIframeThumbnailError,
handleThumbnailLoad: handleIframeThumbnailLoad,
isUnavailable: isIframeThumbnailUnavailable,
thumbnailUrl: resolvedIframeThumbnail,
} = useYouTubeThumbnailFallback(iframeThumbnail);
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const handleLoad = () => setIsLoaded(true);
const handleError = () => setHasError(true);
const handleIframeLoad = (event: SyntheticEvent<HTMLImageElement>) => {
if (handleIframeThumbnailLoad(event.currentTarget)) {
return;
}
handleLoad();
};
const handleIframeError = () => {
if (handleIframeThumbnailError()) {
return;
}
handleError();
};
const loadingStyle = { opacity: isLoaded ? 1 : 0 };
const { imageSize } = useCatalogStyleStore();
@@ -96,22 +117,24 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
);
} else if (type === 'webpage' && !hasError) {
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
} else if (type === 'iframe' && iframeThumbnail && !hasError) {
thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
} else if (type === 'iframe' && resolvedIframeThumbnail && !hasError) {
thumbnailComponent = (
<img src={resolvedIframeThumbnail} alt='' onLoad={handleIframeLoad} onError={handleIframeError} style={loadingStyle} width={numericWidth} height={numericHeight} />
);
} else if (type === 'audio') {
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
}
return (
<div
className={hasError ? '' : styles.mediaWrapper}
className={hasError || isIframeThumbnailUnavailable ? '' : styles.mediaWrapper}
style={{
...CSSProperties,
...(matchedFilterColor ? { border: `3px solid ${matchedFilterColor}` } : {}),
}}
>
{!isLoaded && !hasError && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
{hasError ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
{!isLoaded && !hasError && !isIframeThumbnailUnavailable && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
{hasError || isIframeThumbnailUnavailable ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
</div>
);
};
@@ -29,6 +29,17 @@ vi.mock('../../../lib/utils/media-utils', () => ({
getDisplayMediaInfoType: (type: string) => type,
getHasThumbnail: () => testState.getHasThumbnailResult,
getMediaDimensions: () => '640x480',
getYouTubeThumbnailFallbackUrls: (thumbnailUrl?: string) => {
if (!thumbnailUrl?.includes('img.youtube.com/vi/')) return thumbnailUrl ? [thumbnailUrl] : [];
const videoId = thumbnailUrl.split('/vi/')[1]?.split('/')[0];
return [
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
`https://img.youtube.com/vi/${videoId}/sddefault.jpg`,
`https://img.youtube.com/vi/${videoId}/mqdefault.jpg`,
`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
];
},
isMissingYouTubeThumbnailImage: (thumbnailUrl: string, width: number, height: number) => thumbnailUrl.includes('img.youtube.com/vi/') && width === 120 && height === 90,
}));
vi.mock('../../../lib/utils/url-utils', () => ({
@@ -64,12 +75,15 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../embed', () => ({
vi.mock('../../embed/embed', () => ({
__esModule: true,
canEmbed: () => testState.canEmbed,
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
}));
vi.mock('../../embed/embed-utils', () => ({
canEmbed: () => testState.canEmbed,
}));
vi.mock('@ruffle-rs/ruffle', () => ({}));
let container: HTMLDivElement;
@@ -298,7 +312,7 @@ describe('CommentMedia', () => {
it('renders a youtube thumbnail before the embed is opened', async () => {
await renderMedia({
commentMediaInfo: {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
},
@@ -306,10 +320,34 @@ describe('CommentMedia', () => {
showThumbnail: true,
});
expect(container.querySelector('img[src="https://img.youtube.com/vi/abc123/0.jpg"]')).toBeTruthy();
expect(container.querySelector('img[src="https://img.youtube.com/vi/abc123/maxresdefault.jpg"]')).toBeTruthy();
expect(container.querySelector('[data-testid="embed"]')).toBeNull();
});
it('falls back when youtube serves the missing-thumbnail placeholder as a loaded image', async () => {
await renderMedia({
commentMediaInfo: {
patternThumbnailUrl: 'https://img.youtube.com/vi/missing-max/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=missing-max',
},
setShowThumbnail: setShowThumbnailMock,
showThumbnail: true,
});
const image = container.querySelector<HTMLImageElement>('img[src="https://img.youtube.com/vi/missing-max/maxresdefault.jpg"]');
expect(image).toBeTruthy();
if (!image) throw new Error('Expected youtube thumbnail image to render');
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 120 });
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 90 });
await act(async () => {
image.dispatchEvent(new Event('load', { bubbles: true }));
});
expect(container.querySelector('img[src="https://img.youtube.com/vi/missing-max/sddefault.jpg"]')).toBeTruthy();
});
it('renders the expanded embed view with a close button on mobile', async () => {
testState.isMobile = true;
+12 -4
View File
@@ -5,8 +5,10 @@ import { getHostname, parseHttpUrl } from '../../lib/utils/url-utils';
import useExpandedMediaStore from '../../stores/use-expanded-media-store';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsMobile from '../../hooks/use-is-mobile';
import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback';
import styles from './comment-media.module.css';
import Embed, { canEmbed } from '../embed';
import Embed from '../embed/embed';
import { canEmbed } from '../embed/embed-utils';
import RufflePlayer from './ruffle-player';
interface MediaProps {
@@ -108,8 +110,14 @@ const Thumbnail = ({
let thumbnailComponent: React.ReactNode = null;
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const {
handleThumbnailError: handleIframeThumbnailError,
handleThumbnailLoad: handleIframeThumbnailLoad,
isUnavailable: isIframeThumbnailUnavailable,
thumbnailUrl: resolvedIframeThumbnail,
} = useYouTubeThumbnailFallback(iframeThumbnail);
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
const hasThumbnail = getHasThumbnail(commentMediaInfo, url) && !isIframeThumbnailUnavailable;
const handleOpenMedia = () => setShowThumbnail(false);
if (type === 'gif') {
@@ -137,9 +145,9 @@ const Thumbnail = ({
</button>
);
} else if (type === 'iframe') {
thumbnailComponent = iframeThumbnail ? (
thumbnailComponent = resolvedIframeThumbnail ? (
<button type='button' className={styles.mediaToggleButton} aria-label='Open embedded media preview' onClick={handleOpenMedia}>
<img src={iframeThumbnail} alt='' />
<img src={resolvedIframeThumbnail} alt='' onLoad={(event) => handleIframeThumbnailLoad(event.currentTarget)} onError={() => handleIframeThumbnailError()} />
</button>
) : null;
} else if (type === 'audio') {
@@ -643,7 +643,7 @@ describe('Markdown', () => {
testState.embeddableHosts = new Set(['www.youtube.com']);
testState.mediaInfoByHref = {
'https://www.youtube.com/watch?v=abc123': {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
},
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
subscriptions: ['music-posting.eth'],
},
accountComment: undefined as { communityAddress?: string } | undefined,
bestAvailableYouTubeThumbnailMock: undefined as undefined | ((link: string) => Promise<string | undefined>),
accountCommunityAddresses: ['mod.eth'] as string[],
comments: {} as Record<string, { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
directories: [
@@ -354,7 +355,7 @@ vi.mock('../../../lib/utils/media-utils', () => {
return { type: 'audio', url: link };
}
if (link.includes('youtube.com')) {
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', type: 'iframe', url: link };
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', type: 'iframe', url: link };
}
return { type: 'webpage', url: link };
},
@@ -364,7 +365,21 @@ vi.mock('../../../lib/utils/media-utils', () => {
const url = new URL(link);
if (!url.hostname.includes('youtube.com')) return undefined;
const videoId = url.searchParams.get('v');
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
return videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : undefined;
} catch {
return undefined;
}
},
getBestAvailableYouTubeThumbnailUrlFromLink: async (link: string) => {
if (testState.bestAvailableYouTubeThumbnailMock) {
return testState.bestAvailableYouTubeThumbnailMock(link);
}
try {
const url = new URL(link);
if (!url.hostname.includes('youtube.com')) return undefined;
const videoId = url.searchParams.get('v');
return videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : undefined;
} catch {
return undefined;
}
@@ -516,6 +531,7 @@ describe('PostForm', () => {
subscriptions: ['music-posting.eth'],
};
testState.accountComment = undefined;
testState.bestAvailableYouTubeThumbnailMock = undefined;
testState.accountCommunityAddresses = ['mod.eth'];
testState.comments = {};
testState.directories = [
@@ -689,7 +705,7 @@ describe('PostForm', () => {
it('converts YouTube links to thumbnail file links on media-only post forms', async () => {
const youtubeLink = 'https://www.youtube.com/watch?v=abc123';
const thumbnailLink = 'https://img.youtube.com/vi/abc123/0.jpg';
const thumbnailLink = 'https://img.youtube.com/vi/abc123/maxresdefault.jpg';
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
@@ -749,6 +765,47 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
});
it('ignores duplicate post clicks while youtube thumbnail resolution is pending', async () => {
const youtubeLink = 'https://www.youtube.com/watch?v=slow123';
const thumbnailLink = 'https://img.youtube.com/vi/slow123/maxresdefault.jpg';
let resolveThumbnail: (thumbnailLink: string) => void = () => {};
const thumbnailPromise = new Promise<string | undefined>((resolve) => {
resolveThumbnail = resolve;
});
const resolverMock = vi.fn(() => thumbnailPromise);
testState.bestAvailableYouTubeThumbnailMock = resolverMock;
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const select = table.querySelector('select') as HTMLSelectElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
const postButton = Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
await dispatchChange(select, 'music-posting.eth');
await dispatchInput(textarea, 'Video body');
await dispatchInput(linkInput, youtubeLink);
await clickByText(table, 'post');
expect(postButton.disabled).toBe(true);
await clickByText(table, 'post');
expect(resolverMock).toHaveBeenCalledTimes(1);
expect(testState.publishPostMock).not.toHaveBeenCalled();
await act(async () => {
resolveThumbnail(thumbnailLink);
await thumbnailPromise;
await Promise.resolve();
});
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe(thumbnailLink);
expect(testState.publishedPostOptions?.content).toBe(`${youtubeLink}\nVideo body`);
});
it('publishes known twimg query-format post links with a path extension without editing the field', async () => {
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
+110 -98
View File
@@ -44,6 +44,7 @@ import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion';
import usePublishSubmissionGuard from '../../hooks/use-publish-submission-guard';
import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload';
@@ -104,6 +105,7 @@ const PostFormActions = ({
onPublishReply,
onPublishPost,
handleUpload,
isPublishSubmissionInFlight,
isUploading,
showUploadControls,
}: {
@@ -111,22 +113,23 @@ const PostFormActions = ({
variant: 'reply' | 'post' | 'upload';
t: TFunction;
isInPostView: boolean;
onPublishReply: () => void;
onPublishPost: () => void;
onPublishReply: () => void | Promise<void>;
onPublishPost: () => void | Promise<void>;
handleUpload: () => void;
isPublishSubmissionInFlight: boolean;
isUploading: boolean;
showUploadControls: boolean;
}) => {
if (variant === 'reply' && isInPostView) {
return (
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isPublishSubmissionInFlight || isUploading}>
{t('post')}
</button>
);
}
if (variant === 'post' && !isInPostView) {
return (
<button type='button' onClick={onPublishPost}>
<button type='button' onClick={onPublishPost} disabled={isPublishSubmissionInFlight}>
{t('post')}
</button>
);
@@ -186,11 +189,12 @@ interface PostFormFieldsProps {
showMathTagsPrompt: boolean;
showBbcodeToolbar: boolean;
onBbcodePreviewToggle: () => void;
onPublishReply: () => void;
onPublishPost: () => void;
onPublishReply: () => void | Promise<void>;
onPublishPost: () => void | Promise<void>;
handleUpload: () => void;
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
onOekakiClearUploadedUrl: (url: string) => void;
isPublishSubmissionInFlight: boolean;
disableReplyPublish: boolean;
}
@@ -244,6 +248,7 @@ const PostFormFields = ({
handleUpload,
uploadFile,
onOekakiClearUploadedUrl,
isPublishSubmissionInFlight,
disableReplyPublish,
}: PostFormFieldsProps) => (
<>
@@ -273,6 +278,7 @@ const PostFormFields = ({
onPublishPost={onPublishPost}
handleUpload={handleUpload}
disableReplyPublish={disableReplyPublish}
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
isUploading={isUploading}
showUploadControls={showUploadControls}
/>
@@ -304,6 +310,7 @@ const PostFormFields = ({
onPublishPost={onPublishPost}
handleUpload={handleUpload}
disableReplyPublish={disableReplyPublish}
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
isUploading={isUploading}
showUploadControls={showUploadControls}
/>
@@ -393,6 +400,7 @@ const PostFormFields = ({
onPublishPost={onPublishPost}
handleUpload={handleUpload}
disableReplyPublish={disableReplyPublish}
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
isUploading={isUploading}
showUploadControls={showUploadControls}
/>
@@ -588,6 +596,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const [formError, setFormError] = useState<string | PostOptionsValidationError | null>(null);
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const { isPublishSubmissionInFlight, runPublishSubmission } = usePublishSubmissionGuard();
const checkContentLength = useRef(
debounce((content: string, t: TFunction, options: string, directoryCode: string | undefined) => {
@@ -645,66 +654,67 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
return params?.boardIdentifier ? `/${params.boardIdentifier}` : null;
};
const onPublishPost = () => {
const appliedYouTubeConversion = applyPendingConversion();
const onPublishPost = () =>
runPublishSubmission(async () => {
const appliedYouTubeConversion = await applyPendingConversion();
const currentTitle = subjectRef.current?.value.trim() || '';
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const currentTitle = subjectRef.current?.value.trim() || '';
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLength.cancel();
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
nonokoRedirectPathRef.current = null;
checkContentLength.cancel();
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
nonokoRedirectPathRef.current = null;
if (currentOptionsError) {
setFormError(currentOptionsError);
return;
}
if (currentOptionsError) {
setFormError(currentOptionsError);
return;
}
if (!currentTitle && !publishContent.trim() && !currentUrl) {
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setFormError(expiringMediaLinkAlert);
return;
}
if (!currentTitle && !publishContent.trim() && !currentUrl) {
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setFormError(expiringMediaLinkAlert);
return;
}
if (publishContent.trim().length > 2000) {
setFormError(`${t('error')}: ${t('field_too_long')}`);
return;
}
if (publishContent.trim().length > 2000) {
setFormError(`${t('error')}: ${t('field_too_long')}`);
return;
}
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
return;
}
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
return;
}
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
const publishOptions = {
...flagPublishOptions,
...(flairs ? { flairs } : {}),
};
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
const publishOptions = {
...flagPublishOptions,
...(flairs ? { flairs } : {}),
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
});
// redirect to pending page when pending comment is created
const navigate = useNavigate();
@@ -774,54 +784,55 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setIsBbcodePreviewing(true);
};
const onPublishReply = () => {
const appliedYouTubeConversion = applyPendingConversion();
const onPublishReply = () =>
runPublishSubmission(async () => {
const appliedYouTubeConversion = await applyPendingConversion();
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLength.cancel();
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
nonokoRedirectPathRef.current = null;
checkContentLength.cancel();
checkPostOptions.cancel();
setLengthError(null);
setFormError(null);
nonokoRedirectPathRef.current = null;
if (currentOptionsError) {
setFormError(currentOptionsError);
return;
}
if (currentOptionsError) {
setFormError(currentOptionsError);
return;
}
if (!publishContent.trim() && !currentUrl) {
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
if (!publishContent.trim() && !currentUrl) {
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setFormError(expiringMediaLinkAlert);
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setFormError(expiringMediaLinkAlert);
return;
}
if (publishContent.trim().length > 2000) {
setFormError(`${t('error')}: ${t('field_too_long')}`);
return;
}
if (publishContent.trim().length > 2000) {
setFormError(`${t('error')}: ${t('field_too_long')}`);
return;
}
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
});
const setLinkValue = (nextUrl: string) => {
setUrl(nextUrl);
@@ -966,6 +977,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
handleUpload={handleUpload}
uploadFile={uploadFile}
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
disableReplyPublish={isResolvingExternalQuotes}
/>
</tbody>
@@ -62,6 +62,7 @@ const testState = vi.hoisted(() => ({
address: 'music-posting.eth',
},
} as Record<string, { address: string }>,
fetchMock: vi.fn(),
showUploadControls: true,
uploadComplete: undefined as ((url: string) => void) | undefined,
uploadedFileName: null as string | null,
@@ -190,22 +191,32 @@ vi.mock('../../../hooks/use-stable-community', () => ({
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
}));
vi.mock('../../../hooks/use-publish-reply', () => ({
default: () => ({
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
publishReply: (options?: Record<string, unknown>) => {
if (options) {
testState.setPublishReplyOptionsMock(options);
}
return testState.publishReplyMock(options);
vi.mock('../../../hooks/use-publish-reply', async () => {
const React = await vi.importActual<typeof import('react')>('react');
return {
default: () => {
const [, forceUpdate] = React.useReducer((value: number) => value + 1, 0);
return {
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
publishReply: (options?: Record<string, unknown>) => {
if (options) {
testState.setPublishReplyOptionsMock(options);
}
const result = testState.publishReplyMock(options);
forceUpdate();
return result;
},
publishReplyError: testState.publishReplyError,
publishReplyStateMessage: testState.publishReplyStateMessage,
replyIndex: testState.replyIndex,
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
};
},
publishReplyError: testState.publishReplyError,
publishReplyStateMessage: testState.publishReplyStateMessage,
replyIndex: testState.replyIndex,
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
}),
}));
};
});
vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
@@ -452,6 +463,14 @@ describe('ReplyModal', () => {
address: 'traditional-games.bso',
},
};
testState.fetchMock.mockReset();
testState.fetchMock.mockResolvedValue({
headers: {
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
},
ok: true,
});
vi.stubGlobal('fetch', testState.fetchMock);
testState.showUploadControls = true;
testState.uploadComplete = undefined;
testState.uploadedFileName = null;
@@ -467,6 +486,7 @@ describe('ReplyModal', () => {
container.remove();
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
vi.unstubAllGlobals();
});
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
@@ -772,7 +792,7 @@ describe('ReplyModal', () => {
it('moves YouTube links into reply content and publishes the thumbnail link on media-only boards', async () => {
const youtubeLink = 'https://youtu.be/reply123';
const thumbnailLink = 'https://img.youtube.com/vi/reply123/0.jpg';
const thumbnailLink = 'https://img.youtube.com/vi/reply123/maxresdefault.jpg';
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
@@ -808,6 +828,84 @@ describe('ReplyModal', () => {
});
});
it('rejects unresolved YouTube links on media-only reply modal boards', async () => {
const youtubeLink = 'https://youtu.be/replymissing';
testState.fetchMock.mockResolvedValue({
headers: {
get: () => null,
},
ok: false,
});
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
title: '/mu/ - Music',
};
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
await dispatchInput(linkInput, youtubeLink);
await clickButtonByText('post');
await flushEffects(8);
expect(testState.fetchMock).toHaveBeenCalled();
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(linkInput.value).toBe(youtubeLink);
expect(testState.publishReplyMock).not.toHaveBeenCalled();
});
it('ignores duplicate reply clicks while youtube thumbnail resolution is pending', async () => {
const youtubeLink = 'https://youtu.be/replyslow';
const thumbnailLink = 'https://img.youtube.com/vi/replyslow/maxresdefault.jpg';
let resolveFetch: (response: unknown) => void = () => {};
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
testState.fetchMock.mockReturnValue(fetchPromise);
testState.openEmpty = true;
testState.selectedText = 'reply body';
testState.directoryByAddress['music-posting.eth'] = {
address: 'music-posting.eth',
features: { requirePostLinkIsMedia: true },
title: '/mu/ - Music',
};
await renderReplyModal('/mu/thread/post-1');
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const postButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
await dispatchInput(linkInput, youtubeLink);
await clickButtonByText('post');
expect(postButton.disabled).toBe(true);
await clickButtonByText('post');
expect(testState.fetchMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).not.toHaveBeenCalled();
await act(async () => {
resolveFetch({
headers: {
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
},
ok: true,
});
await fetchPromise;
await Promise.resolve();
});
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`,
link: thumbnailLink,
});
});
it('validates unsupported options and keeps fortune output out of preview state until reply publish', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
@@ -1065,6 +1163,7 @@ describe('ReplyModal', () => {
await dispatchInput(optionsInput, 'nonoko');
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
await clickButtonByText('post');
await flushEffects();
await rerenderReplyModal('/mu/thread/post-1');
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
+46 -39
View File
@@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions } from '../../lib/utils/media-link-validation-utils';
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions, isPublishFileMediaLink } from '../../lib/utils/media-link-validation-utils';
import { getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
import {
@@ -33,6 +33,7 @@ import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload';
import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion';
import usePublishSubmissionGuard from '../../hooks/use-publish-submission-guard';
import { useCommunityField } from '../../hooks/use-stable-community';
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
@@ -127,6 +128,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const [showTexPreview, setShowTexPreview] = useState(false);
const { isPublishSubmissionInFlight, runPublishSubmission } = usePublishSubmissionGuard();
const texButtonRef = useRef<HTMLButtonElement>(null);
// Blur in the close handler so the TeX button doesn't keep a lingering focus state
// (focus-visible promotion on Escape, focus-triggered tooltip) after the preview closes.
@@ -157,51 +159,56 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}, POST_OPTIONS_VALIDATION_DELAY_MS),
);
const onPublishReply = () => {
const appliedYouTubeConversion = applyPendingConversion();
const onPublishReply = () =>
runPublishSubmission(async () => {
const appliedYouTubeConversion = await applyPendingConversion();
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
setLengthError(null);
nonokoRedirectPathRef.current = null;
checkContentLengthRef.current.cancel();
checkPostOptionsRef.current.cancel();
setLengthError(null);
nonokoRedirectPathRef.current = null;
if (currentOptionsError) {
setError(currentOptionsError);
return;
}
if (currentOptionsError) {
setError(currentOptionsError);
return;
}
if (!publishContent.trim() && !currentUrl) {
setError(t('error') + ': ' + t('empty_comment_alert'));
return;
}
if (!publishContent.trim() && !currentUrl) {
setError(t('error') + ': ' + t('empty_comment_alert'));
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setError(t('error') + ': ' + t('invalid_url_alert'));
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setError(expiringMediaLinkAlert);
return;
}
if (currentUrl && !isValidPublishURL(currentUrl)) {
setError(t('error') + ': ' + t('invalid_url_alert'));
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
setError(t('error') + ': ' + t('link_not_image_or_video_alert'));
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setError(expiringMediaLinkAlert);
return;
}
if (publishContent.trim().length > 2000) {
setError(t('error') + ': ' + t('field_too_long'));
return;
}
if (publishContent.trim().length > 2000) {
setError(t('error') + ': ' + t('field_too_long'));
return;
}
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
};
setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
});
useEffect(() => {
if (typeof replyIndex === 'number') {
@@ -698,7 +705,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
]
</span>
)}
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} type='button' onClick={onPublishReply}>
<button className={styles.publishButton} disabled={isResolvingExternalQuotes || isPublishSubmissionInFlight} type='button' onClick={onPublishReply}>
{t('post')}
</button>
</div>
@@ -0,0 +1,64 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useYouTubeThumbnailFallback } from '../use-youtube-thumbnail-fallback';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
let container: HTMLDivElement;
let root: Root;
let latestValue: ReturnType<typeof useYouTubeThumbnailFallback>;
const HookHarness = ({ thumbnailUrl }: { thumbnailUrl: string | undefined }) => {
latestValue = useYouTubeThumbnailFallback(thumbnailUrl);
return null;
};
const renderFallback = async (thumbnailUrl: string | undefined) => {
await act(async () => {
root.render(createElement(HookHarness, { thumbnailUrl }));
});
};
const getPlaceholderImage = () => ({ naturalHeight: 90, naturalWidth: 120 }) as HTMLImageElement;
describe('useYouTubeThumbnailFallback', () => {
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('advances to the next YouTube fallback when a placeholder loads before the final candidate', async () => {
await renderFallback('https://img.youtube.com/vi/missing-max/maxresdefault.jpg');
let handled = false;
await act(async () => {
handled = latestValue.handleThumbnailLoad(getPlaceholderImage());
});
expect(handled).toBe(true);
expect(latestValue.isUnavailable).toBe(false);
expect(latestValue.thumbnailUrl).toBe('https://img.youtube.com/vi/missing-max/sddefault.jpg');
});
it('marks the thumbnail unavailable when the last YouTube candidate is a placeholder', async () => {
await renderFallback('https://img.youtube.com/vi/missing-hq/hqdefault.jpg');
let handled = false;
await act(async () => {
handled = latestValue.handleThumbnailLoad(getPlaceholderImage());
});
expect(handled).toBe(true);
expect(latestValue.isUnavailable).toBe(true);
expect(latestValue.thumbnailUrl).toBeUndefined();
});
});
+26
View File
@@ -0,0 +1,26 @@
import { useCallback, useRef, useState } from 'react';
const usePublishSubmissionGuard = () => {
const publishSubmissionInFlightRef = useRef(false);
const [isPublishSubmissionInFlight, setIsPublishSubmissionInFlight] = useState(false);
const runPublishSubmission = useCallback(async (publish: () => Promise<void>) => {
if (publishSubmissionInFlightRef.current) {
return;
}
publishSubmissionInFlightRef.current = true;
setIsPublishSubmissionInFlight(true);
try {
await publish();
} finally {
publishSubmissionInFlightRef.current = false;
setIsPublishSubmissionInFlight(false);
}
}, []);
return { isPublishSubmissionInFlight, runPublishSubmission };
};
export default usePublishSubmissionGuard;
@@ -0,0 +1,48 @@
import { useCallback, useMemo, useState } from 'react';
import { getYouTubeThumbnailFallbackUrls, isMissingYouTubeThumbnailImage } from '../lib/utils/media-utils';
interface YouTubeThumbnailFallbackState {
sourceUrl?: string;
index: number;
unavailable: boolean;
}
export const useYouTubeThumbnailFallback = (thumbnailUrl: string | undefined) => {
const fallbackUrls = useMemo(() => getYouTubeThumbnailFallbackUrls(thumbnailUrl), [thumbnailUrl]);
const [state, setState] = useState<YouTubeThumbnailFallbackState>({ index: 0, unavailable: false });
const index = state.sourceUrl === thumbnailUrl ? state.index : 0;
const unavailable = state.sourceUrl === thumbnailUrl ? state.unavailable : false;
const resolvedThumbnailUrl = unavailable ? undefined : (fallbackUrls[index] ?? thumbnailUrl);
const advance = useCallback(() => {
if (!thumbnailUrl || !fallbackUrls.length) {
return false;
}
if (index < fallbackUrls.length - 1) {
setState({ index: index + 1, sourceUrl: thumbnailUrl, unavailable: false });
return true;
}
setState({ index, sourceUrl: thumbnailUrl, unavailable: true });
return true;
}, [fallbackUrls, index, thumbnailUrl]);
const handleThumbnailLoad = useCallback(
(image: HTMLImageElement) => {
if (!resolvedThumbnailUrl || !isMissingYouTubeThumbnailImage(resolvedThumbnailUrl, image.naturalWidth, image.naturalHeight)) {
return false;
}
return advance();
},
[advance, resolvedThumbnailUrl],
);
return {
handleThumbnailError: advance,
handleThumbnailLoad,
isUnavailable: unavailable,
thumbnailUrl: resolvedThumbnailUrl,
};
};
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getYouTubeThumbnailUrlFromLink } from '../lib/utils/media-utils';
import { getBestAvailableYouTubeThumbnailUrlFromLink, getYouTubeThumbnailUrlFromLink } from '../lib/utils/media-utils';
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_DELAY_SECONDS = 3;
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_INTERVAL_MS = 1000;
@@ -9,7 +9,7 @@ type ElementRef<T extends HTMLElement> = {
};
interface PendingYouTubeThumbnailLinkConversion {
thumbnailLink: string;
thumbnailLinkPromise: Promise<string | undefined>;
youtubeLink: string;
}
@@ -29,13 +29,13 @@ const getContentWithYouTubeLinkAtTop = (content: string, youtubeLink: string): s
const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => {
const youtubeLink = link.trim();
const thumbnailLink = getYouTubeThumbnailUrlFromLink(youtubeLink);
const preferredThumbnailLink = getYouTubeThumbnailUrlFromLink(youtubeLink);
if (!youtubeLink || !thumbnailLink || youtubeLink === thumbnailLink) {
if (!youtubeLink || !preferredThumbnailLink || youtubeLink === preferredThumbnailLink) {
return null;
}
return { thumbnailLink, youtubeLink };
return { thumbnailLinkPromise: getBestAvailableYouTubeThumbnailUrlFromLink(youtubeLink), youtubeLink };
};
export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, onLinkChange, textRef, urlRef }: UseYouTubeThumbnailLinkConversionOptions) => {
@@ -56,7 +56,7 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
setNoticeCountdown(null);
}, [clearCountdownTimer]);
const applyPendingConversion = useCallback(() => {
const applyPendingConversion = useCallback(async () => {
if (!enabled) {
cancelPendingConversion();
return false;
@@ -69,6 +69,13 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
return false;
}
const thumbnailLink = await pendingConversion.thumbnailLinkPromise;
const latestLink = urlRef.current?.value.trim() || '';
if (!thumbnailLink || latestLink !== pendingConversion.youtubeLink) {
cancelPendingConversion();
return false;
}
clearCountdownTimer();
pendingConversionRef.current = null;
setNoticeCountdown(null);
@@ -80,11 +87,11 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
textRef.current.value = nextContent;
}
if (urlRef.current) {
urlRef.current.value = pendingConversion.thumbnailLink;
urlRef.current.value = thumbnailLink;
}
onContentChange(nextContent);
onLinkChange(pendingConversion.thumbnailLink);
onLinkChange(thumbnailLink);
return true;
}, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);
@@ -99,7 +106,7 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
}
if (seconds <= 0) {
applyPendingConversion();
void applyPendingConversion();
return;
}
+98 -8
View File
@@ -4,6 +4,7 @@ const testState = vi.hoisted(() => ({
cachedThumbnails: new Map<string, string>(),
canEmbedHosts: new Set<string>(),
capacitorHttpGetMock: vi.fn(),
capacitorHttpRequestMock: vi.fn(),
consoleErrorMock: vi.fn(),
fetchMock: vi.fn(),
isNativePlatform: false,
@@ -34,10 +35,12 @@ vi.mock('@capacitor/core', () => ({
},
CapacitorHttp: {
get: (options: unknown) => testState.capacitorHttpGetMock(options),
request: (options: unknown) => testState.capacitorHttpRequestMock(options),
},
}));
import {
getBestAvailableYouTubeThumbnailUrlFromLink,
fetchWebpageThumbnailIfNeeded,
getCommentMediaInfo,
getDisplayMediaInfoType,
@@ -47,7 +50,10 @@ import {
getPostMediaTypeLabel,
getTwimgMediaFilePublishUrl,
getYouTubeEmbedPostMediaFileLink,
getYouTubeThumbnailCandidateUrlsFromLink,
getYouTubeThumbnailFallbackUrls,
getYouTubeThumbnailUrlFromLink,
isMissingYouTubeThumbnailImage,
} from '../media-utils';
const clearMemoizedCache = (fn: unknown) => {
@@ -78,6 +84,20 @@ const createFetchResponse = (html: string, ok = true) => {
};
};
const createHeadResponse = (ok: boolean, contentLength = '12345') => ({
headers: {
get: (name: string) => (name.toLowerCase() === 'content-length' ? contentLength : null),
},
ok,
});
const createNativeHeadResponse = (status: number, contentLength = '12345') => ({
headers: {
'Content-Length': contentLength,
},
status,
});
describe('media-utils', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
@@ -92,6 +112,7 @@ describe('media-utils', () => {
});
testState.fetchMock.mockReset();
testState.capacitorHttpGetMock.mockReset();
testState.capacitorHttpRequestMock.mockReset();
vi.stubGlobal('fetch', testState.fetchMock);
clearMemoizedCache(getHasThumbnail);
clearMemoizedCache(getLinkMediaInfo);
@@ -119,18 +140,87 @@ describe('media-utils', () => {
it('uses the youtube thumbnail url for post media file links and labels', () => {
const mediaInfo = {
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
};
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/0.jpg');
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/maxresdefault.jpg');
expect(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video');
expect(getYouTubeEmbedPostMediaFileLink({ type: 'iframe', url: 'https://streamable.com/clip123' })).toBeUndefined();
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/0.jpg');
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/maxresdefault.jpg');
expect(getYouTubeThumbnailUrlFromLink('https://example.com/watch?v=not-youtube')).toBeUndefined();
});
it('builds youtube thumbnail candidates and detects the served missing-thumbnail placeholder', () => {
expect(getYouTubeThumbnailCandidateUrlsFromLink('https://www.youtube.com/watch?v=abc123')).toEqual([
'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
'https://img.youtube.com/vi/abc123/sddefault.jpg',
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
]);
expect(getYouTubeThumbnailFallbackUrls('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg')).toEqual([
'https://i3.ytimg.com/vi/abc123/maxresdefault.jpg',
'https://img.youtube.com/vi/abc123/sddefault.jpg',
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
]);
expect(getYouTubeThumbnailFallbackUrls('https://i3.ytimg.com/vi/abc123/sddefault.jpg')).toEqual([
'https://i3.ytimg.com/vi/abc123/sddefault.jpg',
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
]);
expect(isMissingYouTubeThumbnailImage('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg', 120, 90)).toBe(true);
expect(isMissingYouTubeThumbnailImage('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg', 1280, 720)).toBe(false);
expect(isMissingYouTubeThumbnailImage('https://example.com/thumb.jpg', 120, 90)).toBe(false);
});
it('resolves the best available youtube thumbnail without accepting the failed placeholder response', async () => {
testState.fetchMock
.mockResolvedValueOnce(createHeadResponse(false, '1097'))
.mockResolvedValueOnce(createHeadResponse(true, '1097'))
.mockResolvedValueOnce(createHeadResponse(true, '8710'));
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=resolve123')).resolves.toBe(
'https://img.youtube.com/vi/resolve123/mqdefault.jpg',
);
expect(testState.fetchMock).toHaveBeenNthCalledWith(1, 'https://img.youtube.com/vi/resolve123/maxresdefault.jpg', expect.objectContaining({ method: 'HEAD' }));
expect(testState.fetchMock).toHaveBeenNthCalledWith(2, 'https://img.youtube.com/vi/resolve123/sddefault.jpg', expect.objectContaining({ method: 'HEAD' }));
expect(testState.fetchMock).toHaveBeenNthCalledWith(3, 'https://img.youtube.com/vi/resolve123/mqdefault.jpg', expect.objectContaining({ method: 'HEAD' }));
});
it('uses native http requests when resolving youtube thumbnails in native builds', async () => {
testState.isNativePlatform = true;
testState.capacitorHttpRequestMock.mockResolvedValueOnce(createNativeHeadResponse(404, '1097')).mockResolvedValueOnce(createNativeHeadResponse(200, '8765'));
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=native123')).resolves.toBe(
'https://img.youtube.com/vi/native123/sddefault.jpg',
);
expect(testState.fetchMock).not.toHaveBeenCalled();
expect(testState.capacitorHttpRequestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
connectTimeout: 3000,
method: 'HEAD',
readTimeout: 3000,
url: 'https://img.youtube.com/vi/native123/maxresdefault.jpg',
}),
);
expect(testState.capacitorHttpRequestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: 'HEAD',
url: 'https://img.youtube.com/vi/native123/sddefault.jpg',
}),
);
});
it('returns no youtube thumbnail when every candidate is unavailable', async () => {
testState.fetchMock.mockResolvedValue(createHeadResponse(false, '1097'));
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=missing123')).resolves.toBeUndefined();
});
it('recognizes which media types expose thumbnails', () => {
expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false);
expect(getHasThumbnail({ type: 'image', url: 'https://example.com/file.png' }, 'https://example.com/file.png')).toBe(true);
@@ -141,7 +231,7 @@ describe('media-utils', () => {
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
expect(
getHasThumbnail(
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/0.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/maxresdefault.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
'https://www.youtube.com/watch?v=abc',
),
).toBe(true);
@@ -171,7 +261,7 @@ describe('media-utils', () => {
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
});
@@ -181,13 +271,13 @@ describe('media-utils', () => {
url: 'https://streamable.com/clip123',
});
expect(getLinkMediaInfo('https://yt.example/watch?v=yt123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/maxresdefault.jpg',
type: 'iframe',
url: 'https://yt.example/watch?v=yt123',
});
testState.canEmbedHosts = new Set(['yewtu.be']);
expect(getLinkMediaInfo('https://yewtu.be/invidious123')).toEqual({
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/maxresdefault.jpg',
type: 'iframe',
url: 'https://yewtu.be/invidious123',
});
@@ -237,7 +327,7 @@ describe('media-utils', () => {
expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({
linkHeight: 450,
linkWidth: 800,
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
thumbnail: undefined,
type: 'iframe',
url: 'https://www.youtube.com/watch?v=abc123',
+132 -5
View File
@@ -66,19 +66,146 @@ export const getPostMediaTypeLabel = (commentMediaInfo: CommentMediaInfo | undef
};
const isYouTubeLikeUrl = (url: URL): boolean => youtubeHosts.has(url.host) || url.host.startsWith('yt.');
const YOUTUBE_THUMBNAIL_FILENAMES = ['maxresdefault.jpg', 'sddefault.jpg', 'mqdefault.jpg', 'hqdefault.jpg'] as const;
const YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH = '1097';
const YOUTUBE_MISSING_THUMBNAIL_HEIGHT = 90;
const YOUTUBE_MISSING_THUMBNAIL_WIDTH = 120;
const YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS = 3000;
const youtubeThumbnailResolutionPromises = new Map<string, Promise<string | undefined>>();
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
if (!isYouTubeLikeUrl(url)) {
const getHeaderValue = (headers: Record<string, string>, headerName: string): string | undefined => {
const matchingHeader = Object.entries(headers).find(([name]) => name.toLowerCase() === headerName);
return matchingHeader?.[1];
};
const getYouTubeThumbnailUrlFromVideoId = (videoId: string, filename: (typeof YOUTUBE_THUMBNAIL_FILENAMES)[number]): string => {
return `https://img.youtube.com/vi/${videoId}/${filename}`;
};
const getYouTubeThumbnailVideoId = (url: URL): string | undefined => {
const hostname = url.hostname.toLowerCase();
if (hostname !== 'img.youtube.com' && hostname !== 'i.ytimg.com' && !/^i\d+\.ytimg\.com$/.test(hostname)) {
return undefined;
}
const pathParts = url.pathname.split('/').filter(Boolean);
if (pathParts.length !== 3 || pathParts[0] !== 'vi') {
return undefined;
}
return pathParts[1];
};
export const getYouTubeThumbnailCandidateUrls = (url: URL): string[] => {
if (!isYouTubeLikeUrl(url)) {
return [];
}
const videoId = getYouTubeVideoId(url);
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
return videoId ? YOUTUBE_THUMBNAIL_FILENAMES.map((filename) => getYouTubeThumbnailUrlFromVideoId(videoId, filename)) : [];
};
export const getYouTubeThumbnailCandidateUrlsFromLink = (link: string): string[] => {
const parsedUrl = parseHttpUrl(link.trim());
return parsedUrl ? getYouTubeThumbnailCandidateUrls(parsedUrl) : [];
};
export const getYouTubeThumbnailFallbackUrls = (thumbnailUrl: string | undefined): string[] => {
if (!thumbnailUrl) {
return [];
}
const parsedUrl = parseHttpUrl(thumbnailUrl);
const videoId = parsedUrl ? getYouTubeThumbnailVideoId(parsedUrl) : undefined;
if (!videoId) {
return [thumbnailUrl];
}
const currentFilename = parsedUrl?.pathname.split('/').filter(Boolean)[2];
const startIndex = YOUTUBE_THUMBNAIL_FILENAMES.findIndex((filename) => filename === currentFilename);
const filenames = startIndex >= 0 ? YOUTUBE_THUMBNAIL_FILENAMES.slice(startIndex) : YOUTUBE_THUMBNAIL_FILENAMES;
return filenames.map((filename) => (filename === currentFilename ? thumbnailUrl : getYouTubeThumbnailUrlFromVideoId(videoId, filename)));
};
export const isMissingYouTubeThumbnailImage = (thumbnailUrl: string, width: number, height: number): boolean => {
const parsedUrl = parseHttpUrl(thumbnailUrl);
return Boolean(parsedUrl && getYouTubeThumbnailVideoId(parsedUrl) && width === YOUTUBE_MISSING_THUMBNAIL_WIDTH && height === YOUTUBE_MISSING_THUMBNAIL_HEIGHT);
};
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
return getYouTubeThumbnailCandidateUrls(url)[0];
};
export const getYouTubeThumbnailUrlFromLink = (link: string): string | undefined => {
const parsedUrl = parseHttpUrl(link.trim());
return parsedUrl ? getYouTubeThumbnailUrl(parsedUrl) : undefined;
return getYouTubeThumbnailCandidateUrlsFromLink(link)[0];
};
const isAvailableYouTubeThumbnailUrl = async (thumbnailUrl: string): Promise<boolean> => {
if (Capacitor.isNativePlatform()) {
try {
const response = await CapacitorHttp.request({
url: thumbnailUrl,
method: 'HEAD',
readTimeout: YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS,
connectTimeout: YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS,
});
return response.status >= 200 && response.status < 300 && getHeaderValue(response.headers, 'content-length') !== YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH;
} catch {
return false;
}
}
if (typeof fetch !== 'function') {
return false;
}
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS);
try {
const response = await fetch(thumbnailUrl, {
method: 'HEAD',
signal: controller.signal,
});
return response.ok && response.headers.get('content-length') !== YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH;
} catch {
return false;
} finally {
globalThis.clearTimeout(timeoutId);
}
};
export const getBestAvailableYouTubeThumbnailUrlFromLink = (link: string): Promise<string | undefined> => {
const candidateUrls = getYouTubeThumbnailCandidateUrlsFromLink(link);
if (!candidateUrls.length) {
return Promise.resolve(undefined);
}
const cacheKey = candidateUrls.join('\n');
const cachedPromise = youtubeThumbnailResolutionPromises.get(cacheKey);
if (cachedPromise) {
return cachedPromise;
}
const resolutionPromise = (async () => {
for (const candidateUrl of candidateUrls) {
if (await isAvailableYouTubeThumbnailUrl(candidateUrl)) {
return candidateUrl;
}
}
return undefined;
})().then((thumbnailUrl) => {
if (!thumbnailUrl) {
youtubeThumbnailResolutionPromises.delete(cacheKey);
}
return thumbnailUrl;
});
youtubeThumbnailResolutionPromises.set(cacheKey, resolutionPromise);
return resolutionPromise;
};
export const getHasThumbnail = memoize(