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'] );
+ }
+}