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