diff --git a/projects/plugins/jetpack/changelog/add-featured-image-duplicate-notice b/projects/plugins/jetpack/changelog/add-featured-image-duplicate-notice new file mode 100644 index 000000000000..57f987327f91 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-featured-image-duplicate-notice @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Editor: Let writers know when the featured image is also in the post, and hide it on the post where the theme supports that. diff --git a/projects/plugins/jetpack/extensions/index.json b/projects/plugins/jetpack/extensions/index.json index 7c68ef9c4ddc..2d6c1941d078 100644 --- a/projects/plugins/jetpack/extensions/index.json +++ b/projects/plugins/jetpack/extensions/index.json @@ -13,6 +13,7 @@ "contact-info", "donations", "eventbrite", + "featured-image-duplicate", "field-file", "gif", "google-calendar", diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/components.tsx b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/components.tsx new file mode 100644 index 000000000000..039971c85f95 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/components.tsx @@ -0,0 +1,142 @@ +import { CheckboxControl, Notice } from '@wordpress/components'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { PostPreviewButton, store as editorStore } from '@wordpress/editor'; +import { useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { HIDE_META_KEY, useFeaturedImageDuplicate } from '.'; +import type { ComponentType } from 'react'; +import './editor.scss'; + +/** + * Notice and checkbox shown under the featured image. + * + * @return Content, or null when there's nothing to show. + */ +function FeaturedImageDuplicate() { + const { postType, isHidden, showNotice, showCheckbox, dismiss, setHidden } = + useFeaturedImageDuplicate(); + + if ( ! showNotice && ! showCheckbox ) { + return null; + } + + // Separate objects, so minification can't merge the __() calls and break string extraction. + const copy = + postType === 'page' + ? { + notice: __( 'This image is also in your page, so it may appear twice.', 'jetpack' ), + label: __( 'Hide featured image on this page', 'jetpack' ), + help: __( 'It still shows as the thumbnail and when the page is shared.', 'jetpack' ), + } + : { + notice: __( 'This image is also in your post, so it may appear twice.', 'jetpack' ), + label: __( 'Hide featured image on this post', 'jetpack' ), + help: __( 'It still shows as the thumbnail and when the post is shared.', 'jetpack' ), + }; + + return ( +
+ { showNotice && ( + +

{ copy.notice }

+ { /* @ts-expect-error -- Its JSDoc types mark the optional props as required. */ } + +
+ ) } + { showCheckbox && ( + + ) } +
+ ); +} + +/** + * Adds the notice under core's featured image control. + */ +export const withDuplicateNotice = createHigherOrderComponent( + OriginalComponent => props => ( + <> + + + + ), + 'withDuplicateNotice' // Name for React DevTools. +); + +/** + * Stops hiding the featured image once it's no longer in the content. + * + * Registered as an editor plugin so it runs even when the sidebar is closed. + * + * @return Nothing. + */ +export function HiddenSync() { + const { isHidden, isDuplicate, isResolved, setHidden } = useFeaturedImageDuplicate(); + + useEffect( () => { + if ( isHidden && isResolved && ! isDuplicate ) { + setHidden( false ); + } + }, [ isHidden, isResolved, isDuplicate, setHidden ] ); + + return null; +} + +type FeaturedImageBlockProps = { + context?: { postId?: number; queryId?: number }; +}; + +/** + * The template's Featured Image block, hidden when this post hides its featured image. + * + * @param props - Props. + * @param props.BlockEdit - Original block edit component. + * @return The block, or null when hidden. + */ +function FeaturedImageBlock( { + BlockEdit, + ...props +}: FeaturedImageBlockProps & { BlockEdit: ComponentType< FeaturedImageBlockProps > } ) { + const { postId, queryId } = props.context ?? {}; + const isHidden = useSelect( + select => { + // Inside a Query Loop it's a list of posts, not this post's header. + if ( queryId !== undefined ) { + return false; + } + const editor = select( editorStore ); + const meta = ( editor.getEditedPostAttribute( 'meta' ) ?? {} ) as Record< string, unknown >; + return postId === editor.getCurrentPostId() && !! meta[ HIDE_META_KEY ]; + }, + [ postId, queryId ] + ); + + return isHidden ? null : ; +} + +/** + * Hides the template's Featured Image block in the editor too, matching the site. + */ +export const withHiddenFeaturedImageBlock = createHigherOrderComponent( + BlockEdit => props => + props.name === 'core/post-featured-image' ? ( + + ) : ( + + ), + 'withHiddenFeaturedImageBlock' +); diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/editor.js b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/editor.js new file mode 100644 index 000000000000..165857d89131 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/editor.js @@ -0,0 +1,14 @@ +import { + getJetpackExtensionAvailability, + registerJetpackPlugin, +} from '@automattic/jetpack-shared-extension-utils'; +import { addFilter } from '@wordpress/hooks'; +import { HiddenSync, withDuplicateNotice, withHiddenFeaturedImageBlock } from './components'; +import { FEATURE_NAME } from '.'; + +if ( getJetpackExtensionAvailability( FEATURE_NAME ).available ) { + addFilter( 'editor.PostFeaturedImage', `jetpack/${ FEATURE_NAME }`, withDuplicateNotice ); // Notice and checkbox under the featured image. + addFilter( 'editor.BlockEdit', `jetpack/${ FEATURE_NAME }`, withHiddenFeaturedImageBlock ); // Hide the template's Featured Image block when ticked. +} + +registerJetpackPlugin( FEATURE_NAME, { render: HiddenSync } ); // Untick checkbox once the duplicate is gone. diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/editor.scss b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/editor.scss new file mode 100644 index 000000000000..f4016da6a9de --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/editor.scss @@ -0,0 +1,19 @@ +.jetpack-featured-image-duplicate { + display: flex; + flex-direction: column; + gap: 12px; +} + +.jetpack-featured-image-duplicate__notice { + margin: 0; + + p { + margin-block: 0 4px; + margin-inline: 0; + } + + // Core greys out the Preview link while it autosaves; keep it blue. + .components-button.is-link:is(:active, :disabled, [aria-disabled="true"]) { + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); + } +} diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/featured-image-duplicate.php b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/featured-image-duplicate.php new file mode 100644 index 000000000000..eae1d09f0693 --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/featured-image-duplicate.php @@ -0,0 +1,263 @@ + 'boolean', + 'description' => __( 'Whether to hide the featured image on this post’s own page.', 'jetpack' ), + 'single' => true, + 'default' => false, + 'show_in_rest' => true, + 'revisions_enabled' => true, // Saved with autosaves, so Preview of a published post matches the checkbox. + 'auth_callback' => __NAMESPACE__ . '\can_edit_post_meta', + ) + ); + register_post_meta( + $post_type, + DISMISSED_META_KEY, + array( + 'type' => 'integer', + 'description' => __( 'Featured image ID whose duplicate notice was dismissed.', 'jetpack' ), + 'single' => true, + 'default' => 0, + 'show_in_rest' => true, + 'sanitize_callback' => 'absint', + 'auth_callback' => __NAMESPACE__ . '\can_edit_post_meta', + ) + ); + } +} +add_action( 'init', __NAMESPACE__ . '\register_meta_keys' ); + +/** + * Whether hiding the featured image on this post's own page is known to work on the active theme. + * + * @param WP_Post|int $post Post or post ID. + * @return bool + */ +function is_hide_supported( $post ) { + $post = get_post( $post ); + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, POST_TYPES, true ) ) { + return false; + } + + if ( ! wp_is_block_theme() ) { + return classic_theme_supports_hiding( $post->post_type ); + } + + // Block themes: check the template hierarchy to see if the Featured Image block is present in the template WordPress renders for this post. + $hierarchy = get_template_hierarchy_for_post( $post ); + $contents = wp_list_pluck( get_block_templates( array( 'slug__in' => $hierarchy ), 'wp_template' ), 'content', 'slug' ); // Slug => content. + + // Several templates may match; walk the hierarchy in priority order to find the one WordPress renders. + foreach ( $hierarchy as $slug ) { + if ( isset( $contents[ $slug ] ) ) { + return blocks_contain_featured_image( parse_blocks( $contents[ $slug ] ) ); + } + } + + return false; +} + +/** + * Whether the classic theme declares Content Options featured image support for this post type. + * + * @param string $post_type Post type. + * @return bool + */ +function classic_theme_supports_hiding( $post_type ) { + $options = get_theme_support( 'jetpack-content-options' ); // Declared by the theme or Jetpack's compat file; no site owner action needed. + return ! empty( $options[0]['featured-images'][ $post_type ] ); +} + +/** + * Template slugs WordPress tries for this post, most specific first. + * + * @param WP_Post $post Post. + * @return string[] + */ +function get_template_hierarchy_for_post( WP_Post $post ): array { + if ( 'page' === $post->post_type ) { + $hierarchy = array( "page-{$post->post_name}", "page-{$post->ID}", 'page', 'singular', 'index' ); + } else { + $hierarchy = array( "single-{$post->post_type}-{$post->post_name}", "single-{$post->post_type}", 'single', 'singular', 'index' ); + } + + $custom = get_page_template_slug( $post ); + if ( $custom ) { + array_unshift( $hierarchy, $custom ); + } + + return $hierarchy; +} + +/** + * Whether the blocks render the current post's featured image. + * + * Follows patterns and template parts; skips Query Loops, which show other posts. + * + * @param array $blocks Parsed blocks. + * @param int $depth Recursion depth. + * @return bool + */ +function blocks_contain_featured_image( array $blocks, $depth = 0 ): bool { + if ( $depth > 5 ) { + return false; + } + + foreach ( resolve_pattern_blocks( $blocks ) as $block ) { + $name = $block['blockName'] ?? ''; + $inner = $block['innerBlocks'] ?? array(); + + if ( 'core/post-featured-image' === $name ) { + return true; + } + + if ( 'core/query' === $name ) { + continue; + } + + if ( 'core/template-part' === $name && ! empty( $block['attrs']['slug'] ) ) { + $part = get_block_template( ( $block['attrs']['theme'] ?? get_stylesheet() ) . '//' . $block['attrs']['slug'], 'wp_template_part' ); + $inner = $part ? parse_blocks( $part->content ) : array(); + } + + if ( $inner && blocks_contain_featured_image( $inner, $depth + 1 ) ) { + return true; + } + } + + return false; +} + +/** + * Tell the editor whether hiding works for the post being edited. + * + * @param array $flags Editor feature flags. + * @return array + */ +function add_editor_feature_flag( $flags ): array { + $flags['featured-image-hide-supported'] = is_hide_supported( get_post() ); + return $flags; +} +add_filter( 'jetpack_block_editor_feature_flags', __NAMESPACE__ . '\add_editor_feature_flag' ); + +/** + * Only filter thumbnail reads on pages that hide the featured image. + * + * @return void + */ +function maybe_filter_thumbnail_id() { + // Block themes hide the Featured Image block instead; see hide_featured_image_block(). + if ( ! wp_is_block_theme() && should_hide_for_post( get_queried_object_id() ) ) { + // Late, since core overrides the thumbnail ID in _wp_preview_post_thumbnail_filter. + add_filter( 'get_post_metadata', __NAMESPACE__ . '\filter_thumbnail_id', PHP_INT_MAX, 3 ); + } +} +add_action( 'template_redirect', __NAMESPACE__ . '\maybe_filter_thumbnail_id' ); // Fires on every front-end page load. + +/** + * Whether we're on this post's own page and the writer chose to hide its featured image there. + * + * @param int $post_id Post ID. + * @return bool + */ +function should_hide_for_post( $post_id ): bool { + return is_singular( POST_TYPES ) + && get_queried_object_id() === $post_id + && (bool) get_post_meta( $post_id, HIDE_META_KEY, true ); +} + +/** + * Filter the post thumbnail ID to hide the featured image on post's own page. + * + * Outside the loop (Open Graph, schema) and inside blocks (Latest Posts) it's still returned. + * + * @param mixed $value Short-circuit value. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @return mixed + */ +function filter_thumbnail_id( $value, $object_id, $meta_key ) { + if ( + '_thumbnail_id' !== $meta_key + || get_queried_object_id() !== (int) $object_id + || ! in_the_loop() // Not in the main loop. + || null !== WP_Block_Supports::$block_to_render // Inside a block, don't hide the thumbnail. + ) { + return $value; + } + + return false; +} + +/** + * Block themes: skip the template's Featured Image block on the post's own page. + * + * @param string $block_content Rendered block. + * @param array $block Parsed block. + * @param WP_Block $instance Block instance. + * @return string + */ +function hide_featured_image_block( $block_content, $block, $instance ): string { + $context = $instance->context; // Set from the template and parent blocks. + + // Inside a Query Loop it's a list of posts, not this post's header. + if ( array_key_exists( 'queryId', $context ) || empty( $context['postId'] ) ) { + return $block_content; + } + + return should_hide_for_post( (int) $context['postId'] ) ? '' : $block_content; +} +add_filter( 'render_block_core/post-featured-image', __NAMESPACE__ . '\hide_featured_image_block', 10, 3 ); diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/index.ts b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/index.ts new file mode 100644 index 000000000000..3865eb9b1cee --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/index.ts @@ -0,0 +1,168 @@ +import { hasFeatureFlag } from '@automattic/jetpack-shared-extension-utils'; +import { store as coreStore } from '@wordpress/core-data'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useCallback } from '@wordpress/element'; + +export const FEATURE_NAME = 'featured-image-duplicate'; +export const HIDE_META_KEY = '_jetpack_hide_featured_image'; +const DISMISSED_META_KEY = '_jetpack_featured_image_duplicate_dismissed'; +const POST_TYPES = [ 'post', 'page' ]; + +const SIZE_SUFFIX = /-(\d+x\d+|scaled)(?=\.[a-z0-9]+$)/i; // Matches "-300x200" or "-scaled" before the file extension. +const UPLOADS = '/wp-content/uploads'; // Matches the uploads path, which is the same for all sites. +const PHOTON_HOST = /^i\d\.wp\.com$/; // Matches the Photon CDN host, which is the same for all sites. + +type EditorBlock = { + name: string; + attributes?: Record< string, unknown >; + innerBlocks?: EditorBlock[]; +}; + +type MediaRecord = { + source_url?: string; + media_details?: { sizes?: Record< string, { source_url?: string } | undefined > }; +}; + +type PostType = 'post' | 'page'; + +/** + * Reduce an image URL to its upload path, without size suffix, CDN host or query. + * + * @param url - Image URL. + * @return Normalized path, or null. + */ +function normalizeImageUrl( url: unknown ): string | null { + if ( ! url || typeof url !== 'string' ) { + return null; + } + + let parsed: URL; + try { + parsed = new URL( url, 'https://localhost' ); + } catch { + return null; + } + + let path = parsed.pathname; + if ( PHOTON_HOST.test( parsed.hostname ) ) { + path = path.replace( /^\/[^/]+/, '' ); + } + + const uploadsIndex = path.indexOf( UPLOADS + '/' ); + if ( uploadsIndex !== -1 ) { + path = path.slice( uploadsIndex + UPLOADS.length ); + } + + try { + path = decodeURIComponent( path ); + } catch { + // Keep the encoded path. + } + + return path.replace( SIZE_SUFFIX, '' ).toLowerCase(); +} + +/** + * Normalized URLs of an attachment and all its sizes. + * + * @param media - Media REST record. + * @return Normalized paths. + */ +function getImageUrls( media?: MediaRecord ): Set< string > { + const urls = [ + media?.source_url, + ...Object.values( media?.media_details?.sizes ?? {} ).map( size => size?.source_url ), + ]; + return new Set( urls.map( normalizeImageUrl ).filter( ( url ): url is string => !! url ) ); +} + +/** + * Whether an Image block in the content (outside galleries) shows the featured image, by ID or URL. + * + * @param blocks - Editor blocks. + * @param id - Featured image ID. + * @param getUrls - Normalized featured image URLs, built on first use. + * @return Whether a copy was found. + */ +function blocksContainImage( + blocks: EditorBlock[] | undefined, + id: number, + getUrls: () => Set< string > +): boolean { + for ( const { name: blockName, attributes = {}, innerBlocks } of blocks ?? [] ) { + // Images with an ID are matched by ID; only ID-less ones (e.g. pasted URLs) need a URL compare. + if ( + blockName === 'core/image' && + ( attributes.id + ? Number( attributes.id ) === id + : getUrls().has( normalizeImageUrl( attributes.url ) ?? '' ) ) + ) { + return true; + } + + // Galleries are a deliberate choice, so an image in one isn't treated as a duplicate. + if ( blockName !== 'core/gallery' && blocksContainImage( innerBlocks, id, getUrls ) ) { + return true; + } + } + + return false; +} + +/** + * What to show about a duplicated featured image, plus actions to change it. + * + * Dismissal stores the image ID, so a new featured image brings the notice back. + * + * @return State and actions. + */ +export function useFeaturedImageDuplicate() { + const state = useSelect( select => { + const editor = select( editorStore ); + const postType = editor.getCurrentPostType() as PostType; + const featuredId = editor.getEditedPostAttribute( 'featured_media' ) as number; + const meta = ( editor.getEditedPostAttribute( 'meta' ) ?? {} ) as Record< string, unknown >; + const isHidden = !! meta[ HIDE_META_KEY ]; + const isApplicable = POST_TYPES.includes( postType ) && !! featuredId; + + const query = { context: 'view' }; // Get the media record as it appears on the front end, not the editor. + let urls: Set< string > | undefined; + const getUrls = () => + ( urls ??= getImageUrls( select( coreStore ).getMedia( featuredId, query ) as MediaRecord ) ); + + const isDuplicate = + isApplicable && + blocksContainImage( editor.getEditorBlocks() as EditorBlock[], featuredId, getUrls ); + const isDismissed = meta[ DISMISSED_META_KEY ] === featuredId; + + return { + postType, + featuredId, + isHidden, + isDuplicate, + // URL matches need the media record, so don't trust a miss before it loads. + isResolved: + ! urls || select( coreStore ).hasFinishedResolution( 'getMedia', [ featuredId, query ] ), + showNotice: isDuplicate && ! isHidden && ! isDismissed, + showCheckbox: + hasFeatureFlag( 'featured-image-hide-supported' ) && + isDuplicate && + ( ! isDismissed || isHidden ), + }; + }, [] ); + + const { editPost } = useDispatch( editorStore ); + const { featuredId } = state; + + const dismiss = useCallback( + () => editPost( { meta: { [ DISMISSED_META_KEY ]: featuredId } } ), + [ editPost, featuredId ] + ); + const setHidden = useCallback( + ( hidden: boolean ) => editPost( { meta: { [ HIDE_META_KEY ]: hidden } } ), + [ editPost ] + ); + + return { ...state, dismiss, setHidden }; +} diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/test/components.test.tsx b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/test/components.test.tsx new file mode 100644 index 000000000000..8886270367fb --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/test/components.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { HiddenSync, withHiddenFeaturedImageBlock } from '../components'; + +let mockDuplicate: Record< string, unknown >; +const mockSetHidden = jest.fn(); +let mockEditor: { postId: number; hidden: boolean }; + +jest.mock( '..', () => ( { + HIDE_META_KEY: '_jetpack_hide_featured_image', + useFeaturedImageDuplicate: () => ( { ...mockDuplicate, setHidden: mockSetHidden } ), +} ) ); +jest.mock( '@wordpress/editor', () => ( { store: 'core/editor', PostPreviewButton: () => null } ) ); +jest.mock( '@wordpress/data', () => { + const mocks = { + useSelect: selector => + selector( () => ( { + getCurrentPostId: () => mockEditor.postId, + getEditedPostAttribute: () => ( { _jetpack_hide_featured_image: mockEditor.hidden } ), + } ) ), + }; + // Keep the real registry so @wordpress/components' own stores still work. + return new Proxy( jest.requireActual( '@wordpress/data' ), { + get: ( target, property ) => mocks[ property ] ?? target[ property ], + } ); +} ); + +describe( 'HiddenSync', () => { + beforeEach( () => jest.clearAllMocks() ); + + it.each( [ + [ + 'unticks once the duplicate is gone', + { isHidden: true, isResolved: true, isDuplicate: false }, + true, + ], + [ + 'keeps it while still duplicated', + { isHidden: true, isResolved: true, isDuplicate: true }, + false, + ], + [ + 'waits for the media record', + { isHidden: true, isResolved: false, isDuplicate: false }, + false, + ], + ] )( '%s', ( _, state, expected ) => { + mockDuplicate = state; + render( ); + expect( mockSetHidden.mock.calls ).toEqual( expected ? [ [ false ] ] : [] ); + } ); +} ); + +describe( 'withHiddenFeaturedImageBlock', () => { + const Block = withHiddenFeaturedImageBlock( () =>
block
); + + it.each( [ + [ 'hides this post’s featured image', 'core/post-featured-image', { postId: 1 }, true, false ], + [ 'keeps it when not hidden', 'core/post-featured-image', { postId: 1 }, false, true ], + [ + 'keeps it inside a Query Loop', + 'core/post-featured-image', + { postId: 1, queryId: 0 }, // The first Query Loop gets ID 0. + true, + true, + ], + [ 'leaves other blocks alone', 'core/paragraph', {}, true, true ], + ] )( '%s', ( _, name, context, hidden, shown ) => { + mockEditor = { postId: 1, hidden }; + render( ); + expect( !! screen.queryByText( 'block' ) ).toBe( shown ); + } ); +} ); diff --git a/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/test/index.test.ts b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/test/index.test.ts new file mode 100644 index 000000000000..75eb9ebdfc3e --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/featured-image-duplicate/test/index.test.ts @@ -0,0 +1,157 @@ +import { renderHook } from '@testing-library/react'; +import { useFeaturedImageDuplicate } from '..'; + +const FEATURED_ID = 42; +const UPLOADS = 'https://example.com/wp-content/uploads/2026/10'; + +let mockPost: { + postType: string; + featuredId: number; + meta: Record< string, unknown >; + blocks: unknown[]; + mediaResolved: boolean; + flag: boolean; +}; +const mockEditPost = jest.fn(); + +jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { + // Must match the flag PHP sets in add_editor_feature_flag(). + hasFeatureFlag: name => name === 'featured-image-hide-supported' && mockPost.flag, +} ) ); +jest.mock( '@wordpress/editor', () => ( { store: 'core/editor' } ) ); +jest.mock( '@wordpress/core-data', () => ( { store: 'core' } ) ); +jest.mock( '@wordpress/data', () => ( { + useSelect: selector => + selector( store => + store === 'core/editor' + ? { + getCurrentPostType: () => mockPost.postType, + getEditedPostAttribute: attribute => + attribute === 'featured_media' ? mockPost.featuredId : mockPost.meta, + getEditorBlocks: () => mockPost.blocks, + } + : { + getMedia: () => + mockPost.mediaResolved + ? { + source_url: `${ UPLOADS }/onion-scaled.jpg`, + media_details: { + sizes: { large: { source_url: `${ UPLOADS }/onion-1024x768.jpg` } }, + }, + } + : undefined, + hasFinishedResolution: () => mockPost.mediaResolved, + } + ), + useDispatch: () => ( { editPost: mockEditPost } ), +} ) ); + +const image = ( attributes: Record< string, unknown > ) => ( { + name: 'core/image', + attributes, + innerBlocks: [], +} ); + +const render = ( post: Partial< typeof mockPost > = {} ) => { + mockPost = { + postType: 'post', + featuredId: FEATURED_ID, + meta: {}, + blocks: [ image( { id: FEATURED_ID } ) ], + mediaResolved: true, + flag: true, // Assume the theme supports hiding the featured image by default. + ...post, + }; + return renderHook( () => useFeaturedImageDuplicate() ).result.current; +}; + +describe( 'useFeaturedImageDuplicate', () => { + beforeEach( () => jest.clearAllMocks() ); + + it.each( [ + [ 'same attachment ID', [ image( { id: FEATURED_ID } ) ], true ], + [ + 'resized copy url without an ID', + [ image( { url: `${ UPLOADS }/onion-300x200.jpg` } ) ], + true, + ], + [ + 'Photon URL without an ID', + [ + image( { + url: 'https://i0.wp.com/example.com/wp-content/uploads/2026/10/onion.jpg?w=640', + } ), + ], + true, + ], + [ + 'image nested in a group', + [ { name: 'core/group', attributes: {}, innerBlocks: [ image( { id: FEATURED_ID } ) ] } ], + true, + ], + [ + 'image nested in a gallery', + [ { name: 'core/gallery', attributes: {}, innerBlocks: [ image( { id: FEATURED_ID } ) ] } ], + false, + ], + [ + 'different attachment with the same file name', + [ image( { id: 7, url: `${ UPLOADS }/onion.jpg` } ) ], + false, + ], + [ 'unrelated image', [ image( { url: `${ UPLOADS }/carrot.jpg` } ) ], false ], + [ 'non-image block', [ { name: 'core/cover', attributes: { id: FEATURED_ID } } ], false ], + ] )( 'detects a duplicate for %s', ( _, blocks, expected ) => { + expect( render( { blocks } ).isDuplicate ).toBe( expected ); + } ); + + it.each( [ + [ 'a duplicate', {}, { showNotice: true, showCheckbox: true } ], + [ 'a theme that cannot hide it', { flag: false }, { showNotice: true, showCheckbox: false } ], + [ + 'a hidden image', + { meta: { _jetpack_hide_featured_image: true } }, + { showNotice: false, showCheckbox: true }, + ], + [ + 'a dismissed image', + { meta: { _jetpack_featured_image_duplicate_dismissed: FEATURED_ID } }, + { showNotice: false, showCheckbox: false }, + ], + [ + // Reachable by switching featured images; the ticked box must stay to be unticked. + 'a dismissed image that is still hidden', + { + meta: { + _jetpack_featured_image_duplicate_dismissed: FEATURED_ID, + _jetpack_hide_featured_image: true, + }, + }, + { showNotice: false, showCheckbox: true }, + ], + [ + 'a new image after dismissing another', + { meta: { _jetpack_featured_image_duplicate_dismissed: 7 } }, + { showNotice: true }, + ], + [ 'no featured image', { featuredId: 0 }, { showNotice: false, showCheckbox: false } ], + [ 'other post types', { postType: 'product' }, { showNotice: false, showCheckbox: false } ], + ] )( 'shows the right controls for %s', ( _, post, expected ) => { + expect( render( post ) ).toMatchObject( expected ); + } ); + + it( 'does not trust a URL miss before the media record loads', () => { + const blocks = [ image( { url: `${ UPLOADS }/carrot.jpg` } ) ]; + expect( render( { blocks, mediaResolved: false } ).isResolved ).toBe( false ); + expect( render( { blocks: [ image( { id: 7 } ) ], mediaResolved: false } ).isResolved ).toBe( + true + ); + } ); + + it( 'dismisses for the current featured image only', () => { + render().dismiss(); + expect( mockEditPost ).toHaveBeenCalledWith( { + meta: { _jetpack_featured_image_duplicate_dismissed: FEATURED_ID }, + } ); + } ); +} ); diff --git a/projects/plugins/jetpack/tests/php/extensions/plugins/featured-image-duplicate/Featured_Image_Duplicate_Test.php b/projects/plugins/jetpack/tests/php/extensions/plugins/featured-image-duplicate/Featured_Image_Duplicate_Test.php new file mode 100644 index 000000000000..b6b0410bd75d --- /dev/null +++ b/projects/plugins/jetpack/tests/php/extensions/plugins/featured-image-duplicate/Featured_Image_Duplicate_Test.php @@ -0,0 +1,235 @@ +theme_directories = $GLOBALS['wp_theme_directories']; + register_theme_directory( DIR_TESTDATA . '/themedir1' ); + Featured_Image_Duplicate\register_meta_keys(); + } + + public function tear_down() { + $GLOBALS['wp_theme_directories'] = $this->theme_directories; + remove_theme_support( 'jetpack-content-options' ); + remove_filter( 'get_post_metadata', 'Automattic\Jetpack\Extensions\Featured_Image_Duplicate\filter_thumbnail_id', PHP_INT_MAX ); + parent::tear_down(); + } + + public function test_meta_requires_edit_post_capability() { + $post_id = self::factory()->post->create(); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->assertFalse( current_user_can( 'edit_post_meta', $post_id, Featured_Image_Duplicate\HIDE_META_KEY ) ); + $this->assertFalse( current_user_can( 'edit_post_meta', $post_id, Featured_Image_Duplicate\DISMISSED_META_KEY ) ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + $this->assertTrue( current_user_can( 'edit_post_meta', $post_id, Featured_Image_Duplicate\HIDE_META_KEY ) ); + } + + public function test_classic_theme_support_follows_content_options() { + switch_theme( 'default' ); + $post = self::factory()->post->create_and_get(); + $page = self::factory()->post->create_and_get( array( 'post_type' => 'page' ) ); + + $this->assertFalse( Featured_Image_Duplicate\is_hide_supported( $post ) ); + + add_theme_support( 'jetpack-content-options', array( 'featured-images' => array( 'post' => true ) ) ); + $this->assertTrue( Featured_Image_Duplicate\is_hide_supported( $post ) ); + $this->assertFalse( Featured_Image_Duplicate\is_hide_supported( $page ) ); + } + + public function test_editor_flag_reports_hide_support_for_the_edited_post() { + switch_theme( 'default' ); + add_theme_support( 'jetpack-content-options', array( 'featured-images' => array( 'post' => true ) ) ); + $GLOBALS['post'] = self::factory()->post->create_and_get(); + + // The editor reads this flag name in index.ts. + $this->assertSame( array( 'featured-image-hide-supported' => true ), Featured_Image_Duplicate\add_editor_feature_flag( array() ) ); + + unset( $GLOBALS['post'] ); + } + + public static function data_block_templates() { + return array( + 'Featured Image block' => array( '
', true ), + 'no Featured Image block' => array( '', false ), + 'only inside a Query Loop' => array( '
', false ), + 'inside a template part' => array( '', true ), + ); + } + + /** + * Block themes are supported when the post's template renders its featured image. + * + * @dataProvider data_block_templates + * + * @param string $template Template markup. + * @param bool $expected Expected support. + */ + #[DataProvider( 'data_block_templates' )] + public function test_block_theme_template( $template, $expected ) { + switch_theme( 'block-theme' ); + $this->create_template( 'post-hero', '', 'wp_template_part' ); + $this->create_template( 'single', $template ); + + $this->assertSame( $expected, Featured_Image_Duplicate\is_hide_supported( self::factory()->post->create_and_get() ) ); + } + + /** + * Only the template WordPress would render counts: a chosen template, then the hierarchy. + */ + public function test_block_theme_uses_the_rendered_template() { + switch_theme( 'block-theme' ); + $this->create_template( 'index', '' ); + $this->create_template( 'single', '' ); + $post_id = self::factory()->post->create(); + + $this->assertFalse( Featured_Image_Duplicate\is_hide_supported( $post_id ) ); + + $this->create_template( 'single-with-image', '' ); + update_post_meta( $post_id, '_wp_page_template', 'single-with-image' ); + $this->assertTrue( Featured_Image_Duplicate\is_hide_supported( $post_id ) ); + } + + public function test_classic_hides_only_in_the_loop_on_the_post_page() { + list( $post_id, $attachment_id ) = $this->create_post_with_thumbnail( true ); + $this->visit( $post_id ); + + // Outside the loop, e.g. Open Graph tags. + $this->assertSame( $attachment_id, get_post_thumbnail_id( $post_id ) ); + while ( have_posts() ) { + the_post(); + $this->assertFalse( has_post_thumbnail() ); + } + + // The blog home keeps it as the thumbnail. + $this->go_to( home_url( '/' ) ); + Featured_Image_Duplicate\maybe_filter_thumbnail_id(); + while ( have_posts() ) { + the_post(); + $this->assertTrue( has_post_thumbnail() ); + } + } + + /** + * Classic themes: nothing changes without the meta. + */ + public function test_classic_keeps_it_without_meta() { + list( $post_id ) = $this->create_post_with_thumbnail( false ); + $this->visit( $post_id ); + while ( have_posts() ) { + the_post(); + $this->assertTrue( has_post_thumbnail() ); + } + } + + /** + * Cases for the block theme Featured Image block. + * + * @return array + */ + public static function data_featured_image_block() { + return array( + 'this post' => array( true, array(), '' ), + 'not hidden' => array( false, array(), '
img
' ), + 'inside Query Loop' => array( true, array( 'queryId' => 0 ), '
img
' ), + ); + } + + /** + * Block themes: the template's Featured Image block renders nothing for this post only. + * + * @dataProvider data_featured_image_block + * + * @param bool $hide Whether "Hide featured image" is ticked. + * @param array $context Extra block context. + * @param string $expected Rendered output. + */ + #[DataProvider( 'data_featured_image_block' )] + public function test_block_theme_featured_image_block( $hide, $context, $expected ) { + list( $post_id ) = $this->create_post_with_thumbnail( $hide ); + $this->visit( $post_id ); + $block = new WP_Block( + array( + 'blockName' => 'core/post-featured-image', + 'attrs' => array(), + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ), + array_merge( array( 'postId' => $post_id ), $context ) + ); + + $this->assertSame( $expected, Featured_Image_Duplicate\hide_featured_image_block( '
img
', $block->parsed_block, $block ) ); + } + + /** + * Store a Site Editor template or template part for the active theme. + * + * @param string $slug Template slug. + * @param string $content Block markup. + * @param string $type `wp_template` or `wp_template_part`. + */ + private function create_template( $slug, $content, $type = 'wp_template' ) { + $id = self::factory()->post->create( + array( + 'post_type' => $type, + 'post_name' => $slug, + 'post_title' => $slug, + 'post_content' => $content, + 'post_status' => 'publish', + ) + ); + wp_set_post_terms( $id, get_stylesheet(), 'wp_theme' ); + } + + /** + * Create a post with a featured image. + * + * @param bool $hide Whether "Hide featured image" is ticked. + * @return int[] Post ID and attachment ID. + */ + private function create_post_with_thumbnail( $hide ) { + $post_id = self::factory()->post->create(); + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg', $post_id ); + set_post_thumbnail( $post_id, $attachment_id ); + if ( $hide ) { + update_post_meta( $post_id, Featured_Image_Duplicate\HIDE_META_KEY, true ); + } + return array( $post_id, $attachment_id ); + } + + /** + * Visit a post's own page, as WordPress would before rendering it. + * + * @param int $post_id Post ID. + */ + private function visit( $post_id ) { + $this->go_to( get_permalink( $post_id ) ); + Featured_Image_Duplicate\maybe_filter_thumbnail_id(); + } +}