Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
},
preload: {},
features: {},
has: {
notifications: false,
},
};
window.wpApiSettings = {};
</script>
Expand Down
3 changes: 3 additions & 0 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
],
Expand Down
8 changes: 6 additions & 2 deletions src/components/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -74,15 +75,18 @@ export function Sidebar( props ) {
className="Sidebar border-r border-solid border-hm-beige"
>
<div className="">
<div className="px-4 py-6 bg-hm-beige">
<h2 className="text-xl font-bold">
<div className="px-4 py-6 bg-hm-beige flex items-center justify-between gap-2">
<h2 className="text-xl font-bold m-0">
<Link
className="block hover:underline"
to="/"
>
{ site.name }
</Link>
</h2>
{ window.H2Data.has && window.H2Data.has.notifications && (
<NotificationSettings />
) }
</div>
<div className="px-4 py-4 bg-hm-beige/50">
<p className="text-sm opacity-60 m-0">{ site.description || 'No description yet.' }</p>
Expand Down
228 changes: 228 additions & 0 deletions src/components/Sidebar/NotificationSettings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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';

const MODES = [
{
id: 'none',
label: 'None',
description: 'Unsubscribe from all email notifications.',
value: {
post: '',
comment: '',
},
},
{
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, 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;
}

// Set to a combination we don't offer (e.g. via the profile screen).
return 'custom';
};

const getModeLabel = mode => {
if ( mode === 'custom' ) {
return 'Custom';
}

return MODES.find( m => m.id === mode ).label;
};

const BellIcon = ( { mode } ) => {
const strokeProps = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
};

if ( mode === 'none' ) {
return (
<svg aria-hidden="true" className="size-5" viewBox="0 0 24 24" { ...strokeProps }>
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
<path d="M18.63 13A17.89 17.89 0 0 1 18 8" />
<path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14" />
<path d="M18 8a6 6 0 0 0-9.33-5" />
<path d="M1 1l22 22" />
</svg>
);
}

return (
<svg aria-hidden="true" className="size-5" viewBox="0 0 24 24" { ...strokeProps }>
<path
d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"
fill={ mode === 'all' ? 'currentColor' : 'none' }
/>
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
{ mode === 'participant' && (
<circle cx="19" cy="5" r="3" fill="currentColor" stroke="none" />
) }
</svg>
);
};

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( () => {
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( () => {
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 ] );

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;
}

const mode = getMode( prefs );
const label = `Email notifications: ${ getModeLabel( mode ) }`;

const onSelect = next => {
props.onUpdateCurrentUser( {
falcon_preferences: {
[ CONNECTOR ]: next.value,
},
} );
};

return (
<div
ref={ container }
className="relative shrink-0"
>
<button
aria-expanded={ open }
aria-haspopup="true"
aria-label={ label }
className="flex items-center justify-center size-8 p-0 m-0 rounded-sm border-0 bg-transparent cursor-pointer hover:bg-white/50 focus-visible:bg-white/50"
title={ label }
type="button"
onClick={ () => setOpen( ! open ) }
>
<BellIcon mode={ mode } />
</button>

{ open && (
<div
className="absolute right-0 top-full z-10 mt-2 w-72 bg-white border border-solid border-hm-beige rounded-sm shadow-lg text-sm"
role="menu"
>
<p className="m-0 px-4 pt-3 pb-2 font-bold">
Email notifications
</p>
<ul className="m-0 p-0 list-none divide-y divide-hm-beige/50 border-t border-solid border-hm-beige/50">
{ MODES.map( option => (
<li key={ option.id }>
<button
aria-checked={ mode === option.id }
className="w-full grid grid-cols-[1.25rem_auto] gap-x-2 px-4 py-2 m-0 text-left bg-transparent border-0 cursor-pointer hover:bg-hm-beige/20 disabled:cursor-wait"
disabled={ savingCurrentUser }
role="menuitemradio"
type="button"
onClick={ () => onSelect( option ) }
>
<span aria-hidden="true">
{ mode === option.id ? '✓' : '' }
</span>
<span>
<span className="block font-bold">{ option.label }</span>
<span className="block opacity-60 text-xs">{ option.description }</span>
</span>
</button>
</li>
) ) }
</ul>
</div>
) }
</div>
);
}

NotificationSettings.propTypes = {
currentUser: User,
loadingCurrentUser: PropTypes.bool,
savingCurrentUser: PropTypes.bool,
onLoadCurrentUser: PropTypes.func.isRequired,
onUpdateCurrentUser: PropTypes.func.isRequired,
};

export default withCurrentUser( NotificationSettings );
2 changes: 2 additions & 0 deletions src/hocs.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ export const withCurrentUser = connect(
state => ( {
currentUser: users.getSingle( state.users, state.users.current ),
loadingCurrentUser: users.isPostLoading( state.users, state.users.current ),
savingCurrentUser: users.isPostSaving( state.users, state.users.current ),
} ),
dispatch => ( {
onLoadCurrentUser: context => dispatch( users.fetchCurrent( context ) ),
onUpdateCurrentUser: data => dispatch( users.updateCurrent( data ) ),
} )
);
Expand Down
8 changes: 7 additions & 1 deletion src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ export const users = new handler( {
nonce: window.wpApiSettings.nonce,
query: {
// Dodges Yoast SEO data computation.
_fields: 'id,name,facts,link,slug,avatar_urls,meta',
// (falcon_preferences is only returned for the current user in the edit context.)
_fields: 'id,name,facts,link,slug,avatar_urls,meta,falcon_preferences',
},
} );
users.registerArchive( 'me', state => ( { include: state.users.current } ) );
users.registerArchive( 'all', { per_page: 100 } );
users.fetchCurrent = ( function ( context = 'view' ) {
return ( dispatch, getState ) => {
return dispatch( this.fetchSingle( getState().users.current, context ) );
};
} ).bind( users );
users.updateCurrent = ( function ( data ) {
return ( dispatch, getState ) => {
return dispatch(
Expand Down
Loading