add mark as seen to notifications

This commit is contained in:
Sean Yesmunt 2020-08-21 15:44:54 -04:00
parent 2ae3484363
commit 9ee4b256fb
15 changed files with 236 additions and 152 deletions

View file

@ -7,6 +7,7 @@ declare type WebNotification = {
is_device_notified: boolean, is_device_notified: boolean,
is_emailed: boolean, is_emailed: boolean,
is_read: boolean, is_read: boolean,
is_seen: boolean,
notification_parameters: { notification_parameters: {
device: { device: {
analytics_label: string, analytics_label: string,
@ -23,6 +24,7 @@ declare type WebNotification = {
comment_author: string, comment_author: string,
hash: string, hash: string,
claim_title: string, claim_title: string,
comment?: string,
}, },
email: {}, email: {},
}, },

View file

@ -1,4 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doSeeNotifications } from 'redux/actions/notifications';
import Notification from './view'; import Notification from './view';
export default connect()(Notification); export default connect(null, {
doSeeNotifications,
})(Notification);

View file

@ -3,6 +3,7 @@
import { NOTIFICATION_CREATOR_SUBSCRIBER, NOTIFICATION_COMMENT } from 'constants/notifications'; import { NOTIFICATION_CREATOR_SUBSCRIBER, NOTIFICATION_COMMENT } from 'constants/notifications';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import classnames from 'classnames';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import DateTime from 'component/dateTime'; import DateTime from 'component/dateTime';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
@ -14,58 +15,87 @@ type Props = {
notification: WebNotification, notification: WebNotification,
menuButton: boolean, menuButton: boolean,
children: any, children: any,
doSeeNotifications: ([number]) => void,
}; };
export default function Notification(props: Props) { export default function Notification(props: Props) {
const { notification, menuButton = false } = props; const { notification, menuButton = false, doSeeNotifications } = props;
const { push } = useHistory(); const { push } = useHistory();
const notificationTarget = notification && notification.notification_parameters.device.target; const { notification_rule, notification_parameters, is_seen, id } = notification;
const notificationTarget = notification && notification_parameters.device.target;
const commentText = notification_rule === NOTIFICATION_COMMENT && notification_parameters.dynamic.comment;
let notificationLink = formatLbryUrlForWeb(notificationTarget); let notificationLink = formatLbryUrlForWeb(notificationTarget);
if (notification.notification_rule === NOTIFICATION_COMMENT && notification.notification_parameters.dynamic.hash) { if (notification_rule === NOTIFICATION_COMMENT && notification_parameters.dynamic.hash) {
notificationLink += `?lc=${notification.notification_parameters.dynamic.hash}`; notificationLink += `?lc=${notification_parameters.dynamic.hash}`;
} }
let icon; let icon;
switch (notification.notification_rule) { switch (notification_rule) {
case NOTIFICATION_CREATOR_SUBSCRIBER: case NOTIFICATION_CREATOR_SUBSCRIBER:
icon = <Icon icon={ICONS.SUBSCRIBE} sectionIcon className="notification__icon" />; icon = <Icon icon={ICONS.SUBSCRIBE} sectionIcon className="notification__icon" />;
break; break;
case NOTIFICATION_COMMENT: case NOTIFICATION_COMMENT:
icon = <ChannelThumbnail small uri={notification.notification_parameters.dynamic.comment_author} />; icon = <ChannelThumbnail small uri={notification_parameters.dynamic.comment_author} />;
break; break;
default: default:
icon = <Icon icon={ICONS.NOTIFICATION} sectionIcon className="notification__icon" />; icon = <Icon icon={ICONS.NOTIFICATION} sectionIcon className="notification__icon" />;
} }
function handleNotificationClick() {
if (!is_seen) {
doSeeNotifications([id]);
}
if (notificationLink) {
push(notificationLink);
}
}
const Wrapper = menuButton const Wrapper = menuButton
? (props: { children: any }) => ( ? (props: { children: any }) => (
<MenuItem className="menu__link--notification" onSelect={() => push(notificationLink)}> <MenuItem className="menu__link--notification" onSelect={handleNotificationClick}>
{props.children} {props.children}
</MenuItem> </MenuItem>
) )
: (props: { children: any }) => ( : notificationLink
<a className="menu__link--notification" onClick={() => push(notificationLink)}> ? (props: { children: any }) => (
<a className="menu__link--notification" onClick={handleNotificationClick}>
{props.children} {props.children}
</a> </a>
)
: (props: { children: any }) => (
<span
className={is_seen ? 'menu__link--notification-nolink' : 'menu__link--notification'}
onClick={handleNotificationClick}
>
{props.children}
</span>
); );
return ( return (
<Wrapper> <Wrapper>
<div className="notification__wrapper"> <div
className={classnames('notification__wrapper', {
'notification__wrapper--unseen': !is_seen,
})}
>
<div className="notification__icon">{icon}</div> <div className="notification__icon">{icon}</div>
<div className="notification__content"> <div className="notification__content">
<div> <div>
{notification.notification_rule !== NOTIFICATION_COMMENT && ( {notification_rule !== NOTIFICATION_COMMENT && (
<div className="notification__title">{notification.notification_parameters.device.title}</div> <div className="notification__title">{notification_parameters.device.title}</div>
)} )}
<div className="notification__text"> {notification_rule === NOTIFICATION_COMMENT && commentText ? (
{notification.notification_parameters.device.text.replace( <>
// This is terrible and will be replaced when I make the comment channel clickable <div className="notification__title">{notification_parameters.device.title}</div>
'commented on', <div className="notification__text mobile-hidden">{commentText}</div>
notification.group_count ? `left ${notification.group_count} comments on` : 'commented on' </>
)} ) : (
</div> <>
<div className="notification__text">{notification_parameters.device.text}</div>
</>
)}
</div> </div>
<div className="notification__time"> <div className="notification__time">

View file

@ -3,28 +3,18 @@ import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import Notification from 'component/notification';
import NotificationBubble from 'component/notificationBubble'; import NotificationBubble from 'component/notificationBubble';
import Button from 'component/button'; import Button from 'component/button';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
// import { Menu, MenuList, MenuButton, MenuPopover, MenuItems, MenuItem } from '@reach/menu-button';
type Props = { type Props = {
unreadCount: number, unreadCount: number,
fetching: boolean,
notifications: ?Array<Notification>,
doReadNotifications: () => void, doReadNotifications: () => void,
user: ?User, user: ?User,
}; };
export default function NotificationHeaderButton(props: Props) { export default function NotificationHeaderButton(props: Props) {
const { const { unreadCount, doReadNotifications, user } = props;
unreadCount,
// notifications,
fetching,
doReadNotifications,
user,
} = props;
const notificationsEnabled = user && user.experimental_ui; const notificationsEnabled = user && user.experimental_ui;
const { push } = useHistory(); const { push } = useHistory();
@ -43,7 +33,6 @@ export default function NotificationHeaderButton(props: Props) {
return ( return (
<Button <Button
onClick={handleMenuClick} onClick={handleMenuClick}
disabled={fetching}
aria-label={__('Notifications')} aria-label={__('Notifications')}
title={__('Notifications')} title={__('Notifications')}
className="header__navigation-item menu__title header__navigation-item--icon" className="header__navigation-item menu__title header__navigation-item--icon"
@ -52,41 +41,4 @@ export default function NotificationHeaderButton(props: Props) {
<NotificationBubble /> <NotificationBubble />
</Button> </Button>
); );
// Below is disabled until scroll style issues are resolved
// return (
// <Menu>
// <MenuButton
// onClick={handleMenuClick}
// disabled={fetching}
// aria-label={__('Notifications')}
// title={__('Notifications')}
// className="header__navigation-item menu__title header__navigation-item--icon"
// >
// <Icon size={18} icon={ICONS.NOTIFICATION} aria-hidden />
// {unreadCount > 0 && <span className="notification__bubble">{unreadCount}</span>}
// </MenuButton>
// {notifications && notifications.length > 0 ? (
// <MenuList className="menu__list--header">
// {notifications.slice(0, 7).map((notification, index) => (
// <Notification menuButton key={notification.id} id={notification.id} notification={notification} />
// ))}
// <MenuItem className="menu__link" onSelect={() => push(`/$/${PAGES.NOTIFICATIONS}`)}>
// <Icon aria-hidden icon={ICONS.NOTIFICATION} />
// {__('View All')}
// </MenuItem>
// </MenuList>
// ) : (
// <MenuPopover>
// <div className="menu__list--header notifications__empty">No notifications yet.</div>
// {/* Below is needed because MenuPopover isn't meant to be used this way */}
// <MenuItems>
// <MenuItem disabled onSelect={() => {}} />
// </MenuItems>
// </MenuPopover>
// )}
// </Menu>
// );
} }

View file

@ -239,6 +239,9 @@ export const NOTIFICATION_LIST_FAILED = 'NOTIFICATION_LIST_FAILED';
export const NOTIFICATION_READ_STARTED = 'NOTIFICATION_READ_STARTED'; export const NOTIFICATION_READ_STARTED = 'NOTIFICATION_READ_STARTED';
export const NOTIFICATION_READ_COMPLETED = 'NOTIFICATION_READ_COMPLETED'; export const NOTIFICATION_READ_COMPLETED = 'NOTIFICATION_READ_COMPLETED';
export const NOTIFICATION_READ_FAILED = 'NOTIFICATION_READ_FAILED'; export const NOTIFICATION_READ_FAILED = 'NOTIFICATION_READ_FAILED';
export const NOTIFICATION_SEEN_STARTED = 'NOTIFICATION_SEEN_STARTED';
export const NOTIFICATION_SEEN_COMPLETED = 'NOTIFICATION_SEEN_COMPLETED';
export const NOTIFICATION_SEEN_FAILED = 'NOTIFICATION_SEEN_FAILED';
export const CREATE_TOAST = 'CREATE_TOAST'; export const CREATE_TOAST = 'CREATE_TOAST';
export const DISMISS_TOAST = 'DISMISS_TOAST'; export const DISMISS_TOAST = 'DISMISS_TOAST';
export const CREATE_ERROR = 'CREATE_ERROR'; export const CREATE_ERROR = 'CREATE_ERROR';

View file

@ -3,16 +3,20 @@ import {
selectNotifications, selectNotifications,
selectIsFetchingNotifications, selectIsFetchingNotifications,
selectUnreadNotificationCount, selectUnreadNotificationCount,
selectUnseenNotificationCount,
} from 'redux/selectors/notifications'; } from 'redux/selectors/notifications';
import { doReadNotifications } from 'redux/actions/notifications'; import { doReadNotifications, doNotificationList, doSeeAllNotifications } from 'redux/actions/notifications';
import NotificationsPage from './view'; import NotificationsPage from './view';
const select = state => ({ const select = state => ({
notifications: selectNotifications(state), notifications: selectNotifications(state),
fetching: selectIsFetchingNotifications(state), fetching: selectIsFetchingNotifications(state),
unreadCount: selectUnreadNotificationCount(state), unreadCount: selectUnreadNotificationCount(state),
unseenCount: selectUnseenNotificationCount(state),
}); });
export default connect(select, { export default connect(select, {
doReadNotifications, doReadNotifications,
doNotificationList,
doSeeAllNotifications,
})(NotificationsPage); })(NotificationsPage);

View file

@ -1,68 +1,28 @@
// @flow // @flow
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import { NOTIFICATION_COMMENT } from 'constants/notifications';
import React from 'react'; import React from 'react';
import Page from 'component/page'; import Page from 'component/page';
import Card from 'component/common/card'; import Card from 'component/common/card';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import Notification from 'component/notification'; import Notification from 'component/notification';
import Yrbl from 'component/yrbl';
import Button from 'component/button'; import Button from 'component/button';
import Yrbl from 'component/yrbl';
import usePrevious from 'effects/use-previous';
type Props = { type Props = {
notifications: ?Array<Notification>, notifications: Array<Notification>,
fetching: boolean, fetching: boolean,
unreadCount: number, unreadCount: number,
unseenCount: number,
doSeeAllNotifications: () => void,
doReadNotifications: () => void, doReadNotifications: () => void,
}; };
export default function NotificationsPage(props: Props) { export default function NotificationsPage(props: Props) {
const { notifications, fetching, unreadCount, doReadNotifications } = props; const { notifications, fetching, unreadCount, unseenCount, doSeeAllNotifications, doReadNotifications } = props;
const [hasFetched, setHasFetched] = React.useState(false);
// Group sequential comment notifications if they are by the same author const previousFetching = usePrevious(fetching);
let groupedCount = 1; const hasNotifications = notifications.length > 0;
const groupedNotifications =
notifications &&
notifications.reduce((list, notification, index) => {
if (index === 0) {
return [notification];
}
const previousNotification = notifications[index - 1];
const isCommentNotification = notification.notification_rule === NOTIFICATION_COMMENT;
const previousIsCommentNotification = previousNotification.notification_rule === NOTIFICATION_COMMENT;
if (isCommentNotification && previousIsCommentNotification) {
const notificationTarget = notification.notification_parameters.device.target;
const previousTarget = previousNotification && previousNotification.notification_parameters.device.target;
const author = notification.notification_parameters.dynamic.comment_author;
const previousAuthor = previousNotification.notification_parameters.dynamic.comment_author;
if (author === previousAuthor && notificationTarget === previousTarget) {
const newList = [...list];
newList.pop();
groupedCount += 1;
const newNotification = {
...previousNotification,
group_count: groupedCount,
};
newList[index - groupedCount] = newNotification;
return newList;
} else {
if (groupedCount > 1) {
groupedCount = 1;
}
return [...list, notification];
}
} else {
if (groupedCount > 1) {
groupedCount = 1;
}
return [...list, notification];
}
}, []);
React.useEffect(() => { React.useEffect(() => {
if (unreadCount > 0) { if (unreadCount > 0) {
@ -70,43 +30,64 @@ export default function NotificationsPage(props: Props) {
} }
}, [unreadCount, doReadNotifications]); }, [unreadCount, doReadNotifications]);
React.useEffect(() => {
if ((fetching === false && previousFetching === true) || hasNotifications) {
setHasFetched(true);
}
}, [fetching, previousFetching, setHasFetched, hasNotifications]);
return ( return (
<Page> <Page>
{fetching && ( {fetching && !hasNotifications && (
<div className="main--empty"> <div className="main--empty">
<Spinner delayed /> <Spinner delayed />
</div> </div>
)} )}
{groupedNotifications && groupedNotifications.length > 0 ? ( {hasFetched && (
<Card <>
isBodyList {notifications && notifications.length > 0 ? (
title={__('Notifications')} <Card
body={ isBodyList
<div className="notification_list"> title={
{groupedNotifications.map((notification, index) => { <span>
if (!notification) { {__('Notifications')}
return null; {fetching && <Spinner type="small" />}
} </span>
}
return <Notification key={notification.id} notification={notification} />; titleActions={
})} unseenCount > 0 && (
</div> <Button
} icon={ICONS.EYE}
/> onClick={doSeeAllNotifications}
) : ( button="secondary"
<div className="main--empty"> label={__('Mark all as read')}
<Yrbl />
title={__('No Notifications')} )
subtitle={ }
<div> body={
<p>{__("You don't have any notifications yet, but they will be here when you do!")}</p> <div className="notification_list">
<div className="section__actions"> {notifications.map((notification, index) => {
<Button button="primary" icon={ICONS.HOME} label={__('Go Home')} navigate="/" /> return <Notification key={notification.id} notification={notification} />;
})}
</div> </div>
</div> }
} />
/> ) : (
</div> <div className="main--empty">
<Yrbl
title={__('No Notifications')}
subtitle={
<div>
<p>{__("You don't have any notifications yet, but they will be here when you do!")}</p>
<div className="section__actions">
<Button button="primary" icon={ICONS.HOME} label={__('Go Home')} navigate="/" />
</div>
</div>
}
/>
</div>
)}
</>
)} )}
</Page> </Page>
); );

View file

@ -3,6 +3,7 @@ import * as ACTIONS from 'constants/action_types';
import { Lbryio } from 'lbryinc'; import { Lbryio } from 'lbryinc';
import uuid from 'uuid/v4'; import uuid from 'uuid/v4';
import { selectNotifications } from 'redux/selectors/notifications'; import { selectNotifications } from 'redux/selectors/notifications';
import { doResolveUris } from 'lbry-redux';
export function doToast(params: ToastParams) { export function doToast(params: ToastParams) {
if (!params) { if (!params) {
@ -45,6 +46,20 @@ export function doNotificationList() {
return Lbryio.call('notification', 'list') return Lbryio.call('notification', 'list')
.then(response => { .then(response => {
const notifications = response || []; const notifications = response || [];
const channelsToResolve = notifications
.filter((notification: WebNotification) => {
if (
notification.notification_parameters.dynamic &&
notification.notification_parameters.dynamic.comment_author
) {
return true;
} else {
return false;
}
})
.map(notification => notification.notification_parameters.dynamic.comment_author);
dispatch(doResolveUris(channelsToResolve));
dispatch({ type: ACTIONS.NOTIFICATION_LIST_COMPLETED, data: { notifications } }); dispatch({ type: ACTIONS.NOTIFICATION_LIST_COMPLETED, data: { notifications } });
}) })
.catch(error => { .catch(error => {
@ -74,3 +89,32 @@ export function doReadNotifications() {
}); });
}; };
} }
export function doSeeNotifications(notificationIds: Array<string>) {
return (dispatch: Dispatch) => {
dispatch({ type: ACTIONS.NOTIFICATION_SEEN_STARTED });
return Lbryio.call('notification', 'edit', { notification_ids: notificationIds.join(','), is_seen: true })
.then(() => {
dispatch({
type: ACTIONS.NOTIFICATION_SEEN_COMPLETED,
data: {
notificationIds,
},
});
})
.catch(error => {
dispatch({ type: ACTIONS.NOTIFICATION_SEEN_FAILED, data: { error } });
});
};
}
export function doSeeAllNotifications() {
return (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const notifications = selectNotifications(state);
const unSeenNotifications =
notifications && notifications.filter(notification => !notification.is_seen).map(notification => notification.id);
dispatch(doSeeNotifications(unSeenNotifications));
};
}

View file

@ -66,6 +66,22 @@ export default handleActions(
...state, ...state,
}; };
}, },
[ACTIONS.NOTIFICATION_SEEN_COMPLETED]: (state, action) => {
const { notifications } = state;
const { notificationIds } = action.data;
const newNotifications = notifications.map(notification => {
if (notificationIds.includes(notification.id)) {
return { ...notification, is_seen: true };
}
return notification;
});
return {
...state,
notifications: newNotifications,
};
},
// Errors // Errors
[ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoError) => { [ACTIONS.CREATE_ERROR]: (state: NotificationState, action: DoError) => {

View file

@ -10,6 +10,10 @@ export const selectUnreadNotificationCount = createSelector(selectNotifications,
return notifications ? notifications.filter(notification => !notification.is_read).length : 0; return notifications ? notifications.filter(notification => !notification.is_read).length : 0;
}); });
export const selectUnseenNotificationCount = createSelector(selectNotifications, notifications => {
return notifications ? notifications.filter(notification => !notification.is_seen).length : 0;
});
export const selectToast = createSelector(selectState, state => { export const selectToast = createSelector(selectState, state => {
if (state.toasts.length) { if (state.toasts.length) {
const { id, params } = state.toasts[0]; const { id, params } = state.toasts[0];

View file

@ -213,6 +213,10 @@
& > *:not(:last-child) { & > *:not(:last-child) {
margin-right: 0; margin-right: 0;
} }
@media (max-width: $breakpoint-small) {
align-items: center;
}
} }
.card__media--nsfw { .card__media--nsfw {

View file

@ -26,6 +26,26 @@
.channel-thumbnail { .channel-thumbnail {
@include handleChannelGif(3rem); @include handleChannelGif(3rem);
} }
@media (max-width: $breakpoint-small) {
.channel-thumbnail {
@include handleChannelGif(2rem);
}
}
}
.notification__wrapper--unseen {
padding: var(--spacing-m);
border-radius: var(--border-radius);
background-color: var(--color-card-background-highlighted);
&:hover {
background-color: var(--color-button-secondary-bg);
}
@media (max-width: $breakpoint-small) {
padding: var(--spacing-s);
}
} }
.notification__content { .notification__content {
@ -33,23 +53,36 @@
flex: 1; flex: 1;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@media (max-width: $breakpoint-small) {
align-items: flex-start;
}
} }
.notification__title { .notification__title {
font-size: var(--font-small); font-size: var(--font-small);
font-weight: bold;
color: var(--color-text); color: var(--color-text);
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
@media (max-width: $breakpoint-small) {
margin-bottom: 0;
}
} }
.notification__text { .notification__text {
font-size: var(--font-body); font-size: var(--font-body);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.notification__time { .notification__time {
@extend .help; @extend .help;
margin-bottom: 0; margin-bottom: 0;
margin-top: 0; margin-top: 0;
margin-left: var(--spacing-s);
flex-shrink: 0;
} }
.notification__bubble { .notification__bubble {

View file

@ -108,6 +108,14 @@
} }
} }
.menu__link--notification-nolink {
@extend .menu__link--notification;
&:hover {
cursor: default;
}
}
.menu__link--all-notifications { .menu__link--all-notifications {
@extend .button--alt; @extend .button--alt;
width: auto; width: auto;

View file

@ -97,6 +97,6 @@ $breakpoint-large: 1600px;
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
:root { :root {
--font-base: 16px; --font-body: 0.8rem;
} }
} }

View file

@ -49,7 +49,7 @@
--color-placeholder-background: #f0f0f0; --color-placeholder-background: #f0f0f0;
--color-header-background: #ffffff; --color-header-background: #ffffff;
--color-card-background: #ffffff; --color-card-background: #ffffff;
--color-card-background-highlighted: #f6faff; --color-card-background-highlighted: #f0f7ff;
--color-list-header: #fff; --color-list-header: #fff;
--color-file-viewer-background: var(--color-card-background); --color-file-viewer-background: var(--color-card-background);
--color-tabs-background: var(--color-card-background); --color-tabs-background: var(--color-card-background);