class-yoast-notification-center.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. <?php
  2. /**
  3. * WPSEO plugin file.
  4. *
  5. * @package WPSEO\Admin\Notifications
  6. */
  7. /**
  8. * Handles notifications storage and display.
  9. */
  10. class Yoast_Notification_Center {
  11. /** Option name to store notifications on */
  12. const STORAGE_KEY = 'yoast_notifications';
  13. /** @var \Yoast_Notification_Center The singleton instance of this object */
  14. private static $instance = null;
  15. /** @var $notifications Yoast_Notification[] */
  16. private $notifications = array();
  17. /** @var array Notifications there are newly added */
  18. private $new = array();
  19. /** @var array Notifications that were resolved this execution */
  20. private $resolved = 0;
  21. /** @var array Internal storage for transaction before notifications have been retrieved from storage. */
  22. private $queued_transactions = array();
  23. /** @var bool Internal flag for whether notifications have been retrieved from storage. */
  24. private $notifications_retrieved = false;
  25. /**
  26. * Construct
  27. */
  28. private function __construct() {
  29. add_action( 'init', array( $this, 'setup_current_notifications' ), 1 );
  30. add_action( 'all_admin_notices', array( $this, 'display_notifications' ) );
  31. add_action( 'wp_ajax_yoast_get_notifications', array( $this, 'ajax_get_notifications' ) );
  32. add_action( 'wpseo_deactivate', array( $this, 'deactivate_hook' ) );
  33. add_action( 'shutdown', array( $this, 'update_storage' ) );
  34. }
  35. /**
  36. * Singleton getter
  37. *
  38. * @return Yoast_Notification_Center
  39. */
  40. public static function get() {
  41. if ( null === self::$instance ) {
  42. self::$instance = new self();
  43. }
  44. return self::$instance;
  45. }
  46. /**
  47. * Dismiss a notification
  48. */
  49. public static function ajax_dismiss_notification() {
  50. $notification_center = self::get();
  51. $notification_id = filter_input( INPUT_POST, 'notification' );
  52. if ( empty( $notification_id ) ) {
  53. die( '-1' );
  54. }
  55. $notification = $notification_center->get_notification_by_id( $notification_id );
  56. if ( false === ( $notification instanceof Yoast_Notification ) ) {
  57. // Permit legacy.
  58. $notification = new Yoast_Notification( '', array(
  59. 'id' => $notification_id,
  60. 'dismissal_key' => $notification_id,
  61. ) );
  62. }
  63. if ( self::maybe_dismiss_notification( $notification ) ) {
  64. die( '1' );
  65. }
  66. die( '-1' );
  67. }
  68. /**
  69. * Check if the user has dismissed a notification
  70. *
  71. * @param Yoast_Notification $notification The notification to check for dismissal.
  72. * @param null|int $user_id User ID to check on.
  73. *
  74. * @return bool
  75. */
  76. public static function is_notification_dismissed( Yoast_Notification $notification, $user_id = null ) {
  77. $user_id = ( ! is_null( $user_id ) ? $user_id : get_current_user_id() );
  78. $dismissal_key = $notification->get_dismissal_key();
  79. // This checks both the site-specific user option and the meta value.
  80. $current_value = get_user_option( $dismissal_key, $user_id );
  81. // Migrate old user meta to user option on-the-fly.
  82. if ( ! empty( $current_value )
  83. && metadata_exists( 'user', $user_id, $dismissal_key )
  84. && update_user_option( $user_id, $dismissal_key, $current_value ) ) {
  85. delete_user_meta( $user_id, $dismissal_key );
  86. }
  87. return ! empty( $current_value );
  88. }
  89. /**
  90. * Check if the nofitication is being dismissed
  91. *
  92. * @param string|Yoast_Notification $notification Notification to check dismissal of.
  93. * @param string $meta_value Value to set the meta value to if dismissed.
  94. *
  95. * @return bool True if dismissed.
  96. */
  97. public static function maybe_dismiss_notification( Yoast_Notification $notification, $meta_value = 'seen' ) {
  98. // Only persistent notifications are dismissible.
  99. if ( ! $notification->is_persistent() ) {
  100. return false;
  101. }
  102. // If notification is already dismissed, we're done.
  103. if ( self::is_notification_dismissed( $notification ) ) {
  104. return true;
  105. }
  106. $dismissal_key = $notification->get_dismissal_key();
  107. $notification_id = $notification->get_id();
  108. $is_dismissing = ( $dismissal_key === self::get_user_input( 'notification' ) );
  109. if ( ! $is_dismissing ) {
  110. $is_dismissing = ( $notification_id === self::get_user_input( 'notification' ) );
  111. }
  112. // Fallback to ?dismissal_key=1&nonce=bla when JavaScript fails.
  113. if ( ! $is_dismissing ) {
  114. $is_dismissing = ( '1' === self::get_user_input( $dismissal_key ) );
  115. }
  116. if ( ! $is_dismissing ) {
  117. return false;
  118. }
  119. $user_nonce = self::get_user_input( 'nonce' );
  120. if ( false === wp_verify_nonce( $user_nonce, $notification_id ) ) {
  121. return false;
  122. }
  123. return self::dismiss_notification( $notification, $meta_value );
  124. }
  125. /**
  126. * Dismisses a notification.
  127. *
  128. * @param Yoast_Notification $notification Notification to dismiss.
  129. * @param string $meta_value Value to save in the dismissal.
  130. *
  131. * @return bool True if dismissed, false otherwise.
  132. */
  133. public static function dismiss_notification( Yoast_Notification $notification, $meta_value = 'seen' ) {
  134. // Dismiss notification.
  135. return update_user_option( get_current_user_id(), $notification->get_dismissal_key(), $meta_value ) !== false;
  136. }
  137. /**
  138. * Restores a notification.
  139. *
  140. * @param Yoast_Notification $notification Notification to restore.
  141. *
  142. * @return bool True if restored, false otherwise.
  143. */
  144. public static function restore_notification( Yoast_Notification $notification ) {
  145. $user_id = get_current_user_id();
  146. $dismissal_key = $notification->get_dismissal_key();
  147. // Restore notification.
  148. $restored = delete_user_option( $user_id, $dismissal_key );
  149. // Delete unprefixed user meta too for backward-compatibility.
  150. if ( metadata_exists( 'user', $user_id, $dismissal_key ) ) {
  151. $restored = delete_user_meta( $user_id, $dismissal_key ) && $restored;
  152. }
  153. return $restored;
  154. }
  155. /**
  156. * Clear dismissal information for the specified Notification
  157. *
  158. * When a cause is resolved, the next time it is present we want to show
  159. * the message again.
  160. *
  161. * @param string|Yoast_Notification $notification Notification to clear the dismissal of.
  162. *
  163. * @return bool
  164. */
  165. public function clear_dismissal( $notification ) {
  166. global $wpdb;
  167. if ( $notification instanceof Yoast_Notification ) {
  168. $dismissal_key = $notification->get_dismissal_key();
  169. }
  170. if ( is_string( $notification ) ) {
  171. $dismissal_key = $notification;
  172. }
  173. if ( empty( $dismissal_key ) ) {
  174. return false;
  175. }
  176. // Remove notification dismissal for all users.
  177. $deleted = delete_metadata( 'user', 0, $wpdb->get_blog_prefix() . $dismissal_key, '', true );
  178. // Delete unprefixed user meta too for backward-compatibility.
  179. $deleted = delete_metadata( 'user', 0, $dismissal_key, '', true ) || $deleted;
  180. return $deleted;
  181. }
  182. /**
  183. * Retrieves notifications from the storage and merges in previous notification changes.
  184. *
  185. * The current user in WordPress is not loaded shortly before the 'init' hook, but the plugin
  186. * sometimes needs to add or remove notifications before that. In such cases, the transactions
  187. * are not actually executed, but added to a queue. That queue is then handled in this method,
  188. * after notifications for the current user have been set up.
  189. *
  190. * @return void
  191. */
  192. public function setup_current_notifications() {
  193. $this->retrieve_notifications_from_storage();
  194. foreach ( $this->queued_transactions as $transaction ) {
  195. list( $callback, $args ) = $transaction;
  196. call_user_func_array( $callback, $args );
  197. }
  198. $this->queued_transactions = array();
  199. }
  200. /**
  201. * Add notification to the cookie
  202. *
  203. * @param Yoast_Notification $notification Notification object instance.
  204. */
  205. public function add_notification( Yoast_Notification $notification ) {
  206. $callback = array( $this, __METHOD__ );
  207. $args = func_get_args();
  208. if ( $this->queue_transaction( $callback, $args ) ) {
  209. return;
  210. }
  211. // Don't add if the user can't see it.
  212. if ( ! $notification->display_for_current_user() ) {
  213. return;
  214. }
  215. $notification_id = $notification->get_id();
  216. // Empty notifications are always added.
  217. if ( $notification_id !== '' ) {
  218. // If notification ID exists in notifications, don't add again.
  219. $present_notification = $this->get_notification_by_id( $notification_id );
  220. if ( ! is_null( $present_notification ) ) {
  221. $this->remove_notification( $present_notification, false );
  222. }
  223. if ( is_null( $present_notification ) ) {
  224. $this->new[] = $notification_id;
  225. }
  226. }
  227. // Add to list.
  228. $this->notifications[] = $notification;
  229. }
  230. /**
  231. * Get the notification by ID
  232. *
  233. * @param string $notification_id The ID of the notification to search for.
  234. *
  235. * @return null|Yoast_Notification
  236. */
  237. public function get_notification_by_id( $notification_id ) {
  238. foreach ( $this->notifications as & $notification ) {
  239. if ( $notification_id === $notification->get_id() ) {
  240. return $notification;
  241. }
  242. }
  243. return null;
  244. }
  245. /**
  246. * Display the notifications
  247. *
  248. * @param bool $echo_as_json True when notifications should be printed directly.
  249. *
  250. * @return void
  251. */
  252. public function display_notifications( $echo_as_json = false ) {
  253. // Never display notifications for network admin.
  254. if ( function_exists( 'is_network_admin' ) && is_network_admin() ) {
  255. return;
  256. }
  257. $sorted_notifications = $this->get_sorted_notifications();
  258. $notifications = array_filter( $sorted_notifications, array( $this, 'is_notification_persistent' ) );
  259. if ( empty( $notifications ) ) {
  260. return;
  261. }
  262. array_walk( $notifications, array( $this, 'remove_notification' ) );
  263. $notifications = array_unique( $notifications );
  264. if ( $echo_as_json ) {
  265. $notification_json = array();
  266. /**
  267. * @var Yoast_Notification[] $notifications
  268. */
  269. foreach ( $notifications as $notification ) {
  270. $notification_json[] = $notification->render();
  271. }
  272. echo wp_json_encode( $notification_json );
  273. return;
  274. }
  275. foreach ( $notifications as $notification ) {
  276. echo $notification;
  277. }
  278. }
  279. /**
  280. * Remove notification after it has been displayed
  281. *
  282. * @param Yoast_Notification $notification Notification to remove.
  283. * @param bool $resolve Resolve as fixed.
  284. */
  285. public function remove_notification( Yoast_Notification $notification, $resolve = true ) {
  286. $callback = array( $this, __METHOD__ );
  287. $args = func_get_args();
  288. if ( $this->queue_transaction( $callback, $args ) ) {
  289. return;
  290. }
  291. $index = false;
  292. // Match persistent Notifications by ID, non persistent by item in the array.
  293. if ( $notification->is_persistent() ) {
  294. foreach ( $this->notifications as $current_index => $present_notification ) {
  295. if ( $present_notification->get_id() === $notification->get_id() ) {
  296. $index = $current_index;
  297. break;
  298. }
  299. }
  300. }
  301. else {
  302. $index = array_search( $notification, $this->notifications, true );
  303. }
  304. if ( false === $index ) {
  305. return;
  306. }
  307. if ( $notification->is_persistent() && $resolve ) {
  308. $this->resolved++;
  309. $this->clear_dismissal( $notification );
  310. }
  311. unset( $this->notifications[ $index ] );
  312. $this->notifications = array_values( $this->notifications );
  313. }
  314. /**
  315. * Removes a notification by its ID.
  316. *
  317. * @param string $notification_id The notification id.
  318. * @param bool $resolve Resolve as fixed.
  319. *
  320. * @return void
  321. */
  322. public function remove_notification_by_id( $notification_id, $resolve = true ) {
  323. $notification = $this->get_notification_by_id( $notification_id );
  324. if ( $notification === null ) {
  325. return;
  326. }
  327. $this->remove_notification( $notification, $resolve );
  328. }
  329. /**
  330. * Get the notification count
  331. *
  332. * @param bool $dismissed Count dismissed notifications.
  333. *
  334. * @return int Number of notifications
  335. */
  336. public function get_notification_count( $dismissed = false ) {
  337. $notifications = $this->get_notifications();
  338. $notifications = array_filter( $notifications, array( $this, 'filter_persistent_notifications' ) );
  339. if ( ! $dismissed ) {
  340. $notifications = array_filter( $notifications, array( $this, 'filter_dismissed_notifications' ) );
  341. }
  342. return count( $notifications );
  343. }
  344. /**
  345. * Get the number of notifications resolved this execution
  346. *
  347. * These notifications have been resolved and should be counted when active again.
  348. *
  349. * @return int
  350. */
  351. public function get_resolved_notification_count() {
  352. return $this->resolved;
  353. }
  354. /**
  355. * Return the notifications sorted on type and priority
  356. *
  357. * @return array|Yoast_Notification[] Sorted Notifications
  358. */
  359. public function get_sorted_notifications() {
  360. $notifications = $this->get_notifications();
  361. if ( empty( $notifications ) ) {
  362. return array();
  363. }
  364. // Sort by severity, error first.
  365. usort( $notifications, array( $this, 'sort_notifications' ) );
  366. return $notifications;
  367. }
  368. /**
  369. * AJAX display notifications
  370. */
  371. public function ajax_get_notifications() {
  372. $echo = filter_input( INPUT_POST, 'version' ) === '2';
  373. // Display the notices.
  374. $this->display_notifications( $echo );
  375. // AJAX die.
  376. exit;
  377. }
  378. /**
  379. * Remove storage when the plugin is deactivated
  380. */
  381. public function deactivate_hook() {
  382. $this->clear_notifications();
  383. }
  384. /**
  385. * Save persistent notifications to storage
  386. *
  387. * We need to be able to retrieve these so they can be dismissed at any time during the execution.
  388. *
  389. * @since 3.2
  390. *
  391. * @return void
  392. */
  393. public function update_storage() {
  394. $notifications = $this->get_notifications();
  395. /**
  396. * Filter: 'yoast_notifications_before_storage' - Allows developer to filter notifications before saving them.
  397. *
  398. * @api Yoast_Notification[] $notifications
  399. */
  400. $notifications = apply_filters( 'yoast_notifications_before_storage', $notifications );
  401. // No notifications to store, clear storage.
  402. if ( empty( $notifications ) ) {
  403. $this->remove_storage();
  404. return;
  405. }
  406. $notifications = array_map( array( $this, 'notification_to_array' ), $notifications );
  407. // Save the notifications to the storage.
  408. update_user_option( get_current_user_id(), self::STORAGE_KEY, $notifications );
  409. }
  410. /**
  411. * Provide a way to verify present notifications
  412. *
  413. * @return array|Yoast_Notification[] Registered notifications.
  414. */
  415. public function get_notifications() {
  416. return $this->notifications;
  417. }
  418. /**
  419. * Get newly added notifications
  420. *
  421. * @return array
  422. */
  423. public function get_new_notifications() {
  424. return array_map( array( $this, 'get_notification_by_id' ), $this->new );
  425. }
  426. /**
  427. * Get information from the User input
  428. *
  429. * @param string $key Key to retrieve.
  430. *
  431. * @return mixed value of key if set.
  432. */
  433. private static function get_user_input( $key ) {
  434. $filter_input_type = INPUT_GET;
  435. if ( 'POST' === strtoupper( $_SERVER['REQUEST_METHOD'] ) ) {
  436. $filter_input_type = INPUT_POST;
  437. }
  438. return filter_input( $filter_input_type, $key );
  439. }
  440. /**
  441. * Retrieve the notifications from storage
  442. *
  443. * @return array Yoast_Notification[] Notifications
  444. */
  445. private function retrieve_notifications_from_storage() {
  446. if ( $this->notifications_retrieved ) {
  447. return;
  448. }
  449. $this->notifications_retrieved = true;
  450. $stored_notifications = get_user_option( self::STORAGE_KEY, get_current_user_id() );
  451. // Check if notifications are stored.
  452. if ( empty( $stored_notifications ) ) {
  453. return;
  454. }
  455. if ( is_array( $stored_notifications ) ) {
  456. $notifications = array_map( array( $this, 'array_to_notification' ), $stored_notifications );
  457. // Apply array_values to ensure we get a 0-indexed array.
  458. $notifications = array_values( array_filter( $notifications, array( $this, 'filter_notification_current_user' ) ) );
  459. $this->notifications = $notifications;
  460. }
  461. }
  462. /**
  463. * Sort on type then priority
  464. *
  465. * @param Yoast_Notification $a Compare with B.
  466. * @param Yoast_Notification $b Compare with A.
  467. *
  468. * @return int 1, 0 or -1 for sorting offset.
  469. */
  470. private function sort_notifications( Yoast_Notification $a, Yoast_Notification $b ) {
  471. $a_type = $a->get_type();
  472. $b_type = $b->get_type();
  473. if ( $a_type === $b_type ) {
  474. return WPSEO_Utils::calc( $b->get_priority(), 'compare', $a->get_priority() );
  475. }
  476. if ( 'error' === $a_type ) {
  477. return -1;
  478. }
  479. if ( 'error' === $b_type ) {
  480. return 1;
  481. }
  482. return 0;
  483. }
  484. /**
  485. * Remove all notifications from storage
  486. */
  487. private function remove_storage() {
  488. delete_user_option( get_current_user_id(), self::STORAGE_KEY );
  489. }
  490. /**
  491. * Clear local stored notifications
  492. */
  493. private function clear_notifications() {
  494. $this->notifications = array();
  495. $this->notifications_retrieved = false;
  496. }
  497. /**
  498. * Filter out non-persistent notifications.
  499. *
  500. * @param Yoast_Notification $notification Notification to test for persistent.
  501. *
  502. * @since 3.2
  503. *
  504. * @return bool
  505. */
  506. private function filter_persistent_notifications( Yoast_Notification $notification ) {
  507. return $notification->is_persistent();
  508. }
  509. /**
  510. * Filter out dismissed notifications
  511. *
  512. * @param Yoast_Notification $notification Notification to check.
  513. *
  514. * @return bool
  515. */
  516. private function filter_dismissed_notifications( Yoast_Notification $notification ) {
  517. return ! $this->maybe_dismiss_notification( $notification );
  518. }
  519. /**
  520. * Convert Notification to array representation
  521. *
  522. * @param Yoast_Notification $notification Notification to convert.
  523. *
  524. * @since 3.2
  525. *
  526. * @return array
  527. */
  528. private function notification_to_array( Yoast_Notification $notification ) {
  529. $notification_data = $notification->to_array();
  530. if ( isset( $notification_data['nonce'] ) ) {
  531. unset( $notification_data['nonce'] );
  532. }
  533. return $notification_data;
  534. }
  535. /**
  536. * Convert stored array to Notification.
  537. *
  538. * @param array $notification_data Array to convert to Notification.
  539. *
  540. * @return Yoast_Notification
  541. */
  542. private function array_to_notification( $notification_data ) {
  543. if ( isset( $notification_data['options']['nonce'] ) ) {
  544. unset( $notification_data['options']['nonce'] );
  545. }
  546. return new Yoast_Notification(
  547. $notification_data['message'],
  548. $notification_data['options']
  549. );
  550. }
  551. /**
  552. * Filter notifications that should not be displayed for the current user
  553. *
  554. * @param Yoast_Notification $notification Notification to test.
  555. *
  556. * @return bool
  557. */
  558. private function filter_notification_current_user( Yoast_Notification $notification ) {
  559. return $notification->display_for_current_user();
  560. }
  561. /**
  562. * Checks if given notification is persistent.
  563. *
  564. * @param Yoast_Notification $notification The notification to check.
  565. *
  566. * @return bool True when notification is not persistent.
  567. */
  568. private function is_notification_persistent( Yoast_Notification $notification ) {
  569. return ! $notification->is_persistent();
  570. }
  571. /**
  572. * Queues a notification transaction for later execution if notifications are not yet set up.
  573. *
  574. * @param callable $callback Callback that performs the transaction.
  575. * @param array $args Arguments to pass to the callback.
  576. *
  577. * @return bool True if transaction was queued, false if it can be performed immediately.
  578. */
  579. private function queue_transaction( $callback, $args ) {
  580. if ( $this->notifications_retrieved ) {
  581. return false;
  582. }
  583. $this->add_transaction_to_queue( $callback, $args );
  584. return true;
  585. }
  586. /**
  587. * Adds a notification transaction to the queue for later execution.
  588. *
  589. * @param callable $callback Callback that performs the transaction.
  590. * @param array $args Arguments to pass to the callback.
  591. */
  592. private function add_transaction_to_queue( $callback, $args ) {
  593. $this->queued_transactions[] = array( $callback, $args );
  594. }
  595. }