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