Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.29% covered (warning)
61.29%
95 / 155
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
61.29% covered (warning)
61.29%
95 / 155
36.84% covered (danger)
36.84%
7 / 19
237.90
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 add_wp_admin_submenu
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 add_admin_shell_member
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 load_as_admin_shell_guest
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 admin_init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_i18n_loader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_tracks_transport
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_load_wp_build
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 inject_podcast_script_data
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
11.56
 get_tracks_user_data
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 get_selected_category
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 load_wp_build
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 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
 opt_out_of_jitms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hide_jitms_on_wp_build_dashboard
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 is_podcast_admin_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2/**
3 * Registers the Jetpack Podcast wp-admin page and loads the wp-build dashboard.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Admin_UI\Admin_Shell;
12use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13use Automattic\Jetpack\My_Jetpack\Products as My_Jetpack_Products;
14use Automattic\Jetpack\Status\Host;
15use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
16use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
17
18/**
19 * Adds the "Jetpack > Podcast" wp-admin screen.
20 */
21class Admin_Page {
22
23    const ADMIN_PAGE_SLUG = 'jetpack-podcast';
24
25    /**
26     * Where the Podcast item used to sit in the Jetpack submenu on self-hosted.
27     *
28     * Unread since Podcast registers without a position; kept so consumers do not fatal.
29     *
30     * @deprecated 2.1.1
31     */
32    const MENU_POSITION = 11;
33
34    /**
35     * Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
36     * callback only fires when `$screen->id` matches this value, so we alias
37     * the screen id around that check without changing the user-facing URL.
38     */
39    const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
40
41    /**
42     * Whether `init()` has already wired its hooks.
43     *
44     * @var bool
45     */
46    private static $initialized = false;
47
48    /**
49     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
50     *
51     * @var string|null
52     */
53    private static $wp_build_original_screen_id = null;
54
55    /**
56     * The dashboard screen hide_jitms_on_wp_build_dashboard() opts out of JITMs.
57     *
58     * @var string|null
59     */
60    private static $jitm_opt_out_screen_id = null;
61
62    /**
63     * Wire admin hooks. Idempotent.
64     */
65    public static function init() {
66        if ( self::$initialized ) {
67            return;
68        }
69        self::$initialized = true;
70
71        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
72
73        if ( class_exists( Admin_Shell::class ) ) {
74            add_filter( 'jetpack_admin_shell_members', array( __CLASS__, 'add_admin_shell_member' ) );
75        }
76
77        // On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
78        // priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
79        // has no such file, so we register our own. Priority 999 queues the item
80        // before Admin_Menu's priority-1000 callback.
81        if ( ! ( new Host() )->is_wpcom_platform() ) {
82            add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
83        }
84    }
85
86    /**
87     * Register the Podcast submenu under the Jetpack menu.
88     */
89    public static function add_wp_admin_submenu() {
90        // Prefer the wp-build render function once it's defined (by
91        // maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
92        $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
93        $callback        = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
94
95        if ( ( new Host() )->is_wpcom_platform() ) {
96            $page_suffix = add_submenu_page(
97                'jetpack',
98                /** "Podcast" is a product name, do not translate. */
99                'Podcast',
100                'Podcast',
101                'manage_options',
102                self::ADMIN_PAGE_SLUG,
103                $callback
104            );
105        } else {
106            $page_suffix = Admin_Menu::add_menu(
107                /** "Podcast" is a product name, do not translate. */
108                'Podcast',
109                'Podcast',
110                'manage_options',
111                self::ADMIN_PAGE_SLUG,
112                $callback,
113                null,
114                // Podcast has no My Jetpack product class, so the module is the only gate available.
115                array(
116                    'module' => 'podcast',
117                    'key'    => 'jetpack-podcast',
118                )
119            );
120        }
121
122        if ( $page_suffix ) {
123            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
124            self::opt_out_of_jitms( $page_suffix );
125        }
126    }
127
128    /**
129     * Register Podcast with the admin shell, which switches between Jetpack dashboards without a page load.
130     *
131     * @since $$next-version$$
132     *
133     * @param array $members Shell members keyed by admin page slug.
134     * @return array
135     */
136    public static function add_admin_shell_member( $members ) {
137        if ( ( new Host() )->is_wpcom_simple() || ! file_exists( dirname( __DIR__ ) . '/build/build.php' ) ) {
138            return $members;
139        }
140
141        $members[ self::ADMIN_PAGE_SLUG ] = array(
142            'build_prefix' => 'jetpack_podcast',
143            'build_page'   => self::WP_BUILD_SLUG,
144            'init_modules' => array( '@jetpack-podcast/init' ),
145            /** "Podcast" is a product name, do not translate. */
146            'title'        => 'Podcast',
147            'capability'   => 'manage_options',
148            'handles'      => array( 'jetpack-script-data', 'jp-tracks', 'wp-jp-i18n-loader' ),
149            'media'        => true,
150            'load'         => array( __CLASS__, 'load_as_admin_shell_guest' ),
151        );
152        return $members;
153    }
154
155    /**
156     * Ready the Podcast routes and page data while another dashboard's page is open, without the screen ID alias.
157     *
158     * @since $$next-version$$
159     *
160     * @return void
161     */
162    public static function load_as_admin_shell_guest() {
163        self::load_wp_build();
164        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
165        self::admin_init();
166    }
167
168    /**
169     * Wire admin-init actions once we know the Podcast page is loading.
170     */
171    public static function admin_init() {
172        // MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
173        add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
174        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_tracks_transport' ) );
175        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) );
176    }
177
178    /**
179     * Enqueue the JS translation loader so the dashboard's init module can download its catalogs.
180     *
181     * The loader is registered on every admin page by jetpack-assets but only enqueued when
182     * something depends on it; the esbuild bundles don't pull it in.
183     */
184    public static function enqueue_i18n_loader() {
185        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
186            wp_enqueue_script( 'wp-jp-i18n-loader' );
187        }
188    }
189
190    /**
191     * Load the Tracks transport for the dashboard's client-side events.
192     *
193     * `jetpackAnalytics.tracks.recordEvent()` only pushes onto `window._tkq`,
194     * which stays an inert array until `w.js` loads and drains it. Nothing
195     * supplies that on Atomic or self-hosted, so without this the queue grows
196     * for the life of the page. Simple is skipped because stats.php already
197     * prints the same script on `admin_footer`, and loading it twice would
198     * re-drain a queue that has already been flushed.
199     */
200    public static function enqueue_tracks_transport() {
201        if ( ( new Host() )->is_wpcom_simple() ) {
202            return;
203        }
204
205        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
206    }
207
208    /**
209     * Hooked at admin_menu priority 1 so polyfills register before
210     * `wp_default_scripts` fires and the wp-build render function is defined
211     * before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
212     * on Simple/Atomic).
213     */
214    public static function maybe_load_wp_build() {
215        if ( ! self::is_podcast_admin_request() ) {
216            return;
217        }
218
219        self::load_wp_build_with_screen_alias();
220        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
221    }
222
223    /**
224     * Add the podcast gate boolean to `window.JetpackScriptData`.
225     *
226     * Hooked from `maybe_load_wp_build()` so it only runs when the request is
227     * for the podcast admin page.
228     *
229     * @param array $data Script data being injected.
230     * @return array
231     */
232    public static function inject_podcast_script_data( $data ) {
233        if ( ! is_array( $data ) ) {
234            $data = array();
235        }
236
237        $is_wpcom = ( new Host() )->is_wpcom_platform();
238
239        if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
240            $blog_id = (int) Connection_Manager::get_site_id( true );
241            if ( $blog_id > 0 ) {
242                $data['site']['wpcom']['blog_id'] = $blog_id;
243            }
244        }
245
246        $upgrade = array(
247            'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
248            'plan_name'    => $is_wpcom ? 'Premium' : 'Growth',
249        );
250        if ( ! $is_wpcom && method_exists( My_Jetpack_Products::class, 'get_bundle_offer' ) ) {
251            $offer = My_Jetpack_Products::get_bundle_offer( 'growth' );
252            if ( 'growth' !== ( $offer['slug'] ?? '' ) ) {
253                $upgrade = array(
254                    'product_slug' => $offer['wpcom_product_slug'] ?? '',
255                    'plan_name'    => $offer['title'] ?? '',
256                );
257            }
258        }
259        $data['podcast'] = array(
260            'has_product_access'  => Podcast_Gate::has_product_access(),
261            'is_connected'        => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
262            'show_url_hosts'      => Settings::SHOW_URL_HOSTS,
263            'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
264            'feed_limit_max'      => Settings::feed_limit_max(),
265            'preload'             => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
266            'selected_category'   => self::get_selected_category(),
267            'tracks_user_data'    => self::get_tracks_user_data(),
268            'upgrade'             => $upgrade,
269        );
270
271        return $data;
272    }
273
274    /**
275     * Connected-user identity for Tracks, so client events aren't anonymous on
276     * Atomic and self-hosted. Null on Simple, where stats.php already pushes
277     * `identifyUser` before our bundle runs.
278     *
279     * Deliberately narrower than `get_connected_user_tracks_identity()`, which
280     * also returns email, blogid and locale — none of which Tracks needs here.
281     *
282     * @return array{userid:mixed, username:mixed}|null
283     */
284    private static function get_tracks_user_data() {
285        if ( ! class_exists( 'Jetpack_Tracks_Client' ) ) {
286            return null;
287        }
288
289        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
290        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
291            return null;
292        }
293
294        return array(
295            'userid'   => $identity['userid'],
296            'username' => $identity['username'],
297        );
298    }
299
300    /**
301     * The currently designated podcast category, injected so the settings
302     * picker can label its selected option on first paint instead of waiting on
303     * the client-side taxonomy→terms fetch. The full list still loads lazily.
304     *
305     * @return array{id:int, name:string}|null Null when no category is set.
306     */
307    public static function get_selected_category() {
308        $category_id = (int) get_option( 'podcasting_category_id', 0 );
309        if ( $category_id <= 0 ) {
310            return null;
311        }
312
313        $term = get_term( $category_id, 'category' );
314        if ( ! $term instanceof \WP_Term ) {
315            return null;
316        }
317
318        return array(
319            'id'   => (int) $term->term_id,
320            'name' => $term->name,
321        );
322    }
323
324    /**
325     * The build artifact may be absent on a fresh checkout before
326     * `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
327     * to `render()` so the page still loads (just without the React app).
328     */
329    private static function load_wp_build() {
330        $build_index = dirname( __DIR__ ) . '/build/build.php';
331
332        if ( ! file_exists( $build_index ) ) {
333            return;
334        }
335
336        require_once $build_index;
337
338        // wp-build hooks module registration to wp_default_scripts, which has
339        // already fired by admin_menu — call it directly or the init module
340        // never reaches the import map.
341        if ( function_exists( 'jetpack_podcast_register_script_modules' ) ) {
342            jetpack_podcast_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
343        }
344
345        WP_Build_Polyfills::register(
346            'jetpack-podcast',
347            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
348        );
349    }
350
351    /**
352     * Load wp-build with the screen ID aliased across its generated enqueue check.
353     *
354     * @see WP_Build_Screen_Id::load_with_alias()
355     * @return void
356     */
357    private static function load_wp_build_with_screen_alias() {
358        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
359        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
360            WP_Build_Screen_Id::load_with_alias(
361                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
362                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
363                function () {
364                    self::load_wp_build();
365                }
366            );
367            return;
368        }
369
370        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
371        self::load_wp_build();
372        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
373    }
374
375    /**
376     * Alias the current screen id to wp-build's expected slug.
377     *
378     * @since 2.1.3 Takes no argument; hooked on `admin_enqueue_scripts`.
379     */
380    public static function alias_screen_id_for_wp_build() {
381        $screen = get_current_screen();
382        if ( ! $screen ) {
383            return;
384        }
385
386        self::$wp_build_original_screen_id = $screen->id;
387        $screen->id                        = self::WP_BUILD_SLUG;
388    }
389
390    /**
391     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
392     *
393     * @since 2.1.3
394     */
395    public static function restore_screen_id_after_wp_build() {
396        $screen = get_current_screen();
397        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
398            return;
399        }
400
401        $screen->id                        = self::$wp_build_original_screen_id;
402        self::$wp_build_original_screen_id = null;
403    }
404
405    /**
406     * Opt the dashboard's screen out of JITMs.
407     *
408     * @param string $screen_id The hook suffix the page was registered under, which is its screen ID.
409     */
410    private static function opt_out_of_jitms( $screen_id ) {
411        self::$jitm_opt_out_screen_id = $screen_id;
412        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_jitms_on_wp_build_dashboard' ), 10, 2 );
413    }
414
415    /**
416     * Keep JITMs off the wp-build dashboard, which has no `#jp-admin-notices` to show them in.
417     *
418     * Fetching a JITM records a view, so one the page hides would still be counted.
419     *
420     * @since 2.1.3
421     *
422     * @param bool   $show      Whether to show JITMs on the screen.
423     * @param string $screen_id The screen ID.
424     * @return bool
425     */
426    public static function hide_jitms_on_wp_build_dashboard( $show, $screen_id ) {
427        if ( null !== self::$jitm_opt_out_screen_id && self::$jitm_opt_out_screen_id === $screen_id ) {
428            return false;
429        }
430
431        return $show;
432    }
433
434    /**
435     * Fallback render used when the wp-build artifact is missing.
436     */
437    public static function render() {
438        ?>
439        <div class="wrap">
440            <h1>Podcast</h1>
441        </div>
442        <?php
443    }
444
445    /**
446     * Whether the current request targets the Podcast admin page.
447     */
448    private static function is_podcast_admin_request() {
449        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
450        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
451            return false;
452        }
453
454        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
455        return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
456    }
457}