Repository navigation
RTC: Add branded editor notices and room-limit enforcement for real-time collaboration rollout on WoW sites #47681
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rcrdortiz
merged 33 commits into
trunk
from
dotcom-16395-add-one-time-editor-notice-for-rtc-rollout
Mar 23, 2026
Merged
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 e8e2454
Add changelog entry
rcrdortiz 4e2a5cc
Fix i18n string literals broken by production minifier
rcrdortiz 70903b5
Add PHP tests for RTC notices and limits
rcrdortiz 5917b1c
Only show RTC notices when collaboration is enabled in Writing Settings
rcrdortiz 5c91951
Match modal sizing to Figma specs: 409px max-width, 20px title
rcrdortiz ef46e52
Hide welcome notice when sync connection is lost
rcrdortiz ae97c82
Use typed interface instead of any for getSyncConnectionStatus
rcrdortiz d93d508
Check both RTC option names and fix apostrophes in copy
rcrdortiz 765105c
Add edit_post permission checks and switch dismiss to user options fo…
rcrdortiz 177745e
Use user options for per-site dismiss and move apiFetch to useEffect
rcrdortiz 19ef7ba
Add tests for enqueue, route registration, and admin edit post permis…
rcrdortiz bd37990
Remove unused check_admin_permission method
rcrdortiz ed7fe0d
Default maxPeersPerRoom to 3 to align with Core
rcrdortiz 121f5fd
Gate limit notices behind enableLimitNotices flag, off by default
rcrdortiz df116d1
Show upgrade CTA only to plan owner using cross-platform detection
rcrdortiz 8058543
Fire join request from room-limit.ts so it works without the modal fi…
rcrdortiz d2b5301
Match description font-size to Figma (13px)
rcrdortiz f60d1e3
Always register room-limit filter, gate only branded modals behind en…
rcrdortiz 7a42182
Delay welcome notice 6s to avoid flash before room-limit disconnect
rcrdortiz bba5fd1
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz d0e59e5
Register RTC notices REST routes at priority 20 to avoid interfering …
rcrdortiz 62c523a
Gate REST route registration behind RTC feature check to avoid test i…
rcrdortiz 312f4b3
Load RTC notices only on editor pages to avoid REST API test interfer…
rcrdortiz 530a577
Fix test tear_down: reset current user, clean up options and transients
rcrdortiz 189806d
Reset wp_rest_server in tear_down to prevent stale route leaks into L…
rcrdortiz 7e2ed81
Add tests for wpcom_rtc_is_plan_owner and improve enqueue coverage
rcrdortiz e0cbbfd
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz 25447c7
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz 6851bda
Merge branch 'trunk' into dotcom-16395-add-one-time-editor-notice-for…
rcrdortiz 7f6be1c
Merge remote-tracking branch 'origin/trunk' into dotcom-16395-add-one…
mmtr 94cf331
Disable RTC notices in site editor
mmtr 89800ef
Hide notices on P2 sites
mmtr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
4 changes: 4 additions & 0 deletions
4
...ckages/jetpack-mu-wpcom/changelog/dotcom-16395-add-one-time-editor-notice-for-rtc-rollout
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
237 changes: 237 additions & 0 deletions
237
...ackages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/class-wp-rest-rtc-notices.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
|
|
||
| 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 ) ); | ||
| } | ||
| } | ||
133 changes: 133 additions & 0 deletions
133
...ts/packages/jetpack-mu-wpcom/src/features/gutenberg-rtc-notices/gutenberg-rtc-notices.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
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' ); | ||
|
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' ); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.