Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.18% |
54 / 55 |
|
91.67% |
11 / 12 |
CRAP | |
0.00% |
0 / 1 |
| Expiry_Notice_Dismiss | |
98.18% |
54 / 55 |
|
91.67% |
11 / 12 |
29 | |
0.00% |
0 / 1 |
| register_user_meta | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
| meta_key | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| banner_meta_key | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| dismiss | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| is_dismissible | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| should_show_banner | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| should_show_modal | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| modal_meta_key | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| modal_dismissal | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| is_dismissed | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| term_expiry_ts | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| get_dismissed_at | |
80.00% |
4 / 5 |
|
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 | |
| 8 | declare( strict_types = 1 ); |
| 9 | |
| 10 | namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices; |
| 11 | |
| 12 | use 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 | */ |
| 20 | class 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 | } |