Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6425df4
RTC: Add branded editor notices and room-limit enforcement for real-t…
rcrdortiz Mar 19, 2026
e8e2454
Add changelog entry
rcrdortiz Mar 19, 2026
4e2a5cc
Fix i18n string literals broken by production minifier
rcrdortiz Mar 19, 2026
70903b5
Add PHP tests for RTC notices and limits
rcrdortiz Mar 19, 2026
5917b1c
Only show RTC notices when collaboration is enabled in Writing Settings
rcrdortiz Mar 19, 2026
5c91951
Match modal sizing to Figma specs: 409px max-width, 20px title
rcrdortiz Mar 19, 2026
ef46e52
Hide welcome notice when sync connection is lost
rcrdortiz Mar 19, 2026
ae97c82
Use typed interface instead of any for getSyncConnectionStatus
rcrdortiz Mar 19, 2026
d93d508
Check both RTC option names and fix apostrophes in copy
rcrdortiz Mar 19, 2026
765105c
Add edit_post permission checks and switch dismiss to user options fo…
rcrdortiz Mar 19, 2026
177745e
Use user options for per-site dismiss and move apiFetch to useEffect
rcrdortiz Mar 19, 2026
19ef7ba
Add tests for enqueue, route registration, and admin edit post permis…
rcrdortiz Mar 19, 2026
bd37990
Remove unused check_admin_permission method
rcrdortiz Mar 19, 2026
ed7fe0d
Default maxPeersPerRoom to 3 to align with Core
rcrdortiz Mar 19, 2026
121f5fd
Gate limit notices behind enableLimitNotices flag, off by default
rcrdortiz Mar 19, 2026
df116d1
Show upgrade CTA only to plan owner using cross-platform detection
rcrdortiz Mar 19, 2026
8058543
Fire join request from room-limit.ts so it works without the modal fi…
rcrdortiz Mar 19, 2026
d2b5301
Match description font-size to Figma (13px)
rcrdortiz Mar 19, 2026
f60d1e3
Always register room-limit filter, gate only branded modals behind en…
rcrdortiz Mar 19, 2026
7a42182
Delay welcome notice 6s to avoid flash before room-limit disconnect
rcrdortiz Mar 19, 2026
bba5fd1
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz Mar 19, 2026
d0e59e5
Register RTC notices REST routes at priority 20 to avoid interfering …
rcrdortiz Mar 19, 2026
62c523a
Gate REST route registration behind RTC feature check to avoid test i…
rcrdortiz Mar 20, 2026
312f4b3
Load RTC notices only on editor pages to avoid REST API test interfer…
rcrdortiz Mar 20, 2026
530a577
Fix test tear_down: reset current user, clean up options and transients
rcrdortiz Mar 20, 2026
189806d
Reset wp_rest_server in tear_down to prevent stale route leaks into L…
rcrdortiz Mar 20, 2026
7e2ed81
Add tests for wpcom_rtc_is_plan_owner and improve enqueue coverage
rcrdortiz Mar 20, 2026
e0cbbfd
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz Mar 20, 2026
25447c7
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz Mar 20, 2026
6851bda
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz Mar 20, 2026
7f6be1c
Merge remote-tracking branch 'origin/trunk' into dotcom-16395-add-one…
mmtr Mar 20, 2026
94cf331
Disable RTC notices in site editor
mmtr Mar 20, 2026
89800ef
Hide notices on P2 sites
mmtr Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

RTC: Add branded editor notices and room-limit enforcement for real-time collaboration.
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ public static function load_etk_features() {
require_once __DIR__ . '/features/tags-education/tags-education.php';
require_once __DIR__ . '/features/wpcom-block-description-links/wpcom-block-description-links.php';
require_once __DIR__ . '/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php';
require_once __DIR__ . '/features/gutenberg-rtc-notices/gutenberg-rtc-notices.php';
require_once __DIR__ . '/features/wpcom-blocks/a8c-posts-list/a8c-posts-list.php';
require_once __DIR__ . '/features/wpcom-blocks/event-countdown/event-countdown.php';
require_once __DIR__ . '/features/wpcom-blocks/timeline/timeline.php';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<?php
/**
* REST API controller for RTC notices.
*
* Handles:
* - Per-user dismissal of the RTC welcome notice via user meta
* (works on Simple, Atomic, and self-hosted sites).
* - Join requests: when a non-admin is blocked by the collaborator limit,
* their browser records a join request so the admin can be notified.
*
* @package automattic/jetpack-mu-wpcom
*/

/**
* Class WP_REST_RTC_Notices
*/
class WP_REST_RTC_Notices extends WP_REST_Controller {
Comment thread
rcrdortiz marked this conversation as resolved.

const OPTION_KEY = 'wpcom_rtc_welcome_notice_dismissed';
const JOIN_REQUEST_OPTION = 'rtc_pending_join_requests';

/**
* Register the routes.
*/
public function register_routes() {
register_rest_route(
'wpcom/v2',
'/rtc-notices/dismiss',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'dismiss_notice' ),
'permission_callback' => array( $this, 'check_permission' ),
)
);

register_rest_route(
'wpcom/v2',
'/rtc-notices/status',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_status' ),
'permission_callback' => array( $this, 'check_permission' ),
)
);

register_rest_route(
'wpcom/v2',
'/rtc-notices/join-request',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'record_join_request' ),
'permission_callback' => array( $this, 'check_edit_post_permission' ),
'args' => array(
'post_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
),
)
);

register_rest_route(
'wpcom/v2',
'/rtc-notices/join-requests',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_join_requests' ),
'permission_callback' => array( $this, 'check_admin_edit_post_permission' ),
'args' => array(
'post_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
),
)
);

register_rest_route(
'wpcom/v2',
'/rtc-notices/join-requests/clear',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'clear_join_requests' ),
'permission_callback' => array( $this, 'check_admin_edit_post_permission' ),
'args' => array(
'post_id' => array(
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
),
),
)
);
}

/**
* Check if the current user is logged in.
*
* @return bool
*/
public function check_permission() {
return is_user_logged_in();
}

/**
* Check if the current user can edit the post specified in the request.
*
* @param WP_REST_Request $request The request.
* @return bool|WP_Error
*/
public function check_edit_post_permission( $request ) {
$post_id = $request->get_param( 'post_id' );

if ( ! get_post( $post_id ) ) {
return new \WP_Error( 'rest_post_invalid_id', __( 'Invalid post ID.', 'jetpack-mu-wpcom' ), array( 'status' => 404 ) );
}

if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to edit this post.', 'jetpack-mu-wpcom' ), array( 'status' => 403 ) );
}

return true;
}

/**
* Check if the current user is an admin who can edit the post.
*
* @param WP_REST_Request $request The request.
* @return bool|WP_Error
*/
public function check_admin_edit_post_permission( $request ) {
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}

return $this->check_edit_post_permission( $request );
}

/**
* Dismiss the welcome notice.
*
* @return WP_REST_Response
*/
public function dismiss_notice() {
update_user_option( get_current_user_id(), self::OPTION_KEY, 'dismissed' );
return rest_ensure_response( array( 'success' => true ) );
}

/**
* Get the dismissed status.
*
* @return WP_REST_Response
*/
public function get_status() {
return rest_ensure_response( array( 'dismissed' => self::is_dismissed() ) );
}

/**
* Check if the welcome notice is dismissed.
* Uses user options (not user meta) so dismissal is per-site on multisite/Simple.
*
* @return bool
*/
public static function is_dismissed() {
return 'dismissed' === get_user_option( self::OPTION_KEY, get_current_user_id() );
}

/**
* Record a join request from a blocked user.
* Stored as a transient per post so it auto-expires.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function record_join_request( $request ) {
$post_id = $request->get_param( 'post_id' );
$user = wp_get_current_user();

$key = self::JOIN_REQUEST_OPTION . '_' . $post_id;
$requests = get_transient( $key );
if ( ! is_array( $requests ) ) {
$requests = array();
}

$requests[ $user->ID ] = array(
'userName' => $user->display_name,
'userId' => $user->ID,
'time' => time(),
);

// Expire after 2 minutes.
set_transient( $key, $requests, 2 * MINUTE_IN_SECONDS );

return rest_ensure_response( array( 'success' => true ) );
}

/**
* Get pending join requests for a post. Admin only.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function get_join_requests( $request ) {
$post_id = $request->get_param( 'post_id' );
$key = self::JOIN_REQUEST_OPTION . '_' . $post_id;
$requests = get_transient( $key );

if ( ! is_array( $requests ) ) {
$requests = array();
}

// Filter out requests older than 60 seconds.
$now = time();
$recent = array();
foreach ( $requests as $uid => $req ) {
if ( $now - $req['time'] < 60 ) {
$recent[ $uid ] = $req;
}
}

return rest_ensure_response( array( 'requests' => array_values( $recent ) ) );
}

/**
* Clear join requests for a post.
*
* @param WP_REST_Request $request The request.
* @return WP_REST_Response
*/
public function clear_join_requests( $request ) {
$post_id = $request->get_param( 'post_id' );
delete_transient( self::JOIN_REQUEST_OPTION . '_' . $post_id );
return rest_ensure_response( array( 'success' => true ) );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php // phpcs:ignore Squiz.Commenting.FileComment.Missing
/**
* Gutenberg RTC Notices file.
*
* @package automattic/jetpack-mu-wpcom
*/

require_once __DIR__ . '/../../utils.php';

/**
* Register the RTC notices REST endpoints.
*/
add_action(
'rest_api_init',
function () {
if ( ! \Automattic\Jetpack\RTC::is_enabled() ) {
return;
}
require_once __DIR__ . '/class-wp-rest-rtc-notices.php';
( new WP_REST_RTC_Notices() )->register_routes();
},
20
);

/**
* Get the maximum number of peers allowed per room.
*
* @return int Max peers per room.
*/
function wpcom_get_rtc_max_peers_per_room() {
return (int) apply_filters( 'wpcom_rtc_max_peers_per_room', 3 );
}

/**
* Get the maximum number of clients (tabs) allowed per user in a room.
*
* @return int Max clients per user.
*/
function wpcom_get_rtc_max_clients_per_user() {
return (int) apply_filters( 'wpcom_rtc_max_clients_per_user', 2 );
}

/**
* Check if the current user is the plan owner for this site.
* Works on Simple sites (via wpcom_get_blog_owner) and Atomic sites
* (via Jetpack connection master_user). Returns false on self-hosted
* since there is no WP.com plan to upgrade.
*
* @return bool
*/
function wpcom_rtc_is_plan_owner() {
$current_user_id = get_current_user_id();

// Simple sites: wpcom_get_blog_owner is the canonical source.
if ( function_exists( 'wpcom_get_blog_owner' ) ) {
$owner_id = wpcom_get_blog_owner( get_wpcom_blog_id() );
return (int) $current_user_id === (int) $owner_id;
}

// Atomic sites: the Jetpack connection master_user is the plan owner.
if ( class_exists( 'Jetpack_Options' ) ) {
$master_user = \Jetpack_Options::get_option( 'master_user' );
if ( $master_user ) {
return (int) $current_user_id === (int) $master_user;
}
}

return false;
}

/**
* Enqueue block editor assets for RTC notices and limits.
*/
function wpcom_enqueue_rtc_notices_assets() {
global $pagenow;

if ( ! \Automattic\Jetpack\RTC::is_enabled() ) {
return;
}

// Real-time collaboration is not enabled in the site editor.
if (
'site-editor.php' === $pagenow ||
( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
) {
return;
}

// Don't show notices if the user has turned off RTC in Writing Settings.
// Both the old and new option names must be false to consider it disabled.
$old_option = get_option( 'wp_enable_real_time_collaboration', false );
$new_option = get_option( 'wp_collaboration_enabled', false );
if ( ! $old_option && ! $new_option ) {
return;
}
Comment thread
rcrdortiz marked this conversation as resolved.

// Notices are not relevant for P2 sites.
if ( function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() ) ) {
return;
}

$handle = jetpack_mu_wpcom_enqueue_assets( 'gutenberg-rtc-notices', array( 'js', 'css' ) );
wp_set_script_translations( $handle, 'jetpack-mu-wpcom' );

require_once __DIR__ . '/class-wp-rest-rtc-notices.php';

$is_admin_user = current_user_can( 'manage_options' );
Comment thread
rcrdortiz marked this conversation as resolved.
$is_plan_owner = wpcom_rtc_is_plan_owner();

$data = wp_json_encode(
array(
'isAdmin' => $is_admin_user,
'isPlanOwner' => $is_plan_owner,
'welcomeDismissed' => WP_REST_RTC_Notices::is_dismissed(),
'postId' => get_the_ID(),
'postTitle' => get_the_title(),
'postEditUrl' => get_edit_post_link( get_the_ID(), 'raw' ),
'postsListUrl' => admin_url( 'edit.php' ),
'siteSlug' => wpcom_get_site_slug(),
'maxPeersPerRoom' => wpcom_get_rtc_max_peers_per_room(),
'maxClientsPerUser' => wpcom_get_rtc_max_clients_per_user(),
'enableLimitNotices' => apply_filters( 'wpcom_rtc_enable_limit_notices', false ),
),
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
);

wp_add_inline_script(
$handle,
"var wpcomRtcNotices = $data;",
'before'
);
}
add_action( 'enqueue_block_editor_assets', 'wpcom_enqueue_rtc_notices_assets' );
Loading
Loading