Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.78% covered (danger)
44.78%
30 / 67
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Open_State_Store
44.78% covered (danger)
44.78%
30 / 67
50.00% covered (danger)
50.00%
3 / 6
65.67
0.00% covered (danger)
0.00%
0 / 1
 fetch
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 update
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 get_cached
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 normalize
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 cache
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 cache_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Open_State_Store file.
4 *
5 * @package automattic/jetpack-agents-manager
6 */
7
8namespace Automattic\Jetpack\Agents_Manager;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Status\Host;
13
14/**
15 * Reads and writes the Agents Manager open state.
16 *
17 * The state is a global, per-user wpcom preference behind the
18 * `/agents-manager/state` endpoint. How the server reads it depends on the site:
19 *
20 * - wpcom Simple: the preference is local, so read `calypso_preferences` directly.
21 * - WoA / self-hosted: the preference is remote, so reads/writes go through this
22 *   store's local REST route, which calls wpcom over the Jetpack Connection and
23 *   caches the result in a per-user transient. Latency-sensitive readers (the
24 *   server-side pre-render) use that transient to skip the round-trip.
25 */
26class Open_State_Store {
27
28    /**
29     * Transient key prefix for the cached per-user open state.
30     *
31     * @var string
32     */
33    private const TRANSIENT_PREFIX = 'agents_manager_open_state_';
34
35    /**
36     * Default state values.
37     *
38     * @var array
39     */
40    public const DEFAULTS = array(
41        'agents_manager_open'              => false,
42        'agents_manager_docked'            => false,
43        'agents_manager_minimized'         => false,
44        'agents_manager_floating_position' => 'right',
45        'agents_manager_router_history'    => null,
46        'agents_manager_last_activity'     => null,
47    );
48
49    /**
50     * Fetch the open state from wpcom and refresh the cache.
51     *
52     * @return array|\WP_Error Normalized state, or WP_Error when the request fails.
53     */
54    public static function fetch() {
55        $body = Client::wpcom_json_api_request_as_user(
56            '/agents-manager/state',
57            '2',
58            array( 'method' => 'GET' )
59        );
60
61        if ( is_wp_error( $body ) ) {
62            return $body;
63        }
64
65        $response = json_decode( wp_remote_retrieve_body( $body ), true );
66        $state    = self::normalize( is_array( $response ) ? $response : array() );
67
68        self::cache( $state );
69
70        return $state;
71    }
72
73    /**
74     * Persist the open state to wpcom and refresh the cache.
75     *
76     * @param array $state Partial state to update (subset of DEFAULTS keys).
77     * @return array|\WP_Error Normalized state, or WP_Error when the request fails.
78     */
79    public static function update( array $state ) {
80        $body = Client::wpcom_json_api_request_as_user(
81            '/agents-manager/state',
82            '2',
83            array( 'method' => 'POST' ),
84            array( 'state' => $state )
85        );
86
87        if ( is_wp_error( $body ) ) {
88            return $body;
89        }
90
91        $response = json_decode( wp_remote_retrieve_body( $body ), true );
92
93        if ( ! is_array( $response ) ) {
94            return new \WP_Error(
95                'invalid_response',
96                'Invalid response from WPCOM endpoint',
97                array( 'status' => 500 )
98            );
99        }
100
101        $normalized = self::normalize( $response );
102
103        self::cache( $normalized );
104
105        return $normalized;
106    }
107
108    /**
109     * Read the current user's open state from the fastest local source.
110     *
111     * For latency-sensitive callers like the server-side pre-render: Simple sites
112     * read `calypso_preferences` directly, everywhere else uses the cached
113     * transient (see the class docblock). Returns null when nothing is known yet,
114     * so callers can skip pre-rendering until the frontend sets the real state.
115     *
116     * @return array|null `{ agents_manager_open, agents_manager_docked, agents_manager_minimized }` or null.
117     */
118    public static function get_cached() {
119        $user_id = get_current_user_id();
120        if ( ! $user_id ) {
121            return null;
122        }
123
124        // Simple sites have the preference locally, so read it directly (the
125        // transient is never primed there).
126        if ( ( new Host() )->is_wpcom_simple() && function_exists( '\get_user_attribute' ) ) {
127            $calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
128            if ( ! is_array( $calypso_prefs ) ) {
129                return null;
130            }
131
132            return array(
133                'agents_manager_open'      => (bool) ( $calypso_prefs['agents_manager_open'] ?? false ),
134                'agents_manager_docked'    => (bool) ( $calypso_prefs['agents_manager_docked'] ?? false ),
135                'agents_manager_minimized' => (bool) ( $calypso_prefs['agents_manager_minimized'] ?? false ),
136            );
137        }
138
139        // The cached state is only as good as the connection that produced it.
140        // Without a user connection the frontend cannot fetch or refresh state
141        // (the app never mounts), so a pre-render from a stale transient would
142        // flash a shell nothing ever takes down.
143        if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
144            return null;
145        }
146
147        $cached = get_transient( self::cache_key( $user_id ) );
148
149        return is_array( $cached ) ? $cached : null;
150    }
151
152    /**
153     * Normalize a raw endpoint response into the full state shape.
154     *
155     * @param array $response Raw decoded response.
156     * @return array Normalized state with all DEFAULTS keys present.
157     */
158    private static function normalize( array $response ): array {
159        return array(
160            'agents_manager_open'              => (bool) ( $response['agents_manager_open'] ?? self::DEFAULTS['agents_manager_open'] ),
161            'agents_manager_docked'            => (bool) ( $response['agents_manager_docked'] ?? self::DEFAULTS['agents_manager_docked'] ),
162            'agents_manager_minimized'         => (bool) ( $response['agents_manager_minimized'] ?? self::DEFAULTS['agents_manager_minimized'] ),
163            'agents_manager_floating_position' => $response['agents_manager_floating_position'] ?? self::DEFAULTS['agents_manager_floating_position'],
164            'agents_manager_router_history'    => $response['agents_manager_router_history'] ?? self::DEFAULTS['agents_manager_router_history'],
165            'agents_manager_last_activity'     => $response['agents_manager_last_activity'] ?? self::DEFAULTS['agents_manager_last_activity'],
166        );
167    }
168
169    /**
170     * Cache the open, docked and minimized bits in a per-user transient.
171     *
172     * Only used on the remote (WoA / self-hosted) path — it's what get_cached()
173     * reads there. Simple sites read `calypso_preferences` directly and skip this.
174     *
175     * @param array $state Normalized state.
176     */
177    private static function cache( array $state ): void {
178        $user_id = get_current_user_id();
179        if ( ! $user_id ) {
180            return;
181        }
182
183        /**
184         * Filter how long the cached open state lives.
185         *
186         * It's refreshed on every read/write through this store, so the TTL mainly
187         * caps how long a value changed elsewhere (e.g. in Calypso) stays stale.
188         *
189         * @since 0.4.0
190         *
191         * @param int $ttl Cache lifetime in seconds.
192         */
193        $ttl = (int) apply_filters( 'agents_manager_open_state_cache_ttl', WEEK_IN_SECONDS );
194
195        set_transient(
196            self::cache_key( $user_id ),
197            array(
198                'agents_manager_open'      => (bool) ( $state['agents_manager_open'] ?? false ),
199                'agents_manager_docked'    => (bool) ( $state['agents_manager_docked'] ?? false ),
200                'agents_manager_minimized' => (bool) ( $state['agents_manager_minimized'] ?? false ),
201            ),
202            $ttl
203        );
204    }
205
206    /**
207     * Build the per-user transient key.
208     *
209     * @param int $user_id User ID.
210     * @return string
211     */
212    private static function cache_key( int $user_id ): string {
213        return self::TRANSIENT_PREFIX . $user_id;
214    }
215}