Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.08% covered (danger)
43.08%
28 / 65
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Open_State_Store
43.08% covered (danger)
43.08%
28 / 65
50.00% covered (danger)
50.00%
3 / 6
70.30
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%
15 / 15
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%
12 / 12
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 }` 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            );
136        }
137
138        // The cached state is only as good as the connection that produced it.
139        // Without a user connection the frontend cannot fetch or refresh state
140        // (the app never mounts), so a pre-render from a stale transient would
141        // flash a shell nothing ever takes down.
142        if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
143            return null;
144        }
145
146        $cached = get_transient( self::cache_key( $user_id ) );
147
148        return is_array( $cached ) ? $cached : null;
149    }
150
151    /**
152     * Normalize a raw endpoint response into the full state shape.
153     *
154     * @param array $response Raw decoded response.
155     * @return array Normalized state with all DEFAULTS keys present.
156     */
157    private static function normalize( array $response ): array {
158        return array(
159            'agents_manager_open'              => (bool) ( $response['agents_manager_open'] ?? self::DEFAULTS['agents_manager_open'] ),
160            'agents_manager_docked'            => (bool) ( $response['agents_manager_docked'] ?? self::DEFAULTS['agents_manager_docked'] ),
161            'agents_manager_minimized'         => (bool) ( $response['agents_manager_minimized'] ?? self::DEFAULTS['agents_manager_minimized'] ),
162            'agents_manager_floating_position' => $response['agents_manager_floating_position'] ?? self::DEFAULTS['agents_manager_floating_position'],
163            'agents_manager_router_history'    => $response['agents_manager_router_history'] ?? self::DEFAULTS['agents_manager_router_history'],
164            'agents_manager_last_activity'     => $response['agents_manager_last_activity'] ?? self::DEFAULTS['agents_manager_last_activity'],
165        );
166    }
167
168    /**
169     * Cache the open/docked bits in a per-user transient.
170     *
171     * Only used on the remote (WoA / self-hosted) path — it's what get_cached()
172     * reads there. Simple sites read `calypso_preferences` directly and skip this.
173     *
174     * @param array $state Normalized state.
175     */
176    private static function cache( array $state ): void {
177        $user_id = get_current_user_id();
178        if ( ! $user_id ) {
179            return;
180        }
181
182        /**
183         * Filter how long the cached open state lives.
184         *
185         * It's refreshed on every read/write through this store, so the TTL mainly
186         * caps how long a value changed elsewhere (e.g. in Calypso) stays stale.
187         *
188         * @since 0.4.0
189         *
190         * @param int $ttl Cache lifetime in seconds.
191         */
192        $ttl = (int) apply_filters( 'agents_manager_open_state_cache_ttl', WEEK_IN_SECONDS );
193
194        set_transient(
195            self::cache_key( $user_id ),
196            array(
197                'agents_manager_open'   => (bool) ( $state['agents_manager_open'] ?? false ),
198                'agents_manager_docked' => (bool) ( $state['agents_manager_docked'] ?? false ),
199            ),
200            $ttl
201        );
202    }
203
204    /**
205     * Build the per-user transient key.
206     *
207     * @param int $user_id User ID.
208     * @return string
209     */
210    private static function cache_key( int $user_id ): string {
211        return self::TRANSIENT_PREFIX . $user_id;
212    }
213}