fix(reply modal): insert YouTube links at cursor and enable conversion everywhere

YouTube thumbnail conversion now inserts links at the textarea caret instead of prepending, runs in reply modals even when media links are optional, and preserves the YouTube brand label on desktop posts.
This commit is contained in:
Tommaso Casaburi
2026-07-01 16:33:32 +07:00
parent 5aaee154d2
commit 4bfe298d4b
7 changed files with 129 additions and 28 deletions
@@ -163,8 +163,10 @@ vi.mock('../../lib/utils/media-utils', () => ({
getDisplayMediaInfoType: (type?: string) => type ?? 'unknown', getDisplayMediaInfoType: (type?: string) => type ?? 'unknown',
getHasThumbnail: () => true, getHasThumbnail: () => true,
getMediaDimensions: () => '100x100', getMediaDimensions: () => '100x100',
getPostMediaTypeLabel: (_commentMediaInfo: unknown, resolvedType?: string) => resolvedType ?? '', getPostMediaTypeLabel: (commentMediaInfo: { url?: string } | undefined, resolvedType?: string) =>
getYouTubeEmbedPostMediaFileLink: () => undefined, commentMediaInfo?.url?.includes('youtube.com') ? 'YouTube video' : (resolvedType ?? ''),
getYouTubeEmbedPostMediaFileLink: (commentMediaInfo: { url?: string } | undefined) =>
commentMediaInfo?.url?.includes('youtube.com') ? 'https://img.youtube.com/vi/abc123/maxresdefault.jpg' : undefined,
})); }));
vi.mock('../../lib/utils/post-utils', () => ({ vi.mock('../../lib/utils/post-utils', () => ({
@@ -227,7 +229,14 @@ vi.mock('../../hooks/use-author-address-click', () => ({
})); }));
vi.mock('../../hooks/use-comment-media-info', () => ({ vi.mock('../../hooks/use-comment-media-info', () => ({
useCommentMediaInfo: (link?: string) => (link ? { type: 'image', url: link } : undefined), useCommentMediaInfo: (link?: string) =>
link
? {
patternThumbnailUrl: link.includes('youtube.com') ? 'https://img.youtube.com/vi/abc123/maxresdefault.jpg' : undefined,
type: link.includes('youtube.com') ? 'iframe' : 'image',
url: link,
}
: undefined,
})); }));
vi.mock('../../hooks/use-count-links-in-replies', () => ({ vi.mock('../../hooks/use-count-links-in-replies', () => ({
@@ -495,6 +504,18 @@ describe('post community address compatibility', () => {
expect(container.textContent).toContain('reply-1'); expect(container.textContent).toContain('reply-1');
}); });
it('renders YouTube media labels without splitting the brand name on desktop', async () => {
const post = {
...makeLegacyThreadWithoutReplies(),
link: 'https://www.youtube.com/watch?v=abc123',
};
await renderWithRoute(createElement(PostDesktop, { post }), '/mu/thread/post-1');
expect(container.textContent).toContain('(youtube video, 100x100)');
expect(container.textContent).not.toContain('you tube video');
});
it('renders mobile multiboard posts with only communityAddress and still fetches replies', async () => { it('renders mobile multiboard posts with only communityAddress and still fetches replies', async () => {
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() })); await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() }));
+1 -2
View File
@@ -40,7 +40,6 @@ import TimeAgoTooltip from '../time-ago-tooltip';
import { PostProps } from '../../views/post/post'; import { PostProps } from '../../views/post/post';
import { create } from 'zustand'; import { create } from 'zustand';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
import { shouldShowSnow } from '../../lib/snow'; import { shouldShowSnow } from '../../lib/snow';
import useReplyModalStore from '../../stores/use-reply-modal-store'; import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
@@ -582,7 +581,7 @@ const PostMedia = ({
const directoryEntry = findDirectoryByAddress(directories, communityAddress); const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
const fileLabel = youtubeFileLink || requirePostLinkIsMedia ? t('file') : t('link'); const fileLabel = youtubeFileLink || requirePostLinkIsMedia ? t('file') : t('link');
const mediaTypeLabel = type ? lowerCase(getPostMediaTypeLabel(commentMediaInfo, type, t)) : ''; const mediaTypeLabel = type ? getPostMediaTypeLabel(commentMediaInfo, type, t).toLowerCase() : '';
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const displayBoardPath = const displayBoardPath =
boardPath && communityAddress && boardPath !== communityAddress boardPath && communityAddress && boardPath !== communityAddress
@@ -735,6 +735,8 @@ describe('PostForm', () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
await dispatchInput(textarea, 'test');
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
await dispatchInput(linkInput, youtubeLink); await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink); expect(linkInput.value).toBe(youtubeLink);
@@ -763,7 +765,7 @@ describe('PostForm', () => {
expect(linkInput.value).toBe(thumbnailLink); expect(linkInput.value).toBe(thumbnailLink);
expect(linkInput.disabled).toBe(false); expect(linkInput.disabled).toBe(false);
expect(textarea.value).toBe(youtubeLink); expect(textarea.value).toBe(`test ${youtubeLink}`);
expect(container.textContent).not.toContain('youtube_thumbnail_link_conversion_notice'); expect(container.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
} finally { } finally {
vi.clearAllTimers(); vi.clearAllTimers();
@@ -774,7 +776,7 @@ describe('PostForm', () => {
expect(testState.publishPostMock).toHaveBeenCalledTimes(1); expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe(thumbnailLink); expect(testState.publishedPostOptions?.link).toBe(thumbnailLink);
expect(testState.publishedPostOptions?.content).toBe(youtubeLink); expect(testState.publishedPostOptions?.content).toBe(`test ${youtubeLink}`);
}); });
it('requires a link when live community features require post links', async () => { it('requires a link when live community features require post links', async () => {
@@ -901,6 +903,7 @@ describe('PostForm', () => {
await dispatchChange(select, 'music-posting.eth'); await dispatchChange(select, 'music-posting.eth');
await dispatchInput(textarea, 'Video body'); await dispatchInput(textarea, 'Video body');
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
await dispatchInput(linkInput, youtubeLink); await dispatchInput(linkInput, youtubeLink);
await clickByText(table, 'post'); await clickByText(table, 'post');
@@ -918,7 +921,7 @@ describe('PostForm', () => {
expect(testState.publishPostMock).toHaveBeenCalledTimes(1); expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.link).toBe(thumbnailLink); expect(testState.publishedPostOptions?.link).toBe(thumbnailLink);
expect(testState.publishedPostOptions?.content).toBe(`${youtubeLink}\nVideo body`); expect(testState.publishedPostOptions?.content).toBe(`Video body ${youtubeLink}`);
}); });
it('ignores duplicate post clicks while publish is pending', async () => { it('ignores duplicate post clicks while publish is pending', async () => {
+8 -1
View File
@@ -787,6 +787,13 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
checkContentLength(publishContent, t, options, postOptionsDirectoryCode); checkContentLength(publishContent, t, options, postOptionsDirectoryCode);
}; };
const handleConvertedContentChange = (content: string, selectionStart: number, selectionEnd: number) => {
if (textRef.current) {
textRef.current.setSelectionRange(selectionStart, selectionEnd);
}
handleContentValueChange(content);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleContentValueChange(e.target.value); handleContentValueChange(e.target.value);
}; };
@@ -881,7 +888,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
queueLinkConversion, queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({ } = useYouTubeThumbnailLinkConversion({
enabled: requireCurrentLinkIsMedia && !(isInPostView && noReplyLinks), enabled: requireCurrentLinkIsMedia && !(isInPostView && noReplyLinks),
onContentChange: handleContentValueChange, onContentChange: handleConvertedContentChange,
onLinkChange: setLinkValue, onLinkChange: setLinkValue,
textRef, textRef,
urlRef, urlRef,
@@ -938,6 +938,9 @@ describe('ReplyModal', () => {
.reverse() .reverse()
.find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice')); .find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice'));
const insertionPoint = 'reply'.length;
textarea.setSelectionRange(insertionPoint, insertionPoint);
await dispatchInput(linkInput, youtubeLink); await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink); expect(linkInput.value).toBe(youtubeLink);
@@ -948,13 +951,64 @@ describe('ReplyModal', () => {
await clickButtonByText('post'); await clickButtonByText('post');
expect(linkInput.value).toBe(thumbnailLink); expect(linkInput.value).toBe(thumbnailLink);
expect(textarea.value).toBe(`${youtubeLink}\nreply body`); expect(textarea.value).toBe(`reply ${youtubeLink} body`);
expect(testState.publishReplyMock).toHaveBeenCalledWith({ expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`, content: `reply ${youtubeLink} body`,
link: thumbnailLink, link: thumbnailLink,
}); });
}); });
it('automatically moves YouTube links into reply content when reply media links are optional', async () => {
const youtubeLink = 'https://youtu.be/replyoptional';
const thumbnailLink = 'https://img.youtube.com/vi/replyoptional/maxresdefault.jpg';
testState.openEmpty = true;
testState.selectedText = 'reply body';
await renderReplyModal('/mu/thread/post-1');
const textarea = container.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
vi.useFakeTimers();
try {
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(linkInput.disabled).toBe(true);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":3}');
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":2}');
expect(linkInput.disabled).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice:{"count":1}');
expect(linkInput.disabled).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
await Promise.resolve();
});
expect(linkInput.value).toBe(thumbnailLink);
expect(linkInput.disabled).toBe(false);
expect(textarea.value).toBe(`reply body ${youtubeLink}`);
expect(container.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ link: thumbnailLink });
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: `reply body ${youtubeLink}` });
} finally {
vi.clearAllTimers();
vi.useRealTimers();
}
});
it('rejects unresolved YouTube links on media-only reply modal boards', async () => { it('rejects unresolved YouTube links on media-only reply modal boards', async () => {
const youtubeLink = 'https://youtu.be/replymissing'; const youtubeLink = 'https://youtu.be/replymissing';
testState.fetchMock.mockResolvedValue({ testState.fetchMock.mockResolvedValue({
@@ -1028,7 +1082,7 @@ describe('ReplyModal', () => {
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1); expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
expect(testState.publishReplyMock).toHaveBeenCalledWith({ expect(testState.publishReplyMock).toHaveBeenCalledWith({
content: `${youtubeLink}\nreply body`, content: `reply body ${youtubeLink}`,
link: thumbnailLink, link: thumbnailLink,
}); });
}); });
+5 -6
View File
@@ -441,12 +441,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
setPublishReplyOptions({ link: nextUrl }); setPublishReplyOptions({ link: nextUrl });
}; };
const handleConvertedContentChange = (content: string) => { const handleConvertedContentChange = (content: string, selectionStart: number, selectionEnd: number) => {
const nextSelection = content.length;
if (textRef.current) { if (textRef.current) {
textRef.current.setSelectionRange(nextSelection, nextSelection); textRef.current.setSelectionRange(selectionStart, selectionEnd);
} }
handleContentValueChange(content, nextSelection, nextSelection); handleContentValueChange(content, selectionStart, selectionEnd);
}; };
const { const {
@@ -455,7 +454,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
noticeCountdown: youtubeThumbnailConversionCountdown, noticeCountdown: youtubeThumbnailConversionCountdown,
queueLinkConversion, queueLinkConversion,
} = useYouTubeThumbnailLinkConversion({ } = useYouTubeThumbnailLinkConversion({
enabled: requireReplyLinkIsMedia && !noReplyLinks, enabled: !noReplyLinks,
onContentChange: handleConvertedContentChange, onContentChange: handleConvertedContentChange,
onLinkChange: setLinkValue, onLinkChange: setLinkValue,
textRef, textRef,
@@ -665,7 +664,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
ref={urlRef} ref={urlRef}
aria-label={requireReplyLinkIsMedia ? t('link_to_file') : t('link')} aria-label={requireReplyLinkIsMedia ? t('link_to_file') : t('link')}
placeholder={requireReplyLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))} placeholder={requireReplyLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))}
disabled={isUploading || noReplyLinks} disabled={isUploading || youtubeThumbnailConversionCountdown !== null || noReplyLinks}
onChange={(e) => { onChange={(e) => {
handleLinkChange(e.target.value); handleLinkChange(e.target.value);
}} }}
@@ -15,16 +15,32 @@ interface PendingYouTubeThumbnailLinkConversion {
interface UseYouTubeThumbnailLinkConversionOptions { interface UseYouTubeThumbnailLinkConversionOptions {
enabled: boolean; enabled: boolean;
onContentChange: (content: string) => void; onContentChange: (content: string, selectionStart: number, selectionEnd: number) => void;
onLinkChange: (link: string) => void; onLinkChange: (link: string) => void;
textRef: ElementRef<HTMLTextAreaElement>; textRef: ElementRef<HTMLTextAreaElement>;
urlRef: ElementRef<HTMLInputElement>; urlRef: ElementRef<HTMLInputElement>;
} }
const getContentWithYouTubeLinkAtTop = (content: string, youtubeLink: string): string => { const clampSelectionPosition = (position: number | null | undefined, contentLength: number): number => {
if (!content) return youtubeLink; if (typeof position !== 'number' || Number.isNaN(position)) return contentLength;
if (content === youtubeLink || content.startsWith(`${youtubeLink}\n`) || content.startsWith(`${youtubeLink}\r\n`)) return content; return Math.max(0, Math.min(position, contentLength));
return `${youtubeLink}\n${content}`; };
const getContentWithInsertedYouTubeLink = (content: string, youtubeLink: string, selectionStart?: number | null, selectionEnd?: number | null) => {
const start = clampSelectionPosition(selectionStart, content.length);
const end = Math.max(start, clampSelectionPosition(selectionEnd, content.length));
const before = content.slice(0, start);
const after = content.slice(end);
const leadingSeparator = before && !/\s$/.test(before) ? ' ' : '';
const trailingSeparator = after && !/^\s/.test(after) ? ' ' : '';
const insertedText = `${leadingSeparator}${youtubeLink}${trailingSeparator}`;
const nextSelection = before.length + insertedText.length;
return {
content: `${before}${insertedText}${after}`,
selectionEnd: nextSelection,
selectionStart: nextSelection,
};
}; };
const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => { const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => {
@@ -80,17 +96,19 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
pendingConversionRef.current = null; pendingConversionRef.current = null;
setNoticeCountdown(null); setNoticeCountdown(null);
const currentContent = textRef.current?.value || ''; const textarea = textRef.current;
const nextContent = getContentWithYouTubeLinkAtTop(currentContent, pendingConversion.youtubeLink); const currentContent = textarea?.value || '';
const nextContent = getContentWithInsertedYouTubeLink(currentContent, pendingConversion.youtubeLink, textarea?.selectionStart, textarea?.selectionEnd);
if (textRef.current) { if (textarea) {
textRef.current.value = nextContent; textarea.value = nextContent.content;
textarea.setSelectionRange(nextContent.selectionStart, nextContent.selectionEnd);
} }
if (urlRef.current) { if (urlRef.current) {
urlRef.current.value = thumbnailLink; urlRef.current.value = thumbnailLink;
} }
onContentChange(nextContent); onContentChange(nextContent.content, nextContent.selectionStart, nextContent.selectionEnd);
onLinkChange(thumbnailLink); onLinkChange(thumbnailLink);
return true; return true;
}, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]); }, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);