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