From 6cb28b0e4f03505a59ade1585ff96d2b1bcf5beb Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sat, 13 Jun 2026 13:39:22 +0700 Subject: [PATCH] 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. --- .github/workflows/react-doctor.yml | 16 +- .../__tests__/catalog-row.test.tsx | 2 +- src/components/catalog-row/catalog-row.tsx | 37 +++- .../__tests__/comment-media.test.tsx | 46 +++- .../comment-media/comment-media.tsx | 16 +- .../markdown/__tests__/markdown.test.tsx | 2 +- .../post-form/__tests__/post-form.test.tsx | 63 +++++- src/components/post-form/post-form.tsx | 208 +++++++++--------- .../__tests__/reply-modal.test.tsx | 131 +++++++++-- src/components/reply-modal/reply-modal.tsx | 85 +++---- .../use-youtube-thumbnail-fallback.test.tsx | 64 ++++++ src/hooks/use-publish-submission-guard.ts | 26 +++ src/hooks/use-youtube-thumbnail-fallback.ts | 48 ++++ .../use-youtube-thumbnail-link-conversion.ts | 25 ++- src/lib/utils/__tests__/media-utils.test.ts | 106 ++++++++- src/lib/utils/media-utils.ts | 137 +++++++++++- 16 files changed, 813 insertions(+), 199 deletions(-) create mode 100644 src/hooks/__tests__/use-youtube-thumbnail-fallback.test.tsx create mode 100644 src/hooks/use-publish-submission-guard.ts create mode 100644 src/hooks/use-youtube-thumbnail-fallback.ts diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 15dbfed3..b6280129 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -1,7 +1,7 @@ # React Doctor PR review. -# Reports only issues this PR INTRODUCES (diffed against the merge-base) as inline -# review comments + a sticky summary. It reads doctor.config.jsonc, so the -# React-Compiler rules we don't enforce stay suppressed (see that file and +# Reports only issues this PR INTRODUCES (diffed against the base branch) as +# GitHub annotations. It reads doctor.config.jsonc, so the React-Compiler rules +# we don't enforce stay suppressed (see that file and # docs/agent-playbooks/known-surprises.md). We do NOT chase the aggregate score. name: React Doctor @@ -25,4 +25,12 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # so React Doctor can diff against the merge-base for new-vs-existing - - uses: millionco/react-doctor@v1 + - name: Setup Node.js v22 + uses: actions/setup-node@v4 + with: + node-version: 22 + - run: corepack enable + - name: Install dependencies + run: yarn install --immutable + - name: Run React Doctor on changed files + run: yarn doctor --verbose --scope changed --base "origin/${{ github.base_ref }}" --annotations --blocking error diff --git a/src/components/catalog-row/__tests__/catalog-row.test.tsx b/src/components/catalog-row/__tests__/catalog-row.test.tsx index 5df18c9c..36ad7ed0 100644 --- a/src/components/catalog-row/__tests__/catalog-row.test.tsx +++ b/src/components/catalog-row/__tests__/catalog-row.test.tsx @@ -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'), })); diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index c9ba79b1..0a08c294 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, type SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useParams } from 'react-router-dom'; @@ -16,10 +16,11 @@ import useEditCommentPrivileges from '../../hooks/use-author-privileges'; import { useCommentMediaInfo } from '../../hooks/use-comment-media-info'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; +import { useYouTubeThumbnailFallback } from '../../hooks/use-youtube-thumbnail-fallback'; import useHide from '../../hooks/use-hide'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { CATALOG_PREVIEW_MARKDOWN_OPTIONS, removeMarkdown } from '../../lib/utils/post-utils'; -import PostMenuDesktop from '../post-desktop/post-menu-desktop'; +import PostMenuDesktop from '../post-desktop/post-menu-desktop/post-menu-desktop'; import styles from './catalog-row.module.css'; import capitalize from 'lodash/capitalize'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; @@ -39,11 +40,31 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight, void cid; const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {}; const iframeThumbnail = patternThumbnailUrl || thumbnail; + const { + handleThumbnailError: handleIframeThumbnailError, + handleThumbnailLoad: handleIframeThumbnailLoad, + isUnavailable: isIframeThumbnailUnavailable, + thumbnailUrl: resolvedIframeThumbnail, + } = useYouTubeThumbnailFallback(iframeThumbnail); const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined); const [isLoaded, setIsLoaded] = useState(false); const [hasError, setHasError] = useState(false); const handleLoad = () => setIsLoaded(true); const handleError = () => setHasError(true); + const handleIframeLoad = (event: SyntheticEvent) => { + if (handleIframeThumbnailLoad(event.currentTarget)) { + return; + } + + handleLoad(); + }; + const handleIframeError = () => { + if (handleIframeThumbnailError()) { + return; + } + + handleError(); + }; const loadingStyle = { opacity: isLoaded ? 1 : 0 }; const { imageSize } = useCatalogStyleStore(); @@ -96,22 +117,24 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight, ); } else if (type === 'webpage' && !hasError) { thumbnailComponent = ; - } else if (type === 'iframe' && iframeThumbnail && !hasError) { - thumbnailComponent = ; + } else if (type === 'iframe' && resolvedIframeThumbnail && !hasError) { + thumbnailComponent = ( + + ); } else if (type === 'audio') { thumbnailComponent =