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