settings = $settings; } /** * Registers hooks. * * @return void */ public function register(): void { add_filter( 'yangra_conditional_preview_values', array( $this, 'filter_values' ) ); add_action( 'admin_bar_menu', array( $this, 'admin_bar' ), 80 ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) ); add_action( 'admin_post_' . self::ACTION, array( $this, 'handle' ) ); } /** * The faked value of a preview group for this request (e.g. "visitor" is * "logged_out" or "role:editor"), or '' when it isn't being previewed. * * Conditions call this before reading the real visitor. Pro conditions use * it for the groups they add with `yangra_conditional_preview_groups`. * * @param string $key Group key. * @return string */ public static function value( string $key ): string { /** * Filters the values being previewed, as group key => value. * * @param array $values Previewed values. Empty when not previewing. */ $values = apply_filters( 'yangra_conditional_preview_values', array() ); return $values[ $key ] ?? ''; } /** * Adds the active preview to the values conditions read. * * @param mixed $values Previewed values. * @return array */ public function filter_values( $values ): array { return array_merge( is_array( $values ) ? $values : array(), $this->active() ); } /** * Whether the current user can preview at least one group. * * @return bool */ public function can_preview(): bool { return ! empty( $this->settings->all()['admin_bar_preview'] ) && array() !== $this->allowed_groups(); } /** * The groups the current user may preview. * * @return array> */ public function allowed_groups(): array { return array_filter( $this->groups(), array( self::class, 'allows' ) ); } /** * Whether the current user may preview a group: they have one of its * `capabilities`, or `edit_theme_options` when it names none. * * @param array $group Group from groups(). * @return bool */ public static function allows( array $group ): bool { $capabilities = isset( $group['capabilities'] ) && is_array( $group['capabilities'] ) ? $group['capabilities'] : array( self::CAPABILITY ); foreach ( $capabilities as $capability ) { if ( is_string( $capability ) && current_user_can( $capability ) ) { return true; } } return false; } /** * The choices in the preview cookie that are still valid, or none when the * current user can't preview. * * @return array */ public function active(): array { if ( ! isset( $_COOKIE[ self::COOKIE ] ) || ! $this->can_preview() ) { return array(); } $raw = json_decode( wp_unslash( (string) $_COOKIE[ self::COOKIE ] ), true ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Checked against the allowed values in sanitize(). return $this->sanitize( $raw ); } /** * What can be previewed: group key => label and options (value => label). * * A group can take a typed value instead of a list of options (Pro's date * and time): it then has `input` (an `` type), `sanitize` (returns * the clean value, or '' when it isn't valid) and `display` (the value as * people read it), and its `options` are empty. * * A group can also name the `capabilities` that may preview it (any of * them). Without it, only users with `edit_theme_options` can. * * @return array, input?: string, sanitize?: callable, display?: callable, capabilities?: array}> */ public function groups(): array { $visitor = array( 'logged_out' => __( 'Logged out', 'yangra-conditional' ) ); foreach ( wp_roles()->get_names() as $slug => $name ) { $visitor[ 'role:' . $slug ] = translate_user_role( $name ); } $visitor['no_role'] = __( 'Logged in, no role', 'yangra-conditional' ); $groups = array( 'visitor' => array( 'label' => __( 'Visitor', 'yangra-conditional' ), 'real' => __( 'Yourself', 'yangra-conditional' ), 'options' => $visitor, ), 'device' => array( 'label' => __( 'Device', 'yangra-conditional' ), 'real' => __( 'Your device', 'yangra-conditional' ), 'options' => array( 'mobile' => __( 'Mobile', 'yangra-conditional' ), 'desktop' => __( 'Desktop', 'yangra-conditional' ), ), ), ); /** * Filters what "Preview as" offers. Pro adds its visitor conditions here. * * @param array, input?: string, sanitize?: callable, display?: callable, capabilities?: array}> $groups * Group key => label, label of the real value, and options (value => label). A group * with `input`, `sanitize` and `display` takes a typed value instead of an option. * `capabilities` lists who may preview it (any of them; `edit_theme_options` by default). */ return apply_filters( 'yangra_conditional_preview_groups', $groups ); } /** * Keeps only known groups the current user may preview, with one of their * options, or with a value their `sanitize` callback accepts. * * @param mixed $raw Choices, as group key => value. * @return array */ public function sanitize( $raw ): array { if ( ! is_array( $raw ) ) { return array(); } $clean = array(); foreach ( $this->allowed_groups() as $key => $group ) { if ( ! isset( $raw[ $key ] ) || ! is_string( $raw[ $key ] ) ) { continue; } if ( self::is_input( $group ) ) { $value = (string) call_user_func( $group['sanitize'], $raw[ $key ] ); if ( '' !== $value ) { $clean[ (string) $key ] = $value; } } elseif ( isset( $group['options'][ $raw[ $key ] ] ) ) { $clean[ (string) $key ] = $raw[ $key ]; } } return $clean; } /** * Whether a group takes a typed value instead of one of its options. * * @param array $group Group from groups(). * @return bool */ public static function is_input( array $group ): bool { return isset( $group['input'], $group['sanitize'], $group['display'] ) && is_string( $group['input'] ) && is_callable( $group['sanitize'] ) && is_callable( $group['display'] ); } /** * A group's value as people read it. * * @param array $group Group from groups(). * @param string $value Value. * @return string */ private static function label( array $group, string $value ): string { if ( self::is_input( $group ) ) { return (string) call_user_func( $group['display'], $value ); } return (string) ( $group['options'][ $value ] ?? $value ); } /** * The choices after picking one value in a group. '' goes back to the real value. * * @param array $state Current choices. * @param string $key Group key. * @param string $value New value, or ''. * @return array */ public function with_choice( array $state, string $key, string $value ): array { unset( $state[ $key ] ); if ( '' !== $value ) { $state[ $key ] = $value; } return $this->sanitize( $state ); } /** * "Previewing as: Editor, Mobile", or "Preview as" when nothing is previewed. * * @param array $state Choices. * @return string */ public function title( array $state ): string { $labels = array(); foreach ( $this->allowed_groups() as $key => $group ) { if ( isset( $state[ $key ] ) ) { $labels[] = self::label( $group, $state[ $key ] ); } } if ( array() === $labels ) { return __( 'Preview as', 'yangra-conditional' ); } /* translators: %s: what is being previewed, e.g. "Editor, Mobile". */ return sprintf( __( 'Previewing as: %s', 'yangra-conditional' ), implode( ', ', $labels ) ); } /** * Adds "Preview as" to the admin bar on the front end. * * @param WP_Admin_Bar $admin_bar Admin bar. * @return void */ public function admin_bar( WP_Admin_Bar $admin_bar ): void { if ( is_admin() || ! $this->can_preview() ) { return; } $state = $this->active(); $admin_bar->add_node( array( 'id' => self::NODE, 'title' => esc_html( $this->title( $state ) ), 'meta' => array( 'class' => array() === $state ? '' : 'yangra-conditional-previewing' ), ) ); // First, so it's always in reach, however long the lists below are. if ( array() !== $state ) { $admin_bar->add_node( array( 'parent' => self::NODE, 'id' => self::NODE . '-exit', 'title' => esc_html__( 'Exit preview', 'yangra-conditional' ), 'href' => $this->url( 'exit', '' ), ) ); } foreach ( $this->allowed_groups() as $key => $group ) { $group_id = self::NODE . '-' . $key; $admin_bar->add_group( array( 'parent' => self::NODE, 'id' => $group_id, // Long lists (e.g. countries) scroll instead of running off the screen. 'meta' => array( 'class' => count( $group['options'] ) > self::LONG_GROUP ? 'yangra-conditional-preview-long' : '' ), ) ); $admin_bar->add_node( array( 'parent' => $group_id, 'id' => $group_id . '-heading', 'title' => esc_html( $group['label'] ), 'meta' => array( 'class' => 'yangra-conditional-preview-heading' ), ) ); if ( self::is_input( $group ) ) { $this->add_input( $admin_bar, $group_id, (string) $key, $group, $state[ $key ] ?? '' ); continue; } // `+`, not array_merge(), which would renumber numeric values such as IDs. $choices = array( '' => $group['real'] ) + $group['options']; foreach ( $choices as $value => $label ) { $value = (string) $value; $current = ( $state[ $key ] ?? '' ) === $value; $admin_bar->add_node( array( 'parent' => $group_id, 'id' => $group_id . '-' . ( '' === $value ? 'real' : sanitize_key( str_replace( ':', '-', $value ) ) ), 'title' => esc_html( $label ) . ( $current ? ' ' . esc_html__( '(current)', 'yangra-conditional' ) . '' : '' ), 'href' => $this->url( $key, $value ), 'meta' => array( 'class' => 'yangra-conditional-preview-choice' . ( $current ? ' yangra-conditional-preview-current' : '' ) ), ) ); } } } /** * Adds a group that takes a typed value: its real value, then a small form * that sends the value to the same nonce-checked handler as the links. * * @param WP_Admin_Bar $admin_bar Admin bar. * @param string $group_id Admin bar group ID. * @param string $key Group key. * @param array $group Group from groups(). * @param string $current Value being previewed, or ''. * @return void */ private function add_input( WP_Admin_Bar $admin_bar, string $group_id, string $key, array $group, string $current ): void { $real = '' === $current; $admin_bar->add_node( array( 'parent' => $group_id, 'id' => $group_id . '-real', 'title' => esc_html( $group['real'] ) . ( $real ? ' ' . esc_html__( '(current)', 'yangra-conditional' ) . '' : '' ), 'href' => $this->url( $key, '' ), 'meta' => array( 'class' => 'yangra-conditional-preview-choice' . ( $real ? ' yangra-conditional-preview-current' : '' ) ), ) ); $field = 'yangra-conditional-preview-' . $key . '-value'; $form = sprintf( '
' . '' . '' . '' . '' . '' . '' . '' . '
', esc_url( admin_url( 'admin-post.php' ) ), esc_attr( self::ACTION ), esc_attr( $key ), esc_attr( $this->current_url() ), esc_attr( wp_create_nonce( self::ACTION ) ), esc_attr( $field ), esc_html( $group['label'] ), esc_attr( $group['input'] ), esc_attr( $current ), esc_html__( 'Preview', 'yangra-conditional' ) ); $admin_bar->add_node( array( 'parent' => $group_id, 'id' => $group_id . '-input', 'title' => $form, 'meta' => array( 'class' => 'yangra-conditional-preview-input' . ( $real ? '' : ' yangra-conditional-preview-current' ) ), ) ); } /** * Styles for the menu's headings and the current choices. * * @return void */ public function enqueue_styles(): void { if ( ! is_admin_bar_showing() || ! $this->can_preview() ) { return; } wp_add_inline_style( 'admin-bar', '#wpadminbar .yangra-conditional-preview-heading > .ab-item { font-weight: 600; }' // Every choice gets the same gutter, so labels line up whether or not they're checked. . '#wpadminbar .yangra-conditional-preview-choice > .ab-item { position: relative; padding-left: 2.25em; }' // Core floats .ab-item:before, which would push the next row in, so the mark is placed in the gutter. . '#wpadminbar .yangra-conditional-preview-current > .ab-item::before { content: "\2713"; position: absolute; float: none; left: 1em; top: 0; margin: 0; padding: 0; font: inherit; color: inherit; }' . '#wpadminbar .yangra-conditional-previewing > .ab-item { background: #996800; color: #fff; }' // The menu never runs past the bottom of the window, and long lists (e.g. countries) scroll on their own. . '#wpadminbar #wp-admin-bar-' . self::NODE . ' > .ab-sub-wrapper { max-height: calc(100vh - 46px); overflow-y: auto; }' . '#wpadminbar .yangra-conditional-preview-long { max-height: 16em; overflow-y: auto; }' // Typed values (Pro's date and time): a field and a button on one row. . '#wpadminbar .yangra-conditional-preview-input > .ab-item { height: auto; padding: 4px 10px 6px 2.25em; }' . '#wpadminbar .yangra-conditional-preview-input.yangra-conditional-preview-current > .ab-item::before { top: 4px; }' . '#wpadminbar .yangra-conditional-preview-form { display: flex; gap: 4px; }' . '#wpadminbar .yangra-conditional-preview-form input:not([type="hidden"]) { height: 26px; padding: 0 4px; font: inherit; font-size: 13px; line-height: 24px; color: #1d2327; background: #fff; border: 1px solid #8c8f94; border-radius: 2px; }' . '#wpadminbar .yangra-conditional-preview-form button { height: 26px; padding: 0 8px; font: inherit; font-size: 13px; line-height: 26px; color: #fff; background: #2271b1; border: 0; border-radius: 2px; cursor: pointer; }' ); } /** * Link that picks a value in a group, or exits the preview (key "exit"). * * @param string $key Group key, or "exit". * @param string $value Value, or '' for the real value. * @return string */ public function url( string $key, string $value ): string { return wp_nonce_url( add_query_arg( array( 'action' => self::ACTION, 'key' => $key, 'value' => rawurlencode( $value ), 'redirect_to' => rawurlencode( $this->current_url() ), ), admin_url( 'admin-post.php' ) ), self::ACTION ); } /** * Saves a choice from the admin bar and goes back to the page. * * @return void */ public function handle(): void { if ( ! $this->can_preview() ) { wp_die( esc_html__( 'Sorry, you are not allowed to preview as other visitors.', 'yangra-conditional' ), 403 ); } check_admin_referer( self::ACTION ); $key = isset( $_GET['key'] ) ? sanitize_key( wp_unslash( $_GET['key'] ) ) : ''; $value = isset( $_GET['value'] ) ? sanitize_text_field( wp_unslash( $_GET['value'] ) ) : ''; $state = 'exit' === $key ? array() : $this->with_choice( $this->active(), $key, $value ); $this->store( $state ); $redirect = isset( $_GET['redirect_to'] ) ? esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ) : ''; wp_safe_redirect( wp_validate_redirect( $redirect, home_url( '/' ) ) ); exit; } /** * Saves the choices in the cookie, for this session only. No choices clears it. * * @param array $state Choices. * @return void */ protected function store( array $state ): void { $value = array() === $state ? '' : (string) wp_json_encode( $state ); $expires = array() === $state ? time() - YEAR_IN_SECONDS : 0; setcookie( self::COOKIE, $value, $expires, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); if ( array() === $state ) { unset( $_COOKIE[ self::COOKIE ] ); } else { $_COOKIE[ self::COOKIE ] = $value; } } /** * The page being viewed, to come back to after a choice. * * @return string */ private function current_url(): string { $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; $path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return '' === $host ? home_url( '/' ) : set_url_scheme( 'http://' . $host . $path ); } }