diff --git a/src/components/__tests__/post-community-address-compat.test.tsx b/src/components/__tests__/post-community-address-compat.test.tsx index c5f9a251..305dc78d 100644 --- a/src/components/__tests__/post-community-address-compat.test.tsx +++ b/src/components/__tests__/post-community-address-compat.test.tsx @@ -163,8 +163,10 @@ vi.mock('../../lib/utils/media-utils', () => ({ getDisplayMediaInfoType: (type?: string) => type ?? 'unknown', getHasThumbnail: () => true, getMediaDimensions: () => '100x100', - getPostMediaTypeLabel: (_commentMediaInfo: unknown, resolvedType?: string) => resolvedType ?? '', - getYouTubeEmbedPostMediaFileLink: () => undefined, + getPostMediaTypeLabel: (commentMediaInfo: { url?: string } | undefined, resolvedType?: string) => + 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', () => ({ @@ -227,7 +229,14 @@ vi.mock('../../hooks/use-author-address-click', () => ({ })); 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', () => ({ @@ -495,6 +504,18 @@ describe('post community address compatibility', () => { 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 () => { await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() })); diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 89ece100..a0368a24 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -40,7 +40,6 @@ import TimeAgoTooltip from '../time-ago-tooltip'; import { PostProps } from '../../views/post/post'; import { create } from 'zustand'; import capitalize from 'lodash/capitalize'; -import lowerCase from 'lodash/lowerCase'; import { shouldShowSnow } from '../../lib/snow'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; @@ -582,7 +581,7 @@ const PostMedia = ({ const directoryEntry = findDirectoryByAddress(directories, communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; 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 displayBoardPath = boardPath && communityAddress && boardPath !== communityAddress diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index adf7a886..dd5f73d3 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -735,6 +735,8 @@ describe('PostForm', () => { vi.useFakeTimers(); try { + await dispatchInput(textarea, 'test'); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); await dispatchInput(linkInput, youtubeLink); expect(linkInput.value).toBe(youtubeLink); @@ -763,7 +765,7 @@ describe('PostForm', () => { expect(linkInput.value).toBe(thumbnailLink); 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'); } finally { vi.clearAllTimers(); @@ -774,7 +776,7 @@ describe('PostForm', () => { expect(testState.publishPostMock).toHaveBeenCalledTimes(1); 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 () => { @@ -901,6 +903,7 @@ describe('PostForm', () => { await dispatchChange(select, 'music-posting.eth'); await dispatchInput(textarea, 'Video body'); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); await dispatchInput(linkInput, youtubeLink); await clickByText(table, 'post'); @@ -918,7 +921,7 @@ describe('PostForm', () => { expect(testState.publishPostMock).toHaveBeenCalledTimes(1); 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 () => { diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 4df6bb4c..63e1940a 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -787,6 +787,13 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: 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) => { handleContentValueChange(e.target.value); }; @@ -881,7 +888,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: queueLinkConversion, } = useYouTubeThumbnailLinkConversion({ enabled: requireCurrentLinkIsMedia && !(isInPostView && noReplyLinks), - onContentChange: handleContentValueChange, + onContentChange: handleConvertedContentChange, onLinkChange: setLinkValue, textRef, urlRef, diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 05d01d93..be4ae559 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -938,6 +938,9 @@ describe('ReplyModal', () => { .reverse() .find((element) => element.textContent?.includes('youtube_thumbnail_link_conversion_notice')); + const insertionPoint = 'reply'.length; + textarea.setSelectionRange(insertionPoint, insertionPoint); + await dispatchInput(linkInput, youtubeLink); expect(linkInput.value).toBe(youtubeLink); @@ -948,13 +951,64 @@ describe('ReplyModal', () => { await clickButtonByText('post'); expect(linkInput.value).toBe(thumbnailLink); - expect(textarea.value).toBe(`${youtubeLink}\nreply body`); + expect(textarea.value).toBe(`reply ${youtubeLink} body`); expect(testState.publishReplyMock).toHaveBeenCalledWith({ - content: `${youtubeLink}\nreply body`, + content: `reply ${youtubeLink} body`, 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('textarea') as HTMLTextAreaElement; + const linkInput = container.querySelectorAll('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 () => { const youtubeLink = 'https://youtu.be/replymissing'; testState.fetchMock.mockResolvedValue({ @@ -1028,7 +1082,7 @@ describe('ReplyModal', () => { expect(testState.publishReplyMock).toHaveBeenCalledTimes(1); expect(testState.publishReplyMock).toHaveBeenCalledWith({ - content: `${youtubeLink}\nreply body`, + content: `reply body ${youtubeLink}`, link: thumbnailLink, }); }); diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 5dcbad60..2c8f9d5f 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -441,12 +441,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa setPublishReplyOptions({ link: nextUrl }); }; - const handleConvertedContentChange = (content: string) => { - const nextSelection = content.length; + const handleConvertedContentChange = (content: string, selectionStart: number, selectionEnd: number) => { if (textRef.current) { - textRef.current.setSelectionRange(nextSelection, nextSelection); + textRef.current.setSelectionRange(selectionStart, selectionEnd); } - handleContentValueChange(content, nextSelection, nextSelection); + handleContentValueChange(content, selectionStart, selectionEnd); }; const { @@ -455,7 +454,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa noticeCountdown: youtubeThumbnailConversionCountdown, queueLinkConversion, } = useYouTubeThumbnailLinkConversion({ - enabled: requireReplyLinkIsMedia && !noReplyLinks, + enabled: !noReplyLinks, onContentChange: handleConvertedContentChange, onLinkChange: setLinkValue, textRef, @@ -665,7 +664,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa ref={urlRef} aria-label={requireReplyLinkIsMedia ? t('link_to_file') : t('link')} placeholder={requireReplyLinkIsMedia ? FILE_LINK_PLACEHOLDER : capitalize(t('link'))} - disabled={isUploading || noReplyLinks} + disabled={isUploading || youtubeThumbnailConversionCountdown !== null || noReplyLinks} onChange={(e) => { handleLinkChange(e.target.value); }} diff --git a/src/hooks/use-youtube-thumbnail-link-conversion.ts b/src/hooks/use-youtube-thumbnail-link-conversion.ts index 6e46690d..45bf043a 100644 --- a/src/hooks/use-youtube-thumbnail-link-conversion.ts +++ b/src/hooks/use-youtube-thumbnail-link-conversion.ts @@ -15,16 +15,32 @@ interface PendingYouTubeThumbnailLinkConversion { interface UseYouTubeThumbnailLinkConversionOptions { enabled: boolean; - onContentChange: (content: string) => void; + onContentChange: (content: string, selectionStart: number, selectionEnd: number) => void; onLinkChange: (link: string) => void; textRef: ElementRef; urlRef: ElementRef; } -const getContentWithYouTubeLinkAtTop = (content: string, youtubeLink: string): string => { - if (!content) return youtubeLink; - if (content === youtubeLink || content.startsWith(`${youtubeLink}\n`) || content.startsWith(`${youtubeLink}\r\n`)) return content; - return `${youtubeLink}\n${content}`; +const clampSelectionPosition = (position: number | null | undefined, contentLength: number): number => { + if (typeof position !== 'number' || Number.isNaN(position)) return contentLength; + return Math.max(0, Math.min(position, contentLength)); +}; + +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 => { @@ -80,17 +96,19 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on pendingConversionRef.current = null; setNoticeCountdown(null); - const currentContent = textRef.current?.value || ''; - const nextContent = getContentWithYouTubeLinkAtTop(currentContent, pendingConversion.youtubeLink); + const textarea = textRef.current; + const currentContent = textarea?.value || ''; + const nextContent = getContentWithInsertedYouTubeLink(currentContent, pendingConversion.youtubeLink, textarea?.selectionStart, textarea?.selectionEnd); - if (textRef.current) { - textRef.current.value = nextContent; + if (textarea) { + textarea.value = nextContent.content; + textarea.setSelectionRange(nextContent.selectionStart, nextContent.selectionEnd); } if (urlRef.current) { urlRef.current.value = thumbnailLink; } - onContentChange(nextContent); + onContentChange(nextContent.content, nextContent.selectionStart, nextContent.selectionEnd); onLinkChange(thumbnailLink); return true; }, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);