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
@@ -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>