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.
|
# React Doctor PR review.
|
||||||
# Reports only issues this PR INTRODUCES (diffed against the merge-base) as inline
|
# Reports only issues this PR INTRODUCES (diffed against the base branch) as
|
||||||
# review comments + a sticky summary. It reads doctor.config.jsonc, so the
|
# GitHub annotations. It reads doctor.config.jsonc, so the React-Compiler rules
|
||||||
# React-Compiler rules we don't enforce stay suppressed (see that file and
|
# we don't enforce stay suppressed (see that file and
|
||||||
# docs/agent-playbooks/known-surprises.md). We do NOT chase the aggregate score.
|
# docs/agent-playbooks/known-surprises.md). We do NOT chase the aggregate score.
|
||||||
name: React Doctor
|
name: React Doctor
|
||||||
|
|
||||||
@@ -25,4 +25,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # so React Doctor can diff against the merge-base for new-vs-existing
|
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'),
|
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 { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
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 { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
|
||||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||||
|
import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback';
|
||||||
import useHide from '../../hooks/use-hide';
|
import useHide from '../../hooks/use-hide';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import { CATALOG_PREVIEW_MARKDOWN_OPTIONS, removeMarkdown } from '../../lib/utils/post-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 styles from './catalog-row.module.css';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||||
@@ -39,11 +40,31 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
|||||||
void cid;
|
void cid;
|
||||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
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 { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const handleLoad = () => setIsLoaded(true);
|
const handleLoad = () => setIsLoaded(true);
|
||||||
const handleError = () => setHasError(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 loadingStyle = { opacity: isLoaded ? 1 : 0 };
|
||||||
|
|
||||||
const { imageSize } = useCatalogStyleStore();
|
const { imageSize } = useCatalogStyleStore();
|
||||||
@@ -96,22 +117,24 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
|||||||
);
|
);
|
||||||
} else if (type === 'webpage' && !hasError) {
|
} else if (type === 'webpage' && !hasError) {
|
||||||
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||||
} else if (type === 'iframe' && iframeThumbnail && !hasError) {
|
} else if (type === 'iframe' && resolvedIframeThumbnail && !hasError) {
|
||||||
thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
thumbnailComponent = (
|
||||||
|
<img src={resolvedIframeThumbnail} alt='' onLoad={handleIframeLoad} onError={handleIframeError} style={loadingStyle} width={numericWidth} height={numericHeight} />
|
||||||
|
);
|
||||||
} else if (type === 'audio') {
|
} else if (type === 'audio') {
|
||||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={hasError ? '' : styles.mediaWrapper}
|
className={hasError || isIframeThumbnailUnavailable ? '' : styles.mediaWrapper}
|
||||||
style={{
|
style={{
|
||||||
...CSSProperties,
|
...CSSProperties,
|
||||||
...(matchedFilterColor ? { border: `3px solid ${matchedFilterColor}` } : {}),
|
...(matchedFilterColor ? { border: `3px solid ${matchedFilterColor}` } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isLoaded && !hasError && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
|
{!isLoaded && !hasError && !isIframeThumbnailUnavailable && type !== 'video' && type !== 'audio' && <span className={styles.loadingSkeleton} />}
|
||||||
{hasError ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
|
{hasError || isIframeThumbnailUnavailable ? <img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' /> : thumbnailComponent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ vi.mock('../../../lib/utils/media-utils', () => ({
|
|||||||
getDisplayMediaInfoType: (type: string) => type,
|
getDisplayMediaInfoType: (type: string) => type,
|
||||||
getHasThumbnail: () => testState.getHasThumbnailResult,
|
getHasThumbnail: () => testState.getHasThumbnailResult,
|
||||||
getMediaDimensions: () => '640x480',
|
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', () => ({
|
vi.mock('../../../lib/utils/url-utils', () => ({
|
||||||
@@ -64,12 +75,15 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
|
|||||||
default: () => testState.isMobile,
|
default: () => testState.isMobile,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../embed', () => ({
|
vi.mock('../../embed/embed', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
canEmbed: () => testState.canEmbed,
|
|
||||||
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../embed/embed-utils', () => ({
|
||||||
|
canEmbed: () => testState.canEmbed,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@ruffle-rs/ruffle', () => ({}));
|
vi.mock('@ruffle-rs/ruffle', () => ({}));
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
@@ -298,7 +312,7 @@ describe('CommentMedia', () => {
|
|||||||
it('renders a youtube thumbnail before the embed is opened', async () => {
|
it('renders a youtube thumbnail before the embed is opened', async () => {
|
||||||
await renderMedia({
|
await renderMedia({
|
||||||
commentMediaInfo: {
|
commentMediaInfo: {
|
||||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||||
type: 'iframe',
|
type: 'iframe',
|
||||||
url: 'https://www.youtube.com/watch?v=abc123',
|
url: 'https://www.youtube.com/watch?v=abc123',
|
||||||
},
|
},
|
||||||
@@ -306,10 +320,34 @@ describe('CommentMedia', () => {
|
|||||||
showThumbnail: true,
|
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();
|
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 () => {
|
it('renders the expanded embed view with a close button on mobile', async () => {
|
||||||
testState.isMobile = true;
|
testState.isMobile = true;
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { getHostname, parseHttpUrl } from '../../lib/utils/url-utils';
|
|||||||
import useExpandedMediaStore from '../../stores/use-expanded-media-store';
|
import useExpandedMediaStore from '../../stores/use-expanded-media-store';
|
||||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
|
import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback';
|
||||||
import styles from './comment-media.module.css';
|
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';
|
import RufflePlayer from './ruffle-player';
|
||||||
|
|
||||||
interface MediaProps {
|
interface MediaProps {
|
||||||
@@ -108,8 +110,14 @@ const Thumbnail = ({
|
|||||||
let thumbnailComponent: React.ReactNode = null;
|
let thumbnailComponent: React.ReactNode = null;
|
||||||
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
|
const thumbnailDimensions = { '--width': displayWidth, '--height': displayHeight } as React.CSSProperties;
|
||||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||||
|
const {
|
||||||
|
handleThumbnailError: handleIframeThumbnailError,
|
||||||
|
handleThumbnailLoad: handleIframeThumbnailLoad,
|
||||||
|
isUnavailable: isIframeThumbnailUnavailable,
|
||||||
|
thumbnailUrl: resolvedIframeThumbnail,
|
||||||
|
} = useYouTubeThumbnailFallback(iframeThumbnail);
|
||||||
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
|
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, url) && !isIframeThumbnailUnavailable;
|
||||||
const handleOpenMedia = () => setShowThumbnail(false);
|
const handleOpenMedia = () => setShowThumbnail(false);
|
||||||
|
|
||||||
if (type === 'gif') {
|
if (type === 'gif') {
|
||||||
@@ -137,9 +145,9 @@ const Thumbnail = ({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (type === 'iframe') {
|
} else if (type === 'iframe') {
|
||||||
thumbnailComponent = iframeThumbnail ? (
|
thumbnailComponent = resolvedIframeThumbnail ? (
|
||||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open embedded media preview' onClick={handleOpenMedia}>
|
<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>
|
</button>
|
||||||
) : null;
|
) : null;
|
||||||
} else if (type === 'audio') {
|
} else if (type === 'audio') {
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ describe('Markdown', () => {
|
|||||||
testState.embeddableHosts = new Set(['www.youtube.com']);
|
testState.embeddableHosts = new Set(['www.youtube.com']);
|
||||||
testState.mediaInfoByHref = {
|
testState.mediaInfoByHref = {
|
||||||
'https://www.youtube.com/watch?v=abc123': {
|
'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',
|
type: 'iframe',
|
||||||
url: 'https://www.youtube.com/watch?v=abc123',
|
url: 'https://www.youtube.com/watch?v=abc123',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
subscriptions: ['music-posting.eth'],
|
subscriptions: ['music-posting.eth'],
|
||||||
},
|
},
|
||||||
accountComment: undefined as { communityAddress?: string } | undefined,
|
accountComment: undefined as { communityAddress?: string } | undefined,
|
||||||
|
bestAvailableYouTubeThumbnailMock: undefined as undefined | ((link: string) => Promise<string | undefined>),
|
||||||
accountCommunityAddresses: ['mod.eth'] as string[],
|
accountCommunityAddresses: ['mod.eth'] as string[],
|
||||||
comments: {} as Record<string, { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
|
comments: {} as Record<string, { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
|
||||||
directories: [
|
directories: [
|
||||||
@@ -354,7 +355,7 @@ vi.mock('../../../lib/utils/media-utils', () => {
|
|||||||
return { type: 'audio', url: link };
|
return { type: 'audio', url: link };
|
||||||
}
|
}
|
||||||
if (link.includes('youtube.com')) {
|
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 };
|
return { type: 'webpage', url: link };
|
||||||
},
|
},
|
||||||
@@ -364,7 +365,21 @@ vi.mock('../../../lib/utils/media-utils', () => {
|
|||||||
const url = new URL(link);
|
const url = new URL(link);
|
||||||
if (!url.hostname.includes('youtube.com')) return undefined;
|
if (!url.hostname.includes('youtube.com')) return undefined;
|
||||||
const videoId = url.searchParams.get('v');
|
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 {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -516,6 +531,7 @@ describe('PostForm', () => {
|
|||||||
subscriptions: ['music-posting.eth'],
|
subscriptions: ['music-posting.eth'],
|
||||||
};
|
};
|
||||||
testState.accountComment = undefined;
|
testState.accountComment = undefined;
|
||||||
|
testState.bestAvailableYouTubeThumbnailMock = undefined;
|
||||||
testState.accountCommunityAddresses = ['mod.eth'];
|
testState.accountCommunityAddresses = ['mod.eth'];
|
||||||
testState.comments = {};
|
testState.comments = {};
|
||||||
testState.directories = [
|
testState.directories = [
|
||||||
@@ -689,7 +705,7 @@ describe('PostForm', () => {
|
|||||||
|
|
||||||
it('converts YouTube links to thumbnail file links on media-only post forms', async () => {
|
it('converts YouTube links to thumbnail file links on media-only post forms', async () => {
|
||||||
const youtubeLink = 'https://www.youtube.com/watch?v=abc123';
|
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 renderPostForm('/all');
|
||||||
await clickByText(container, 'start_new_thread');
|
await clickByText(container, 'start_new_thread');
|
||||||
@@ -749,6 +765,47 @@ describe('PostForm', () => {
|
|||||||
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
|
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 () => {
|
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 twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
|
||||||
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
|
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 useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||||
import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion';
|
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 usePublishPost from '../../hooks/use-publish-post';
|
||||||
import usePublishReply from '../../hooks/use-publish-reply';
|
import usePublishReply from '../../hooks/use-publish-reply';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||||
@@ -104,6 +105,7 @@ const PostFormActions = ({
|
|||||||
onPublishReply,
|
onPublishReply,
|
||||||
onPublishPost,
|
onPublishPost,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
|
isPublishSubmissionInFlight,
|
||||||
isUploading,
|
isUploading,
|
||||||
showUploadControls,
|
showUploadControls,
|
||||||
}: {
|
}: {
|
||||||
@@ -111,22 +113,23 @@ const PostFormActions = ({
|
|||||||
variant: 'reply' | 'post' | 'upload';
|
variant: 'reply' | 'post' | 'upload';
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
isInPostView: boolean;
|
isInPostView: boolean;
|
||||||
onPublishReply: () => void;
|
onPublishReply: () => void | Promise<void>;
|
||||||
onPublishPost: () => void;
|
onPublishPost: () => void | Promise<void>;
|
||||||
handleUpload: () => void;
|
handleUpload: () => void;
|
||||||
|
isPublishSubmissionInFlight: boolean;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
showUploadControls: boolean;
|
showUploadControls: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (variant === 'reply' && isInPostView) {
|
if (variant === 'reply' && isInPostView) {
|
||||||
return (
|
return (
|
||||||
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isUploading}>
|
<button type='button' onClick={onPublishReply} disabled={disableReplyPublish || isPublishSubmissionInFlight || isUploading}>
|
||||||
{t('post')}
|
{t('post')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (variant === 'post' && !isInPostView) {
|
if (variant === 'post' && !isInPostView) {
|
||||||
return (
|
return (
|
||||||
<button type='button' onClick={onPublishPost}>
|
<button type='button' onClick={onPublishPost} disabled={isPublishSubmissionInFlight}>
|
||||||
{t('post')}
|
{t('post')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -186,11 +189,12 @@ interface PostFormFieldsProps {
|
|||||||
showMathTagsPrompt: boolean;
|
showMathTagsPrompt: boolean;
|
||||||
showBbcodeToolbar: boolean;
|
showBbcodeToolbar: boolean;
|
||||||
onBbcodePreviewToggle: () => void;
|
onBbcodePreviewToggle: () => void;
|
||||||
onPublishReply: () => void;
|
onPublishReply: () => void | Promise<void>;
|
||||||
onPublishPost: () => void;
|
onPublishPost: () => void | Promise<void>;
|
||||||
handleUpload: () => void;
|
handleUpload: () => void;
|
||||||
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
|
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
|
||||||
onOekakiClearUploadedUrl: (url: string) => void;
|
onOekakiClearUploadedUrl: (url: string) => void;
|
||||||
|
isPublishSubmissionInFlight: boolean;
|
||||||
disableReplyPublish: boolean;
|
disableReplyPublish: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +248,7 @@ const PostFormFields = ({
|
|||||||
handleUpload,
|
handleUpload,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
onOekakiClearUploadedUrl,
|
onOekakiClearUploadedUrl,
|
||||||
|
isPublishSubmissionInFlight,
|
||||||
disableReplyPublish,
|
disableReplyPublish,
|
||||||
}: PostFormFieldsProps) => (
|
}: PostFormFieldsProps) => (
|
||||||
<>
|
<>
|
||||||
@@ -273,6 +278,7 @@ const PostFormFields = ({
|
|||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
disableReplyPublish={disableReplyPublish}
|
disableReplyPublish={disableReplyPublish}
|
||||||
|
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
/>
|
/>
|
||||||
@@ -304,6 +310,7 @@ const PostFormFields = ({
|
|||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
disableReplyPublish={disableReplyPublish}
|
disableReplyPublish={disableReplyPublish}
|
||||||
|
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
/>
|
/>
|
||||||
@@ -393,6 +400,7 @@ const PostFormFields = ({
|
|||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
disableReplyPublish={disableReplyPublish}
|
disableReplyPublish={disableReplyPublish}
|
||||||
|
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
/>
|
/>
|
||||||
@@ -588,6 +596,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const [formError, setFormError] = useState<string | PostOptionsValidationError | null>(null);
|
const [formError, setFormError] = useState<string | PostOptionsValidationError | null>(null);
|
||||||
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
||||||
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
||||||
|
const { isPublishSubmissionInFlight, runPublishSubmission } = usePublishSubmissionGuard();
|
||||||
|
|
||||||
const checkContentLength = useRef(
|
const checkContentLength = useRef(
|
||||||
debounce((content: string, t: TFunction, options: string, directoryCode: string | undefined) => {
|
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;
|
return params?.boardIdentifier ? `/${params.boardIdentifier}` : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPublishPost = () => {
|
const onPublishPost = () =>
|
||||||
const appliedYouTubeConversion = applyPendingConversion();
|
runPublishSubmission(async () => {
|
||||||
|
const appliedYouTubeConversion = await applyPendingConversion();
|
||||||
|
|
||||||
const currentTitle = subjectRef.current?.value.trim() || '';
|
const currentTitle = subjectRef.current?.value.trim() || '';
|
||||||
const currentContent = textRef.current?.value || '';
|
const currentContent = textRef.current?.value || '';
|
||||||
const currentUrl = urlRef.current?.value.trim() || '';
|
const currentUrl = urlRef.current?.value.trim() || '';
|
||||||
const currentOptions = optionsRef.current?.value || '';
|
const currentOptions = optionsRef.current?.value || '';
|
||||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||||
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||||
|
|
||||||
checkContentLength.cancel();
|
checkContentLength.cancel();
|
||||||
checkPostOptions.cancel();
|
checkPostOptions.cancel();
|
||||||
setLengthError(null);
|
setLengthError(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
nonokoRedirectPathRef.current = null;
|
nonokoRedirectPathRef.current = null;
|
||||||
|
|
||||||
if (currentOptionsError) {
|
if (currentOptionsError) {
|
||||||
setFormError(currentOptionsError);
|
setFormError(currentOptionsError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentTitle && !publishContent.trim() && !currentUrl) {
|
if (!currentTitle && !publishContent.trim() && !currentUrl) {
|
||||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||||
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||||
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||||
if (expiringMediaLinkAlert) {
|
if (expiringMediaLinkAlert) {
|
||||||
setFormError(expiringMediaLinkAlert);
|
setFormError(expiringMediaLinkAlert);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publishContent.trim().length > 2000) {
|
if (publishContent.trim().length > 2000) {
|
||||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
|
if ((isInAllView || isInSubscriptionsView || isInModView) && !publishPostOptions.communityAddress) {
|
||||||
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
|
setFormError(`${t('error')}: ${t('no_board_selected_warning')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||||
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
|
const flashTagPublishOptions = getFlashTagPublishOptionsForDirectoryCode(postOptionsDirectoryCode, flashTagRef.current?.value);
|
||||||
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
|
const flairs = mergeFlairs(flagPublishOptions.flairs, flashTagPublishOptions.flairs);
|
||||||
const publishOptions = {
|
const publishOptions = {
|
||||||
...flagPublishOptions,
|
...flagPublishOptions,
|
||||||
...(flairs ? { flairs } : {}),
|
...(flairs ? { flairs } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
|
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
|
||||||
};
|
});
|
||||||
|
|
||||||
// redirect to pending page when pending comment is created
|
// redirect to pending page when pending comment is created
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -774,54 +784,55 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
setIsBbcodePreviewing(true);
|
setIsBbcodePreviewing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPublishReply = () => {
|
const onPublishReply = () =>
|
||||||
const appliedYouTubeConversion = applyPendingConversion();
|
runPublishSubmission(async () => {
|
||||||
|
const appliedYouTubeConversion = await applyPendingConversion();
|
||||||
|
|
||||||
const currentUrl = urlRef.current?.value.trim() || '';
|
const currentUrl = urlRef.current?.value.trim() || '';
|
||||||
const currentOptions = optionsRef.current?.value || '';
|
const currentOptions = optionsRef.current?.value || '';
|
||||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||||
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||||
|
|
||||||
checkContentLength.cancel();
|
checkContentLength.cancel();
|
||||||
checkPostOptions.cancel();
|
checkPostOptions.cancel();
|
||||||
setLengthError(null);
|
setLengthError(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
nonokoRedirectPathRef.current = null;
|
nonokoRedirectPathRef.current = null;
|
||||||
|
|
||||||
if (currentOptionsError) {
|
if (currentOptionsError) {
|
||||||
setFormError(currentOptionsError);
|
setFormError(currentOptionsError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!publishContent.trim() && !currentUrl) {
|
if (!publishContent.trim() && !currentUrl) {
|
||||||
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
setFormError(`${t('error')}: ${t('empty_comment_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||||
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||||
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||||
if (expiringMediaLinkAlert) {
|
if (expiringMediaLinkAlert) {
|
||||||
setFormError(expiringMediaLinkAlert);
|
setFormError(expiringMediaLinkAlert);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publishContent.trim().length > 2000) {
|
if (publishContent.trim().length > 2000) {
|
||||||
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
setFormError(`${t('error')}: ${t('field_too_long')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||||
|
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||||
};
|
});
|
||||||
|
|
||||||
const setLinkValue = (nextUrl: string) => {
|
const setLinkValue = (nextUrl: string) => {
|
||||||
setUrl(nextUrl);
|
setUrl(nextUrl);
|
||||||
@@ -966,6 +977,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
uploadFile={uploadFile}
|
uploadFile={uploadFile}
|
||||||
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
||||||
|
isPublishSubmissionInFlight={isPublishSubmissionInFlight}
|
||||||
disableReplyPublish={isResolvingExternalQuotes}
|
disableReplyPublish={isResolvingExternalQuotes}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
},
|
},
|
||||||
} as Record<string, { address: string }>,
|
} as Record<string, { address: string }>,
|
||||||
|
fetchMock: vi.fn(),
|
||||||
showUploadControls: true,
|
showUploadControls: true,
|
||||||
uploadComplete: undefined as ((url: string) => void) | undefined,
|
uploadComplete: undefined as ((url: string) => void) | undefined,
|
||||||
uploadedFileName: null as string | null,
|
uploadedFileName: null as string | null,
|
||||||
@@ -190,22 +191,32 @@ vi.mock('../../../hooks/use-stable-community', () => ({
|
|||||||
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
|
selector(communityAddress ? { roles: testState.rolesByCommunity[communityAddress] } : undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-publish-reply', () => ({
|
vi.mock('../../../hooks/use-publish-reply', async () => {
|
||||||
default: () => ({
|
const React = await vi.importActual<typeof import('react')>('react');
|
||||||
isResolvingExternalQuotes: testState.isResolvingExternalQuotes,
|
|
||||||
publishReply: (options?: Record<string, unknown>) => {
|
return {
|
||||||
if (options) {
|
default: () => {
|
||||||
testState.setPublishReplyOptionsMock(options);
|
const [, forceUpdate] = React.useReducer((value: number) => value + 1, 0);
|
||||||
}
|
|
||||||
return testState.publishReplyMock(options);
|
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', () => ({
|
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||||
default: () => testState.isMobile,
|
default: () => testState.isMobile,
|
||||||
@@ -452,6 +463,14 @@ describe('ReplyModal', () => {
|
|||||||
address: 'traditional-games.bso',
|
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.showUploadControls = true;
|
||||||
testState.uploadComplete = undefined;
|
testState.uploadComplete = undefined;
|
||||||
testState.uploadedFileName = null;
|
testState.uploadedFileName = null;
|
||||||
@@ -467,6 +486,7 @@ describe('ReplyModal', () => {
|
|||||||
container.remove();
|
container.remove();
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
document.body.style.webkitUserSelect = '';
|
document.body.style.webkitUserSelect = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
|
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 () => {
|
it('moves YouTube links into reply content and publishes the thumbnail link on media-only boards', async () => {
|
||||||
const youtubeLink = 'https://youtu.be/reply123';
|
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.openEmpty = true;
|
||||||
testState.selectedText = 'reply body';
|
testState.selectedText = 'reply body';
|
||||||
testState.directoryByAddress['music-posting.eth'] = {
|
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 () => {
|
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);
|
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||||
testState.openEmpty = true;
|
testState.openEmpty = true;
|
||||||
@@ -1065,6 +1163,7 @@ describe('ReplyModal', () => {
|
|||||||
await dispatchInput(optionsInput, 'nonoko');
|
await dispatchInput(optionsInput, 'nonoko');
|
||||||
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
|
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
|
||||||
await clickButtonByText('post');
|
await clickButtonByText('post');
|
||||||
|
await flushEffects();
|
||||||
await rerenderReplyModal('/mu/thread/post-1');
|
await rerenderReplyModal('/mu/thread/post-1');
|
||||||
|
|
||||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
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 { getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
|
||||||
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
|
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory } from '../../lib/comment-flag-selection';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,7 @@ import usePublishReply from '../../hooks/use-publish-reply';
|
|||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||||
import { useYouTubeThumbnailLinkConversion } from '../../hooks/use-youtube-thumbnail-link-conversion';
|
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 { useCommunityField } from '../../hooks/use-stable-community';
|
||||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
||||||
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
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 [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
|
||||||
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
|
||||||
const [showTexPreview, setShowTexPreview] = useState(false);
|
const [showTexPreview, setShowTexPreview] = useState(false);
|
||||||
|
const { isPublishSubmissionInFlight, runPublishSubmission } = usePublishSubmissionGuard();
|
||||||
const texButtonRef = useRef<HTMLButtonElement>(null);
|
const texButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
// Blur in the close handler so the TeX button doesn't keep a lingering focus state
|
// 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.
|
// (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),
|
}, POST_OPTIONS_VALIDATION_DELAY_MS),
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPublishReply = () => {
|
const onPublishReply = () =>
|
||||||
const appliedYouTubeConversion = applyPendingConversion();
|
runPublishSubmission(async () => {
|
||||||
|
const appliedYouTubeConversion = await applyPendingConversion();
|
||||||
|
|
||||||
const currentContent = textRef.current?.value || '';
|
const currentContent = textRef.current?.value || '';
|
||||||
const currentUrl = urlRef.current?.value.trim() || '';
|
const currentUrl = urlRef.current?.value.trim() || '';
|
||||||
const currentOptions = optionsRef.current?.value || '';
|
const currentOptions = optionsRef.current?.value || '';
|
||||||
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
|
||||||
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||||
|
|
||||||
checkContentLengthRef.current.cancel();
|
checkContentLengthRef.current.cancel();
|
||||||
checkPostOptionsRef.current.cancel();
|
checkPostOptionsRef.current.cancel();
|
||||||
setLengthError(null);
|
setLengthError(null);
|
||||||
nonokoRedirectPathRef.current = null;
|
nonokoRedirectPathRef.current = null;
|
||||||
|
|
||||||
if (currentOptionsError) {
|
if (currentOptionsError) {
|
||||||
setError(currentOptionsError);
|
setError(currentOptionsError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!publishContent.trim() && !currentUrl) {
|
if (!publishContent.trim() && !currentUrl) {
|
||||||
setError(t('error') + ': ' + t('empty_comment_alert'));
|
setError(t('error') + ': ' + t('empty_comment_alert'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
if (currentUrl && !isValidPublishURL(currentUrl)) {
|
||||||
setError(t('error') + ': ' + t('invalid_url_alert'));
|
setError(t('error') + ': ' + t('invalid_url_alert'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
if (currentUrl && requirePostLinkIsMedia && !isPublishFileMediaLink(currentUrl)) {
|
||||||
if (expiringMediaLinkAlert) {
|
setError(t('error') + ': ' + t('link_not_image_or_video_alert'));
|
||||||
setError(expiringMediaLinkAlert);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||||
|
if (expiringMediaLinkAlert) {
|
||||||
|
setError(expiringMediaLinkAlert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (publishContent.trim().length > 2000) {
|
if (publishContent.trim().length > 2000) {
|
||||||
setError(t('error') + ': ' + t('field_too_long'));
|
setError(t('error') + ': ' + t('field_too_long'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? `/${postOptionsDirectoryCode || params.boardIdentifier || communityAddress}` : null;
|
||||||
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
await publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||||
};
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof replyIndex === 'number') {
|
if (typeof replyIndex === 'number') {
|
||||||
@@ -698,7 +705,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
]
|
]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button className={styles.publishButton} disabled={isResolvingExternalQuotes} type='button' onClick={onPublishReply}>
|
<button className={styles.publishButton} disabled={isResolvingExternalQuotes || isPublishSubmissionInFlight} type='button' onClick={onPublishReply}>
|
||||||
{t('post')}
|
{t('post')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 { 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_DELAY_SECONDS = 3;
|
||||||
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_INTERVAL_MS = 1000;
|
const YOUTUBE_THUMBNAIL_LINK_CONVERSION_INTERVAL_MS = 1000;
|
||||||
@@ -9,7 +9,7 @@ type ElementRef<T extends HTMLElement> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface PendingYouTubeThumbnailLinkConversion {
|
interface PendingYouTubeThumbnailLinkConversion {
|
||||||
thumbnailLink: string;
|
thumbnailLinkPromise: Promise<string | undefined>;
|
||||||
youtubeLink: string;
|
youtubeLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,13 +29,13 @@ const getContentWithYouTubeLinkAtTop = (content: string, youtubeLink: string): s
|
|||||||
|
|
||||||
const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => {
|
const getPendingConversion = (link: string): PendingYouTubeThumbnailLinkConversion | null => {
|
||||||
const youtubeLink = link.trim();
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { thumbnailLink, youtubeLink };
|
return { thumbnailLinkPromise: getBestAvailableYouTubeThumbnailUrlFromLink(youtubeLink), youtubeLink };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, onLinkChange, textRef, urlRef }: UseYouTubeThumbnailLinkConversionOptions) => {
|
export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, onLinkChange, textRef, urlRef }: UseYouTubeThumbnailLinkConversionOptions) => {
|
||||||
@@ -56,7 +56,7 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
|||||||
setNoticeCountdown(null);
|
setNoticeCountdown(null);
|
||||||
}, [clearCountdownTimer]);
|
}, [clearCountdownTimer]);
|
||||||
|
|
||||||
const applyPendingConversion = useCallback(() => {
|
const applyPendingConversion = useCallback(async () => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
cancelPendingConversion();
|
cancelPendingConversion();
|
||||||
return false;
|
return false;
|
||||||
@@ -69,6 +69,13 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thumbnailLink = await pendingConversion.thumbnailLinkPromise;
|
||||||
|
const latestLink = urlRef.current?.value.trim() || '';
|
||||||
|
if (!thumbnailLink || latestLink !== pendingConversion.youtubeLink) {
|
||||||
|
cancelPendingConversion();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
clearCountdownTimer();
|
clearCountdownTimer();
|
||||||
pendingConversionRef.current = null;
|
pendingConversionRef.current = null;
|
||||||
setNoticeCountdown(null);
|
setNoticeCountdown(null);
|
||||||
@@ -80,11 +87,11 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
|||||||
textRef.current.value = nextContent;
|
textRef.current.value = nextContent;
|
||||||
}
|
}
|
||||||
if (urlRef.current) {
|
if (urlRef.current) {
|
||||||
urlRef.current.value = pendingConversion.thumbnailLink;
|
urlRef.current.value = thumbnailLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
onContentChange(nextContent);
|
onContentChange(nextContent);
|
||||||
onLinkChange(pendingConversion.thumbnailLink);
|
onLinkChange(thumbnailLink);
|
||||||
return true;
|
return true;
|
||||||
}, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);
|
}, [cancelPendingConversion, clearCountdownTimer, enabled, onContentChange, onLinkChange, textRef, urlRef]);
|
||||||
|
|
||||||
@@ -99,7 +106,7 @@ export const useYouTubeThumbnailLinkConversion = ({ enabled, onContentChange, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (seconds <= 0) {
|
if (seconds <= 0) {
|
||||||
applyPendingConversion();
|
void applyPendingConversion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
cachedThumbnails: new Map<string, string>(),
|
cachedThumbnails: new Map<string, string>(),
|
||||||
canEmbedHosts: new Set<string>(),
|
canEmbedHosts: new Set<string>(),
|
||||||
capacitorHttpGetMock: vi.fn(),
|
capacitorHttpGetMock: vi.fn(),
|
||||||
|
capacitorHttpRequestMock: vi.fn(),
|
||||||
consoleErrorMock: vi.fn(),
|
consoleErrorMock: vi.fn(),
|
||||||
fetchMock: vi.fn(),
|
fetchMock: vi.fn(),
|
||||||
isNativePlatform: false,
|
isNativePlatform: false,
|
||||||
@@ -34,10 +35,12 @@ vi.mock('@capacitor/core', () => ({
|
|||||||
},
|
},
|
||||||
CapacitorHttp: {
|
CapacitorHttp: {
|
||||||
get: (options: unknown) => testState.capacitorHttpGetMock(options),
|
get: (options: unknown) => testState.capacitorHttpGetMock(options),
|
||||||
|
request: (options: unknown) => testState.capacitorHttpRequestMock(options),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getBestAvailableYouTubeThumbnailUrlFromLink,
|
||||||
fetchWebpageThumbnailIfNeeded,
|
fetchWebpageThumbnailIfNeeded,
|
||||||
getCommentMediaInfo,
|
getCommentMediaInfo,
|
||||||
getDisplayMediaInfoType,
|
getDisplayMediaInfoType,
|
||||||
@@ -47,7 +50,10 @@ import {
|
|||||||
getPostMediaTypeLabel,
|
getPostMediaTypeLabel,
|
||||||
getTwimgMediaFilePublishUrl,
|
getTwimgMediaFilePublishUrl,
|
||||||
getYouTubeEmbedPostMediaFileLink,
|
getYouTubeEmbedPostMediaFileLink,
|
||||||
|
getYouTubeThumbnailCandidateUrlsFromLink,
|
||||||
|
getYouTubeThumbnailFallbackUrls,
|
||||||
getYouTubeThumbnailUrlFromLink,
|
getYouTubeThumbnailUrlFromLink,
|
||||||
|
isMissingYouTubeThumbnailImage,
|
||||||
} from '../media-utils';
|
} from '../media-utils';
|
||||||
|
|
||||||
const clearMemoizedCache = (fn: unknown) => {
|
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', () => {
|
describe('media-utils', () => {
|
||||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
@@ -92,6 +112,7 @@ describe('media-utils', () => {
|
|||||||
});
|
});
|
||||||
testState.fetchMock.mockReset();
|
testState.fetchMock.mockReset();
|
||||||
testState.capacitorHttpGetMock.mockReset();
|
testState.capacitorHttpGetMock.mockReset();
|
||||||
|
testState.capacitorHttpRequestMock.mockReset();
|
||||||
vi.stubGlobal('fetch', testState.fetchMock);
|
vi.stubGlobal('fetch', testState.fetchMock);
|
||||||
clearMemoizedCache(getHasThumbnail);
|
clearMemoizedCache(getHasThumbnail);
|
||||||
clearMemoizedCache(getLinkMediaInfo);
|
clearMemoizedCache(getLinkMediaInfo);
|
||||||
@@ -119,18 +140,87 @@ describe('media-utils', () => {
|
|||||||
|
|
||||||
it('uses the youtube thumbnail url for post media file links and labels', () => {
|
it('uses the youtube thumbnail url for post media file links and labels', () => {
|
||||||
const mediaInfo = {
|
const mediaInfo = {
|
||||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||||
type: 'iframe',
|
type: 'iframe',
|
||||||
url: 'https://www.youtube.com/watch?v=abc123',
|
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(getPostMediaTypeLabel(mediaInfo, 'iframe', (key) => key)).toBe('youtube_video');
|
||||||
expect(getYouTubeEmbedPostMediaFileLink({ type: 'iframe', url: 'https://streamable.com/clip123' })).toBeUndefined();
|
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();
|
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', () => {
|
it('recognizes which media types expose thumbnails', () => {
|
||||||
expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false);
|
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);
|
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({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
getHasThumbnail(
|
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',
|
'https://www.youtube.com/watch?v=abc',
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -171,7 +261,7 @@ describe('media-utils', () => {
|
|||||||
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
|
expect(getLinkMediaInfo('https://example.com/file.swf')).toMatchObject({ type: 'swf' });
|
||||||
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
|
expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' });
|
||||||
expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({
|
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',
|
type: 'iframe',
|
||||||
url: 'https://www.youtube.com/watch?v=abc123',
|
url: 'https://www.youtube.com/watch?v=abc123',
|
||||||
});
|
});
|
||||||
@@ -181,13 +271,13 @@ describe('media-utils', () => {
|
|||||||
url: 'https://streamable.com/clip123',
|
url: 'https://streamable.com/clip123',
|
||||||
});
|
});
|
||||||
expect(getLinkMediaInfo('https://yt.example/watch?v=yt123')).toEqual({
|
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',
|
type: 'iframe',
|
||||||
url: 'https://yt.example/watch?v=yt123',
|
url: 'https://yt.example/watch?v=yt123',
|
||||||
});
|
});
|
||||||
testState.canEmbedHosts = new Set(['yewtu.be']);
|
testState.canEmbedHosts = new Set(['yewtu.be']);
|
||||||
expect(getLinkMediaInfo('https://yewtu.be/invidious123')).toEqual({
|
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',
|
type: 'iframe',
|
||||||
url: 'https://yewtu.be/invidious123',
|
url: 'https://yewtu.be/invidious123',
|
||||||
});
|
});
|
||||||
@@ -237,7 +327,7 @@ describe('media-utils', () => {
|
|||||||
expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({
|
expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({
|
||||||
linkHeight: 450,
|
linkHeight: 450,
|
||||||
linkWidth: 800,
|
linkWidth: 800,
|
||||||
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg',
|
patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg',
|
||||||
thumbnail: undefined,
|
thumbnail: undefined,
|
||||||
type: 'iframe',
|
type: 'iframe',
|
||||||
url: 'https://www.youtube.com/watch?v=abc123',
|
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 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 => {
|
const getHeaderValue = (headers: Record<string, string>, headerName: string): string | undefined => {
|
||||||
if (!isYouTubeLikeUrl(url)) {
|
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;
|
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);
|
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 => {
|
export const getYouTubeThumbnailUrlFromLink = (link: string): string | undefined => {
|
||||||
const parsedUrl = parseHttpUrl(link.trim());
|
return getYouTubeThumbnailCandidateUrlsFromLink(link)[0];
|
||||||
return parsedUrl ? getYouTubeThumbnailUrl(parsedUrl) : undefined;
|
};
|
||||||
|
|
||||||
|
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(
|
export const getHasThumbnail = memoize(
|
||||||
|
|||||||
Reference in New Issue
Block a user