by PHP, before any script runs. Without that, a dark * mode user gets a white flash while React mounts, which reads as a broken * plugin rather than a loading one. PHP cannot resolve 'system' on the server, * so it prints the preference and a CSS media query settles it; once React * mounts it swaps in the resolved value. * * Saving needs no controller of our own: register_meta() with show_in_rest * makes core's POST /wp/v2/users/me accept the field, and a logged-in user may * always edit their own profile. * * @package Booktics/Admin */ class Theme_Mode implements Hookable_Service_Contract { /** * User meta key holding the preference. */ const META_KEY = 'booktics_theme_mode'; /** * The only values that may ever be stored or printed. */ const MODES = array( 'light', 'dark', 'system' ); /** * Used when nothing is stored, and when anything invalid is. */ const DEFAULT_MODE = 'system'; /** * The one admin screen Booktics owns. */ const SCREEN = 'toplevel_page_booktics'; /** * Register hooks. * * @return void */ public function register() { // Called directly, not hooked. Service providers are booted from // init_classes() which already runs on `init` (base/booktics.php:92), // so adding another `init` callback here fires too late and the meta // never reaches the REST user schema. $this->register_meta(); add_filter( 'admin_body_class', array( $this, 'body_class' ) ); } /** * Expose the preference on core's user REST endpoint. * * @return void */ public function register_meta() { register_meta( 'user', self::META_KEY, array( 'type' => 'string', 'single' => true, 'default' => self::DEFAULT_MODE, 'show_in_rest' => true, 'sanitize_callback' => array( __CLASS__, 'sanitize' ), 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { return current_user_can( 'edit_user', $object_id ); }, ) ); } /** * Force any value into one of the three modes. * * This value ends up inside a class attribute on , so it is a trust * boundary. The REST endpoint belongs to core, which means the payload * shape is not ours to control: arrays and nulls have to fall through * quietly rather than raise a warning. * * @param mixed $value Raw value from REST, the database, or WP-CLI. * * @return string One of self::MODES. */ public static function sanitize( $value ) { if ( ! is_string( $value ) ) { return self::DEFAULT_MODE; } return in_array( $value, self::MODES, true ) ? $value : self::DEFAULT_MODE; } /** * The preference for a user. * * Sanitises on the way out as well as in. Meta can be written by WP-CLI, * by other code, or by an older version of this plugin, so reading it is * just as much a trust boundary as writing it. * * @param int $user_id 0 for the current user. * * @return string One of self::MODES. */ public static function get_for_user( $user_id = 0 ) { $user_id = $user_id ? $user_id : get_current_user_id(); if ( ! $user_id ) { return self::DEFAULT_MODE; } return self::sanitize( get_user_meta( $user_id, self::META_KEY, true ) ); } /** * Whether the feature is visible to anyone. * * On since the conversion finished. The filter stays so a site owner who * does not want dark mode can turn the whole thing off in one line: * * add_filter( 'booktics_enable_dark_mode', '__return_false' ); * * @return bool */ public static function is_enabled() { return (bool) apply_filters( 'booktics_enable_dark_mode', true ); } /** * Add the theme class to on the Booktics screen only. * * Returns other screens untouched, and does not look up a user on them, * so this costs nothing anywhere else in wp-admin. * * @param string $classes Space separated classes from WordPress. * * @return string */ public function body_class( $classes ) { if ( ! $this->is_booktics_screen() ) { return $classes; } return $classes . ' booktics-theme-' . self::get_for_user(); } /** * Whether the current request is rendering the Booktics admin screen. * * get_current_screen() is unavailable early in the request, hence the * function_exists guard. * * @return bool */ private function is_booktics_screen() { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); return $screen && self::SCREEN === $screen->base; } }