This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,130 @@
<?php
/**
* A class that adds a link to the WordPress.com Reader to the admin bar.
*
* @package automattic/jetpack-newsletter
*/
namespace Automattic\Jetpack\Newsletter;
use Automattic\Jetpack\Connection\Urls;
use Automattic\Jetpack\Modules;
use Automattic\Jetpack\Status\Host;
use WP_Admin_Bar;
/**
* Add a link to the WordPress.com Reader to the admin bar.
*/
class Reader_Link {
/**
* Whether the class has been initialized.
*
* @var bool
*/
private static $initialized = false;
/**
* Initialize the Reader Link functionality.
*
* This method sets up all necessary hooks for the Reader menu item
* and its associated styles. It can be called multiple times safely
* as it will only initialize once.
*
* @since 0.4.0
*
* @return void
*/
public static function init() {
if ( self::$initialized ) {
return;
}
if ( ! is_user_logged_in() || ! is_admin_bar_showing() ) {
return;
}
self::$initialized = true;
$instance = new self();
add_action( 'admin_bar_menu', array( $instance, 'add_reader_menu' ), 11 );
add_action( 'wp_enqueue_scripts', array( $instance, 'enqueue_stylesheet' ) );
add_action( 'admin_enqueue_scripts', array( $instance, 'enqueue_stylesheet' ) );
}
/**
* Enqueue the stylesheet used to display the Reader icon.
*
* @since 0.4.0
*
* @return void
*/
public function enqueue_stylesheet() {
$build_path = dirname( __DIR__ ) . '/build/reader-link.css';
if ( ! file_exists( $build_path ) ) {
return;
}
$asset_file = dirname( __DIR__ ) . '/build/reader-link.asset.php';
$version = file_exists( $asset_file )
? include $asset_file
: array( 'version' => filemtime( $build_path ) );
wp_enqueue_style(
'jetpack-newsletter-reader-link',
plugins_url( '../build/reader-link.css', __FILE__ ),
array(),
$version['version'] ?? filemtime( $build_path )
);
}
/**
* Add the Reader menu.
*
* Hook into 'admin_bar_menu' to add to the wp-admin bar.
*
* @since 0.4.0
*
* @param WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar core object.
*/
public function add_reader_menu( $wp_admin_bar ) {
$reader_menu_settings = array(
'id' => 'reader',
'title' => '<span class="ab-icon" title="' . __( 'Read the blogs and topics you follow', 'jetpack-newsletter' ) . '" aria-hidden="true"></span>' .
'<span class="ab-label">' . __( 'Reader', 'jetpack-newsletter' ) . '</span>',
'href' => method_exists( Urls::class, 'maybe_add_origin_site_id' )
? Urls::maybe_add_origin_site_id( 'https://wordpress.com/reader' )
: 'https://wordpress.com/reader',
'meta' => array(
'class' => 'wp-admin-bar-reader',
),
'parent' => 'top-secondary',
);
/*
* On self-hosted sites, open the Reader link in a new tab
* since they're not necessarily logged in to WordPress.com
* and may not want to navigate away from their site.
*/
if ( ! ( new Host() )->is_wpcom_platform() ) {
$reader_menu_settings['meta']['target'] = '_blank';
}
$wp_admin_bar->add_menu( $reader_menu_settings );
}
/**
* Activate the wpcom-reader module when a site is first connected to WordPress.com.
*
* Only activates on truly fresh connections. If modules were previously initialized
* (e.g., the user disconnected and reconnected), we respect their prior module choices.
*
* @since 0.4.0
*/
public static function activate_on_connection() {
if ( \Jetpack_Options::get_option( 'active_modules_initialized' ) ) {
return;
}
( new Modules() )->activate( 'wpcom-reader', false, false );
}
}
@@ -0,0 +1,604 @@
<?php
/**
* A class that adds a newsletter settings screen to wp-admin.
*
* @package automattic/jetpack-newsletter
*/
namespace Automattic\Jetpack\Newsletter;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Modules;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\Status\Visitor;
use Jetpack_Tracks_Client;
/**
* A class responsible for adding a newsletter settings screen to wp-admin.
*/
class Settings {
const PACKAGE_VERSION = '0.11.1';
const ADMIN_PAGE_SLUG = 'jetpack-newsletter';
/**
* Filter name that gates the wp-buildbased dashboard.
*
* When this filter returns true, "Jetpack > Newsletter" renders the new
* wp-build dashboard instead of the legacy Newsletter Settings React app.
*/
const MODERNIZATION_FILTER = 'rsm_jetpack_ui_modernization_newsletter';
/**
* Percentage of sites the modernized Newsletter experience defaults on for
* during the staged rollout.
*
* Currently 0: the Simple-site rollout is driven from the WordPress.com backend
* instead (a server-side feature-flag value that can be rolled back instantly),
* so the Jetpack-side cohort is held at zero. The cohort code stays in place for
* Atomic (WoA) and self-hosted Jetpack sites; bumping this single number is the
* one-line change that widens the rollout once the Simple cohort is validated.
* Automatticians get the experience regardless of this percentage.
*
* Sites are bucketed deterministically by their wpcom blog ID, so every gate
* that reads this lands on the same answer for a given site.
*/
const MODERNIZATION_ROLLOUT_PERCENTAGE = 0;
/**
* Whether the class has been initialized
*
* @var boolean
*/
private static $initialized = false;
/**
* Init Newsletter Settings if it wasn't already.
*/
public static function init() {
if ( ! self::$initialized ) {
self::$initialized = true;
( new self() )->init_hooks();
}
}
/**
* Check if the subscriptions module is active.
*
* @return bool
*/
private function is_subscriptions_active() {
return ( new Modules() )->is_active( 'subscriptions' );
}
/**
* Determine whether to show the Newsletter menu item.
* When true, shown regardless of subscriptions module state.
*
* @return bool
*/
private function should_show_menu_item() {
/**
* Filter to control Newsletter menu item visibility.
* Defaults to true.
*
* @since 0.6.0
* @param bool $show Whether to show the menu item.
*/
return apply_filters(
'jetpack_show_newsletter_menu_item',
true
);
}
/**
* Subscribe to necessary hooks.
*/
public function init_hooks() {
// Transitional Subscribers announcement page (active only while the
// modernization filter is on): registers its AJAX/admin-post handlers
// and wp-build loading here so they exist on admin-ajax.php and
// admin-post.php requests. The menu itself is added by the Jetpack
// plugin's subscriptions module, which owns the Subscribers placement.
Subscribers_Announcement::init();
// Add the Reading settings notice as long as subscriptions are active.
if ( $this->is_subscriptions_active() ) {
add_action( 'admin_init', array( $this, 'add_reading_page_notice' ) );
}
// Hijack the config URLs to point to our settings page.
// Priority 20 to override the default URL set in subscriptions.php.
add_filter(
'jetpack_module_configuration_url_subscriptions',
function () {
return Urls::get_newsletter_settings_url();
},
20
);
// Defer wp-build loading to admin_menu (priority 1) on every host. The
// modernization filter — which third parties typically register from a
// plugins_loaded callback — needs to have been applied before we read it,
// and the wp-build render function needs to be defined before any menu
// callback runs (priority 999 on standalone Jetpack, priority 999999 on
// wpcom Simple via wpcom-admin-menu.php's call to add_wp_admin_submenu).
// Settings::init() runs synchronously from load-jetpack.php at
// plugin-file-include time — before any plugins_loaded callback fires —
// so an inline check here would always see the unfiltered default.
add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
$host = new Host();
// On wpcom Simple, the Jetpack menu is created at priority 999999 by wpcom-admin-menu.php,
// which will call add_wp_admin_submenu() directly. Skip adding the menu here to avoid
// trying to add a submenu before the parent menu exists.
if ( $host->is_wpcom_simple() ) {
return;
}
// Add admin menu item.
// Use priority 999 to ensure menu items are queued BEFORE Admin_Menu::admin_menu_hook_callback
// runs at priority 1000 to process all queued items.
add_action( 'admin_menu', array( $this, 'add_wp_admin_menu' ), 999 );
}
/**
* Load wp-build for the Newsletter admin page when modernization is enabled.
*
* Hooked to `admin_menu` priority 1 so the modernization filter has been
* registered by any opt-in code (mu-plugins, snippets, themes) before we
* read it, and so the wp-build render function and enqueue hook are in
* place before `add_wp_admin_menu` runs at priority 999.
*
* @return void
*/
public static function maybe_load_wp_build() {
if ( ! self::is_modernized() || ! self::is_newsletter_admin_request() ) {
return;
}
self::load_wp_build();
add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
}
/**
* Add the newsletter settings submenu to the Jetpack menu.
*
* Note: This method is NOT called on wpcom Simple sites. Simple sites use
* add_wp_admin_submenu() called from wpcom-admin-menu.php instead.
*/
public function add_wp_admin_menu() {
// On sites using Jetpack, only show the menu if the site is connected.
if ( ! ( new Connection_Manager() )->is_connected() ) {
return;
}
// On the modernized dashboard, the Newsletter screen is only useful when the
// subscriptions module is active, so skip registering the menu entirely when it
// is off. Gated on the modernization flag to leave legacy behavior unchanged.
if ( self::is_modernized() && ! $this->is_subscriptions_active() ) {
return;
}
$host = new Host();
// should_show_menu_item() controls visibility of the menu item.
$show_menu = $this->should_show_menu_item();
$parent_slug = $show_menu ? 'jetpack' : '';
// On Atomic, use add_submenu_page. On standalone Jetpack, use Admin_Menu when showing in menu.
$use_jetpack_menu = ! $host->is_woa_site() && $show_menu;
$callback = self::is_modernized() && function_exists( 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page' )
? 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page'
: array( $this, 'render' );
// Register menu item.
if ( $use_jetpack_menu ) {
$page_suffix = Admin_Menu::add_menu(
/** "Newsletter" is a product name, do not translate. */
'Newsletter',
'Newsletter',
'manage_options',
'jetpack-newsletter',
$callback,
10
);
} else {
$page_suffix = add_submenu_page(
$parent_slug,
/** "Newsletter" is a product name, do not translate. */
'Newsletter',
'Newsletter',
'manage_options',
'jetpack-newsletter',
$callback
);
}
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
}
}
/**
* Add the newsletter settings submenu directly under the Jetpack menu.
*
* This method is called from wpcom-admin-menu.php on Simple sites at late priority
* (999999) when the Jetpack menu already exists.
*/
public function add_wp_admin_submenu() {
// On the modernized dashboard, the Newsletter screen is only useful when the
// subscriptions module is active, so skip registering the menu entirely when it
// is off. Gated on the modernization flag to leave legacy behavior unchanged.
if ( self::is_modernized() && ! $this->is_subscriptions_active() ) {
return;
}
$parent_slug = $this->should_show_menu_item() ? 'jetpack' : '';
$callback = self::is_modernized() && function_exists( 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page' )
? 'jetpack_newsletter_jetpack_newsletter_dashboard_wp_admin_render_page'
: array( $this, 'render' );
$page_suffix = add_submenu_page(
$parent_slug,
/** "Newsletter" is a product name, do not translate. */
'Newsletter',
'Newsletter',
'manage_options',
'jetpack-newsletter',
$callback
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
}
}
/**
* Admin init actions.
*/
public function admin_init() {
add_filter( 'jetpack_admin_js_script_data', array( $this, 'add_script_data' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
}
/**
* Add newsletter-specific data to the global JetpackScriptData object.
*
* @param array $data The existing script data.
* @return array The modified script data.
*/
public function add_script_data( $data ) {
$current_user = wp_get_current_user();
$theme = wp_get_theme();
$host = new Host();
$status = new Status();
$site_suffix = $status->get_site_suffix();
$blog_id = (int) $host->get_wpcom_site_id();
$is_wpcom = $host->is_wpcom_platform();
$is_block_theme = wp_is_block_theme();
$setup_payment_plan_url = ( $is_wpcom ? 'https://wordpress.com/earn/payments/' : 'https://cloud.jetpack.com/monetize/payments/' ) . $site_suffix;
$wp_admin_subscriber_management_enabled = apply_filters( 'jetpack_wp_admin_subscriber_management_enabled', self::is_modernization_rollout_enabled() );
// Populate blog_id which is needed for API calls on Simple sites.
$data['site']['wpcom']['blog_id'] = $blog_id;
// Add newsletter-specific data.
// Note: Common data like admin_url, rest_nonce, rest_root, title, is_wpcom_platform,
// and user.current_user.display_name are already provided by Script_Data.
$data['newsletter'] = array(
'isBlockTheme' => $is_block_theme,
'themeStylesheet' => $theme->get_stylesheet(),
'email' => $current_user->user_email,
'gravatar' => get_avatar_url( $current_user->ID ),
'dateExample' => gmdate( get_option( 'date_format' ), time() ),
'subscriberManagementUrl' => $this->get_subscriber_management_url( $wp_admin_subscriber_management_enabled, $is_wpcom, $site_suffix, $blog_id ),
'subscriberManagementEnabled' => (bool) $wp_admin_subscriber_management_enabled,
'isSubscriptionSiteEditSupported' => $is_block_theme,
'setupPaymentPlansUrl' => $setup_payment_plan_url,
'isSitePublic' => ! $status->is_private_site() && ! $status->is_coming_soon(),
'tracksUserData' => Jetpack_Tracks_Client::get_connected_user_tracks_identity(),
);
return $data;
}
/**
* Load the admin scripts.
*/
public function load_admin_scripts() {
// This callback is registered via `admin_enqueue_scripts` from `admin_init`,
// which itself fires on `load-{$page_suffix}` in `add_wp_admin_menu()` — so it
// only fires on the Newsletter admin page; no need to re-check the page here.
// The Tracks transport is required on both surfaces — `analytics.initialize`
// only queues events into `window._tkq`; without `jp-tracks` loaded, no
// pixel.gif requests fire and the queue grows forever.
wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
if ( self::is_modernized() ) {
// wp-build manages the rest of its enqueue pipeline. The legacy
// newsletter script and JetpackScriptData are intentionally skipped
// for the wp-build dashboard.
return;
}
Assets::register_script(
'jetpack-newsletter',
'../build/newsletter.js',
__FILE__,
array(
'in_footer' => true,
'textdomain' => 'jetpack-newsletter',
'enqueue' => true,
'dependencies' => array( 'jetpack-script-data' ),
)
);
}
/**
* Get the subscriber management URL based on site type and filter settings.
*
* - If jetpack_wp_admin_subscriber_management_enabled filter is true: wp-admin subscribers page
* - If filter is false AND wpcom site: wordpress.com/subscribers/$domain
* - If filter is false AND Jetpack site: jetpack.com redirect URL
*
* @param bool $wp_admin_enabled Whether wp-admin subscriber management is enabled.
* @param bool $is_wpcom Whether this is a WordPress.com site.
* @param string $site_suffix The Calypso site suffix (home host, slashes as `::`).
* @param int $blog_id The blog ID.
* @return string The subscriber management URL.
*/
private function get_subscriber_management_url( $wp_admin_enabled, $is_wpcom, $site_suffix, $blog_id ) {
// If wp-admin subscriber management is enabled, use the wp-admin page.
if ( $wp_admin_enabled ) {
return admin_url( 'admin.php?page=subscribers' );
}
// For wpcom sites, use the wordpress.com URL.
if ( $is_wpcom ) {
return 'https://wordpress.com/subscribers/' . $site_suffix;
}
// For Jetpack sites, use the jetpack.com redirect URL.
$site_id = $blog_id ? (int) $blog_id : Connection_Manager::get_site_id( true );
$args = ( ! empty( $site_id ) )
? array( 'site' => $site_id )
: array();
return Redirect::get_url(
'jetpack-settings-jetpack-manage-subscribers',
$args
);
}
/**
* Render the newsletter settings page.
*/
public function render() {
?>
<div id="newsletter-settings-root"></div>
<?php
}
/**
* Register a notice on the Reading settings page to clarify that the RSS
* excerpt setting does not control newsletter emails.
*
* @since 0.5.1
*/
public function add_reading_page_notice() {
add_settings_field(
'jetpack_newsletter_reading_notice',
'',
array( $this, 'render_reading_page_notice' ),
'reading',
'default'
);
}
/**
* Render the clarifying notice on the Reading settings page.
*
* Uses JavaScript to relocate the notice next to the "For each post in a feed"
* (rss_use_excerpt) setting.
*
* @since 0.5.1
*/
public function render_reading_page_notice() {
$newsletter_url = Urls::get_newsletter_settings_url();
printf(
'<p class="description" id="jetpack-newsletter-reading-notice">%s</p>',
sprintf(
wp_kses(
/* translators: %s is a link to the Newsletter settings page. */
__( 'To control whats included in newsletter emails, visit your <a href="%s">Newsletter settings</a>.', 'jetpack-newsletter' ),
array(
'a' => array(
'href' => array(),
),
)
),
esc_url( $newsletter_url )
)
);
?>
<script type="text/javascript">
document.addEventListener( 'DOMContentLoaded', function() {
var notice = document.getElementById( 'jetpack-newsletter-reading-notice' );
var excerptInput = document.querySelector( 'input[name="rss_use_excerpt"]' );
var excerptRow = excerptInput ? excerptInput.closest( 'tr' ) : null;
if ( ! notice || ! excerptRow ) {
return;
}
// Remember the original parent before moving the notice.
var originalTable = notice.closest( 'table' );
var excerptTable = excerptRow.closest( 'table' );
// Move the notice into the rss_use_excerpt row's fieldset.
excerptRow.querySelector( 'td' ).appendChild( notice );
// Remove the now-empty original table (if it's different from the excerpt's table).
if ( originalTable && originalTable !== excerptTable ) {
originalTable.remove();
}
} );
</script>
<?php
}
/**
* Load the wp-build entry file and register its polyfills.
*
* Only called on `?page=jetpack-newsletter` admin requests when the
* modernization filter is enabled. Keeps wp-build off every other request.
*
* @return void
*/
private static function load_wp_build() {
$build_index = dirname( __DIR__ ) . '/build/build.php';
if ( ! file_exists( $build_index ) ) {
return;
}
require_once $build_index;
\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(
'jetpack-newsletter',
array_merge(
\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES,
\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS
)
);
}
/**
* Alias the current screen ID to satisfy wp-build's auto-generated enqueue check.
*
* Wp-build's `<page>-wp-admin` enqueue callback enqueues only when the screen ID
* matches the wp-build page slug (`jetpack-newsletter-dashboard`). Our wp-admin
* menu slug stays `jetpack-newsletter`, so we mutate the screen object in place
* to make the check pass without changing the user-facing URL.
*
* Hooked only when modernization is on AND we're on the Newsletter admin page,
* so this never affects any other request.
*
* @param \WP_Screen|null $screen The current screen object (passed by WP).
* @return void
*/
public static function alias_screen_id_for_wp_build( $screen ) {
if ( ! is_object( $screen ) ) {
return;
}
$screen->id = 'jetpack-newsletter-dashboard';
}
/**
* Whether the modernized Newsletter experience should default on for this site.
*
* The release is staged: the modernized dashboard, wp-admin subscriber
* management, and the retired Calypso Subscribers submenu all default on for a
* deterministic slice of sites, keyed on the wpcom blog ID, plus all
* Automatticians.
*
* The percentage cohort (see `MODERNIZATION_ROLLOUT_PERCENTAGE`) spans *all*
* sites — Simple, WoA (Atomic) and self-hosted Jetpack — and is bucketed on the
* wpcom blog ID (`get_current_blog_id()` on Simple, `jetpack_options['id']`
* elsewhere), which is preserved when a Simple site is upgraded to Atomic. Keying
* on the wpcom blog ID rather than the transient `IS_WPCOM` constant means a site
* keeps its cohort decision across the transfer. The percentage is currently 0:
* the Simple-site rollout is driven from the WordPress.com backend instead, and
* this gate stays at zero until the wider rollout is opened by bumping the
* constant. A site with no resolvable wpcom blog ID (e.g. a self-hosted Jetpack
* site that isn't connected) is never bucketed in.
*
* Automatticians get the modernized experience by default regardless of the
* percentage cohort, so a12s can dogfood it and test fixes ahead of the wider
* rollout. This is a dogfooding gate, not an authorization check, so the Simple
* `is_automattician()` global is used without the usual proxied-request pairing;
* Atomic has no non-proxied a12s signal, so it falls back to
* `Visitor::is_automattician_feature_flags_only()` (true for proxied a8c requests).
*
* This is only the filter *default*: hosts (and a11ns who want the legacy view
* back) can still force the experience on or off with the
* `rsm_jetpack_ui_modernization_newsletter` /
* `jetpack_wp_admin_subscriber_management_enabled` filters.
*
* @return bool
*/
public static function is_modernization_rollout_enabled() {
// Automatticians are enrolled regardless of the percentage cohort so they
// can dogfood ahead of the wider rollout. Simple exposes the
// `is_automattician()` global; Atomic has no non-proxied a12s signal, so we
// fall back to the proxied-request check. (Dogfooding gate, so no
// `wpcom_is_proxied_request()` pairing on the Simple branch.)
if (
( function_exists( 'is_automattician' ) && is_automattician() )
|| ( new Visitor() )->is_automattician_feature_flags_only()
) {
return true;
}
// Bucket on the wpcom blog ID, which is stable across a Simple→Atomic
// transfer: the current blog ID on Simple, the stored wpcom ID elsewhere. We
// read the WoA/Jetpack ID from Jetpack options directly rather than via
// `Host::get_wpcom_site_id()`, which additionally requires the Jetpack
// connection to be "ready" — that would drop a freshly transferred site out
// of the cohort until its connection settles. Guard against an unresolvable
// ID so a site without one isn't bucketed as blog ID 0 and enrolled by
// accident once the percentage is non-zero.
$host = new Host();
$blog_id = $host->is_wpcom_simple()
? (int) get_current_blog_id()
: (int) \Jetpack_Options::get_option( 'id' );
if ( $blog_id <= 0 ) {
return false;
}
return ( $blog_id % 100 ) < self::MODERNIZATION_ROLLOUT_PERCENTAGE;
}
/**
* Returns true when the wp-build modernization filter is enabled.
*
* Defaults to the staged-rollout cohort (see
* `is_modernization_rollout_enabled()`): on for Automatticians and for the
* percentage cohort (currently 0%), off everywhere else. Hosts can opt in or out
* explicitly with
* `add_filter( self::MODERNIZATION_FILTER, '__return_true' / '__return_false' );`.
*
* @return bool
*/
private static function is_modernized() {
return (bool) apply_filters( self::MODERNIZATION_FILTER, self::is_modernization_rollout_enabled() );
}
/**
* Returns true when the current request targets the Newsletter admin page.
*
* Used to scope wp-build loading to the one page that needs it. The
* `$_GET['page']` value is populated by wp-admin/admin.php before any of
* our hooks fire, so this check is reliable from `init_hooks()` onwards.
*
* @return bool
*/
private static function is_newsletter_admin_request() {
if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return false;
}
return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::ADMIN_PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
}
@@ -0,0 +1,355 @@
<?php
/**
* Transitional "Subscribers moved" announcement page.
*
* When the Newsletter modernization filter is enabled, the unified
* Jetpack → Newsletter page owns subscriber management and the legacy
* "Subscribers ↗" Calypso shortcut is retired. Instead of silently dropping
* the menu item, this page takes its place so people who rely on the link
* learn the new location before it disappears. They can also remove the
* menu item themselves once they have adopted the new flow.
*
* The whole feature is temporary and kept deliberately small: this class
* (menu, handlers, tracking) plus the `routes/subscribers-announcement`
* wp-build route can be deleted wholesale once the transition period ends.
*
* @package automattic/jetpack-newsletter
*/
namespace Automattic\Jetpack\Newsletter;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Tracking;
/**
* Renders the transitional Subscribers announcement page and handles its
* "remove from sidebar" toggle and "Take me to Newsletter" redirect.
*
* The menu itself is registered by callers that own the Subscribers menu
* placement (the Jetpack plugin's subscriptions module) via add_menu();
* this class self-registers only request handlers and wp-build loading.
*
* @since 0.10.0
*/
class Subscribers_Announcement {
/**
* Admin page slug (kept distinct from the wp-build page name; the screen
* ID is aliased so wp-build's enqueue check still matches).
*
* @var string
*/
const PAGE_SLUG = 'jetpack-subscribers';
/**
* Wp-build page name, matching `routes/subscribers-announcement/package.json`.
*
* @var string
*/
const WP_BUILD_PAGE = 'jetpack-subscribers-announcement';
/**
* Option storing whether the user removed the Subscribers menu item.
*
* @var string
*/
const REMOVED_OPTION = 'jetpack_subscribers_announcement_menu_removed';
/**
* AJAX action toggling the menu item visibility.
*
* @var string
*/
const TOGGLE_ACTION = 'jetpack_subscribers_announcement_toggle_menu';
/**
* Admin-post action tracking the "Take me to Newsletter" click before redirecting.
*
* @var string
*/
const GO_ACTION = 'jetpack_subscribers_announcement_go_to_newsletter';
/**
* Register request handlers and the wp-build loader.
*
* Called from Settings::init_hooks() so the AJAX/admin-post handlers exist
* on admin-ajax.php / admin-post.php requests, where `admin_menu` (and so
* add_menu()) never fires.
*
* @return void
*/
public static function init() {
add_action( 'wp_ajax_' . self::TOGGLE_ACTION, array( __CLASS__, 'handle_toggle_menu' ) );
add_action( 'admin_post_' . self::GO_ACTION, array( __CLASS__, 'handle_go_to_newsletter' ) );
// Priority 1 mirrors Settings::maybe_load_wp_build(): the modernization
// filter has been registered by opt-in code by then, and the wp-build
// render function must exist before menu callbacks are resolved.
add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
}
/**
* Whether the announcement page feature is active.
*
* @return bool
*/
public static function is_enabled() {
/** This filter is documented in projects/packages/newsletter/src/class-settings.php */
return (bool) apply_filters( Settings::MODERNIZATION_FILTER, false );
}
/**
* Load wp-build for the announcement page when the feature is enabled.
*
* @return void
*/
public static function maybe_load_wp_build() {
if ( ! self::is_enabled() || ! self::is_announcement_request() ) {
return;
}
$build_index = dirname( __DIR__ ) . '/build/build.php';
if ( ! file_exists( $build_index ) ) {
return;
}
require_once $build_index;
\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::register(
'jetpack-newsletter',
array_merge(
\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::SCRIPT_HANDLES,
\Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::MODULE_IDS
)
);
add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
}
/**
* Alias the current screen ID to satisfy wp-build's auto-generated enqueue check.
*
* Mirrors Settings::alias_screen_id_for_wp_build(): wp-build enqueues only
* when the screen ID matches the wp-build page name, while our menu slug
* stays `jetpack-subscribers`.
*
* @param \WP_Screen|null $screen The current screen object (passed by WP).
* @return void
*/
public static function alias_screen_id_for_wp_build( $screen ) {
if ( ! is_object( $screen ) ) {
return;
}
$screen->id = self::WP_BUILD_PAGE;
}
/**
* Register the Subscribers announcement page under the Jetpack menu.
*
* When the user opted to remove the menu item, the page stays registered
* (so the page remains reachable directly and the choice can be undone)
* but the sidebar entry is removed.
*
* @return void
*/
public static function add_menu() {
$callback = function_exists( 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' )
? 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page'
: array( __CLASS__, 'render_fallback' );
if ( get_option( self::REMOVED_OPTION ) ) {
// Register as a hidden page (empty parent slug): it stays reachable
// at its URL — so the choice can be undone from the page itself —
// but never appears in the sidebar.
$page_suffix = add_submenu_page(
'',
__( 'Subscribers', 'jetpack-newsletter' ),
__( 'Subscribers', 'jetpack-newsletter' ),
'manage_options',
self::PAGE_SLUG,
$callback
);
} else {
$page_suffix = Admin_Menu::add_menu(
__( 'Subscribers', 'jetpack-newsletter' ),
__( 'Subscribers', 'jetpack-newsletter' ),
'manage_options',
self::PAGE_SLUG,
$callback,
15
);
}
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_page_load' ) );
}
}
/**
* Register the announcement page directly under the Jetpack menu.
*
* Used on WordPress.com (Simple and WoA), where jetpack-mu-wpcom's
* wpcom-admin-menu owns the Jetpack menu and registers submenus with the
* core add_submenu_page() at a late priority — not the standalone plugin's
* Admin_Menu wrapper. Mirrors Settings::add_wp_admin_submenu().
*
* As in add_menu(), an empty parent slug keeps the page reachable at its URL
* (so the "remove from sidebar" choice can be undone) while hiding it from
* the sidebar.
*
* @return void
*/
public static function add_wp_admin_submenu() {
$callback = function_exists( 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page' )
? 'jetpack_newsletter_jetpack_subscribers_announcement_wp_admin_render_page'
: array( __CLASS__, 'render_fallback' );
$parent_slug = get_option( self::REMOVED_OPTION ) ? '' : 'jetpack';
$page_suffix = add_submenu_page(
$parent_slug,
__( 'Subscribers', 'jetpack-newsletter' ),
__( 'Subscribers', 'jetpack-newsletter' ),
'manage_options',
self::PAGE_SLUG,
$callback
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_page_load' ) );
}
}
/**
* Page-load actions: record the page view and expose the app data.
*
* @return void
*/
public static function on_page_load() {
add_action( 'admin_head', array( __CLASS__, 'print_app_data' ) );
self::tracking()->record_user_event(
'subscribers_announcement_page_view',
array( 'menu_removed' => (bool) get_option( self::REMOVED_OPTION ) )
);
}
/**
* Print the data the announcement app needs (URLs, nonce, current state).
*
* @return void
*/
public static function print_app_data() {
$data = array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'toggleAction' => self::TOGGLE_ACTION,
'toggleNonce' => wp_create_nonce( self::TOGGLE_ACTION ),
// Built with add_query_arg (not wp_nonce_url, which HTML-escapes
// the ampersands) because the app navigates to it via JS.
'goToNewsletterUrl' => add_query_arg(
array(
'action' => self::GO_ACTION,
'_wpnonce' => wp_create_nonce( self::GO_ACTION ),
),
admin_url( 'admin-post.php' )
),
'menuRemoved' => (bool) get_option( self::REMOVED_OPTION ),
'menuSlug' => self::PAGE_SLUG,
);
printf(
'<script>window.JetpackSubscribersAnnouncementData = %s;</script>',
wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT )
);
}
/**
* Minimal fallback when the wp-build bundle is unavailable.
*
* @return void
*/
public static function render_fallback() {
?>
<div class="wrap">
<h1><?php esc_html_e( 'Subscribers moved', 'jetpack-newsletter' ); ?></h1>
<p><?php esc_html_e( 'Now its part of Jetpack → Newsletter.', 'jetpack-newsletter' ); ?></p>
<p>
<a class="button button-primary" href="<?php echo esc_url( Urls::get_newsletter_settings_url() ); ?>">
<?php esc_html_e( 'Take me to Newsletter', 'jetpack-newsletter' ); ?>
</a>
</p>
</div>
<?php
}
/**
* AJAX handler persisting the "remove Subscribers from the sidebar" choice.
*
* @return void
*/
public static function handle_toggle_menu() {
check_ajax_referer( self::TOGGLE_ACTION );
if ( ! current_user_can( 'manage_options' ) || ! self::is_enabled() ) {
wp_send_json_error( 'unauthorized', 403, JSON_HEX_TAG | JSON_HEX_AMP );
}
$removed = isset( $_POST['removed'] ) && '1' === $_POST['removed'];
update_option( self::REMOVED_OPTION, $removed ? 1 : 0, false );
self::tracking()->record_user_event(
'subscribers_announcement_remove_menu_click',
array( 'removed' => $removed )
);
wp_send_json_success( array( 'removed' => $removed ), 200, JSON_HEX_TAG | JSON_HEX_AMP );
}
/**
* Admin-post handler recording the "Take me to Newsletter" click, then redirecting.
*
* Tracking the click server-side before the redirect avoids relying on a
* JS tracking pipeline on a page that is otherwise static.
*
* @return never
*/
public static function handle_go_to_newsletter() {
check_admin_referer( self::GO_ACTION );
if ( current_user_can( 'manage_options' ) && self::is_enabled() ) {
self::tracking()->record_user_event( 'subscribers_announcement_newsletter_click' );
}
wp_safe_redirect( admin_url( 'admin.php?page=' . Settings::ADMIN_PAGE_SLUG ) );
exit( 0 );
}
/**
* Get a Tracking instance.
*
* The product name stays `jetpack` so the events are recorded as
* `jetpack_subscribers_announcement_*` regardless of which plugin
* bundles this package.
*
* @return Tracking
*/
private static function tracking() {
return new Tracking( 'jetpack', new Connection_Manager( 'jetpack' ) );
}
/**
* Returns true when the current request targets the announcement page.
*
* @return bool
*/
private static function is_announcement_request() {
if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return false;
}
return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
}
@@ -0,0 +1,23 @@
<?php
/**
* URL helper for newsletter settings.
*
* @package automattic/jetpack-newsletter
*/
namespace Automattic\Jetpack\Newsletter;
/**
* A class responsible for generating newsletter settings URLs.
*/
class Urls {
/**
* Get the newsletter settings URL.
*
* @return string The newsletter settings URL.
*/
public static function get_newsletter_settings_url() {
return admin_url( 'admin.php?page=jetpack-newsletter' );
}
}
@@ -0,0 +1,201 @@
<?php
/**
* A class that adds the Daily Writing Prompt dashboard widget to wp-admin.
*
* @package automattic/jetpack-newsletter
*/
namespace Automattic\Jetpack\Newsletter;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\Tracking;
use Jetpack_Tracks_Client;
/**
* Register and render the Daily Writing Prompt dashboard widget.
*/
class Writing_Prompt_Widget {
/**
* Whether the class has been initialized.
*
* @var bool
*/
private static $initialized = false;
/**
* Initialize the Daily Writing Prompt widget.
*
* Hooks the widget registration into `wp_dashboard_setup`. It can be called
* multiple times safely as it will only initialize once.
*
* @since 0.9.0
*
* @return void
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'wp_dashboard_setup', array( __CLASS__, 'register_widget' ) );
}
/**
* Register the Daily Writing Prompt dashboard widget.
*
* @since 0.9.0
*
* @return void
*/
public static function register_widget() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
if ( ! self::should_load() ) {
return;
}
wp_add_dashboard_widget(
'wpcom_daily_writing_prompt',
__( 'Daily Writing Prompt', 'jetpack-newsletter' ),
array( __CLASS__, 'render_widget' ),
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. See https://core.trac.wordpress.org/ticket/52539.
array(),
'side',
'high'
);
// Enqueue here (on wp_dashboard_setup, which only fires on the dashboard
// screen and runs before the header is printed) rather than in the render
// callback, so the stylesheet lands in the head like it did previously.
self::enqueue_assets();
}
/**
* Whether the Daily Writing Prompt widget should load.
*
* The widget fetches prompts from WordPress.com through the Jetpack proxy, so
* it can only load where the site is able to reach WordPress.com: on Simple
* sites, or on online, connected Jetpack sites (including Atomic).
*
* This runs on `wp_dashboard_setup` rather than at plugin-load time so that
* the connection check happens after Core's pluggable functions are loaded.
* Checking the connection any earlier triggers a fatal error on Atomic.
*
* @since 0.9.1
*
* @return bool
*/
private static function should_load() {
// Simple sites can always reach WordPress.com, with no Jetpack connection involved.
if ( ( new Host() )->is_wpcom_simple() ) {
return true;
}
// Everywhere else the widget needs an online, connected site to fetch prompts.
return ( new Connection_Manager() )->is_connected() && ! ( new Status() )->is_offline_mode();
}
/**
* Enqueue the assets used to display the Daily Writing Prompt widget.
*
* @since 0.9.0
*
* @return string The script/style handle.
*/
public static function enqueue_assets() {
$handle = 'jetpack-newsletter-writing-prompt';
$js_path = dirname( __DIR__ ) . '/build/writing-prompt.js';
$asset_php = dirname( __DIR__ ) . '/build/writing-prompt.asset.php';
if ( ! file_exists( $js_path ) ) {
return $handle;
}
$asset_file = file_exists( $asset_php ) ? include $asset_php : array();
$dependencies = $asset_file['dependencies'] ?? array();
$version = $asset_file['version'] ?? filemtime( $js_path );
wp_enqueue_script(
$handle,
plugins_url( '../build/writing-prompt.js', __FILE__ ),
$dependencies,
$version,
true
);
wp_set_script_translations( $handle, 'jetpack-newsletter' );
// Load the Tracks transport so the widget's analytics events can fire.
Tracking::register_tracks_functions_scripts( true );
// Expose the connected user's Tracks identity to the widget script.
add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
$css_ext = is_rtl() ? 'rtl.css' : 'css';
$css_path = dirname( __DIR__ ) . "/build/writing-prompt.$css_ext";
if ( file_exists( $css_path ) ) {
wp_enqueue_style(
$handle,
plugins_url( "../build/writing-prompt.$css_ext", __FILE__ ),
array(),
$version
);
}
return $handle;
}
/**
* Add the connected user's Tracks identity to the admin script data.
*
* Mirrors the `newsletter.tracksUserData` shape provided by the Newsletter
* settings page so the widget can reuse the same `getNewsletterScriptData()`
* helper and `analytics.initialize()` flow.
*
* @since 0.9.1
*
* @param array $data The script data.
* @return array The filtered script data.
*/
public static function add_script_data( $data ) {
if ( ! isset( $data['newsletter'] ) || ! is_array( $data['newsletter'] ) ) {
$data['newsletter'] = array();
}
$data['newsletter']['tracksUserData'] = Jetpack_Tracks_Client::get_connected_user_tracks_identity();
return $data;
}
/**
* Render the container of the Daily Writing Prompt widget.
*
* The widget is hydrated client-side by the `writing-prompt` JS entry, which
* mounts the React app into the container rendered here.
*
* @since 0.9.0
*
* @return void
*/
public static function render_widget() {
$warning = __( 'Your Daily Writing Prompt widget requires JavaScript to function properly.', 'jetpack-newsletter' );
?>
<div>
<div class="hide-if-js">
<?php echo esc_html( $warning ); ?>
</div>
<div
id="wpcom_daily_writing_prompt_main"
class="wpcom_daily_writing_prompt hide-if-no-js"
style="height: 100%">
</div>
</div>
<?php
}
}