From 7ca785a872297007e594147fa4bb190f48237354 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 5 Oct 2026 17:48:04 +0100 Subject: [PATCH 1/4] Add ability to manage notifications from sidebar --- .storybook/preview-head.html | 3 + inc/namespace.php | 3 + src/components/Sidebar.js | 8 +- .../Sidebar/NotificationSettings.js | 269 ++++++++++++++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/components/Sidebar/NotificationSettings.js diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index e63e5342..e944fb52 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -10,6 +10,9 @@ }, preload: {}, features: {}, + has: { + notifications: false, + }, }; window.wpApiSettings = {}; diff --git a/inc/namespace.php b/inc/namespace.php index da6fe566..4f1a6701 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -373,6 +373,9 @@ function get_script_data() { 'emoji' => apply_filters( 'h2.custom_emoji', [] ), ], 'features' => [], + 'has' => [ + 'notifications' => defined( 'FALCON_PATH' ), + ], 'plugins' => [ 'reactions' => \class_exists( 'H2\\Reactions\\Reaction' ), ], diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 1f180b7d..521324c2 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -6,6 +6,7 @@ import { Slot } from 'react-slot-fill'; import { showSidebarCategories, showSidebarPages, showSidebarPosts } from '../actions'; import { withWidgets } from '../hocs'; +import NotificationSettings from './Sidebar/NotificationSettings'; import RecentPostsWidget from './Widgets/RecentPosts'; import SearchWidget from './Widgets/Search'; @@ -74,8 +75,8 @@ export function Sidebar( props ) { className="Sidebar border-r border-solid border-hm-beige" >
-
-

+
+

+ { window.H2Data.has && window.H2Data.has.notifications && ( + + ) }

{ site.description || 'No description yet.' }

diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js new file mode 100644 index 00000000..9f4e3eb8 --- /dev/null +++ b/src/components/Sidebar/NotificationSettings.js @@ -0,0 +1,269 @@ +import React, { useEffect, useRef, useState } from 'react'; + +// Falcon stores preferences per-connector; H2 uses the WordPress connector. +const CONNECTOR = 'wordpress'; + +const MODES = [ + { + id: 'posts', + label: 'Just posts', + description: 'Get an email for every new post.', + value: { + post: 'all', + comment: '', + }, + }, + { + id: 'participant', + label: 'Posts & my threads', + description: 'New posts, plus comments on posts you’ve commented on.', + value: { + post: 'all', + comment: 'participant', + }, + }, + { + id: 'all', + label: 'All posts & comments', + description: 'Get an email for every new post and comment.', + value: { + post: 'all', + comment: 'all', + }, + }, +]; + +/** + * Find the mode matching the user's current preferences. + * + * @param {object} prefs Falcon preferences for the connector. + * @returns {string} Mode ID, "none", or "custom". + */ +const getMode = prefs => { + const mode = MODES.find( mode => ( + mode.value.post === prefs.post && mode.value.comment === prefs.comment + ) ); + if ( mode ) { + return mode.id; + } + + if ( ! prefs.post && ! prefs.comment ) { + return 'none'; + } + + // Set to a combination we don't offer (e.g. via the profile screen). + return 'custom'; +}; + +const getModeLabel = mode => { + switch ( mode ) { + case 'none': + return 'Off'; + + case 'custom': + return 'Custom'; + + default: + return MODES.find( m => m.id === mode ).label; + } +}; + +/** + * Make a request for the current user's preferences. + * + * @param {object|null} data Preferences to update, or null to fetch. + * @returns {Promise} Falcon preferences for the user. + */ +const request = ( data = null ) => { + const url = new URL( `${ window.wpApiSettings.root }wp/v2/users/me`, window.location.href ); + url.searchParams.set( 'context', 'edit' ); + url.searchParams.set( '_fields', 'falcon_preferences' ); + + const options = { + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'X-WP-Nonce': window.wpApiSettings.nonce, + }, + }; + if ( data ) { + options.method = 'POST'; + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify( { + falcon_preferences: data, + } ); + } + + return fetch( url, options ) + .then( response => response.json().then( body => { + if ( ! response.ok ) { + throw new Error( body.message || 'Could not load notification settings.' ); + } + return body.falcon_preferences; + } ) ); +}; + +const BellIcon = ( { mode } ) => { + const strokeProps = { + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + }; + + if ( mode === 'none' ) { + return ( + + ); + } + + return ( + + ); +}; + +export default function NotificationSettings() { + const [ prefs, setPrefs ] = useState( null ); + const [ error, setError ] = useState( null ); + const [ saving, setSaving ] = useState( false ); + const [ open, setOpen ] = useState( false ); + const container = useRef( null ); + + useEffect( () => { + let cancelled = false; + request().then( + data => ! cancelled && setPrefs( data[ CONNECTOR ] || null ), + err => ! cancelled && setError( err.message ) + ); + return () => { + cancelled = true; + }; + }, [] ); + + // Close when clicking outside, or pressing Escape. + useEffect( () => { + if ( ! open ) { + return; + } + + const onClick = e => { + if ( container.current && ! container.current.contains( e.target ) ) { + setOpen( false ); + } + }; + const onKeyDown = e => { + if ( e.key === 'Escape' ) { + setOpen( false ); + } + }; + document.addEventListener( 'mousedown', onClick ); + document.addEventListener( 'keydown', onKeyDown ); + return () => { + document.removeEventListener( 'mousedown', onClick ); + document.removeEventListener( 'keydown', onKeyDown ); + }; + }, [ open ] ); + + if ( ! prefs ) { + // Still loading, or Falcon isn't exposing preferences for this site. + return null; + } + + const mode = getMode( prefs ); + const label = `Email notifications: ${ getModeLabel( mode ) }`; + + const onSelect = next => { + const previous = prefs; + setPrefs( { + ...prefs, + ...next.value, + } ); + setError( null ); + setSaving( true ); + + request( { [ CONNECTOR ]: next.value } ).then( + data => { + setSaving( false ); + setPrefs( data[ CONNECTOR ] || null ); + }, + err => { + setSaving( false ); + setPrefs( previous ); + setError( err.message ); + } + ); + }; + + return ( +
+ + + { open && ( +
+

+ Email notifications +

+
    + { MODES.map( option => ( +
  • + +
  • + ) ) } +
+ { error && ( +

+ { error } +

+ ) } +
+ ) } +
+ ); +} From 6378fcdb40cdc977b427b1fb245aca4521fccff2 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 5 Oct 2026 17:59:13 +0100 Subject: [PATCH 2/4] Add none option for notifications --- .../Sidebar/NotificationSettings.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js index 9f4e3eb8..a0504804 100644 --- a/src/components/Sidebar/NotificationSettings.js +++ b/src/components/Sidebar/NotificationSettings.js @@ -31,13 +31,22 @@ const MODES = [ comment: 'all', }, }, + { + id: 'none', + label: 'None', + description: 'Unsubscribe from all email notifications.', + value: { + post: '', + comment: '', + }, + }, ]; /** * Find the mode matching the user's current preferences. * * @param {object} prefs Falcon preferences for the connector. - * @returns {string} Mode ID, "none", or "custom". + * @returns {string} Mode ID, or "custom". */ const getMode = prefs => { const mode = MODES.find( mode => ( @@ -47,25 +56,16 @@ const getMode = prefs => { return mode.id; } - if ( ! prefs.post && ! prefs.comment ) { - return 'none'; - } - // Set to a combination we don't offer (e.g. via the profile screen). return 'custom'; }; const getModeLabel = mode => { - switch ( mode ) { - case 'none': - return 'Off'; - - case 'custom': - return 'Custom'; - - default: - return MODES.find( m => m.id === mode ).label; + if ( mode === 'custom' ) { + return 'Custom'; } + + return MODES.find( m => m.id === mode ).label; }; /** From ce138e749547531f0bc92819aadf70ae2eb39590 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 5 Oct 2026 18:00:31 +0100 Subject: [PATCH 3/4] Reorder "none" to the top --- src/components/Sidebar/NotificationSettings.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js index a0504804..717e519c 100644 --- a/src/components/Sidebar/NotificationSettings.js +++ b/src/components/Sidebar/NotificationSettings.js @@ -4,6 +4,15 @@ import React, { useEffect, useRef, useState } from 'react'; const CONNECTOR = 'wordpress'; const MODES = [ + { + id: 'none', + label: 'None', + description: 'Unsubscribe from all email notifications.', + value: { + post: '', + comment: '', + }, + }, { id: 'posts', label: 'Just posts', @@ -31,15 +40,6 @@ const MODES = [ comment: 'all', }, }, - { - id: 'none', - label: 'None', - description: 'Unsubscribe from all email notifications.', - value: { - post: '', - comment: '', - }, - }, ]; /** From 0a3ad2495fb0ec20338928bb7be4b10c9f36fb76 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 13 Oct 2026 22:30:28 +0200 Subject: [PATCH 4/4] Switch notification settings into Redux --- .../Sidebar/NotificationSettings.js | 103 ++++++------------ src/hocs.js | 2 + src/types.js | 8 +- 3 files changed, 40 insertions(+), 73 deletions(-) diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js index 717e519c..522e6a1b 100644 --- a/src/components/Sidebar/NotificationSettings.js +++ b/src/components/Sidebar/NotificationSettings.js @@ -1,5 +1,9 @@ +import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; +import { withCurrentUser } from '../../hocs'; +import { User } from '../../shapes'; + // Falcon stores preferences per-connector; H2 uses the WordPress connector. const CONNECTOR = 'wordpress'; @@ -68,41 +72,6 @@ const getModeLabel = mode => { return MODES.find( m => m.id === mode ).label; }; -/** - * Make a request for the current user's preferences. - * - * @param {object|null} data Preferences to update, or null to fetch. - * @returns {Promise} Falcon preferences for the user. - */ -const request = ( data = null ) => { - const url = new URL( `${ window.wpApiSettings.root }wp/v2/users/me`, window.location.href ); - url.searchParams.set( 'context', 'edit' ); - url.searchParams.set( '_fields', 'falcon_preferences' ); - - const options = { - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'X-WP-Nonce': window.wpApiSettings.nonce, - }, - }; - if ( data ) { - options.method = 'POST'; - options.headers['Content-Type'] = 'application/json'; - options.body = JSON.stringify( { - falcon_preferences: data, - } ); - } - - return fetch( url, options ) - .then( response => response.json().then( body => { - if ( ! response.ok ) { - throw new Error( body.message || 'Could not load notification settings.' ); - } - return body.falcon_preferences; - } ) ); -}; - const BellIcon = ( { mode } ) => { const strokeProps = { fill: 'none', @@ -138,23 +107,21 @@ const BellIcon = ( { mode } ) => { ); }; -export default function NotificationSettings() { - const [ prefs, setPrefs ] = useState( null ); - const [ error, setError ] = useState( null ); - const [ saving, setSaving ] = useState( false ); +export function NotificationSettings( props ) { + const { currentUser, loadingCurrentUser, savingCurrentUser } = props; const [ open, setOpen ] = useState( false ); const container = useRef( null ); + // Preferences are only exposed in the edit context, which the preloaded + // user doesn't use, so load them if we don't have them yet. + const needsPreferences = currentUser && currentUser.falcon_preferences === undefined; useEffect( () => { - let cancelled = false; - request().then( - data => ! cancelled && setPrefs( data[ CONNECTOR ] || null ), - err => ! cancelled && setError( err.message ) - ); - return () => { - cancelled = true; - }; - }, [] ); + if ( needsPreferences && ! loadingCurrentUser ) { + props.onLoadCurrentUser( 'edit' ); + } + // Only attempt this once per user, to avoid looping on failure. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ currentUser && currentUser.id ] ); // Close when clicking outside, or pressing Escape. useEffect( () => { @@ -180,6 +147,7 @@ export default function NotificationSettings() { }; }, [ open ] ); + const prefs = currentUser && currentUser.falcon_preferences && currentUser.falcon_preferences[ CONNECTOR ]; if ( ! prefs ) { // Still loading, or Falcon isn't exposing preferences for this site. return null; @@ -189,25 +157,11 @@ export default function NotificationSettings() { const label = `Email notifications: ${ getModeLabel( mode ) }`; const onSelect = next => { - const previous = prefs; - setPrefs( { - ...prefs, - ...next.value, - } ); - setError( null ); - setSaving( true ); - - request( { [ CONNECTOR ]: next.value } ).then( - data => { - setSaving( false ); - setPrefs( data[ CONNECTOR ] || null ); + props.onUpdateCurrentUser( { + falcon_preferences: { + [ CONNECTOR ]: next.value, }, - err => { - setSaving( false ); - setPrefs( previous ); - setError( err.message ); - } - ); + } ); }; return ( @@ -241,7 +195,7 @@ export default function NotificationSettings() {