*/ const DEFAULT_STATE = [ 'banner' => [ 'status' => 'pending', 'queued_by' => null, 'queued_at' => null, 'snoozed_until' => null, 'shown_at' => null, 'last_shown_at' => null, 'message' => '', 'cta_url' => '', 'cta_label' => '', ], 'toast' => [ 'notification_id' => '', 'status' => 'pending', 'queued_by' => null, 'queued_at' => null, 'snoozed_until' => null, 'shown_at' => null, 'last_shown_at' => null, 'title' => '', 'message' => '', 'actions' => [], ], 'toast_history' => [], ]; /** * Singleton instance. * * @var self|null */ private static $instance = null; /** * Notification selected to render during the current request. * * @var string|null */ private $active_display = null; /** * Whether the toast is enabled for the current request. * * @var bool */ private $toast_enabled = true; /** * Whether a notification has already rendered during the current request. * * @var bool */ private $has_rendered = false; /** * Get the shared instance. */ public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Register hooks and initialize the single compact option. */ private function __construct() { add_option( self::OPTION_NAME, self::DEFAULT_STATE, '', 'no' ); $activation_time = get_option( 'adminoptim_activated_at' ); if ( false === $activation_time ) { $activation_time = time(); add_option( 'adminoptim_activated_at', $activation_time, '', 'no' ); } $this->activation_time = (int) $activation_time; add_action( 'admin_notices', [ $this, 'maybe_render' ] ); add_action( 'admin_footer', [ $this, 'maybe_render_toast' ] ); add_action( 'current_screen', [ $this, 'maybe_queue_ao_notifications' ] ); add_action( 'wp_ajax_adminoptim_review_notice_snooze', [ $this, 'ajax_snooze' ] ); add_action( 'wp_ajax_adminoptim_review_notice_close', [ $this, 'ajax_close' ] ); add_action( 'wp_ajax_adminoptim_review_notice_dismiss', [ $this, 'ajax_dismiss' ] ); add_filter( 'admin_footer_text', [ $this, 'maybe_render_footer_text' ] ); } /** * Request that the shared review banner be queued. * * Calling modules decide when to make this request. Requests made before the * plugin has been active for 14 days are rejected. * * @param string $source Short identifier for the requesting module. * @param string $message Optional custom message to display in the banner. */ public static function request_banner( string $source, string $message = '' ): void { self::get_instance()->queue( $source, [ 'display' => 'banner', 'message' => $message, 'cta_url' => '', 'cta_label' => '', ] ); } /** * Request that a structured toast be queued. * * Calling modules decide when to make this request. Requests made before the * plugin has been active for 14 days are rejected. * * @param string $source Short identifier for the requesting module. * @param string $notification_id Stable identifier for this notification. * @param array $content Title, message, and optional actions. */ public static function request_toast( string $source, string $notification_id, array $content ): void { $content = apply_filters( 'adminoptim_notification_toast_content_' . sanitize_key( $source ), $content, $notification_id ); if ( ! is_array( $content ) ) { return; } self::get_instance()->queue( $source, [ 'display' => 'toast', 'notification_id' => $notification_id, 'title' => $content['title'] ?? '', 'message' => $content['message'] ?? '', 'actions' => $content['actions'] ?? [], ] ); } /** * Shared queuing logic for both the banner and the toast. * * @param string $source Short identifier for the requesting module. * @param array $content Notification display content. */ private function queue( string $source, array $content ): void { $now = time(); if ( $now - $this->activation_time < self::DAY_AFTER_ACTIVATION ) { return; } $state = $this->get_state(); $display = 'toast' === $content['display'] ? 'toast' : 'banner'; $notice = $state[ $display ]; if ( 'toast' === $display ) { $notification_id = sanitize_key( (string) ( $content['notification_id'] ?? '' ) ); if ( '' === $notification_id ) { return; } $history = $state['toast_history'][ $notification_id ] ?? []; $notice = array_merge( self::DEFAULT_STATE['toast'], $history, [ 'notification_id' => $notification_id ] ); } $can_queue = in_array( $notice['status'], [ 'pending', 'shown' ], true ) || ( 'snoozed' === $notice['status'] && is_numeric( $notice['snoozed_until'] ) && (int) $notice['snoozed_until'] <= $now ); if ( ! $can_queue ) { return; } $notice['status'] = 'queued'; $notice['queued_by'] = sanitize_key( $source ); $notice['queued_at'] = $now; $notice['snoozed_until'] = null; $message = $this->filter_message( $source, (string) $content['message'], $display ); $notice['message'] = '' !== $message ? wp_kses( $message, [ 'strong' => [] ] ) : ''; if ( 'toast' === $display ) { $notice['title'] = sanitize_text_field( (string) $content['title'] ); $notice['actions'] = $this->sanitize_toast_actions( $content['actions'] ); if ( ! $this->has_toast_content( $notice ) ) { return; } $state['toast_history'][ $notice['notification_id'] ] = $this->get_toast_history_state( $notice ); } $state[ $display ] = $notice; $this->save_state( $state ); if ( ! $this->has_rendered ) { $this->active_display = $this->select_active_display( $state ); } } /** * Sanitize optional toast actions. * * @param array $actions Actions to sanitize. * @return array */ private function sanitize_toast_actions( $actions ): array { if ( ! is_array( $actions ) ) { return []; } $sanitized = []; foreach ( $actions as $action ) { if ( ! is_array( $action ) ) { continue; } $type = sanitize_key( (string) ( $action['type'] ?? '' ) ); if ( ! in_array( $type, [ 'link', 'snooze', 'dismiss' ], true ) ) { continue; } $item = [ 'type' => $type, 'label' => sanitize_text_field( (string) ( $action['label'] ?? '' ) ), ]; if ( '' === $item['label'] ) { continue; } if ( 'link' === $type ) { $item['url'] = esc_url_raw( (string) ( $action['url'] ?? '' ) ); $item['style'] = 'primary' === ( $action['style'] ?? '' ) ? 'primary' : 'text'; if ( '' === $item['url'] ) { continue; } } $sanitized[] = $item; } return $sanitized; } /** * Return only persistent delivery state for one toast ID. * * @param array $notice Notification state. * @return array */ private function get_toast_history_state( array $notice ): array { return array_intersect_key( $notice, array_flip( [ 'status', 'snoozed_until', 'shown_at', 'last_shown_at' ] ) ); } /** * Determine whether a toast has anything meaningful to render. * * @param array $notice Notification state. * @return bool */ private function has_toast_content( array $notice ): bool { return '' !== trim( (string) $notice['title'] ) || '' !== trim( wp_strip_all_tags( (string) $notice['message'] ) ) || ! empty( $notice['actions'] ); } /** * Filter a notification message for a specific source. * * @param string $source Short identifier for the requesting module. * @param string $message Default notification message. * @param string $display Notification display mode: banner or toast. * @return string */ private function filter_message( string $source, string $message, string $display ): string { return (string) apply_filters( 'adminoptim_notification_message_' . sanitize_key( $source ), $message, $display ); } /** * Queue the default review banner on Admin Optimizer screens. * * Toasts are queued only when a module explicitly calls request_toast(). * * @param \WP_Screen $screen Current admin screen. */ public function maybe_queue_ao_notifications( $screen ): void { global $plugin_page; if ( ! current_user_can( 'manage_options' ) || ! $this->is_ao_screen( $screen ) ) { return; } $source = $this->get_screen_source( $screen ); $default_toast_enabled = ADMINOPTIMIZER_MODULES_MENU_SLUG !== $plugin_page; /** * Filter whether an on-demand toast is enabled on the current screen. * * @param bool $enabled Whether the toast is enabled. * @param \WP_Screen $screen Current admin screen. * @param string $source Notification source for the screen. */ $this->toast_enabled = (bool) apply_filters( 'adminoptim_notification_toast_enabled', $default_toast_enabled, $screen, $source ); self::request_banner( $source ); $this->active_display = $this->select_active_display( $this->get_state() ); } /** * Select one queued notification for the current request. * * @param array $state Notification state. * @return string|null */ private function select_active_display( array $state ) { $selected = null; $selected_last_shown_at = null; foreach ( [ 'banner', 'toast' ] as $display ) { if ( 'toast' === $display && ! $this->toast_enabled ) { continue; } $notice = $state[ $display ]; if ( 'queued' !== $notice['status'] ) { continue; } $last_shown_at = is_numeric( $notice['last_shown_at'] ) ? (int) $notice['last_shown_at'] : 0; if ( null === $selected || $last_shown_at < $selected_last_shown_at ) { $selected = $display; $selected_last_shown_at = $last_shown_at; } } return $selected; } /** * Get the notification selected for the current request. * * @return string|null */ private function get_active_display() { if ( null === $this->active_display ) { $this->active_display = $this->select_active_display( $this->get_state() ); } return $this->active_display; } /** * Resolve a stable notification source for the current screen. * * @param \WP_Screen $screen Current admin screen. * @return string */ private function get_screen_source( $screen ): string { $source = Module_Registry::get_module_slug_for_screen( $screen ); if ( null === $source ) { $source = ! empty( $screen->post_type ) ? $screen->post_type : $screen->id; } return sanitize_key( (string) apply_filters( 'adminoptim_notification_source', sanitize_key( $source ), $screen ) ); } /** * Determine whether the notification is ready to display to the current user. * * @param string $display Notification display mode: banner or toast. * @return bool */ public function is_ready_to_display( string $display = 'banner' ): bool { $state = $this->get_state(); $notice = $state[ 'toast' === $display ? 'toast' : 'banner' ]; if ( 'dismissed' === $notice['status'] ) { return false; } return 'queued' === $notice['status'] && current_user_can( 'manage_options' ); } /** * Enqueue assets and render the banner notice when it is ready. */ public function maybe_render(): void { if ( 'banner' !== $this->get_active_display() ) { return; } $state = $this->get_state(); $notice = $state['banner']; // Keep permanently dismissed installs on the cheapest possible path. if ( 'dismissed' === $notice['status'] || ! $this->is_ready_to_display( 'banner' ) ) { return; } $this->has_rendered = true; $this->enqueue_assets(); $now = time(); $message = $this->get_message( $notice ); ?>

[] ] ); ?>

   | 

record_shown( 'banner', $state, $now ); } /** * Enqueue assets and render the toast when it is ready. * * Toast title, message, and actions are supplied by the calling module. */ public function maybe_render_toast(): void { if ( 'toast' !== $this->get_active_display() ) { return; } $state = $this->get_state(); $notice = $state['toast']; if ( ! $this->has_toast_content( $notice ) || 'dismissed' === $notice['status'] || ! $this->is_ready_to_display( 'toast' ) ) { return; } $this->has_rendered = true; $this->enqueue_assets(); $now = time(); ?>

[] ] ); ?>

record_shown( 'toast', $state, $now ); } /** * Resolve the message to display for the banner. * * @param array $state Notification state. * * @return string */ private function get_message( array $state ): string { return ! empty( $state['message'] ) ? $state['message'] : __( 'Admin Optimizer has been helping keep your site running smoothly. Would you take a moment to leave us a quick review?', 'admin-optimizer' ); } /** * Enqueue the shared notification script. */ private function enqueue_assets(): void { wp_enqueue_style( 'adminoptim-notification', ADMINOPTIMIZER_URI . 'admin/assets/css/notification.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-notification', ADMINOPTIMIZER_URI . 'admin/assets/js/notification.min.js', [], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptim-notification', 'adminoptimNotice', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), ] ); } /** * Record that the notification was shown, transitioning state to 'shown'. * * @param string $display Notification display mode. * @param array $state Notification state. * @param int $now Current timestamp. */ private function record_shown( string $display, array $state, int $now ): void { $notice = $state[ $display ]; $is_first_display = null === $notice['shown_at']; $notice['status'] = 'shown'; if ( $is_first_display ) { $notice['shown_at'] = $now; } $notice['last_shown_at'] = $now; $state[ $display ] = $notice; if ( 'toast' === $display && '' !== $notice['notification_id'] ) { $state['toast_history'][ $notice['notification_id'] ] = $this->get_toast_history_state( $notice ); } $this->save_state( $state ); } /** * Snooze the notification for the configured duration. */ public function ajax_snooze(): void { if ( ! $this->is_valid_ajax_request() ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do that.', 'admin-optimizer' ) ], 403 ); } $display = $this->get_requested_display(); $state = $this->get_state(); $state[ $display ]['status'] = 'snoozed'; $state[ $display ]['snoozed_until'] = time() + self::SNOOZE_DURATION; if ( 'toast' === $display ) { $this->update_current_toast_history( $state ); } $this->save_state( $state ); wp_send_json_success(); } /** * Snooze a closed toast for seven days. */ public function ajax_close(): void { if ( ! $this->is_valid_ajax_request() ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do that.', 'admin-optimizer' ) ], 403 ); } $display = $this->get_requested_display(); if ( 'toast' !== $display ) { wp_send_json_error( [ 'message' => __( 'Invalid notification type.', 'admin-optimizer' ) ], 400 ); } $state = $this->get_state(); $state['toast']['status'] = 'snoozed'; $state['toast']['snoozed_until'] = time() + self::CLOSE_SNOOZE_DURATION; $this->update_current_toast_history( $state ); $this->save_state( $state ); wp_send_json_success(); } /** * Permanently dismiss the notification. */ public function ajax_dismiss(): void { if ( ! $this->is_valid_ajax_request() ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do that.', 'admin-optimizer' ) ], 403 ); } $display = $this->get_requested_display(); $state = $this->get_state(); $state[ $display ]['status'] = 'dismissed'; $state[ $display ]['snoozed_until'] = null; if ( 'toast' === $display ) { $this->update_current_toast_history( $state ); } $this->save_state( $state ); wp_send_json_success(); } /** * Validate the feature nonce and required capability. * * @return bool */ private function is_valid_ajax_request(): bool { return (bool) check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) && current_user_can( 'manage_options' ); } /** * Read and validate the notification type supplied by an AJAX action. * * @return string */ private function get_requested_display(): string { $display = isset( $_POST['notification_type'] ) ? sanitize_key( wp_unslash( $_POST['notification_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing return 'toast' === $display ? 'toast' : 'banner'; } /** * Update persistent delivery state for the current toast ID. * * @param array $state Current state of toast. */ private function update_current_toast_history( array &$state ): void { $notification_id = sanitize_key( (string) $state['toast']['notification_id'] ); if ( '' !== $notification_id ) { $state['toast_history'][ $notification_id ] = $this->get_toast_history_state( $state['toast'] ); } } /** * Read and normalize the stored state. * * @return array */ private function get_state(): array { $state = get_option( self::OPTION_NAME, self::DEFAULT_STATE ); if ( ! is_array( $state ) ) { return self::DEFAULT_STATE; } if ( isset( $state['banner'] ) || isset( $state['toast'] ) ) { $toast = is_array( $state['toast'] ?? null ) ? array_merge( self::DEFAULT_STATE['toast'], $state['toast'] ) : self::DEFAULT_STATE['toast']; $toast['actions'] = $this->sanitize_toast_actions( $toast['actions'] ); return [ 'banner' => is_array( $state['banner'] ?? null ) ? array_merge( self::DEFAULT_STATE['banner'], $state['banner'] ) : self::DEFAULT_STATE['banner'], 'toast' => $toast, 'toast_history' => is_array( $state['toast_history'] ?? null ) ? $state['toast_history'] : [], ]; } $display = isset( $state['display'] ) && 'toast' === $state['display'] ? 'toast' : 'banner'; $legacy = array_intersect_key( $state, self::DEFAULT_STATE[ $display ] ); $normalized = self::DEFAULT_STATE; $normalized[ $display ] = array_merge( self::DEFAULT_STATE[ $display ], $legacy ); return $normalized; } /** * Save the complete state in the shared option. * * @param array $state Notification state. */ private function save_state( array $state ): void { update_option( self::OPTION_NAME, $state, false ); } /** * Determine whether the current admin screen belongs to Admin Optimizer. * * @return bool */ private function is_ao_pages(): bool { if ( function_exists( 'get_admin_page_parent' ) && ADMINOPTIMIZER_MODULES_MENU_SLUG === get_admin_page_parent() ) { return true; } if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); return $screen ? $this->is_ao_screen( $screen ) : false; } /** * Determine whether a screen belongs to Admin Optimizer. * * @param \WP_Screen $screen Admin screen. * @return bool */ private function is_ao_screen( $screen ): bool { $screen_id = is_object( $screen ) && isset( $screen->id ) ? (string) $screen->id : ''; return false !== strpos( $screen_id, 'adminoptim' ) || false !== strpos( $screen_id, 'admin-optimizer' ); } /** * Add Admin Footer Text * * @param string $text Footer text. * @return string */ public function maybe_render_footer_text( $text ) { /* translators: 1: Opening strong tag, 2: Closing strong tag, 3: Opening review link tag, 4: Closing review link tag. */ $message = sprintf( __( '%1$sEnjoying Admin Optimizer?%2$s %3$sLeave a ★★★★★ review%4$s to help support future development.', 'admin-optimizer' ), '', '', '', '' ); if ( $this->is_ao_pages() ) { return $message; } else { return $text; } } }