fix(post-form): auto-convert YouTube links to thumbnail URLs

Add a countdown notice and shared hook to replace YouTube watch links
with img.youtube.com thumbnails in post and reply forms, with i18n copy.
This commit is contained in:
Tommaso Casaburi
2026-06-02 14:59:14 +07:00
parent ea7883b107
commit 0a601c9392
42 changed files with 462 additions and 81 deletions
@@ -621,6 +621,44 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
});
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';
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 textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
const linkContainer = linkInput.parentElement as HTMLElement;
const getConversionNotice = () =>
Array.from(container.querySelectorAll<HTMLDivElement>('div'))
.reverse()
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":3}');
expect(linkContainer.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
expect(getConversionNotice()?.className).toContain('error');
await clickButtonByText('post');
expect(linkInput.value).toBe(thumbnailLink);
expect(textarea.value).toBe(`${youtubeLink}\nreply body`);
expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`,
link: thumbnailLink,
});
});
it('validates unsupported options and stores fortune output in reply content', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
+49 -8
View File
@@ -28,6 +28,7 @@ import { findDirectoryByAddress, useDirectories } from '../../hooks/use-director
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 { 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';
@@ -140,6 +141,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
);
const onPublishReply = () => {
const appliedYouTubeConversion = applyPendingConversion();
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
@@ -180,7 +183,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setError(null);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
publishReply({ content: publishContent, ...flagPublishOptions });
publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
};
useEffect(() => {
@@ -391,6 +394,39 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setIsBbcodePreviewing(true);
};
const setLinkValue = (nextUrl: string) => {
setUrl(nextUrl);
setPublishReplyOptions({ link: nextUrl });
};
const handleConvertedContentChange = (content: string) => {
const nextSelection = content.length;
if (textRef.current) {
textRef.current.setSelectionRange(nextSelection, nextSelection);
}
handleContentValueChange(content, nextSelection, nextSelection);
};
const {
applyPendingConversion,
cancelPendingConversion,
noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({
enabled: requirePostLinkIsMedia,
onContentChange: handleConvertedContentChange,
onLinkChange: setLinkValue,
textRef,
urlRef,
});
const handleLinkChange = (nextUrl: string) => {
setLinkValue(nextUrl);
if (queueLinkConversion(nextUrl)) {
setError(null);
}
};
useEffect(() => {
const canInsertQuote = showReplyModal && quoteInsertRequestId !== 0 && !!textRef.current;
@@ -438,25 +474,27 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
cancelPendingConversion();
setLinkValue(uploadedUrl);
if (urlRef.current) {
urlRef.current.value = uploadedUrl;
}
setPublishReplyOptions({ link: uploadedUrl });
}
},
});
const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
if ((urlRef.current?.value || url) !== uploadedUrl) return;
setUrl('');
cancelPendingConversion();
setLinkValue('');
if (urlRef.current) {
urlRef.current.value = '';
}
setPublishReplyOptions({ link: '' });
};
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
const youtubeThumbnailConversionNotice =
youtubeThumbnailConversionCountdown !== null ? t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown }) : null;
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -555,8 +593,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading}
onChange={(e) => {
setUrl(e.target.value);
setPublishReplyOptions({ link: e.target.value });
handleLinkChange(e.target.value);
}}
/>
</div>
@@ -613,7 +650,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
</button>
</div>
{moderationPostingWarning ? <div className={styles.error}>{moderationPostingWarning}</div> : null}
{lengthError ? (
{youtubeThumbnailConversionNotice ? (
<div className={styles.error} aria-live='polite'>
{youtubeThumbnailConversionNotice}
</div>
) : lengthError ? (
<div className={styles.error}>{lengthError}</div>
) : error ? (
<div className={styles.error}>{isPostOptionsValidationError(error) ? <PostOptionsErrorMessage error={error} directories={directories} /> : error}</div>