Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.53% covered (warning)
50.53%
48 / 95
8.33% covered (danger)
8.33%
1 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
50.53% covered (warning)
50.53%
48 / 95
8.33% covered (danger)
8.33%
1 / 12
192.94
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%
20 / 20
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
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 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
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 alias_screen_id_for_wp_build
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 is_podcast_admin_request
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
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;
14
15/**
16 * Adds the "Jetpack > Podcast" wp-admin screen.
17 */
18class Admin_Page {
19
20    const ADMIN_PAGE_SLUG = 'jetpack-podcast';
21
22    /**
23     * Where the Podcast item used to sit in the Jetpack submenu on self-hosted.
24     *
25     * Unread since Podcast registers without a position; kept so consumers do not fatal.
26     *
27     * @deprecated 2.1.1
28     */
29    const MENU_POSITION = 11;
30
31    /**
32     * Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
33     * callback only fires when `$screen->id` matches this value, so we alias
34     * the screen id via `current_screen` without changing the user-facing URL.
35     */
36    const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
37
38    /**
39     * Whether `init()` has already wired its hooks.
40     *
41     * @var bool
42     */
43    private static $initialized = false;
44
45    /**
46     * Wire admin hooks. Idempotent.
47     */
48    public static function init() {
49        if ( self::$initialized ) {
50            return;
51        }
52        self::$initialized = true;
53
54        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
55
56        // On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
57        // priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
58        // has no such file, so we register our own. Priority 999 queues the item
59        // before Admin_Menu's priority-1000 callback.
60        if ( ! ( new Host() )->is_wpcom_platform() ) {
61            add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
62        }
63    }
64
65    /**
66     * Register the Podcast submenu under the Jetpack menu.
67     */
68    public static function add_wp_admin_submenu() {
69        // Prefer the wp-build render function once it's defined (by
70        // maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
71        $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
72        $callback        = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
73
74        if ( ( new Host() )->is_wpcom_platform() ) {
75            $page_suffix = add_submenu_page(
76                'jetpack',
77                /** "Podcast" is a product name, do not translate. */
78                'Podcast',
79                'Podcast',
80                'manage_options',
81                self::ADMIN_PAGE_SLUG,
82                $callback
83            );
84        } else {
85            $page_suffix = Admin_Menu::add_menu(
86                /** "Podcast" is a product name, do not translate. */
87                'Podcast',
88                'Podcast',
89                'manage_options',
90                self::ADMIN_PAGE_SLUG,
91                $callback
92            );
93        }
94
95        if ( $page_suffix ) {
96            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
97        }
98    }
99
100    /**
101     * Wire admin-init actions once we know the Podcast page is loading.
102     */
103    public static function admin_init() {
104        // MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
105        add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
106        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_tracks_transport' ) );
107    }
108
109    /**
110     * Load the Tracks transport for the dashboard's client-side events.
111     *
112     * `jetpackAnalytics.tracks.recordEvent()` only pushes onto `window._tkq`,
113     * which stays an inert array until `w.js` loads and drains it. Nothing
114     * supplies that on Atomic or self-hosted, so without this the queue grows
115     * for the life of the page. Simple is skipped because stats.php already
116     * prints the same script on `admin_footer`, and loading it twice would
117     * re-drain a queue that has already been flushed.
118     */
119    public static function enqueue_tracks_transport() {
120        if ( ( new Host() )->is_wpcom_simple() ) {
121            return;
122        }
123
124        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
125    }
126
127    /**
128     * Hooked at admin_menu priority 1 so polyfills register before
129     * `wp_default_scripts` fires and the wp-build render function is defined
130     * before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
131     * on Simple/Atomic).
132     */
133    public static function maybe_load_wp_build() {
134        if ( ! self::is_podcast_admin_request() ) {
135            return;
136        }
137
138        self::load_wp_build();
139        add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
140        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
141    }
142
143    /**
144     * Add the podcast gate boolean to `window.JetpackScriptData`.
145     *
146     * Hooked from `maybe_load_wp_build()` so it only runs when the request is
147     * for the podcast admin page.
148     *
149     * @param array $data Script data being injected.
150     * @return array
151     */
152    public static function inject_podcast_script_data( $data ) {
153        if ( ! is_array( $data ) ) {
154            $data = array();
155        }
156
157        $is_wpcom = ( new Host() )->is_wpcom_platform();
158
159        if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
160            $blog_id = (int) Connection_Manager::get_site_id( true );
161            if ( $blog_id > 0 ) {
162                $data['site']['wpcom']['blog_id'] = $blog_id;
163            }
164        }
165
166        // Self-hosted upsells the Growth plan; WordPress.com keeps Premium.
167        // `product_slug` is fed straight to the checkout URL; `plan_name` is a
168        // product name shown in the locked-preview copy (not translated).
169        $data['podcast'] = array(
170            'has_product_access'  => Podcast_Gate::has_product_access(),
171            'is_connected'        => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
172            'show_url_hosts'      => Settings::SHOW_URL_HOSTS,
173            'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
174            'feed_limit_max'      => Settings::feed_limit_max(),
175            'preload'             => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
176            'selected_category'   => self::get_selected_category(),
177            'tracks_user_data'    => self::get_tracks_user_data(),
178            'upgrade'             => array(
179                'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
180                'plan_name'    => $is_wpcom ? 'Premium' : 'Growth',
181            ),
182        );
183
184        return $data;
185    }
186
187    /**
188     * Connected-user identity for Tracks, so client events aren't anonymous on
189     * Atomic and self-hosted. Null on Simple, where stats.php already pushes
190     * `identifyUser` before our bundle runs.
191     *
192     * Deliberately narrower than `get_connected_user_tracks_identity()`, which
193     * also returns email, blogid and locale — none of which Tracks needs here.
194     *
195     * @return array{userid:mixed, username:mixed}|null
196     */
197    private static function get_tracks_user_data() {
198        if ( ! class_exists( 'Jetpack_Tracks_Client' ) ) {
199            return null;
200        }
201
202        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
203        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
204            return null;
205        }
206
207        return array(
208            'userid'   => $identity['userid'],
209            'username' => $identity['username'],
210        );
211    }
212
213    /**
214     * The currently designated podcast category, injected so the settings
215     * picker can label its selected option on first paint instead of waiting on
216     * the client-side taxonomy→terms fetch. The full list still loads lazily.
217     *
218     * @return array{id:int, name:string}|null Null when no category is set.
219     */
220    public static function get_selected_category() {
221        $category_id = (int) get_option( 'podcasting_category_id', 0 );
222        if ( $category_id <= 0 ) {
223            return null;
224        }
225
226        $term = get_term( $category_id, 'category' );
227        if ( ! $term instanceof \WP_Term ) {
228            return null;
229        }
230
231        return array(
232            'id'   => (int) $term->term_id,
233            'name' => $term->name,
234        );
235    }
236
237    /**
238     * The build artifact may be absent on a fresh checkout before
239     * `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
240     * to `render()` so the page still loads (just without the React app).
241     */
242    private static function load_wp_build() {
243        $build_index = dirname( __DIR__ ) . '/build/build.php';
244
245        if ( ! file_exists( $build_index ) ) {
246            return;
247        }
248
249        require_once $build_index;
250
251        WP_Build_Polyfills::register(
252            'jetpack-podcast',
253            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
254        );
255    }
256
257    /**
258     * Alias the current screen id to wp-build's expected slug.
259     *
260     * @param \WP_Screen|null $screen The current screen object (passed by WP).
261     */
262    public static function alias_screen_id_for_wp_build( $screen ) {
263        if ( ! is_object( $screen ) ) {
264            return;
265        }
266
267        $screen->id = self::WP_BUILD_SLUG;
268    }
269
270    /**
271     * Fallback render used when the wp-build artifact is missing.
272     */
273    public static function render() {
274        ?>
275        <div class="wrap">
276            <h1>Podcast</h1>
277        </div>
278        <?php
279    }
280
281    /**
282     * Whether the current request targets the Podcast admin page.
283     */
284    private static function is_podcast_admin_request() {
285        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
286        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
287            return false;
288        }
289
290        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
291        return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
292    }
293}