'false', 'data-no-optimize' => '1', 'data-no-defer' => '1', 'nowprocket' => true, ); /** * Whether a block or shortcode on this page needs the widget. * * @var bool */ private $needed_by_content = false; /** * The decision made for this request, once made. * * @var array|null */ private $decision = null; /** * Hooks. */ public function register(): void { add_action( 'wp_enqueue_scripts', array( $this, 'register_scripts' ), 5 ); add_action( 'wp_enqueue_scripts', array( $this, 'link_style' ) ); add_action( 'wp_footer', array( $this, 'enqueue_for_page' ), 1 ); add_filter( 'wp_script_attributes', array( $this, 'script_attributes' ) ); add_filter( 'wp_inline_script_attributes', array( $this, 'inline_script_attributes' ) ); } /** * Registers the widget and the plugin's small front-end helper. */ public function register_scripts(): void { if ( wp_script_is( self::WIDGET_HANDLE, 'registered' ) ) { return; } // The CDN copy is evergreen; a ?ver= would only split the CDN cache. wp_register_script( self::WIDGET_HANDLE, Service_Urls::get( 'widget' ), array(), null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- See above. array( 'in_footer' => true, 'strategy' => 'defer', ) ); $asset = self::asset( 'frontend' ); wp_register_script( self::HELPER_HANDLE, STAND_CHAT_URL . 'build/frontend.js', array(), $asset['version'], array( 'in_footer' => true, 'strategy' => 'defer', ) ); } /** * Links that open chat (#stand-chat, .stand-open-chat) stay hidden until * the helper says someone can answer: before that, before cookie consent, * or where chat doesn't load, a click would do nothing. */ public function link_style(): void { wp_register_style( 'stand-chat-links', false, array(), STAND_CHAT_VERSION ); wp_enqueue_style( 'stand-chat-links' ); // The menu items need :has(), in a rule of their own: a browser without // it drops the whole rule, and the links themselves stay hidden. wp_add_inline_style( 'stand-chat-links', 'html:not(.stand-chat-available) a[href$="#stand-chat"],' . 'html:not(.stand-chat-available) .stand-open-chat' . '{display:none!important}' . 'html:not(.stand-chat-available) li.menu-item:has(> a[href$="#stand-chat"]),' . 'html:not(.stand-chat-available) li.wp-block-navigation-item:has(a[href$="#stand-chat"])' . '{display:none!important}' ); } /** * Blocks and shortcodes call this. Pages left out of "Where chat appears" * still load the widget for them, without the corner chat button. */ public function content_needs_widget(): void { $this->needed_by_content = true; if ( ! did_action( 'wp_footer' ) || did_action( 'wp_print_footer_scripts' ) ) { return; } // Content rendered inside the footer, after the decision point and // before the scripts print: a page left out of "Where chat appears" // loads the widget for it after all. The other reasons still hold. if ( null === $this->decision || 'page_excluded' === $this->decision['reason'] ) { $this->decision = null; $this->enqueue_for_page(); } } /** * Whether pages show the demo rather than the saved Site ID: on a site * that isn't live, when set to. * * @param array $settings Settings. * @param bool $production Whether this is a production install. */ public static function shows_demo( array $settings, bool $production ): bool { return ! $production && 'demo' === ( $settings['non_production'] ?? '' ); } /** * Decides whether and how to load chat on this request. * * @return array{load: bool, reason: string, site_id: string, hide_button: bool, demo: bool} */ public function decide(): array { $no = static function ( string $reason ): array { return array( 'load' => false, 'reason' => $reason, 'site_id' => '', 'hide_button' => true, 'demo' => false, ); }; if ( is_admin() || is_feed() || is_robots() || is_trackback() || is_embed() || wp_is_json_request() ) { return $no( 'not_public' ); } $settings = Settings::all(); if ( ! $settings['chat_enabled'] ) { return $no( 'off' ); } $site_id = $settings['site_id']; if ( '' === $site_id ) { return $no( 'no_site_id' ); } $hidden = Environment::hidden_reason(); if ( '' !== $hidden ) { return $no( $hidden ); } if ( $settings['hide_for_admins'] && current_user_can( 'manage_options' ) ) { return $no( 'administrator' ); } $included = Visibility::current_page_included(); if ( ! $included && ! $this->needed_by_content ) { return $no( 'page_excluded' ); } $demo = self::shows_demo( $settings, Environment::is_production() ); return array( 'load' => true, 'reason' => '', 'site_id' => $demo ? self::DEMO_SITE_ID : $site_id, 'hide_button' => ! $settings['corner_button'] || ! $included, 'demo' => $demo, ); } /** * Enqueues the widget, or the loader that adds it after consent. */ public function enqueue_for_page(): void { if ( null !== $this->decision ) { return; } $this->decision = $this->decide(); if ( ! $this->decision['load'] ) { return; } $this->register_scripts(); $settings = Settings::all(); $config = $this->widget_config(); $helper = array( 'links' => true, 'identity' => Identity::for_current_user(), ); if ( null !== $helper['identity'] && ! defined( 'DONOTCACHEPAGE' ) ) { // The page names this visitor. WordPress already sends no-store for // logged-in visitors; page caches that skip it still honor this. define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- The constant page caches check. } $needs_consent = 'consent' === $settings['consent']['mode']; if ( $needs_consent || $settings['loader'] ) { $helper['load'] = array( 'src' => Service_Urls::get( 'widget' ), 'attributes' => $this->widget_attributes(), 'config' => $config, 'consent' => $needs_consent ? $settings['consent']['category'] : '', ); } else { wp_add_inline_script( self::WIDGET_HANDLE, 'window.StandChatConfig = ' . wp_json_encode( $config ) . ';', 'before' ); wp_enqueue_script( self::WIDGET_HANDLE ); } wp_add_inline_script( self::HELPER_HANDLE, 'window.StandChatWordPress = ' . wp_json_encode( $helper ) . ';', 'before' ); wp_enqueue_script( self::HELPER_HANDLE ); } /** * The decision for this request (after wp_footer), for tests and Site Health. * * @return array|null */ public function last_decision(): ?array { return $this->decision; } /** * The window.StandChatConfig object for the widget. * * @return array */ private function widget_config(): array { $config = array( 'siteId' => $this->decision['site_id'], 'hideButton' => (bool) $this->decision['hide_button'], ); if ( ! Service_Urls::api_is_inferred() ) { $config['apiBaseUrl'] = Service_Urls::get( 'api' ); } if ( '' !== Service_Urls::get( 'ws' ) ) { $config['wsBaseUrl'] = Service_Urls::get( 'ws' ); } return $config; } /** * Attributes for the widget's script tag. * * @return array */ private function widget_attributes(): array { $attributes = array( 'data-stand-id' => $this->decision['site_id'] ); if ( $this->decision['hide_button'] ) { $attributes['data-stand-hide-button'] = 'true'; } if ( ! Service_Urls::api_is_inferred() ) { $attributes['data-stand-api-base'] = Service_Urls::get( 'api' ); } if ( '' !== Service_Urls::get( 'ws' ) ) { $attributes['data-stand-ws-base'] = Service_Urls::get( 'ws' ); } return array_merge( $attributes, self::OPTIMIZER_ATTRIBUTES ); } /** * Adds the Site ID and optimizer exclusions to the widget's script tag. * * @param array $attributes Script tag attributes. * @return array */ public function script_attributes( $attributes ) { if ( ! is_array( $attributes ) || null === $this->decision || empty( $this->decision['load'] ) ) { return $attributes; } $id = $attributes['id'] ?? ''; if ( self::WIDGET_HANDLE . '-js' === $id ) { $extra = $this->widget_attributes(); } elseif ( self::HELPER_HANDLE . '-js' === $id ) { $extra = self::OPTIMIZER_ATTRIBUTES; } else { return $attributes; } // Cloudflare Rocket Loader only honors data-cfasync when it comes before src. return array_merge( array( 'data-cfasync' => 'false' ), $attributes, $extra ); } /** * Keeps the inline configuration next to the script it configures. * * @param array $attributes Inline script tag attributes. * @return array */ public function inline_script_attributes( $attributes ) { if ( ! is_array( $attributes ) ) { return $attributes; } $id = $attributes['id'] ?? ''; if ( in_array( $id, array( self::WIDGET_HANDLE . '-js-before', self::HELPER_HANDLE . '-js-before' ), true ) ) { return array_merge( $attributes, self::OPTIMIZER_ATTRIBUTES ); } return $attributes; } /** * A built asset's dependencies and version. * * @param string $name Entry name in build/. * @return array{dependencies: string[], version: string} */ public static function asset( string $name ): array { $file = STAND_CHAT_DIR . 'build/' . $name . '.asset.php'; if ( is_readable( $file ) ) { $asset = include $file; if ( is_array( $asset ) ) { return array( 'dependencies' => isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ? $asset['dependencies'] : array(), 'version' => isset( $asset['version'] ) ? (string) $asset['version'] : STAND_CHAT_VERSION, ); } } return array( 'dependencies' => array(), 'version' => STAND_CHAT_VERSION, ); } }