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