From 210d86bd142dcc7948a3a22d3431c87f32221732 Mon Sep 17 00:00:00 2001 From: Gergely Juhasz Date: Fri, 12 Jun 2026 12:58:27 +0200 Subject: [PATCH] Social: Add image focal point data model and picker in the media section --- .../components/media-section-v2/index.tsx | 21 +- .../media-section-v2/media-focal-point.tsx | 50 ++++ .../media-section-v2/test/index.test.tsx | 161 ++++++++++- .../test/media-focal-point.test.tsx | 79 ++++++ .../_inc/components/media-section-v2/types.ts | 16 ++ .../_inc/hooks/use-post-meta/index.js | 23 +- .../hooks/use-post-meta/test/index.test.js | 18 ++ .../test/index.test.ts | 2 + .../publicize/_inc/utils/constants.ts | 1 + .../packages/publicize/_inc/utils/types.ts | 14 + .../changelog/2026-06-12-08-59-39-305207 | 4 + .../publicize/src/class-publicize-base.php | 24 ++ .../tests/php/Social_Options_Meta_Test.php | 251 ++++++++++++++++++ 13 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 projects/packages/publicize/_inc/components/media-section-v2/media-focal-point.tsx create mode 100644 projects/packages/publicize/_inc/components/media-section-v2/test/media-focal-point.test.tsx create mode 100644 projects/packages/publicize/changelog/2026-06-12-08-59-39-305207 create mode 100644 projects/packages/publicize/tests/php/Social_Options_Meta_Test.php diff --git a/projects/packages/publicize/_inc/components/media-section-v2/index.tsx b/projects/packages/publicize/_inc/components/media-section-v2/index.tsx index 6bf00e79e56c..b8aa87d850ab 100644 --- a/projects/packages/publicize/_inc/components/media-section-v2/index.tsx +++ b/projects/packages/publicize/_inc/components/media-section-v2/index.tsx @@ -5,6 +5,7 @@ import { GeneralPurposeImage } from '@automattic/jetpack-ai-client'; import { getRedirectUrl, ThemeProvider } from '@automattic/jetpack-components'; +import { siteHasFeature } from '@automattic/jetpack-script-data'; import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import { MediaUpload } from '@wordpress/block-editor'; import { BaseControl, Button } from '@wordpress/components'; @@ -18,7 +19,9 @@ import useMediaDetails from '../../hooks/use-media-details'; import { SELECTABLE_MEDIA_TYPES } from '../../hooks/use-media-restrictions/restrictions'; import { usePostMeta } from '../../hooks/use-post-meta'; import useSigPreview from '../../hooks/use-sig-preview'; +import { features } from '../../utils'; import CustomMediaToggle from './custom-media-toggle'; +import MediaFocalPoint from './media-focal-point'; import MediaPreview from './media-preview'; import MediaSourceMenu from './media-source-menu'; import styles from './styles.module.scss'; @@ -188,6 +191,18 @@ export default function MediaSectionV2( { // Preview will render the SIG image whenever SIG is the explicit source or the OG fallback in Default mode. const isPreviewingSig = currentSource === 'sig' || ( ! currentSource && sigEnabled ); + /* + * The focal point can only be set against a real image attachment: hidden for SIG + * (attachment id 0) and video. It is per image, not per connection, so it shows in + * controlled/per-network mode too — MediaFocalPoint reads and writes the post-level + * image_focal_points map directly via use-post-meta, never through onMediaChange. + * Gated on the wpcom-controlled rollout flag until the cropping consumers ship. + */ + const showFocalPointPicker = + siteHasFeature( features.IMAGE_FOCAL_POINT ) && + previewData?.type === 'image' && + previewData.id > 0; + // Handle media source selection from dropdown const handleSourceSelect = useCallback( ( source: MediaSourceType ) => { @@ -428,7 +443,11 @@ export default function MediaSectionV2( { { /* Show preview + dropdown when there's media */ } { previewData && ( <> - + { showFocalPointPicker ? ( + + ) : ( + + ) }
{ + updateImageFocalPoint( attachmentId, { + x: Math.round( focalPoint.x * 100 ) / 100, + y: Math.round( focalPoint.y * 100 ) / 100, + } ); + }, + [ attachmentId, updateImageFocalPoint ] + ); + + return ( + + ); +} diff --git a/projects/packages/publicize/_inc/components/media-section-v2/test/index.test.tsx b/projects/packages/publicize/_inc/components/media-section-v2/test/index.test.tsx index b7da371dc6ce..ac0ae55061f4 100644 --- a/projects/packages/publicize/_inc/components/media-section-v2/test/index.test.tsx +++ b/projects/packages/publicize/_inc/components/media-section-v2/test/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MediaSectionV2 from '..'; import useFeaturedImage from '../../../hooks/use-featured-image'; @@ -12,6 +12,15 @@ const mockUpdateJetpackSocialOptions = jest.fn(); const mockRecordEvent = jest.fn(); const mockOpenUnifiedModal = jest.fn(); const mockApplyFilters = jest.fn(); +const mockSiteHasFeature = jest.fn< boolean, [ string ] >( () => true ); + +jest.mock( '@automattic/jetpack-script-data', () => { + const actual = jest.requireActual( '@automattic/jetpack-script-data' ); + return { + ...actual, + siteHasFeature: ( feature: string ) => mockSiteHasFeature( feature ), + }; +} ); // Mock the social store to prevent importing @wordpress/editor jest.mock( '../../../social-store', () => ( { @@ -85,6 +94,7 @@ jest.mock( '../../../utils', () => ( { jetpack: { version: '15.5' }, }, } ) ), + features: jest.requireActual( '../../../utils/constants' ).features, } ) ); jest.mock( @@ -399,6 +409,155 @@ describe( 'MediaSectionV2', () => { } ); } ); + describe( 'Focal point picker', () => { + const attachedImageState = { + attachedMedia: [ { id: 789, url: 'https://example.com/attached.jpg', type: 'image/jpeg' } ], + imageGeneratorSettings: { enabled: false }, + mediaSource: 'media-library', + updateJetpackSocialOptions: mockUpdateJetpackSocialOptions, + }; + + afterEach( () => { + mockSiteHasFeature.mockReturnValue( true ); + ( usePostMeta as jest.Mock ).mockReturnValue( { + attachedMedia: [], + imageGeneratorSettings: { enabled: false }, + mediaSource: undefined, + updateJetpackSocialOptions: mockUpdateJetpackSocialOptions, + } ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ + { + mediaData: { sourceUrl: 'https://example.com/featured.jpg' }, + metaData: { mime: 'image/jpeg' }, + }, + false, + ] ); + } ); + + it( 'should hide the picker when the feature flag is off', () => { + mockSiteHasFeature.mockReturnValue( false ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ + { + mediaData: { sourceUrl: 'https://example.com/featured.jpg' }, + metaData: { mime: 'image/jpeg' }, + }, + false, + ] ); + + render( ); + + expect( mockSiteHasFeature ).toHaveBeenCalledWith( 'social-image-focal-point' ); + expect( screen.queryByText( 'Focal point' ) ).not.toBeInTheDocument(); + // The plain preview is shown instead. + expect( screen.getByRole( 'img' ) ).toHaveAttribute( + 'src', + 'https://example.com/featured.jpg' + ); + } ); + + it( 'should show the picker for an attached image', () => { + ( usePostMeta as jest.Mock ).mockReturnValue( attachedImageState ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ + { + mediaData: { sourceUrl: 'https://example.com/attached.jpg' }, + metaData: { mime: 'image/jpeg' }, + }, + false, + ] ); + + render( ); + + expect( screen.getByText( 'Focal point' ) ).toBeInTheDocument(); + } ); + + it( 'should show the picker for the featured image', () => { + render( ); + + expect( screen.getByText( 'Focal point' ) ).toBeInTheDocument(); + } ); + + it( 'should hide the picker when SIG is the media source', () => { + ( usePostMeta as jest.Mock ).mockReturnValue( { + attachedMedia: [], + imageGeneratorSettings: { enabled: true }, + mediaSource: 'sig', + updateJetpackSocialOptions: mockUpdateJetpackSocialOptions, + } ); + + render( ); + + expect( screen.queryByText( 'Focal point' ) ).not.toBeInTheDocument(); + // The plain preview is shown instead. + expect( screen.getByRole( 'img' ) ).toHaveAttribute( + 'src', + 'https://example.com/sig-preview.jpg' + ); + } ); + + it( 'should hide the picker for videos', () => { + ( usePostMeta as jest.Mock ).mockReturnValue( { + ...attachedImageState, + attachedMedia: [ { id: 789, url: 'https://example.com/video.mp4', type: 'video/mp4' } ], + } ); + ( useMediaDetails as jest.Mock ).mockReturnValue( [ + { + mediaData: { sourceUrl: 'https://example.com/video.mp4' }, + metaData: { mime: 'video/mp4' }, + }, + false, + ] ); + + render( ); + + expect( screen.queryByText( 'Focal point' ) ).not.toBeInTheDocument(); + } ); + + it( 'should show the picker in per-network/controlled mode', () => { + render( + + ); + + // The point is per image, not per connection, so the picker renders in + // controlled mode too and writes the post-level map directly. + expect( screen.getByText( 'Focal point' ) ).toBeInTheDocument(); + } ); + + it( 'should not touch the focal point map when the media changes', async () => { + const user = userEvent.setup(); + + render( ); + + await user.click( screen.getByRole( 'button', { name: 'Select' } ) ); + await user.click( screen.getByRole( 'menuitemradio', { name: 'From Media Library' } ) ); + + await waitFor( () => expect( mockUpdateJetpackSocialOptions ).toHaveBeenCalled() ); + + // Each image keeps its own entry — switching media must not clear the map. + const updates = mockUpdateJetpackSocialOptions.mock.calls[ 0 ][ 0 ]; + expect( updates.media_source ).toBe( 'media-library' ); + expect( 'image_focal_points' in updates ).toBe( false ); + } ); + + it( 'should not touch the focal point map when toggling share as attachment', async () => { + const user = userEvent.setup(); + + render( ); + + await user.click( screen.getByRole( 'checkbox', { name: 'Share as attachment' } ) ); + + const updates = mockUpdateJetpackSocialOptions.mock.calls[ 0 ][ 0 ]; + expect( updates.media_source ).toBe( 'featured-image' ); + expect( 'image_focal_points' in updates ).toBe( false ); + } ); + } ); + describe( 'imageGenerationHandler filter', () => { it( 'should call applyFilters with correct arguments', () => { mockApplyFilters.mockReturnValue( null ); diff --git a/projects/packages/publicize/_inc/components/media-section-v2/test/media-focal-point.test.tsx b/projects/packages/publicize/_inc/components/media-section-v2/test/media-focal-point.test.tsx new file mode 100644 index 000000000000..4f581b132407 --- /dev/null +++ b/projects/packages/publicize/_inc/components/media-section-v2/test/media-focal-point.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { usePostMeta } from '../../../hooks/use-post-meta'; +import MediaFocalPoint from '../media-focal-point'; + +const mockUpdateImageFocalPoint = jest.fn(); + +jest.mock( '../../../hooks/use-post-meta', () => ( { + usePostMeta: jest.fn(), +} ) ); + +describe( 'MediaFocalPoint', () => { + beforeEach( () => { + jest.clearAllMocks(); + ( usePostMeta as jest.Mock ).mockReturnValue( { + imageFocalPoints: {}, + updateImageFocalPoint: mockUpdateImageFocalPoint, + } ); + } ); + + it( 'should render the picker with the image and a centered default point', () => { + render( ); + + expect( screen.getByRole( 'img' ) ).toHaveAttribute( 'src', 'https://example.com/image.jpg' ); + expect( screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ) ).toHaveValue( + 50 + ); + expect( screen.getByRole( 'spinbutton', { name: 'Focal point top position' } ) ).toHaveValue( + 50 + ); + } ); + + it( 'should use the stored point of this image', () => { + ( usePostMeta as jest.Mock ).mockReturnValue( { + imageFocalPoints: { 123: { x: 0.25, y: 0.75 } }, + updateImageFocalPoint: mockUpdateImageFocalPoint, + } ); + + render( ); + + expect( screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ) ).toHaveValue( + 25 + ); + expect( screen.getByRole( 'spinbutton', { name: 'Focal point top position' } ) ).toHaveValue( + 75 + ); + } ); + + it( 'should ignore points stored for other images', () => { + ( usePostMeta as jest.Mock ).mockReturnValue( { + imageFocalPoints: { 999: { x: 0.25, y: 0.75 } }, + updateImageFocalPoint: mockUpdateImageFocalPoint, + } ); + + render( ); + + expect( screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ) ).toHaveValue( + 50 + ); + expect( screen.getByRole( 'spinbutton', { name: 'Focal point top position' } ) ).toHaveValue( + 50 + ); + } ); + + it( 'should commit the point for this image when changed', async () => { + const user = userEvent.setup(); + + render( ); + + const leftInput = screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ); + await user.clear( leftInput ); + await user.type( leftInput, '75' ); + + expect( mockUpdateImageFocalPoint ).toHaveBeenLastCalledWith( 123, { + x: 0.75, + y: 0.5, + } ); + } ); +} ); diff --git a/projects/packages/publicize/_inc/components/media-section-v2/types.ts b/projects/packages/publicize/_inc/components/media-section-v2/types.ts index e02c12975b1d..4a65b5ba3fde 100644 --- a/projects/packages/publicize/_inc/components/media-section-v2/types.ts +++ b/projects/packages/publicize/_inc/components/media-section-v2/types.ts @@ -150,6 +150,22 @@ export interface MediaSourceMenuProps { children?: ( { open }: { open: () => void } ) => React.ReactNode; } +/** + * Props for MediaFocalPoint component + */ +export interface MediaFocalPointProps { + /** + * URL of the image to pick the focal point on + */ + url: string; + + /** + * ID of the attachment whose entry in the image_focal_points map this + * picker reads and writes. + */ + attachmentId: number; +} + /** * Props for MediaPreview component */ diff --git a/projects/packages/publicize/_inc/hooks/use-post-meta/index.js b/projects/packages/publicize/_inc/hooks/use-post-meta/index.js index 729ce6f1f155..e261dd455177 100644 --- a/projects/packages/publicize/_inc/hooks/use-post-meta/index.js +++ b/projects/packages/publicize/_inc/hooks/use-post-meta/index.js @@ -32,6 +32,7 @@ export function usePostMeta() { const imageGeneratorSettings = jetpackSocialOptions.image_generator_settings ?? DEFAULT_IMAGE_GENERATOR_SETTINGS; const mediaSource = jetpackSocialOptions.media_source; + const imageFocalPoints = jetpackSocialOptions.image_focal_points || EMPTY_OBJECT; const isPostAlreadyShared = meta.jetpack_social_post_already_shared ?? false; let shareMessage = meta.jetpack_publicize_message || ''; @@ -46,6 +47,7 @@ export function usePostMeta() { attachedMedia, imageGeneratorSettings, mediaSource, + imageFocalPoints, isPostAlreadyShared, shareMessage, }; @@ -88,13 +90,32 @@ export function usePostMeta() { [ updateMeta ] ); + const updateImageFocalPoint = useCallback( + ( attachmentId, point ) => { + // Merge into the map explicitly — the top-level shallow merge in + // updateJetpackSocialOptions would clobber the other images' entries. + updateJetpackSocialOptions( 'image_focal_points', { + ...jetpackSocialOptionsRef.current.image_focal_points, + [ attachmentId ]: point, + } ); + }, + [ updateJetpackSocialOptions ] + ); + return useMemo( () => ( { ...metaValues, togglePublicizeFeature, updateJetpackSocialOptions, + updateImageFocalPoint, updateMeta, } ), - [ metaValues, togglePublicizeFeature, updateJetpackSocialOptions, updateMeta ] + [ + metaValues, + togglePublicizeFeature, + updateJetpackSocialOptions, + updateImageFocalPoint, + updateMeta, + ] ); } diff --git a/projects/packages/publicize/_inc/hooks/use-post-meta/test/index.test.js b/projects/packages/publicize/_inc/hooks/use-post-meta/test/index.test.js index 5bc6457a2438..3af8cbeb2151 100644 --- a/projects/packages/publicize/_inc/hooks/use-post-meta/test/index.test.js +++ b/projects/packages/publicize/_inc/hooks/use-post-meta/test/index.test.js @@ -94,6 +94,20 @@ describe( 'usePostMeta', () => { result.current.updateJetpackSocialOptions( 'should_upload_attached_media', true ); } ); + // Per-image focal points merge into the map without clobbering other entries. + act( () => { + result.current.updateImageFocalPoint( 26, { x: 0.25, y: 0.75 } ); + } ); + + act( () => { + result.current.updateImageFocalPoint( 27, { x: 0.1, y: 0.2 } ); + } ); + + expect( result.current.imageFocalPoints ).toEqual( { + 26: { x: 0.25, y: 0.75 }, + 27: { x: 0.1, y: 0.2 }, + } ); + expect( result.current.isPublicizeEnabled ).toBe( false ); expect( result.current.shareMessage ).toBe( 'updated message' ); expect( result.current.isPostAlreadyShared ).toBe( true ); @@ -115,6 +129,10 @@ describe( 'usePostMeta', () => { enabled: true, }, should_upload_attached_media: true, + image_focal_points: { + 26: { x: 0.25, y: 0.75 }, + 27: { x: 0.1, y: 0.2 }, + }, version: 2, } ); } ); diff --git a/projects/packages/publicize/_inc/hooks/use-social-preview-post-data/test/index.test.ts b/projects/packages/publicize/_inc/hooks/use-social-preview-post-data/test/index.test.ts index c8e63fdef604..83a8eac13b51 100644 --- a/projects/packages/publicize/_inc/hooks/use-social-preview-post-data/test/index.test.ts +++ b/projects/packages/publicize/_inc/hooks/use-social-preview-post-data/test/index.test.ts @@ -49,10 +49,12 @@ const getDefaultMockPostMeta = () => ( { isPublicizeEnabled: true, jetpackSocialOptions: {}, mediaSource: undefined, + imageFocalPoints: {}, shareMessage: '', togglePublicizeFeature: jest.fn(), updateMeta: jest.fn(), updateJetpackSocialOptions: jest.fn(), + updateImageFocalPoint: jest.fn(), } ); const getDefaultLinkPreviewData = () => ( { diff --git a/projects/packages/publicize/_inc/utils/constants.ts b/projects/packages/publicize/_inc/utils/constants.ts index dbee04c23907..e5595032597e 100644 --- a/projects/packages/publicize/_inc/utils/constants.ts +++ b/projects/packages/publicize/_inc/utils/constants.ts @@ -1,5 +1,6 @@ export const features = { ENHANCED_PUBLISHING: 'social-enhanced-publishing', + IMAGE_FOCAL_POINT: 'social-image-focal-point', IMAGE_GENERATOR: 'social-image-generator', MESSAGE_TEMPLATES: 'social-message-templates', }; diff --git a/projects/packages/publicize/_inc/utils/types.ts b/projects/packages/publicize/_inc/utils/types.ts index 1ec711ff58a2..8fb99e660d03 100644 --- a/projects/packages/publicize/_inc/utils/types.ts +++ b/projects/packages/publicize/_inc/utils/types.ts @@ -16,10 +16,22 @@ export type AttachedMedia = { export type MediaSourceValue = 'featured-image' | 'sig' | 'media-library' | 'upload-video' | 'none'; +export type FocalPoint = { + x: number; + y: number; +}; + +/** + * One focal point per image, keyed by attachment ID. Consumers look up the + * image they are processing; a missing key means unset. + */ +export type ImageFocalPoints = Record< number, FocalPoint >; + export type JetpackSocialOptions = { attached_media?: Array< AttachedMedia >; image_generator_settings?: SIGSettings; media_source?: MediaSourceValue; + image_focal_points?: ImageFocalPoints; }; export type JetpackSocialPostMeta = { @@ -34,6 +46,7 @@ export type UsePostMeta = { imageGeneratorSettings: SIGSettings; isPostAlreadyShared: boolean; isPublicizeEnabled: boolean; + imageFocalPoints: ImageFocalPoints; jetpackSocialOptions: JetpackSocialOptions; mediaSource: MediaSourceValue | undefined; shareMessage: string; @@ -48,4 +61,5 @@ export type UsePostMeta = { // Batch update with object ( updates: Partial< JetpackSocialOptions > ): void; }; + updateImageFocalPoint: ( attachmentId: number, point: FocalPoint ) => void; }; diff --git a/projects/packages/publicize/changelog/2026-06-12-08-59-39-305207 b/projects/packages/publicize/changelog/2026-06-12-08-59-39-305207 new file mode 100644 index 000000000000..441b75a05e31 --- /dev/null +++ b/projects/packages/publicize/changelog/2026-06-12-08-59-39-305207 @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add focal point data model and picker in the media section diff --git a/projects/packages/publicize/src/class-publicize-base.php b/projects/packages/publicize/src/class-publicize-base.php index 1f921639da7d..895c768c5538 100644 --- a/projects/packages/publicize/src/class-publicize-base.php +++ b/projects/packages/publicize/src/class-publicize-base.php @@ -1231,6 +1231,30 @@ public function register_post_meta() { 'type' => 'string', 'enum' => array( 'featured-image', 'sig', 'media-library', 'upload-video', 'none' ), ), + 'image_focal_points' => array( + 'type' => 'object', + // One focal point per image, keyed by attachment ID. Consumers + // look up the image they are processing; a missing key means unset. + 'patternProperties' => array( + '^[0-9]+$' => array( + 'type' => 'object', + 'properties' => array( + 'x' => array( + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + ), + 'y' => array( + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + ), + ), + 'additionalProperties' => false, + ), + ), + 'additionalProperties' => false, + ), ), ), ), diff --git a/projects/packages/publicize/tests/php/Social_Options_Meta_Test.php b/projects/packages/publicize/tests/php/Social_Options_Meta_Test.php new file mode 100644 index 000000000000..eb6ccfc6d97b --- /dev/null +++ b/projects/packages/publicize/tests/php/Social_Options_Meta_Test.php @@ -0,0 +1,251 @@ +publicize = $this->getMockBuilder( Publicize::class )->onlyMethods( array( 'refresh_connections' ) )->getMock(); + + $this->publicize->method( 'refresh_connections' ) + ->withAnyParameters() + ->willReturn( null ); + + $publicize = $this->publicize; + + $this->admin_id = wp_insert_user( + array( + 'user_login' => 'dummy_admin', + 'user_pass' => 'dummy_pass', + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->admin_id ); + $user = wp_get_current_user(); + $user->add_cap( 'manage_options' ); + $user->set_role( 'administrator' ); + + add_post_type_support( 'post', 'publicize' ); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + $this->publicize->register_post_meta(); + do_action( 'rest_api_init' ); + + $this->draft_id = wp_insert_post( + array( + 'post_author' => $this->admin_id, + 'post_content' => '', + 'post_content_filtered' => '', + 'post_title' => 'Focal point test', + 'post_excerpt' => '', + 'post_status' => 'draft', + 'post_type' => 'post', + 'comment_status' => '', + 'ping_status' => '', + 'post_password' => '', + 'to_ping' => '', + 'pinged' => '', + 'post_parent' => 0, + 'menu_order' => 0, + 'guid' => '', + 'import_id' => 0, + 'context' => '', + 'post_date' => '', + 'post_date_gmt' => '', + ) + ); + } + + /** + * Returning the environment into its initial state. + */ + public function tearDown(): void { + parent::tearDown(); + + $meta_keys = array( + $this->publicize->POST_MESS, + Publicize_Base::POST_PUBLICIZE_FEATURE_ENABLED, + $this->publicize->POST_DONE . 'all', + Publicize_Base::POST_JETPACK_SOCIAL_OPTIONS, + Publicize_Base::POST_CONNECTION_OVERRIDES, + Publicize_Base::POST_CUSTOMIZE_PER_NETWORK, + ); + + foreach ( get_post_types() as $post_type ) { + foreach ( $meta_keys as $meta_key ) { + unregister_meta_key( 'post', $meta_key, $post_type ); + } + } + + remove_post_type_support( 'post', 'publicize' ); + self::reset_active_plan_cache(); + WorDBless_Options::init()->clear_options(); + WorDBless_Posts::init()->clear_all_posts(); + WorDBless_Users::init()->clear_all_users(); + } + + /** + * Force the next `Current_Plan::get()` to re-read from the option store. + */ + private static function reset_active_plan_cache() { + $reflection = new \ReflectionClass( Current_Plan::class ); + $property = $reflection->getProperty( 'active_plan_cache' ); + // @todo Remove this call once we no longer need to support PHP <8.1. + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( null, null ); + } + + /** + * Dispatch a REST request updating jetpack_social_options with the given focal points map. + * + * @param array $focal_points The image_focal_points value to save. + * @return \WP_REST_Response The REST response. + */ + private function update_focal_points( $focal_points ) { + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', $this->draft_id ) ); + $request->set_body_params( + array( + 'meta' => array( + 'jetpack_social_options' => array( + 'version' => 2, + 'image_focal_points' => $focal_points, + ), + ), + ) + ); + + return $this->server->dispatch( $request ); + } + + /** + * Test that the image_focal_points map round-trips through the REST API. + */ + public function test_image_focal_points_round_trip_through_rest() { + $focal_points = array( + '123' => array( + 'x' => 0.25, + 'y' => 0.75, + ), + '456' => array( + 'x' => 0.5, + 'y' => 0.1, + ), + ); + + $response = $this->update_focal_points( $focal_points ); + $this->assertSame( 200, $response->get_status() ); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $this->draft_id ) ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( $focal_points, $data['meta']['jetpack_social_options']['image_focal_points'] ); + } + + /** + * Test that out-of-range focal point coordinates are rejected. + */ + public function test_image_focal_points_reject_out_of_range_coordinates() { + $response = $this->update_focal_points( + array( + '123' => array( + 'x' => 1.5, + 'y' => 0.5, + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_out_of_bounds', $response->get_data()['code'] ); + } + + /** + * Test that unknown properties inside a focal point are rejected. + */ + public function test_image_focal_points_reject_unknown_point_properties() { + $response = $this->update_focal_points( + array( + '123' => array( + 'x' => 0.5, + 'y' => 0.5, + 'zoom' => 2, + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_additional_properties_forbidden', $response->get_data()['code'] ); + } + + /** + * Test that non-numeric map keys are rejected. + */ + public function test_image_focal_points_reject_non_numeric_keys() { + $response = $this->update_focal_points( + array( + 'not-an-id' => array( + 'x' => 0.5, + 'y' => 0.5, + ), + ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_additional_properties_forbidden', $response->get_data()['code'] ); + } +}