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
@@ -88,6 +88,7 @@ vi.mock('react-i18next', async () => {
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'choose_one') return 'Choose one:';
if (typeof options?.count !== 'undefined') return `${key}:${options.count}`;
return options?.domain ? `${key}:${options.domain}` : key;
},
}),
@@ -299,6 +300,16 @@ vi.mock('../../../lib/utils/media-utils', () => ({
}
return { type: 'link', url: link };
},
getYouTubeThumbnailUrlFromLink: (link: string) => {
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}/0.jpg` : undefined;
} catch {
return undefined;
}
},
}));
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
@@ -599,6 +610,63 @@ describe('PostForm', () => {
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
});
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';
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 getConversionNotice = () =>
Array.from(container.querySelectorAll<HTMLDivElement>('div'))
.reverse()
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
await dispatchChange(select, 'music-posting.eth');
vi.useFakeTimers();
try {
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice');
expect(container.textContent).toContain('3');
expect(table.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
expect(getConversionNotice()?.className).toContain('formError');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('2');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('1');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(linkInput.value).toBe(thumbnailLink);
expect(textarea.value).toBe(youtubeLink);
expect(container.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
} finally {
vi.clearAllTimers();
vi.useRealTimers();
}
await clickByText(table, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe(thumbnailLink);
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
});
it('shows Oekaki draw controls only on the /i/ board form', async () => {
testState.directories.push({
address: 'oekaki-posting.bso',
+55 -33
View File
@@ -34,6 +34,7 @@ import useIsMobile from '../../hooks/use-is-mobile';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
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 usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload';
@@ -144,10 +145,10 @@ interface PostFormFieldsProps {
lengthError: string | null;
handleContentChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleContentValueChange: (content: string, options?: string) => void;
handleLinkChange: (link: string) => void;
handleOptionsChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
setPublishPostOptions: (opts: Record<string, unknown>) => void;
setPublishReplyOptions: (opts: Record<string, unknown>) => void;
setUrl: (url: string) => void;
isUploading: boolean;
uploadedFileName: string | null | undefined;
showUploadControls: boolean;
@@ -195,10 +196,10 @@ const PostFormFields = ({
lengthError,
handleContentChange,
handleContentValueChange,
handleLinkChange,
handleOptionsChange,
setPublishPostOptions,
setPublishReplyOptions,
setUrl,
isUploading,
uploadedFileName,
showUploadControls,
@@ -356,12 +357,7 @@ const PostFormFields = ({
ref={urlRef}
disabled={isUploading}
onChange={(e) => {
setUrl(e.target.value);
if (isInPostView) {
setPublishReplyOptions({ link: e.target.value });
} else {
setPublishPostOptions({ link: e.target.value });
}
handleLinkChange(e.target.value);
}}
/>
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
@@ -602,6 +598,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
const onPublishPost = () => {
const appliedYouTubeConversion = applyPendingConversion();
const currentTitle = subjectRef.current?.value.trim() || '';
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
@@ -653,7 +651,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...publishOptions });
publishPost({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...publishOptions });
};
// redirect to pending page when pending comment is created
@@ -725,6 +723,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
const onPublishReply = () => {
const appliedYouTubeConversion = applyPendingConversion();
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
@@ -764,7 +764,36 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishReply({ content: publishContent, ...flagPublishOptions });
publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
};
const setLinkValue = (nextUrl: string) => {
setUrl(nextUrl);
if (isInPostView) {
setPublishReplyOptions({ link: nextUrl });
} else {
setPublishPostOptions({ link: nextUrl });
}
};
const {
applyPendingConversion,
cancelPendingConversion,
noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({
enabled: requirePostLinkIsMedia,
onContentChange: handleContentValueChange,
onLinkChange: setLinkValue,
textRef,
urlRef,
});
const handleLinkChange = (nextUrl: string) => {
setLinkValue(nextUrl);
if (queueLinkConversion(nextUrl)) {
setFormError(null);
}
};
useEffect(() => {
@@ -782,33 +811,22 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
cancelPendingConversion();
setLinkValue(uploadedUrl);
if (urlRef.current) {
urlRef.current.value = uploadedUrl;
}
if (isInPostView) {
setPublishReplyOptions({ link: uploadedUrl });
} else {
setPublishPostOptions({ link: uploadedUrl });
}
}
},
});
const handleOekakiClearUploadedUrl = useCallback(
(uploadedUrl: string) => {
if ((urlRef.current?.value || url) !== uploadedUrl) return;
setUrl('');
if (urlRef.current) {
urlRef.current.value = '';
}
if (isInPostView) {
setPublishReplyOptions({ link: '' });
} else {
setPublishPostOptions({ link: '' });
}
},
[isInPostView, setPublishPostOptions, setPublishReplyOptions, url],
);
const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
if ((urlRef.current?.value || url) !== uploadedUrl) return;
cancelPendingConversion();
setLinkValue('');
if (urlRef.current) {
urlRef.current.value = '';
}
};
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
@@ -846,10 +864,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
lengthError={lengthError}
handleContentChange={handleContentChange}
handleContentValueChange={handleContentValueChange}
handleLinkChange={handleLinkChange}
handleOptionsChange={handleOptionsChange}
setPublishPostOptions={setPublishPostOptions}
setPublishReplyOptions={setPublishReplyOptions}
setUrl={setUrl}
isUploading={isUploading}
uploadedFileName={uploadedFileName}
showUploadControls={showUploadControls}
@@ -881,7 +899,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
</tbody>
</table>
{moderationPostingWarning ? <div className={`${styles.error} ${styles.formError}`}>{moderationPostingWarning}</div> : null}
{formError ? (
{youtubeThumbnailConversionCountdown !== null ? (
<div className={`${styles.error} ${styles.formError}`} aria-live='polite'>
{t('youtube_thumbnail_link_conversion_notice', { count: youtubeThumbnailConversionCountdown })}
</div>
) : formError ? (
<div className={`${styles.error} ${styles.formError}`}>
{isPostOptionsValidationError(formError) ? <PostOptionsErrorMessage error={formError} directories={directories} /> : formError}
</div>