mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user