Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.04% covered (warning)
51.04%
49 / 96
8.33% covered (danger)
8.33%
1 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
51.04% covered (warning)
51.04%
49 / 96
8.33% covered (danger)
8.33%
1 / 12
188.08
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%
21 / 21
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 sits in the Jetpack submenu on self-hosted.
24     *
25     * Placed after content/product items like Newsletter and Search (10), and
26     * above Activity Log (12) so Activity Log stays immediately before Settings (13).
27     */
28    const MENU_POSITION = 11;
29
30    /**
31     * Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
32     * callback only fires when `$screen->id` matches this value, so we alias
33     * the screen id via `current_screen` without changing the user-facing URL.
34     */
35    const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
36
37    /**
38     * Whether `init()` has already wired its hooks.
39     *
40     * @var bool
41     */
42    private static $initialized = false;
43
44    /**
45     * Wire admin hooks. Idempotent.
46     */
47    public static function init() {
48        if ( self::$initialized ) {
49            return;
50        }
51        self::$initialized = true;
52
53        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
54
55        // On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
56        // priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
57        // has no such file, so we register our own. Priority 999 queues the item
58        // before Admin_Menu's priority-1000 callback.
59        if ( ! ( new Host() )->is_wpcom_platform() ) {
60            add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
61        }
62    }
63
64    /**
65     * Register the Podcast submenu under the Jetpack menu.
66     */
67    public static function add_wp_admin_submenu() {
68        // Prefer the wp-build render function once it's defined (by
69        // maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
70        $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
71        $callback        = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
72
73        if ( ( new Host() )->is_wpcom_platform() ) {
74            $page_suffix = add_submenu_page(
75                'jetpack',
76                /** "Podcast" is a product name, do not translate. */
77                'Podcast',
78                'Podcast',
79                'manage_options',
80                self::ADMIN_PAGE_SLUG,
81                $callback
82            );
83        } else {
84            $page_suffix = Admin_Menu::add_menu(
85                /** "Podcast" is a product name, do not translate. */
86                'Podcast',
87                'Podcast',
88                'manage_options',
89                self::ADMIN_PAGE_SLUG,
90                $callback,
91                self::MENU_POSITION
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}