Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.18% covered (success)
98.18%
54 / 55
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expiry_Notice_Dismiss
98.18% covered (success)
98.18%
54 / 55
91.67% covered (success)
91.67%
11 / 12
29
0.00% covered (danger)
0.00%
0 / 1
 register_user_meta
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 meta_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 banner_meta_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dismiss
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 is_dismissible
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 should_show_banner
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 should_show_modal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 modal_meta_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 modal_dismissal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 is_dismissed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 term_expiry_ts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_dismissed_at
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2/**
3 * Expiry_Notice_Dismiss: who has already closed which expiry notice.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices;
11
12use Automattic\Jetpack\Constants;
13
14/**
15 * Dismissals live in user meta written through core's `/wp/v2/users/me`, so
16 * wp-admin and the front end read and write the same record.
17 *
18 * The `META_*` constants are base names; the stored key is per site (see meta_key()).
19 */
20class Expiry_Notice_Dismiss {
21
22    // One key for every banner surface.
23    const META_BANNER = 'wpcom_plan_expiry_notice_dismiss';
24    const META_MODAL  = 'wpcom_plan_expiry_modal_dismiss';
25    // Separate from META_MODAL: a grace dismissal is stamped after `expiry_ts`
26    // and would otherwise satisfy the post-grace check for a modal never seen.
27    const META_MODAL_GRACE = 'wpcom_plan_expiry_modal_dismiss_grace';
28
29    const FINAL_WINDOW_DAYS = 7;
30
31    // Stands in for the "browser session" the design asks for: wp-admin and
32    // Calypso are separate origins, so the dismissal has to live server-side.
33    const MODAL_GRACE_DISMISS_TTL = DAY_IN_SECONDS;
34
35    /**
36     * Register the dismiss meta keys for REST writes by the user themselves.
37     *
38     * The stored value is always the server's clock, whatever the client sent:
39     * it is compared against the term's expiry to tell one lapse from the next.
40     */
41    public static function register_user_meta(): void {
42        foreach ( array( self::META_BANNER, self::META_MODAL, self::META_MODAL_GRACE ) as $base ) {
43            register_meta(
44                'user',
45                self::meta_key( $base ),
46                array(
47                    'show_in_rest'      => true,
48                    'single'            => true,
49                    'type'              => 'integer',
50                    'sanitize_callback' => static function () {
51                        return time();
52                    },
53                    'auth_callback'     => static function () {
54                        return current_user_can( 'manage_options' );
55                    },
56                )
57            );
58        }
59    }
60
61    /**
62     * The stored meta key for a base name, prefixed per site the way core
63     * keys per-site user settings: on Simple every site shares one usermeta
64     * table, and a dismissal on one site must not silence another.
65     *
66     * @param string $base One of the `META_*` constants.
67     */
68    public static function meta_key( string $base ): string {
69        global $wpdb;
70        return $wpdb->get_blog_prefix() . $base;
71    }
72
73    /**
74     * The key the banner dismisses to.
75     */
76    public static function banner_meta_key(): string {
77        return self::meta_key( self::META_BANNER );
78    }
79
80    /**
81     * Stamp a dismissal for a user, now. False for a key that is not one of
82     * this site's dismissal keys, so nothing can write other user meta through
83     * it, and for a user who does not exist.
84     *
85     * @param int    $user_id  The dismissing user.
86     * @param string $meta_key One of this site's dismissal keys, as the surface was given it.
87     */
88    public static function dismiss( int $user_id, string $meta_key ): bool {
89        $allowed = array_map( array( self::class, 'meta_key' ), array( self::META_BANNER, self::META_MODAL, self::META_MODAL_GRACE ) );
90        if ( ! $user_id || ! in_array( $meta_key, $allowed, true ) ) {
91            return false;
92        }
93        $now = time();
94        update_user_meta( $user_id, $meta_key, $now );
95        return (int) get_user_meta( $user_id, $meta_key, true ) === $now;
96    }
97
98    /**
99     * Whether the banner for this state can be dismissed at all.
100     *
101     * Once the plan has expired, except on Atomic, where the revert ahead would break plugins and themes.
102     *
103     * @param array<string,mixed> $expiry_state State from Expiry_Data::get_expiry_state().
104     */
105    public static function is_dismissible( array $expiry_state ): bool {
106        $has_expired = in_array( $expiry_state['state'] ?? '', array( Expiry_Data::STATE_EXPIRED_GRACE, Expiry_Data::STATE_EXPIRED ), true );
107        return $has_expired && ! Constants::is_true( 'IS_ATOMIC' );
108    }
109
110    /**
111     * Whether the banner should show for this user right now.
112     *
113     * @param array<string,mixed> $expiry_state State from Expiry_Data::get_expiry_state().
114     * @param int|null            $user_id      Defaults to the current user.
115     */
116    public static function should_show_banner( array $expiry_state, ?int $user_id = null ): bool {
117        return ! self::is_dismissible( $expiry_state )
118            || ! self::is_dismissed( $user_id, self::banner_meta_key(), self::term_expiry_ts( $expiry_state ) );
119    }
120
121    /**
122     * Whether the modal should show for this user right now.
123     *
124     * @param array<string,mixed> $expiry_state State from Expiry_Data::get_expiry_state().
125     * @param int|null            $user_id      Defaults to the current user.
126     */
127    public static function should_show_modal( array $expiry_state, ?int $user_id = null ): bool {
128        $dismissal = self::modal_dismissal( $expiry_state );
129        if ( null === $dismissal ) {
130            return false;
131        }
132        return ! self::is_dismissed( $user_id, $dismissal['key'], self::term_expiry_ts( $expiry_state ), $dismissal['ttl'] );
133    }
134
135    /**
136     * The meta key the modal dismisses to in this state, or null where it never shows.
137     *
138     * @param array<string,mixed> $expiry_state State from Expiry_Data::get_expiry_state().
139     */
140    public static function modal_meta_key( array $expiry_state ): ?string {
141        return self::modal_dismissal( $expiry_state )['key'] ?? null;
142    }
143
144    /**
145     * How the modal dismisses in this state: in grace it comes back after a
146     * day, after the revert saying so once is enough.
147     *
148     * @param array<string,mixed> $expiry_state State from Expiry_Data::get_expiry_state().
149     * @return array{key:string,ttl:int|null}|null
150     */
151    private static function modal_dismissal( array $expiry_state ): ?array {
152        switch ( $expiry_state['state'] ?? '' ) {
153            case Expiry_Data::STATE_EXPIRED_GRACE:
154                return array(
155                    'key' => self::meta_key( self::META_MODAL_GRACE ),
156                    'ttl' => self::MODAL_GRACE_DISMISS_TTL,
157                );
158            case Expiry_Data::STATE_EXPIRED:
159                return array(
160                    'key' => self::meta_key( self::META_MODAL ),
161                    'ttl' => null,
162                );
163            default:
164                return null;
165        }
166    }
167
168    /**
169     * Whether this user has dismissed the notice for the term the state describes.
170     *
171     * A stamp older than the term's own expiry belongs to a purchase since
172     * renewed and does not count: once the plan is gone, expiry_ts is the
173     * revert time itself, so a dismissal from before the revert never
174     * carries over into the state after it.
175     *
176     * @param int|null $user_id   Defaults to the current user.
177     * @param string   $meta_key  A stored key, from meta_key().
178     * @param int|null $expiry_ts Expiry of the term being judged; null counts any stored dismissal.
179     * @param int|null $ttl       Seconds a dismissal holds for; null never lapses.
180     */
181    public static function is_dismissed( ?int $user_id, string $meta_key, ?int $expiry_ts = null, ?int $ttl = null ): bool {
182        $dismissed_at = self::get_dismissed_at( $user_id, $meta_key );
183        if ( null === $dismissed_at ) {
184            return false;
185        }
186        if ( null !== $ttl && $dismissed_at < time() - $ttl ) {
187            return false;
188        }
189        return null === $expiry_ts || $dismissed_at >= $expiry_ts;
190    }
191
192    /**
193     * The expiry timestamp a state carries, or null when it has none.
194     *
195     * @param array<string,mixed> $expiry_state State from Expiry_Data::get_expiry_state().
196     */
197    private static function term_expiry_ts( array $expiry_state ): ?int {
198        return isset( $expiry_state['expiry_ts'] ) ? (int) $expiry_state['expiry_ts'] : null;
199    }
200
201    /**
202     * The stored dismissal timestamp, or null if none.
203     *
204     * @param int|null $user_id  Defaults to the current user.
205     * @param string   $meta_key A stored key, from meta_key().
206     */
207    private static function get_dismissed_at( ?int $user_id, string $meta_key ): ?int {
208        $user_id ??= get_current_user_id();
209        if ( ! $user_id ) {
210            return null;
211        }
212        $raw = get_user_meta( $user_id, $meta_key, true );
213        return is_numeric( $raw ) && (int) $raw > 0 ? (int) $raw : null;
214    }
215}