Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.36% covered (warning)
66.36%
71 / 107
31.25% covered (danger)
31.25%
5 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Social_Admin_Page
66.36% covered (warning)
66.36%
71 / 107
31.25% covered (danger)
31.25%
5 / 16
117.73
0.00% covered (danger)
0.00%
0 / 1
 wp_build_index_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_load_wp_build
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 add_menu
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 add_admin_shell_member
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 load_as_admin_shell_guest
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 enqueue_guest_scripts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 admin_init
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 render
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_admin_scripts
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 load_wp_build
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 load_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 is_social_admin_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2/**
3 * Social Admin Page class.
4 *
5 * @package automattic/jetpack-publicize
6 */
7
8namespace Automattic\Jetpack\Publicize;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Admin_UI\Admin_Shell;
12use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14use Automattic\Jetpack\Current_Plan;
15use Automattic\Jetpack\Publicize\Publicize_Utils as Utils;
16use Automattic\Jetpack\Status\Host;
17
18/**
19 * The class to handle the Social Admin Page.
20 */
21class Social_Admin_Page {
22
23    /**
24     * Nonce action used when refreshing plan data.
25     */
26    public const REFRESH_PLAN_NONCE_ACTION = 'jetpack_social_refresh_plan_data';
27
28    /**
29     * Script that carries the connection state when the admin shell shows the dashboard on another page.
30     */
31    public const GUEST_DATA_HANDLE = 'jetpack-social-dashboard-data';
32
33    /**
34     * The instance of the class.
35     *
36     * @var Social_Admin_Page
37     */
38    private static $instance;
39
40    /**
41     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
42     *
43     * @var string|null
44     */
45    private static $wp_build_original_screen_id = null;
46
47    /**
48     * Overrides the generated entry point path. Test seam, null in production.
49     *
50     * @var string|null
51     */
52    private static $wp_build_index = null;
53
54    /**
55     * Absolute path to the entry point generated by the WP build script.
56     *
57     * @return string
58     */
59    private static function wp_build_index_path() {
60        return self::$wp_build_index ?? dirname( __DIR__ ) . '/build/build.php';
61    }
62
63    /**
64     * Initialize the class.
65     *
66     * @return Social_Admin_Page
67     */
68    public static function init() {
69        if ( ! isset( self::$instance ) ) {
70            self::$instance = new self();
71        }
72
73        return self::$instance;
74    }
75
76    /**
77     * The constructor.
78     */
79    private function __construct() {
80        // Load the wp-build dashboard at admin_menu priority 1 so its render
81        // function is defined before `add_menu` (priority 10) registers the page.
82        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
83        add_action( 'admin_menu', array( $this, 'add_menu' ) );
84
85        if ( class_exists( Admin_Shell::class ) ) {
86            add_filter( 'jetpack_admin_shell_members', array( $this, 'add_admin_shell_member' ) );
87        }
88    }
89
90    /**
91     * Load the wp-build dashboard on the Social admin request.
92     *
93     * Hooked to `admin_menu` priority 1 so the wp-build render function and
94     * enqueue hook are in place before `add_menu()` runs at the default priority.
95     *
96     * @return void
97     */
98    public static function maybe_load_wp_build() {
99        if ( ! self::is_social_admin_request() ) {
100            return;
101        }
102
103        self::load_wp_build_with_screen_alias();
104
105        // wp-build registers standalone modules (e.g. the init module) on
106        // wp_default_scripts, which has already fired by admin_menu. Register them
107        // directly so the init module makes it into the import map.
108        if ( function_exists( 'jetpack_social_register_script_modules' ) ) {
109            jetpack_social_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
110        }
111    }
112
113    /**
114     * Add the admin menu.
115     */
116    public function add_menu() {
117
118        // Remove the old Social menu item, if it exists.
119        Admin_Menu::remove_menu( 'jetpack-social' );
120
121        // If this isn't an admin (or someone with the capability to change the module status )
122        // and Publicize is inactive, then don't render the admin page.
123        if ( ! Utils::can_open_social_dashboard() ) {
124            return;
125        }
126
127        // We don't need Jetpack connection on WP.com.
128        $needs_site_connection = ! ( new Host() )->is_wpcom_platform() && ! ( new Connection_Manager() )->is_connected();
129
130        /**
131         * If the Jetpack Social plugin is not active,
132         * we want to hide the menu if the site is not connected.
133         */
134        if ( ! defined( 'JETPACK_SOCIAL_PLUGIN_DIR' ) && $needs_site_connection ) {
135            return;
136        }
137
138        $page_suffix = Admin_Menu::add_menu(
139            /** "Jetpack Social" is a product name, do not translate. */
140            'Jetpack Social',
141            'Social',
142            'publish_posts',
143            'jetpack-social',
144            array( $this, 'render' ),
145            null,
146            array(
147                'product' => 'social',
148                'key'     => 'jetpack-social',
149            )
150        );
151
152        add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
153    }
154
155    /**
156     * Register Jetpack Social with the admin shell, which switches between Jetpack dashboards without a page load.
157     *
158     * @since $$next-version$$
159     *
160     * @param array $members Shell members keyed by admin page slug.
161     * @return array
162     */
163    public function add_admin_shell_member( $members ) {
164        $host = new Host();
165        // The same gates add_menu() applies, since admin-ajax never builds the menu.
166        if (
167            $host->is_wpcom_simple()
168            || ! Utils::can_open_social_dashboard()
169            || ( ! defined( 'JETPACK_SOCIAL_PLUGIN_DIR' ) && ! $host->is_wpcom_platform() && ! ( new Connection_Manager() )->is_connected() )
170            || ! file_exists( self::wp_build_index_path() )
171        ) {
172            return $members;
173        }
174
175        $members['jetpack-social'] = array(
176            'build_prefix' => 'jetpack_social',
177            'build_page'   => 'jetpack-social-dashboard',
178            'init_modules' => array( '@jetpack-social/init' ),
179            /** "Jetpack Social" is a product name, do not translate. */
180            'title'        => 'Jetpack Social',
181            'capability'   => 'publish_posts',
182            'handles'      => array( self::GUEST_DATA_HANDLE, 'jetpack-script-data', 'wp-jp-i18n-loader' ),
183            'media'        => true,
184            'load'         => array( $this, 'load_as_admin_shell_guest' ),
185        );
186        return $members;
187    }
188
189    /**
190     * Ready the Jetpack Social routes and page data while another dashboard's page is open, without the screen ID alias.
191     *
192     * @since $$next-version$$
193     *
194     * @return void
195     */
196    public function load_as_admin_shell_guest() {
197        self::load_wp_build();
198        if ( function_exists( 'jetpack_social_register_script_modules' ) ) {
199            jetpack_social_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
200        }
201        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_guest_scripts' ) );
202    }
203
204    /**
205     * Hydrate the connection store on a script of its own: the prerequisites script only exists on the Social page.
206     *
207     * @since $$next-version$$
208     *
209     * @return void
210     */
211    public function enqueue_guest_scripts() {
212        wp_register_script( self::GUEST_DATA_HANDLE, false, array(), false, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion -- No source, so nothing to cache.
213        wp_enqueue_script( self::GUEST_DATA_HANDLE );
214        Connection_Initial_State::render_script( self::GUEST_DATA_HANDLE );
215        $this->enqueue_admin_scripts();
216    }
217
218    /**
219     * Initialize the admin resources.
220     */
221    public function admin_init() {
222        // Refresh data if coming from purchase to ensure it is up to date
223        // without making API calls on every admin page load.
224        if ( isset( $_GET['refresh_plan_data'] ) ) {
225            check_admin_referer( self::REFRESH_PLAN_NONCE_ACTION );
226            if ( apply_filters( 'jetpack_social_should_refresh_plan_data', true ) ) {
227                Current_Plan::refresh_from_wpcom();
228            }
229        }
230
231        /**
232         * Use priority 20 to ensure that we can dequeue the old Social assets.
233         */
234        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ), 20 );
235
236        // Initialize the media library for the social image generator.
237        wp_enqueue_media();
238    }
239
240    /**
241     * Render the admin page by delegating to the wp-build dashboard.
242     *
243     * `maybe_load_wp_build()` defines the render function before this fires;
244     * render nothing on an unbuilt dev checkout rather than fatal.
245     */
246    public function render() {
247        if ( function_exists( 'jetpack_social_jetpack_social_dashboard_wp_admin_render_page' ) ) {
248            // Generated by wp-build (build/pages/jetpack-social-dashboard/page-wp-admin.php), so phan can't see it.
249            // @phan-suppress-next-line PhanUndeclaredFunction
250            jetpack_social_jetpack_social_dashboard_wp_admin_render_page();
251        }
252    }
253
254    /**
255     * Enqueue admin scripts and styles.
256     */
257    public function enqueue_admin_scripts() {
258        /*
259         * wp-build owns its own enqueue pipeline. The chassis reads connection
260         * state via `useConnection()`, which has no REST resolver, so hydrate it
261         * inline onto the prerequisites script that loads before the chassis module.
262         */
263        if ( wp_script_is( 'jetpack-social-dashboard-wp-admin-prerequisites', 'registered' ) ) {
264            Connection_Initial_State::render_script( 'jetpack-social-dashboard-wp-admin-prerequisites' );
265        }
266
267        // The i18n loader is registered on every admin page by jetpack-assets but
268        // only enqueued when depended on; the esbuild bundles don't pull it in.
269        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
270            wp_enqueue_script( 'wp-jp-i18n-loader' );
271        }
272    }
273
274    /**
275     * Load the wp-build entry file and register its polyfills.
276     *
277     * Only called on `?page=jetpack-social` admin requests. Keeps wp-build off
278     * every other request.
279     *
280     * @return void
281     */
282    private static function load_wp_build() {
283        $build_index = self::wp_build_index_path();
284
285        if ( ! file_exists( $build_index ) ) {
286            return;
287        }
288
289        require_once $build_index;
290
291        // The wp-build dashboard (unlike the Social bundles) uses the full polyfill set:
292        // the @wordpress/boot|route|a11y modules, wp-notices, wp-views, etc.
293        if ( ! class_exists( '\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills' ) ) {
294            return;
295        }
296
297        \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(
298            'jetpack-social',
299            array_merge(
300                \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES,
301                \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS
302            )
303        );
304    }
305
306    /**
307     * Load wp-build with the screen ID aliased across its generated enqueue check.
308     *
309     * @see WP_Build_Screen_Id::load_with_alias()
310     * @return void
311     */
312    private static function load_wp_build_with_screen_alias() {
313        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
314        if ( method_exists( \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
315            \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id::load_with_alias(
316                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
317                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
318                function () {
319                    self::load_wp_build();
320                }
321            );
322            return;
323        }
324
325        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
326        self::load_wp_build();
327        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
328    }
329
330    /**
331     * Alias the current screen ID to satisfy wp-build's auto-generated enqueue check.
332     *
333     * The wp-build `<page>-wp-admin` enqueue callback fires only when the screen ID
334     * matches the wp-build page slug (`jetpack-social-dashboard`). Our wp-admin menu
335     * slug stays `jetpack-social`, so we mutate the screen object in place to make
336     * the check pass without changing the user-facing URL.
337     *
338     * Hooked only when we're on the Social admin page, so this never affects any
339     * other request.
340     *
341     * @since 0.87.1 Takes no argument; hooked on `admin_enqueue_scripts`.
342     *
343     * @return void
344     */
345    public static function alias_screen_id_for_wp_build() {
346        $screen = get_current_screen();
347        if ( ! $screen ) {
348            return;
349        }
350
351        self::$wp_build_original_screen_id = $screen->id;
352        $screen->id                        = 'jetpack-social-dashboard';
353    }
354
355    /**
356     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
357     *
358     * @since 0.87.1
359     *
360     * @return void
361     */
362    public static function restore_screen_id_after_wp_build() {
363        $screen = get_current_screen();
364        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
365            return;
366        }
367
368        $screen->id                        = self::$wp_build_original_screen_id;
369        self::$wp_build_original_screen_id = null;
370    }
371
372    /**
373     * Returns true when the current request targets the Social admin page.
374     *
375     * Used to scope wp-build loading to the one page that needs it. The
376     * `$_GET['page']` value is populated by wp-admin/admin.php before any of
377     * our hooks fire, so this check is reliable from the constructor onwards.
378     *
379     * @return bool
380     */
381    private static function is_social_admin_request() {
382        if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
383            return false;
384        }
385
386        return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === 'jetpack-social'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
387    }
388}