mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(youtube thumbnails): prefer best available image
Prefer the best available YouTube thumbnail, fall back cleanly when high-resolution images are unavailable, and keep the React Doctor PR gate scoped to newly introduced issues.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# React Doctor PR review.
|
||||
# Reports only issues this PR INTRODUCES (diffed against the merge-base) as inline
|
||||
# review comments + a sticky summary. It reads doctor.config.jsonc, so the
|
||||
# React-Compiler rules we don't enforce stay suppressed (see that file and
|
||||
# Reports only issues this PR INTRODUCES (diffed against the base branch) as
|
||||
# GitHub annotations. It reads doctor.config.jsonc, so the React-Compiler rules
|
||||
# we don't enforce stay suppressed (see that file and
|
||||
# docs/agent-playbooks/known-surprises.md). We do NOT chase the aggregate score.
|
||||
name: React Doctor
|
||||
|
||||
@@ -25,4 +25,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # so React Doctor can diff against the merge-base for new-vs-existing
|
||||
- uses: millionco/react-doctor@v1
|
||||
- name: Setup Node.js v22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: corepack enable
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Run React Doctor on changed files
|
||||
run: yarn doctor --verbose --scope changed --base "origin/${{ github.base_ref }}" --annotations --blocking error
|
||||
|
||||
@@ -185,7 +185,7 @@ vi.mock('../../../hooks/use-hide', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../post-desktop/post-menu-desktop', () => ({
|
||||
vi.mock('../../post-desktop/post-menu-desktop/post-menu-desktop', () => ({
|
||||
default: ({ postMenu }: { postMenu: { cid?: string } }) => createElement('span', { 'data-testid': `post-menu-${postMenu.cid}` }, 'menu'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, type SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
@@ -16,10 +16,11 @@ import useEditCommentPrivileges from '../../hooks/use-author-privileges';
|
||||
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback';
|
||||
import useHide from '../../hooks/use-hide';
|
||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||
import { CATALOG_PREVIEW_MARKDOWN_OPTIONS, removeMarkdown } from '../../lib/utils/post-utils';
|
||||
import PostMenuDesktop from '../post-desktop/post-menu-desktop';
|
||||
import PostMenuDesktop from '../post-desktop/post-menu-desktop/post-menu-desktop';
|
||||
import styles from './catalog-row.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||
@@ -39,11 +40,31 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
||||
void cid;
|
||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||
const {
|
||||
handleThumbnailError: handleIframeThumbnailError,
|
||||
handleThumbnailLoad: handleIframeThumbnailLoad,
|
||||
isUnavailable: isIframeThumbnailUnavailable,
|
||||
thumbnailUrl: resolvedIframeThumbnail,
|
||||
} = useYouTubeThumbnailFallback(iframeThumbnail);
|
||||
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const handleLoad = () => setIsLoaded(true);
|
||||
const handleError = () => setHasError(true);
|
||||
const handleIframeLoad = (event: SyntheticEvent<HTMLImageElement>) => {
|
||||
if (handleIframeThumbnailLoad(event.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleLoad();
|
||||
};
|
||||
const handleIframeError = () => {
|
||||
if (handleIframeThumbnailError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleError();
|
||||
};
|
||||
const loadingStyle = { opacity: isLoaded ? 1 : 0 };
|
||||
|
||||
const { imageSize } = useCatalogStyleStore();
|
||||
@@ -96,22 +117,24 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
||||
);
|
||||
} else if (type === 'webpage' && !hasError) {
|
||||
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||
} else if (type === 'iframe' && iframeThumbnail && !hasError) {
|
||||
thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||
} else if (type === 'iframe' && resolvedIframeThumbnail && !hasError) {
|
||||
thumbnailComponent = (
|
||||
<img src={resolvedIframeThumbnail} alt='' onLoad={handleIframeLoad} onError={handleIframeError} style={loadingStyle} width={numericWidth} height={numericHeight} />
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={hasError ? '' : styles.mediaWrapper}
|
||||
className={hasError || isIframeThumbnailUnavailable ? '' : styles.mediaWrapper}
|
||||
style={{
|
||||
...CSSProperties,
|
||||
...(matchedFilterColor ? { border: `3px solid ${matchedFilterColor}` } : {}),
|
||||
}}
|
||||
>
|
||||
{!isLoaded && !hasError && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
|
||||
{hasError ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
|
||||
{!isLoaded && !hasError && !isIframeThumbnailUnavailable && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
|
||||
{hasError || isIframeThumbnailUnavailable ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,17 @@ vi.mock('../../../lib/utils/media-utils', () => ({
|
||||
getDisplayMediaInfoType: (type: string) => type,
|
||||
getHasThumbnail: () => testState.getHasThumbnailResult,
|
||||
getMediaDimensions: () => '640x480',
|
||||
getYouTubeThumbnailFallbackUrls: (thumbnailUrl?: string) => {
|
||||
if (!thumbnailUrl?.includes('img.youtube.com/vi/')) return thumbnailUrl ? [thumbnailUrl] : [];
|
||||
const videoId = thumbnailUrl.split('/vi/')[1]?.split('/')[0];
|
||||
return [
|
||||
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
||||
`https://img.youtube.com/vi/${videoId}/sddefault.jpg`,
|
||||
`https://img.youtube.com/vi/${videoId}/mqdefault.jpg`,
|
||||
`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
|
||||
];
|
||||
},
|
||||
isMissingYouTubeThumbnailImage: (thumbnailUrl: string, width: number, height: number) => thumbnailUrl.includes('img.youtube.com/vi/') && width === 120 && height === 90,
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/utils/url-utils', () => ({
|
||||
@@ -64,12 +75,15 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => testState.isMobile,
|
||||
}));
|
||||
|
||||
vi.mock('../../embed', () => ({
|
||||
vi.mock('../../embed/embed', () => ({
|
||||
__esModule: true,
|
||||
canEmbed: () => testState.canEmbed,
|
||||
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
||||
}));
|
||||
|
||||
vi.mock('../../embed/embed-utils', () => ({
|
||||
canEmbed: () => testState.canEmbed,
|
||||
}));
|
||||
|
||||
vi.mock('@ruffle-rs/ruffle', () => ({}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
@@ -298,7 +312,7 @@ describe('CommentMedia', () => {
|
||||
it('renders a youtube thumbnail before the embed is opened', async () => {
|
||||
await renderMedia({
|
||||
commentMediaInfo: {
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
},
|
||||
@@ -306,10 +320,34 @@ describe('CommentMedia', () => {
|
||||
showThumbnail: true,
|
||||
});
|
||||
|
||||
expect(container.querySelector('img[src="https://img.youtube.com/vi/abc123/0.jpg"]')).toBeTruthy();
|
||||
expect(container.querySelector('img[src="https://img.youtube.com/vi/abc123/maxresdefault.jpg"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="embed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back when youtube serves the missing-thumbnail placeholder as a loaded image', async () => {
|
||||
await renderMedia({
|
||||
commentMediaInfo: {
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/missing-max/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://www.youtube.com/watch?v=missing-max',
|
||||
},
|
||||
setShowThumbnail: setShowThumbnailMock,
|
||||
showThumbnail: true,
|
||||
});
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>('img[src="https://img.youtube.com/vi/missing-max/maxresdefault.jpg"]');
|
||||
expect(image).toBeTruthy();
|
||||
if (!image) throw new Error('Expected youtube thumbnail image to render');
|
||||
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 120 });
|
||||
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 90 });
|
||||
await act(async () => {
|
||||
image.dispatchEvent(new Event('load', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('img[src="https://img.youtube.com/vi/missing-max/sddefault.jpg"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the expanded embed view with a close button on mobile', async () => {
|
||||
testState.isMobile = true;
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { getHostname, parseHttpUrl } from '../../lib/utils/url-utils';
|
||||
import useExpandedMediaStore from '../../stores/use-expanded-media-store';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback';
|
||||
import styles from './comment-media.module.css';
|
||||
import Embed, { canEmbed } from '../embed';
|
||||
import Embed from '../embed/embed';
|
||||
import { canEmbed } from '../embed/embed-utils';
|
||||
import RufflePlayer from './ruffle-player';
|
||||
|
||||
interface MediaProps {
|
||||
@@ -108,8 +110,14 @@ const Thumbnail = ({
|
||||
let thumbnailComponent: React.ReactNode = null;
|
||||
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
|
||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||
const {
|
||||
handleThumbnailError: handleIframeThumbnailError,
|
||||
handleThumbnailLoad: handleIframeThumbnailLoad,
|
||||
isUnavailable: isIframeThumbnailUnavailable,
|
||||
thumbnailUrl: resolvedIframeThumbnail,
|
||||
} = useYouTubeThumbnailFallback(iframeThumbnail);
|
||||
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, url) && !isIframeThumbnailUnavailable;
|
||||
const handleOpenMedia = () => setShowThumbnail(false);
|
||||
|
||||
if (type === 'gif') {
|
||||
@@ -137,9 +145,9 @@ const Thumbnail = ({
|
||||
</button>
|
||||
);
|
||||
} else if (type === 'iframe') {
|
||||
thumbnailComponent = iframeThumbnail ? (
|
||||
thumbnailComponent = resolvedIframeThumbnail ? (
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open embedded media preview' onClick={handleOpenMedia}>
|
||||
<img src={iframeThumbnail} alt='' />
|
||||
<img src={resolvedIframeThumbnail} alt='' onLoad={(event) => handleIframeThumbnailLoad(event.currentTarget)} onError={() => handleIframeThumbnailError()} />
|
||||
</button>
|
||||
) : null;
|
||||
} else if (type === 'audio') {
|
||||
|
||||
@@ -643,7 +643,7 @@ describe('Markdown', () => {
|
||||
testState.embeddableHosts = new Set(['www.youtube.com']);
|
||||
testState.mediaInfoByHref = {
|
||||
'https://www.youtube.com/watch?v=abc123': {
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
|
||||
subscriptions: ['music-posting.eth'],
|
||||
},
|
||||
accountComment: undefined as { communityAddress?: string } | undefined,
|
||||
bestAvailableYouTubeThumbnailMock: undefined as undefined | ((link: string) => Promise<string | undefined>),
|
||||
accountCommunityAddresses: ['mod.eth'] as string[],
|
||||
comments: {} as Record<string, { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
|
||||
directories: [
|
||||
@@ -354,7 +355,7 @@ vi.mock('../../../lib/utils/media-utils', () => {
|
||||
return { type: 'audio', url: link };
|
||||
}
|
||||
if (link.includes('youtube.com')) {
|
||||
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', type: 'iframe', url: link };
|
||||
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', type: 'iframe', url: link };
|
||||
}
|
||||
return { type: 'webpage', url: link };
|
||||
},
|
||||
@@ -364,7 +365,21 @@ vi.mock('../../../lib/utils/media-utils', () => {
|
||||
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;
|
||||
return videoId ? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
getBestAvailableYouTubeThumbnailUrlFromLink: async (link: string) => {
|
||||
if (testState.bestAvailableYouTubeThumbnailMock) {
|
||||
return testState.bestAvailableYouTubeThumbnailMock(link);
|
||||
}
|
||||
|
||||
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}/maxresdefault.jpg` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -516,6 +531,7 @@ describe('PostForm', () => {
|
||||
subscriptions: ['music-posting.eth'],
|
||||
};
|
||||
testState.accountComment = undefined;
|
||||
testState.bestAvailableYouTubeThumbnailMock = undefined;
|
||||
testState.accountCommunityAddresses = ['mod.eth'];
|
||||
testState.comments = {};
|
||||
testState.directories = [
|
||||
@@ -689,7 +705,7 @@ describe('PostForm', () => {
|
||||
|
||||
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';
|
||||
const thumbnailLink = 'https://img.youtube.com/vi/abc123/maxresdefault.jpg';
|
||||
|
||||
await renderPostForm('/all');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
@@ -749,6 +765,47 @@ describe('PostForm', () => {
|
||||
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
|
||||
});
|
||||
|
||||
it('ignores duplicate post clicks while youtube thumbnail resolution is pending', async () => {
|
||||
const youtubeLink = 'https://www.youtube.com/watch?v=slow123';
|
||||
const thumbnailLink = 'https://img.youtube.com/vi/slow123/maxresdefault.jpg';
|
||||
let resolveThumbnail: (thumbnailLink: string) => void = () => {};
|
||||
const thumbnailPromise = new Promise<string | undefined>((resolve) => {
|
||||
resolveThumbnail = resolve;
|
||||
});
|
||||
const resolverMock = vi.fn(() => thumbnailPromise);
|
||||
testState.bestAvailableYouTubeThumbnailMock = resolverMock;
|
||||
|
||||
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 postButton = Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
|
||||
|
||||
await dispatchChange(select, 'music-posting.eth');
|
||||
await dispatchInput(textarea, 'Video body');
|
||||
await dispatchInput(linkInput, youtubeLink);
|
||||
|
||||
await clickByText(table, 'post');
|
||||
expect(postButton.disabled).toBe(true);
|
||||
|
||||
await clickByText(table, 'post');
|
||||
expect(resolverMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.publishPostMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveThumbnail(thumbnailLink);
|
||||
await thumbnailPromise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.publishedPostOptions?.link).toBe(thumbnailLink);
|
||||
expect(testState.publishedPostOptions?.content).toBe(`${youtubeLink}\nVideo body`);
|
||||
});
|
||||
|
||||
it('publishes known twimg query-format post links with a path extension without editing the field', async () => {
|
||||
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
|
||||
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-
|
||||
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 usePublishSubmissionGuard from '../../hooks/use-publish-submission-guard';
|
||||
import usePublishPost from '../../hooks/use-publish-post';
|
||||
import usePublishReply from '../../hooks/use-publish-reply';
|
||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||
@@ -104,6 +105,7 @@ const PostFormActions = ({
|
||||
onPublishReply,
|
||||
onPublishPost,
|
||||
handleUpload,
|
||||
isPublishSubmissionInFlight,
|
||||
isUploading,
|
||||
showUploadControls,
|
||||
}: {
|
||||
@@ -111,22 +113,23 @@ const PostFormActions = ({
|
||||
variant: 'reply' | 'post' | 'upload';
|
||||
t: TFunction;
|
||||
isInPostView: boolean;
|
||||
onPublishReply: () => void;
|
||||
onPublishPost: () => void;
|
||||
onPublishReply: () => void | Promise<void>;
|
||||
onPublishPost: () => void | Promise<void>;
|
||||
handleUpload: () => void;
|
||||
isPublishSubmissionInFlight: boolean;
|
||||
isUploading: boolean;
|
||||
showUploadControls: boolean;
|
||||
}) => {
|
||||
if (variant === 'reply' && isInPostView) {
|
||||
return (
|
||||
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
|
||||
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isPublishSubmissionInFlight || isUploading}>
|
||||
{t('post')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (variant === 'post' && !isInPostView) {
|
||||
return (
|
||||
<button type='button' onClick={onPublishPost}>
|
||||
<button type='button' onClick={onPublishPost} disabled={isPublishSubmissionInFlight}>
|
||||
{t('post')}
|
||||
</button>
|
||||
);
|
||||
@@ -186,11 +189,12 @@ interface PostFormFieldsProps {
|
||||
showMathTagsPrompt: boolean;
|
||||
showBbcodeToolbar: boolean;
|
||||
onBbcodePreviewToggle: () => void;
|
||||
onPublishReply: () => void;
|
||||
onPublishPost: () => void;
|
||||
onPublishReply: () => void | Promise<void>;
|
||||
onPublishPost: () => void | Promise<void>;
|
||||
handleUpload: () => void;
|
||||
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
|
||||
onOekakiClearUploadedUrl: (url: string) => void;
|
||||
isPublishSubmissionInFlight: boolean;
|
||||
disableReplyPublish: boolean;
|
||||
}
|
||||
|
||||
@@ -244,6 +248,7 @@ const PostFormFields = ({
|
||||
handleUpload,
|
||||
uploadFile,
|
||||
onOekakiClearUploadedUrl,
|
||||
isPublishSubmissionInFlight,
|
||||
disableReplyPublish,
|
||||
}: PostFormFieldsProps) => (
|
||||
<>
|
||||
@@ -273,6 +278,7 @@ const PostFormFields = ({
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={disableReplyPublish}
|
||||
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
@@ -304,6 +310,7 @@ const PostFormFields = ({
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={disableReplyPublish}
|
||||
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
@@ -393,6 +400,7 @@ const PostFormFields = ({
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
disableReplyPublish={disableReplyPublish}
|
||||
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||
isUploading={isUploading}
|
||||
showUploadControls={showUploadControls}
|
||||
/>
|
||||
@@ -588,6 +596,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const [formError, setFormError] = useState<string | PostOptionsValidationError | null>(null);
|
||||
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
||||
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
||||
const { isPublishSubmissionInFlight, runPublishSubmission } = usePublishSubmissionGuard();
|
||||
|
||||
const checkContentLength = useRef(
|
||||
debounce((content: string, t: TFunction, options: string, directoryCode: string | undefined) => {
|
||||
@@ -645,66 +654,67 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
return params?.boardIdentifier ? `/${params.boardIdentifier}` : null;
|
||||
};
|
||||
|
||||
const onPublishPost = () => {
|
||||
const appliedYouTubeConversion = applyPendingConversion();
|
||||
const onPublishPost = () =>
|
||||
runPublishSubmission(async () => {
|
||||
const appliedYouTubeConversion = await applyPendingConversion();
|
||||
|
||||
const currentTitle = subjectRef.current?.value.trim() || '';
|
||||
const currentContent = textRef.current?.value || '';
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
const currentOptions = optionsRef.current?.value || '';
|
||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
const currentTitle = subjectRef.current?.value.trim() || '';
|
||||
const currentContent = textRef.current?.value || '';
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
const currentOptions = optionsRef.current?.value || '';
|
||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
|
||||
checkContentLength.cancel();
|
||||
checkPostOptions.cancel();
|
||||
setLengthError(null);
|
||||
setFormError(null);
|
||||
nonokoRedirectPathRef.current = null;
|
||||
checkContentLength.cancel();
|
||||
checkPostOptions.cancel();
|
||||
setLengthError(null);
|
||||
setFormError(null);
|
||||
nonokoRedirectPathRef.current = null;
|
||||
|
||||
if (currentOptionsError) {
|
||||
setFormError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
if (currentOptionsError) {
|
||||
setFormError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentTitle && !publishContent.trim() && !currentUrl) {
|
||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setFormError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
if (!currentTitle && !publishContent.trim() && !currentUrl) {
|
||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setFormError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||
return;
|
||||
}
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
|
||||
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
|
||||
return;
|
||||
}
|
||||
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
|
||||
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
|
||||
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
|
||||
const publishOptions = {
|
||||
...flagPublishOptions,
|
||||
...(flairs ? { flairs } : {}),
|
||||
};
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
|
||||
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
|
||||
const publishOptions = {
|
||||
...flagPublishOptions,
|
||||
...(flairs ? { flairs } : {}),
|
||||
};
|
||||
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
|
||||
};
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
|
||||
});
|
||||
|
||||
// redirect to pending page when pending comment is created
|
||||
const navigate = useNavigate();
|
||||
@@ -774,54 +784,55 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
setIsBbcodePreviewing(true);
|
||||
};
|
||||
|
||||
const onPublishReply = () => {
|
||||
const appliedYouTubeConversion = applyPendingConversion();
|
||||
const onPublishReply = () =>
|
||||
runPublishSubmission(async () => {
|
||||
const appliedYouTubeConversion = await applyPendingConversion();
|
||||
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
const currentOptions = optionsRef.current?.value || '';
|
||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
const currentOptions = optionsRef.current?.value || '';
|
||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
|
||||
checkContentLength.cancel();
|
||||
checkPostOptions.cancel();
|
||||
setLengthError(null);
|
||||
setFormError(null);
|
||||
nonokoRedirectPathRef.current = null;
|
||||
checkContentLength.cancel();
|
||||
checkPostOptions.cancel();
|
||||
setLengthError(null);
|
||||
setFormError(null);
|
||||
nonokoRedirectPathRef.current = null;
|
||||
|
||||
if (currentOptionsError) {
|
||||
setFormError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
if (currentOptionsError) {
|
||||
setFormError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!publishContent.trim() && !currentUrl) {
|
||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (!publishContent.trim() && !currentUrl) {
|
||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setFormError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setFormError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||
return;
|
||||
}
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||
};
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||
});
|
||||
|
||||
const setLinkValue = (nextUrl: string) => {
|
||||
setUrl(nextUrl);
|
||||
@@ -966,6 +977,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
handleUpload={handleUpload}
|
||||
uploadFile={uploadFile}
|
||||
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
||||
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||
disableReplyPublish={isResolvingExternalQuotes}
|
||||
/>
|
||||
</tbody>
|
||||
|
||||
@@ -62,6 +62,7 @@ const testState = vi.hoisted(() => ({
|
||||
address: 'music-posting.eth',
|
||||
},
|
||||
} as Record<string, { address: string }>,
|
||||
fetchMock: vi.fn(),
|
||||
showUploadControls: true,
|
||||
uploadComplete: undefined as ((url: string) => void) | undefined,
|
||||
uploadedFileName: null as string | null,
|
||||
@@ -190,22 +191,32 @@ vi.mock('../../../hooks/use-stable-community', () => ({
|
||||
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
||||
default: () => ({
|
||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||
publishReply: (options?: Record<string, unknown>) => {
|
||||
if (options) {
|
||||
testState.setPublishReplyOptionsMock(options);
|
||||
}
|
||||
return testState.publishReplyMock(options);
|
||||
vi.mock('../../../hooks/use-publish-reply', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
default: () => {
|
||||
const [, forceUpdate] = React.useReducer((value: number) => value + 1, 0);
|
||||
|
||||
return {
|
||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
||||
publishReply: (options?: Record<string, unknown>) => {
|
||||
if (options) {
|
||||
testState.setPublishReplyOptionsMock(options);
|
||||
}
|
||||
const result = testState.publishReplyMock(options);
|
||||
forceUpdate();
|
||||
return result;
|
||||
},
|
||||
publishReplyError: testState.publishReplyError,
|
||||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||
replyIndex: testState.replyIndex,
|
||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
||||
};
|
||||
},
|
||||
publishReplyError: testState.publishReplyError,
|
||||
publishReplyStateMessage: testState.publishReplyStateMessage,
|
||||
replyIndex: testState.replyIndex,
|
||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||
setPublishReplyOptions: (options: Record<string, unknown>) => testState.setPublishReplyOptionsMock(options),
|
||||
}),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => testState.isMobile,
|
||||
@@ -452,6 +463,14 @@ describe('ReplyModal', () => {
|
||||
address: 'traditional-games.bso',
|
||||
},
|
||||
};
|
||||
testState.fetchMock.mockReset();
|
||||
testState.fetchMock.mockResolvedValue({
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
|
||||
},
|
||||
ok: true,
|
||||
});
|
||||
vi.stubGlobal('fetch', testState.fetchMock);
|
||||
testState.showUploadControls = true;
|
||||
testState.uploadComplete = undefined;
|
||||
testState.uploadedFileName = null;
|
||||
@@ -467,6 +486,7 @@ describe('ReplyModal', () => {
|
||||
container.remove();
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.webkitUserSelect = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
|
||||
@@ -772,7 +792,7 @@ describe('ReplyModal', () => {
|
||||
|
||||
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';
|
||||
const thumbnailLink = 'https://img.youtube.com/vi/reply123/maxresdefault.jpg';
|
||||
testState.openEmpty = true;
|
||||
testState.selectedText = 'reply body';
|
||||
testState.directoryByAddress['music-posting.eth'] = {
|
||||
@@ -808,6 +828,84 @@ describe('ReplyModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unresolved YouTube links on media-only reply modal boards', async () => {
|
||||
const youtubeLink = 'https://youtu.be/replymissing';
|
||||
testState.fetchMock.mockResolvedValue({
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
ok: false,
|
||||
});
|
||||
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 linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||
|
||||
await dispatchInput(linkInput, youtubeLink);
|
||||
await clickButtonByText('post');
|
||||
await flushEffects(8);
|
||||
|
||||
expect(testState.fetchMock).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
|
||||
expect(linkInput.value).toBe(youtubeLink);
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores duplicate reply clicks while youtube thumbnail resolution is pending', async () => {
|
||||
const youtubeLink = 'https://youtu.be/replyslow';
|
||||
const thumbnailLink = 'https://img.youtube.com/vi/replyslow/maxresdefault.jpg';
|
||||
let resolveFetch: (response: unknown) => void = () => {};
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
testState.fetchMock.mockReturnValue(fetchPromise);
|
||||
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 linkInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||
const postButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
|
||||
|
||||
await dispatchInput(linkInput, youtubeLink);
|
||||
|
||||
await clickButtonByText('post');
|
||||
expect(postButton.disabled).toBe(true);
|
||||
|
||||
await clickButtonByText('post');
|
||||
expect(testState.fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch({
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-length' ? '12345' : null),
|
||||
},
|
||||
ok: true,
|
||||
});
|
||||
await fetchPromise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||||
content: `${youtubeLink}\nreply body`,
|
||||
link: thumbnailLink,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates unsupported options and keeps fortune output out of preview state until reply publish', async () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||
testState.openEmpty = true;
|
||||
@@ -1065,6 +1163,7 @@ describe('ReplyModal', () => {
|
||||
await dispatchInput(optionsInput, 'nonoko');
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
|
||||
await clickButtonByText('post');
|
||||
await flushEffects();
|
||||
await rerenderReplyModal('/mu/thread/post-1');
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions } from '../../lib/utils/media-link-validation-utils';
|
||||
import { getExpiringMediaLinkAlert, getPublishFileDisplayName, getPublishLinkOptions, isPublishFileMediaLink } from '../../lib/utils/media-link-validation-utils';
|
||||
import { getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
|
||||
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
|
||||
import {
|
||||
@@ -33,6 +33,7 @@ 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 usePublishSubmissionGuard from '../../hooks/use-publish-submission-guard';
|
||||
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';
|
||||
@@ -127,6 +128,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
||||
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
||||
const [showTexPreview, setShowTexPreview] = useState(false);
|
||||
const { isPublishSubmissionInFlight, runPublishSubmission } = usePublishSubmissionGuard();
|
||||
const texButtonRef = useRef<HTMLButtonElement>(null);
|
||||
// Blur in the close handler so the TeX button doesn't keep a lingering focus state
|
||||
// (focus-visible promotion on Escape, focus-triggered tooltip) after the preview closes.
|
||||
@@ -157,51 +159,56 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}, POST_OPTIONS_VALIDATION_DELAY_MS),
|
||||
);
|
||||
|
||||
const onPublishReply = () => {
|
||||
const appliedYouTubeConversion = applyPendingConversion();
|
||||
const onPublishReply = () =>
|
||||
runPublishSubmission(async () => {
|
||||
const appliedYouTubeConversion = await applyPendingConversion();
|
||||
|
||||
const currentContent = textRef.current?.value || '';
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
const currentOptions = optionsRef.current?.value || '';
|
||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
const currentContent = textRef.current?.value || '';
|
||||
const currentUrl = urlRef.current?.value.trim() || '';
|
||||
const currentOptions = optionsRef.current?.value || '';
|
||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
|
||||
checkContentLengthRef.current.cancel();
|
||||
checkPostOptionsRef.current.cancel();
|
||||
setLengthError(null);
|
||||
nonokoRedirectPathRef.current = null;
|
||||
checkContentLengthRef.current.cancel();
|
||||
checkPostOptionsRef.current.cancel();
|
||||
setLengthError(null);
|
||||
nonokoRedirectPathRef.current = null;
|
||||
|
||||
if (currentOptionsError) {
|
||||
setError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
if (currentOptionsError) {
|
||||
setError(currentOptionsError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!publishContent.trim() && !currentUrl) {
|
||||
setError(t('error') + ': ' + t('empty_comment_alert'));
|
||||
return;
|
||||
}
|
||||
if (!publishContent.trim() && !currentUrl) {
|
||||
setError(t('error') + ': ' + t('empty_comment_alert'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||
setError(t('error') + ': ' + t('invalid_url_alert'));
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||
setError(t('error') + ': ' + t('invalid_url_alert'));
|
||||
return;
|
||||
}
|
||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||
setError(t('error') + ': ' + t('link_not_image_or_video_alert'));
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setError(t('error') + ': ' + t('field_too_long'));
|
||||
return;
|
||||
}
|
||||
if (publishContent.trim().length > 2000) {
|
||||
setError(t('error') + ': ' + t('field_too_long'));
|
||||
return;
|
||||
}
|
||||
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||
|
||||
setError(null);
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
||||
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||
};
|
||||
setError(null);
|
||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
||||
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof replyIndex === 'number') {
|
||||
@@ -698,7 +705,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
]
|
||||
</span>
|
||||
)}
|
||||
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} type='button' onClick={onPublishReply}>
|
||||
<button className={styles.publishButton} disabled={isResolvingExternalQuotes || isPublishSubmissionInFlight} type='button' onClick={onPublishReply}>
|
||||
{t('post')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useYouTubeThumbnailFallback } from '../use-youtube-thumbnail-fallback';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let latestValue: ReturnType<typeof useYouTubeThumbnailFallback>;
|
||||
|
||||
const HookHarness = ({ thumbnailUrl }: { thumbnailUrl: string | undefined }) => {
|
||||
latestValue = useYouTubeThumbnailFallback(thumbnailUrl);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderFallback = async (thumbnailUrl: string | undefined) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness, { thumbnailUrl }));
|
||||
});
|
||||
};
|
||||
|
||||
const getPlaceholderImage = () => ({ naturalHeight: 90, naturalWidth: 120 }) as HTMLImageElement;
|
||||
|
||||
describe('useYouTubeThumbnailFallback', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('advances to the next YouTube fallback when a placeholder loads before the final candidate', async () => {
|
||||
await renderFallback('https://img.youtube.com/vi/missing-max/maxresdefault.jpg');
|
||||
|
||||
let handled = false;
|
||||
await act(async () => {
|
||||
handled = latestValue.handleThumbnailLoad(getPlaceholderImage());
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(latestValue.isUnavailable).toBe(false);
|
||||
expect(latestValue.thumbnailUrl).toBe('https://img.youtube.com/vi/missing-max/sddefault.jpg');
|
||||
});
|
||||
|
||||
it('marks the thumbnail unavailable when the last YouTube candidate is a placeholder', async () => {
|
||||
await renderFallback('https://img.youtube.com/vi/missing-hq/hqdefault.jpg');
|
||||
|
||||
let handled = false;
|
||||
await act(async () => {
|
||||
handled = latestValue.handleThumbnailLoad(getPlaceholderImage());
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(latestValue.isUnavailable).toBe(true);
|
||||
expect(latestValue.thumbnailUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const usePublishSubmissionGuard = () => {
|
||||
const publishSubmissionInFlightRef = useRef(false);
|
||||
const [isPublishSubmissionInFlight, setIsPublishSubmissionInFlight] = useState(false);
|
||||
|
||||
const runPublishSubmission = useCallback(async (publish: () => Promise<void>) => {
|
||||
if (publishSubmissionInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
publishSubmissionInFlightRef.current = true;
|
||||
setIsPublishSubmissionInFlight(true);
|
||||
|
||||
try {
|
||||
await publish();
|
||||
} finally {
|
||||
publishSubmissionInFlightRef.current = false;
|
||||
setIsPublishSubmissionInFlight(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isPublishSubmissionInFlight, runPublishSubmission };
|
||||
};
|
||||
|
||||
export default usePublishSubmissionGuard;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { getYouTubeThumbnailFallbackUrls, isMissingYouTubeThumbnailImage } from '../lib/utils/media-utils';
|
||||
|
||||
interface YouTubeThumbnailFallbackState {
|
||||
sourceUrl?: string;
|
||||
index: number;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export const useYouTubeThumbnailFallback = (thumbnailUrl: string | undefined) => {
|
||||
const fallbackUrls = useMemo(() => getYouTubeThumbnailFallbackUrls(thumbnailUrl), [thumbnailUrl]);
|
||||
const [state, setState] = useState<YouTubeThumbnailFallbackState>({ index: 0, unavailable: false });
|
||||
const index = state.sourceUrl === thumbnailUrl ? state.index : 0;
|
||||
const unavailable = state.sourceUrl === thumbnailUrl ? state.unavailable : false;
|
||||
const resolvedThumbnailUrl = unavailable ? undefined : (fallbackUrls[index] ?? thumbnailUrl);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (!thumbnailUrl || !fallbackUrls.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (index < fallbackUrls.length - 1) {
|
||||
setState({ index: index + 1, sourceUrl: thumbnailUrl, unavailable: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
setState({ index, sourceUrl: thumbnailUrl, unavailable: true });
|
||||
return true;
|
||||
}, [fallbackUrls, index, thumbnailUrl]);
|
||||
|
||||
const handleThumbnailLoad = useCallback(
|
||||
(image: HTMLImageElement) => {
|
||||
if (!resolvedThumbnailUrl || !isMissingYouTubeThumbnailImage(resolvedThumbnailUrl, image.naturalWidth, image.naturalHeight)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return advance();
|
||||
},
|
||||
[advance, resolvedThumbnailUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
handleThumbnailError: advance,
|
||||
handleThumbnailLoad,
|
||||
isUnavailable: unavailable,
|
||||
thumbnailUrl: resolvedThumbnailUrl,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getYouTubeThumbnailUrlFromLink } from '../lib/utils/media-utils';
|
||||
import { getBestAvailableYouTubeThumbnailUrlFromLink, getYouTubeThumbnailUrlFromLink } from '../lib/utils/media-utils';
|
||||
|
||||
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_DELAY_SECONDS = 3;
|
||||
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_INTERVAL_MS = 1000;
|
||||
@@ -9,7 +9,7 @@ type ElementRef<T extends HTMLElement> = {
|
||||
};
|
||||
|
||||
interface PendingYouTubeThumbnailLinkConversion {
|
||||
thumbnailLink: string;
|
||||
thumbnailLinkPromise: Promise<string | undefined>;
|
||||
youtubeLink: string;
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@ const getContentWithYouTubeLinkAtTop = (content: string, youtubeLink: string): s
|
||||
|
||||
const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => {
|
||||
const youtubeLink = link.trim();
|
||||
const thumbnailLink = getYouTubeThumbnailUrlFromLink(youtubeLink);
|
||||
const preferredThumbnailLink = getYouTubeThumbnailUrlFromLink(youtubeLink);
|
||||
|
||||
if (!youtubeLink || !thumbnailLink || youtubeLink === thumbnailLink) {
|
||||
if (!youtubeLink || !preferredThumbnailLink || youtubeLink === preferredThumbnailLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { thumbnailLink, youtubeLink };
|
||||
return { thumbnailLinkPromise: getBestAvailableYouTubeThumbnailUrlFromLink(youtubeLink), youtubeLink };
|
||||
};
|
||||
|
||||
export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, onLinkChange, textRef, urlRef }: UseYouTubeThumbnailLinkConversionOptions) => {
|
||||
@@ -56,7 +56,7 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
||||
setNoticeCountdown(null);
|
||||
}, [clearCountdownTimer]);
|
||||
|
||||
const applyPendingConversion = useCallback(() => {
|
||||
const applyPendingConversion = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
cancelPendingConversion();
|
||||
return false;
|
||||
@@ -69,6 +69,13 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
||||
return false;
|
||||
}
|
||||
|
||||
const thumbnailLink = await pendingConversion.thumbnailLinkPromise;
|
||||
const latestLink = urlRef.current?.value.trim() || '';
|
||||
if (!thumbnailLink || latestLink !== pendingConversion.youtubeLink) {
|
||||
cancelPendingConversion();
|
||||
return false;
|
||||
}
|
||||
|
||||
clearCountdownTimer();
|
||||
pendingConversionRef.current = null;
|
||||
setNoticeCountdown(null);
|
||||
@@ -80,11 +87,11 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
||||
textRef.current.value = nextContent;
|
||||
}
|
||||
if (urlRef.current) {
|
||||
urlRef.current.value = pendingConversion.thumbnailLink;
|
||||
urlRef.current.value = thumbnailLink;
|
||||
}
|
||||
|
||||
onContentChange(nextContent);
|
||||
onLinkChange(pendingConversion.thumbnailLink);
|
||||
onLinkChange(thumbnailLink);
|
||||
return true;
|
||||
}, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);
|
||||
|
||||
@@ -99,7 +106,7 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
||||
}
|
||||
|
||||
if (seconds <= 0) {
|
||||
applyPendingConversion();
|
||||
void applyPendingConversion();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const testState = vi.hoisted(() => ({
|
||||
cachedThumbnails: new Map<string, string>(),
|
||||
canEmbedHosts: new Set<string>(),
|
||||
capacitorHttpGetMock: vi.fn(),
|
||||
capacitorHttpRequestMock: vi.fn(),
|
||||
consoleErrorMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
isNativePlatform: false,
|
||||
@@ -34,10 +35,12 @@ vi.mock('@capacitor/core', () => ({
|
||||
},
|
||||
CapacitorHttp: {
|
||||
get: (options: unknown) => testState.capacitorHttpGetMock(options),
|
||||
request: (options: unknown) => testState.capacitorHttpRequestMock(options),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getBestAvailableYouTubeThumbnailUrlFromLink,
|
||||
fetchWebpageThumbnailIfNeeded,
|
||||
getCommentMediaInfo,
|
||||
getDisplayMediaInfoType,
|
||||
@@ -47,7 +50,10 @@ import {
|
||||
getPostMediaTypeLabel,
|
||||
getTwimgMediaFilePublishUrl,
|
||||
getYouTubeEmbedPostMediaFileLink,
|
||||
getYouTubeThumbnailCandidateUrlsFromLink,
|
||||
getYouTubeThumbnailFallbackUrls,
|
||||
getYouTubeThumbnailUrlFromLink,
|
||||
isMissingYouTubeThumbnailImage,
|
||||
} from '../media-utils';
|
||||
|
||||
const clearMemoizedCache = (fn: unknown) => {
|
||||
@@ -78,6 +84,20 @@ const createFetchResponse = (html: string, ok = true) => {
|
||||
};
|
||||
};
|
||||
|
||||
const createHeadResponse = (ok: boolean, contentLength = '12345') => ({
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-length' ? contentLength : null),
|
||||
},
|
||||
ok,
|
||||
});
|
||||
|
||||
const createNativeHeadResponse = (status: number, contentLength = '12345') => ({
|
||||
headers: {
|
||||
'Content-Length': contentLength,
|
||||
},
|
||||
status,
|
||||
});
|
||||
|
||||
describe('media-utils', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
@@ -92,6 +112,7 @@ describe('media-utils', () => {
|
||||
});
|
||||
testState.fetchMock.mockReset();
|
||||
testState.capacitorHttpGetMock.mockReset();
|
||||
testState.capacitorHttpRequestMock.mockReset();
|
||||
vi.stubGlobal('fetch', testState.fetchMock);
|
||||
clearMemoizedCache(getHasThumbnail);
|
||||
clearMemoizedCache(getLinkMediaInfo);
|
||||
@@ -119,18 +140,87 @@ describe('media-utils', () => {
|
||||
|
||||
it('uses the youtube thumbnail url for post media file links and labels', () => {
|
||||
const mediaInfo = {
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
};
|
||||
|
||||
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/0.jpg');
|
||||
expect(getYouTubeEmbedPostMediaFileLink(mediaInfo)).toBe('https://img.youtube.com/vi/abc123/maxresdefault.jpg');
|
||||
expect(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video');
|
||||
expect(getYouTubeEmbedPostMediaFileLink({ type: 'iframe', url: 'https://streamable.com/clip123' })).toBeUndefined();
|
||||
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/0.jpg');
|
||||
expect(getYouTubeThumbnailUrlFromLink('https://youtu.be/short123')).toBe('https://img.youtube.com/vi/short123/maxresdefault.jpg');
|
||||
expect(getYouTubeThumbnailUrlFromLink('https://example.com/watch?v=not-youtube')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds youtube thumbnail candidates and detects the served missing-thumbnail placeholder', () => {
|
||||
expect(getYouTubeThumbnailCandidateUrlsFromLink('https://www.youtube.com/watch?v=abc123')).toEqual([
|
||||
'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/sddefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
|
||||
]);
|
||||
expect(getYouTubeThumbnailFallbackUrls('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg')).toEqual([
|
||||
'https://i3.ytimg.com/vi/abc123/maxresdefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/sddefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
|
||||
]);
|
||||
expect(getYouTubeThumbnailFallbackUrls('https://i3.ytimg.com/vi/abc123/sddefault.jpg')).toEqual([
|
||||
'https://i3.ytimg.com/vi/abc123/sddefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/mqdefault.jpg',
|
||||
'https://img.youtube.com/vi/abc123/hqdefault.jpg',
|
||||
]);
|
||||
expect(isMissingYouTubeThumbnailImage('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg', 120, 90)).toBe(true);
|
||||
expect(isMissingYouTubeThumbnailImage('https://i3.ytimg.com/vi/abc123/maxresdefault.jpg', 1280, 720)).toBe(false);
|
||||
expect(isMissingYouTubeThumbnailImage('https://example.com/thumb.jpg', 120, 90)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the best available youtube thumbnail without accepting the failed placeholder response', async () => {
|
||||
testState.fetchMock
|
||||
.mockResolvedValueOnce(createHeadResponse(false, '1097'))
|
||||
.mockResolvedValueOnce(createHeadResponse(true, '1097'))
|
||||
.mockResolvedValueOnce(createHeadResponse(true, '8710'));
|
||||
|
||||
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=resolve123')).resolves.toBe(
|
||||
'https://img.youtube.com/vi/resolve123/mqdefault.jpg',
|
||||
);
|
||||
expect(testState.fetchMock).toHaveBeenNthCalledWith(1, 'https://img.youtube.com/vi/resolve123/maxresdefault.jpg', expect.objectContaining({ method: 'HEAD' }));
|
||||
expect(testState.fetchMock).toHaveBeenNthCalledWith(2, 'https://img.youtube.com/vi/resolve123/sddefault.jpg', expect.objectContaining({ method: 'HEAD' }));
|
||||
expect(testState.fetchMock).toHaveBeenNthCalledWith(3, 'https://img.youtube.com/vi/resolve123/mqdefault.jpg', expect.objectContaining({ method: 'HEAD' }));
|
||||
});
|
||||
|
||||
it('uses native http requests when resolving youtube thumbnails in native builds', async () => {
|
||||
testState.isNativePlatform = true;
|
||||
testState.capacitorHttpRequestMock.mockResolvedValueOnce(createNativeHeadResponse(404, '1097')).mockResolvedValueOnce(createNativeHeadResponse(200, '8765'));
|
||||
|
||||
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=native123')).resolves.toBe(
|
||||
'https://img.youtube.com/vi/native123/sddefault.jpg',
|
||||
);
|
||||
expect(testState.fetchMock).not.toHaveBeenCalled();
|
||||
expect(testState.capacitorHttpRequestMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
connectTimeout: 3000,
|
||||
method: 'HEAD',
|
||||
readTimeout: 3000,
|
||||
url: 'https://img.youtube.com/vi/native123/maxresdefault.jpg',
|
||||
}),
|
||||
);
|
||||
expect(testState.capacitorHttpRequestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: 'HEAD',
|
||||
url: 'https://img.youtube.com/vi/native123/sddefault.jpg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns no youtube thumbnail when every candidate is unavailable', async () => {
|
||||
testState.fetchMock.mockResolvedValue(createHeadResponse(false, '1097'));
|
||||
|
||||
await expect(getBestAvailableYouTubeThumbnailUrlFromLink('https://www.youtube.com/watch?v=missing123')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognizes which media types expose thumbnails', () => {
|
||||
expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false);
|
||||
expect(getHasThumbnail({ type: 'image', url: 'https://example.com/file.png' }, 'https://example.com/file.png')).toBe(true);
|
||||
@@ -141,7 +231,7 @@ describe('media-utils', () => {
|
||||
expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
|
||||
expect(
|
||||
getHasThumbnail(
|
||||
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/0.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
|
||||
{ patternThumbnailUrl: 'https://img.youtube.com/vi/abc/maxresdefault.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' },
|
||||
'https://www.youtube.com/watch?v=abc',
|
||||
),
|
||||
).toBe(true);
|
||||
@@ -171,7 +261,7 @@ describe('media-utils', () => {
|
||||
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
|
||||
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
|
||||
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
});
|
||||
@@ -181,13 +271,13 @@ describe('media-utils', () => {
|
||||
url: 'https://streamable.com/clip123',
|
||||
});
|
||||
expect(getLinkMediaInfo('https://yt.example/watch?v=yt123')).toEqual({
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://yt.example/watch?v=yt123',
|
||||
});
|
||||
testState.canEmbedHosts = new Set(['yewtu.be']);
|
||||
expect(getLinkMediaInfo('https://yewtu.be/invidious123')).toEqual({
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/invidious123/maxresdefault.jpg',
|
||||
type: 'iframe',
|
||||
url: 'https://yewtu.be/invidious123',
|
||||
});
|
||||
@@ -237,7 +327,7 @@ describe('media-utils', () => {
|
||||
expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({
|
||||
linkHeight: 450,
|
||||
linkWidth: 800,
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||
thumbnail: undefined,
|
||||
type: 'iframe',
|
||||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
|
||||
@@ -66,19 +66,146 @@ export const getPostMediaTypeLabel = (commentMediaInfo: CommentMediaInfo | undef
|
||||
};
|
||||
|
||||
const isYouTubeLikeUrl = (url: URL): boolean => youtubeHosts.has(url.host) || url.host.startsWith('yt.');
|
||||
const YOUTUBE_THUMBNAIL_FILENAMES = ['maxresdefault.jpg', 'sddefault.jpg', 'mqdefault.jpg', 'hqdefault.jpg'] as const;
|
||||
const YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH = '1097';
|
||||
const YOUTUBE_MISSING_THUMBNAIL_HEIGHT = 90;
|
||||
const YOUTUBE_MISSING_THUMBNAIL_WIDTH = 120;
|
||||
const YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS = 3000;
|
||||
const youtubeThumbnailResolutionPromises = new Map<string, Promise<string | undefined>>();
|
||||
|
||||
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
|
||||
if (!isYouTubeLikeUrl(url)) {
|
||||
const getHeaderValue = (headers: Record<string, string>, headerName: string): string | undefined => {
|
||||
const matchingHeader = Object.entries(headers).find(([name]) => name.toLowerCase() === headerName);
|
||||
return matchingHeader?.[1];
|
||||
};
|
||||
|
||||
const getYouTubeThumbnailUrlFromVideoId = (videoId: string, filename: (typeof YOUTUBE_THUMBNAIL_FILENAMES)[number]): string => {
|
||||
return `https://img.youtube.com/vi/${videoId}/${filename}`;
|
||||
};
|
||||
|
||||
const getYouTubeThumbnailVideoId = (url: URL): string | undefined => {
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname !== 'img.youtube.com' && hostname !== 'i.ytimg.com' && !/^i\d+\.ytimg\.com$/.test(hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length !== 3 || pathParts[0] !== 'vi') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pathParts[1];
|
||||
};
|
||||
|
||||
export const getYouTubeThumbnailCandidateUrls = (url: URL): string[] => {
|
||||
if (!isYouTubeLikeUrl(url)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const videoId = getYouTubeVideoId(url);
|
||||
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
|
||||
return videoId ? YOUTUBE_THUMBNAIL_FILENAMES.map((filename) => getYouTubeThumbnailUrlFromVideoId(videoId, filename)) : [];
|
||||
};
|
||||
|
||||
export const getYouTubeThumbnailCandidateUrlsFromLink = (link: string): string[] => {
|
||||
const parsedUrl = parseHttpUrl(link.trim());
|
||||
return parsedUrl ? getYouTubeThumbnailCandidateUrls(parsedUrl) : [];
|
||||
};
|
||||
|
||||
export const getYouTubeThumbnailFallbackUrls = (thumbnailUrl: string | undefined): string[] => {
|
||||
if (!thumbnailUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedUrl = parseHttpUrl(thumbnailUrl);
|
||||
const videoId = parsedUrl ? getYouTubeThumbnailVideoId(parsedUrl) : undefined;
|
||||
if (!videoId) {
|
||||
return [thumbnailUrl];
|
||||
}
|
||||
|
||||
const currentFilename = parsedUrl?.pathname.split('/').filter(Boolean)[2];
|
||||
const startIndex = YOUTUBE_THUMBNAIL_FILENAMES.findIndex((filename) => filename === currentFilename);
|
||||
const filenames = startIndex >= 0 ? YOUTUBE_THUMBNAIL_FILENAMES.slice(startIndex) : YOUTUBE_THUMBNAIL_FILENAMES;
|
||||
|
||||
return filenames.map((filename) => (filename === currentFilename ? thumbnailUrl : getYouTubeThumbnailUrlFromVideoId(videoId, filename)));
|
||||
};
|
||||
|
||||
export const isMissingYouTubeThumbnailImage = (thumbnailUrl: string, width: number, height: number): boolean => {
|
||||
const parsedUrl = parseHttpUrl(thumbnailUrl);
|
||||
return Boolean(parsedUrl && getYouTubeThumbnailVideoId(parsedUrl) && width === YOUTUBE_MISSING_THUMBNAIL_WIDTH && height === YOUTUBE_MISSING_THUMBNAIL_HEIGHT);
|
||||
};
|
||||
|
||||
export const getYouTubeThumbnailUrl = (url: URL): string | undefined => {
|
||||
return getYouTubeThumbnailCandidateUrls(url)[0];
|
||||
};
|
||||
|
||||
export const getYouTubeThumbnailUrlFromLink = (link: string): string | undefined => {
|
||||
const parsedUrl = parseHttpUrl(link.trim());
|
||||
return parsedUrl ? getYouTubeThumbnailUrl(parsedUrl) : undefined;
|
||||
return getYouTubeThumbnailCandidateUrlsFromLink(link)[0];
|
||||
};
|
||||
|
||||
const isAvailableYouTubeThumbnailUrl = async (thumbnailUrl: string): Promise<boolean> => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const response = await CapacitorHttp.request({
|
||||
url: thumbnailUrl,
|
||||
method: 'HEAD',
|
||||
readTimeout: YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS,
|
||||
connectTimeout: YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return response.status >= 200 && response.status < 300 && getHeaderValue(response.headers, 'content-length') !== YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof fetch !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), YOUTUBE_THUMBNAIL_RESOLUTION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(thumbnailUrl, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
return response.ok && response.headers.get('content-length') !== YOUTUBE_MISSING_THUMBNAIL_CONTENT_LENGTH;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
export const getBestAvailableYouTubeThumbnailUrlFromLink = (link: string): Promise<string | undefined> => {
|
||||
const candidateUrls = getYouTubeThumbnailCandidateUrlsFromLink(link);
|
||||
if (!candidateUrls.length) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const cacheKey = candidateUrls.join('\n');
|
||||
const cachedPromise = youtubeThumbnailResolutionPromises.get(cacheKey);
|
||||
if (cachedPromise) {
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
const resolutionPromise = (async () => {
|
||||
for (const candidateUrl of candidateUrls) {
|
||||
if (await isAvailableYouTubeThumbnailUrl(candidateUrl)) {
|
||||
return candidateUrl;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})().then((thumbnailUrl) => {
|
||||
if (!thumbnailUrl) {
|
||||
youtubeThumbnailResolutionPromises.delete(cacheKey);
|
||||
}
|
||||
return thumbnailUrl;
|
||||
});
|
||||
|
||||
youtubeThumbnailResolutionPromises.set(cacheKey, resolutionPromise);
|
||||
return resolutionPromise;
|
||||
};
|
||||
|
||||
export const getHasThumbnail = memoize(
|
||||
|
||||
Reference in New Issue
Block a user