From 87d20151da48a9904aeabf5848f5caf98c9682c4 Mon Sep 17 00:00:00 2001 From: Filip Pacurar Date: Wed, 9 Aug 2023 23:52:10 +0300 Subject: [PATCH] Feature: Notification polling on the client --- src/poller.ts | 46 +++++++++++++++++++++++++++++++++++++++++ src/store/actions.ts | 7 +++++++ src/store/reducer.ts | 32 ++++++++++++++++++++++++++++ src/store/resolvers.ts | 10 ++++++--- src/store/selectors.ts | 4 +++- src/wp-notifications.ts | 22 +++++++++++++++++++- 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/poller.ts diff --git a/src/poller.ts b/src/poller.ts new file mode 100644 index 00000000..da7c08c2 --- /dev/null +++ b/src/poller.ts @@ -0,0 +1,46 @@ +export class Poller { + /** + * On pages like the post editor or post list, the heartbeat is fired right at the beginning + * of the page load. We already fetched the notifications on page load, so this would result + * in a duplicate request. To prevent this, we skip the first heartbeat tick for the first X seconds. + */ + SKIP_FIRST_INTERVAL = 5000; + public skipFirstBeat = true; + + constructor( shouldSkipFirstBeat = true ) { + this.skipFirstBeat = shouldSkipFirstBeat; + } + + public pollUpdates() { + if ( this.skipFirstBeat ) { + return; + } + window.wp.notifications.fetchUpdates( true ); + } + + public unsetSkipFirstBeat() { + setTimeout( () => { + this.skipFirstBeat = false; + }, this.SKIP_FIRST_INTERVAL ); + } + + public start() { + if ( this.skipFirstBeat ) { + this.unsetSkipFirstBeat(); + } + + wp.hooks.addAction( + 'heartbeat.tick', + 'wp-notifications/pollUpdates', + this.pollUpdates.bind( this ) + ); + } + + public stop() { + wp.hooks.removeAction( + 'heartbeat.tick', + 'wp-notifications/pollUpdates', + this.pollUpdates.bind( this ) + ); + } +} diff --git a/src/store/actions.ts b/src/store/actions.ts index 390500b7..a33555cf 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -13,6 +13,13 @@ export const hydrate = ( payload: Notice[] ) => { }; }; +export const rehydrate = ( payload: Notice[] ) => { + return { + type: 'REHYDRATE' as const, + payload, + }; +}; + /** * Action creator to clear a notification context from the store. * diff --git a/src/store/reducer.ts b/src/store/reducer.ts index fb3085c8..dff6743e 100644 --- a/src/store/reducer.ts +++ b/src/store/reducer.ts @@ -24,6 +24,38 @@ const reducer: Reducer< State, Action > = ( state = {}, action ) => { } ); return updated; } + case 'REHYDRATE': { + let updated = { ...state }; + // Merge the new notifications with the existing ones. + action.payload.forEach( ( notification ) => { + const context = notification.context || 'adminbar'; + + const existingOnes = updated[ context ] || []; + const existing = existingOnes.findIndex( + ( notice ) => notice.id === notification.id + ); + + if ( existing > -1 ) { + updated[ context ][ existing ] = notification; + } else { + updated = { + ...updated, + [ context ]: [ ...updated[ context ], notification ], + }; + } + } ); + + // Remove any notifications that are no longer in the payload. + for ( const context in updated ) { + updated[ context ] = updated[ context ].filter( ( notice ) => { + return action.payload.find( ( payloadNotice ) => { + return payloadNotice.id === notice.id; + } ); + } ); + } + + return updated; + } case 'ADD': { return { ...state, diff --git a/src/store/resolvers.ts b/src/store/resolvers.ts index 0fdf033e..c4bdc84b 100644 --- a/src/store/resolvers.ts +++ b/src/store/resolvers.ts @@ -1,12 +1,16 @@ -import { fetchAPI, hydrate } from './actions'; +import { fetchAPI, hydrate, rehydrate } from './actions'; /** * Fetch the rest api in order to get new notifications + * + * @param force */ -export const fetchUpdates = function* () { +export const fetchUpdates = function* ( force = false ) { const newNotifications = yield fetchAPI(); + const action = force ? rehydrate : hydrate; + if ( newNotifications ) { - return hydrate( newNotifications ); + return action( newNotifications ); } }; diff --git a/src/store/selectors.ts b/src/store/selectors.ts index 61857b9a..d3f808f9 100644 --- a/src/store/selectors.ts +++ b/src/store/selectors.ts @@ -10,9 +10,11 @@ import type { State } from './index'; * Fetch the rest api in order to get new notifications * * @param state the current state + * @param force * @return the new notifications */ -export const fetchUpdates = ( state: State ): State => state || {}; +export const fetchUpdates = ( state: State, force = false ): State => + state || {}; /** * Get the notices for the given context diff --git a/src/wp-notifications.ts b/src/wp-notifications.ts index e343f23a..92edce35 100644 --- a/src/wp-notifications.ts +++ b/src/wp-notifications.ts @@ -5,6 +5,7 @@ import './styles/wp-notifications.scss'; /** The store default data */ import { STORE_NAMESPACE } from './constants'; +import { Poller } from './poller'; import { contexts } from './store/constants'; import type { Notice } from './types'; import { addContext } from './utils/init'; @@ -20,8 +21,23 @@ export * as store from './store'; const notifications = { /** * Fetch for new notices + * + * @param forceRefresh - Whether to force a refresh of the notices, or use the cached value. */ - fetchUpdates: () => select( STORE_NAMESPACE ).fetchUpdates(), + fetchUpdates: ( forceRefresh = false ) => { + return new Promise( ( resolve ) => { + if ( ! forceRefresh ) { + resolve( select( STORE_NAMESPACE ).fetchUpdates( false ) ); + return; + } + + dispatch( STORE_NAMESPACE ) + .invalidateResolution( 'fetchUpdates', [ true ] ) + .then( () => { + resolve( select( STORE_NAMESPACE ).fetchUpdates( true ) ); + } ); + } ); + }, /** * List all notifications or those of a particular context @@ -67,6 +83,8 @@ const notifications = { */ clear: ( context = 'adminbar' ) => dispatch( STORE_NAMESPACE ).clear( context ), + + poller: new Poller(), }; /** Appends the wp-notifications instance to window.wp in order to provide a public API */ @@ -82,6 +100,8 @@ contexts.forEach( ( context ) => /** after registering contexts we could fetch the notifications */ select( STORE_NAMESPACE ).fetchUpdates(); +notifications.poller.start(); + /** * Loops into contexts and adds a NoticesArea component for each one */