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_emailed: boolean,
is_read: boolean,
is_seen: boolean,
notification_parameters: {
device: {
analytics_label: string,
@ -23,6 +24,7 @@ declare type WebNotification = {
comment_author: string,
hash: string,
claim_title: string,
comment?: string,
},
email: {},
},

View file

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

View file

@ -3,28 +3,18 @@ import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons';
import React from 'react';
import Icon from 'component/common/icon';
import Notification from 'component/notification';
import NotificationBubble from 'component/notificationBubble';
import Button from 'component/button';
import { useHistory } from 'react-router';
// import { Menu, MenuList, MenuButton, MenuPopover, MenuItems, MenuItem } from '@reach/menu-button';
type Props = {
unreadCount: number,
fetching: boolean,
notifications: ?Array<Notification>,
doReadNotifications: () => void,
user: ?User,
};
export default function NotificationHeaderButton(props: Props) {
const {
unreadCount,
// notifications,
fetching,
doReadNotifications,
user,
} = props;
const { unreadCount, doReadNotifications, user } = props;
const notificationsEnabled = user && user.experimental_ui;
const { push } = useHistory();
@ -43,7 +33,6 @@ export default function NotificationHeaderButton(props: Props) {
return (
<Button
onClick={handleMenuClick}
disabled={fetching}
aria-label={__('Notifications')}
title={__('Notifications')}
className="header__navigation-item menu__title header__navigation-item--icon"
@ -52,41 +41,4 @@ export default function NotificationHeaderButton(props: Props) {
<NotificationBubble />
</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_COMPLETED = 'NOTIFICATION_READ_COMPLETED';
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 DISMISS_TOAST = 'DISMISS_TOAST';
export const CREATE_ERROR = 'CREATE_ERROR';

View file

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

View file

@ -1,68 +1,28 @@
// @flow
import * as ICONS from 'constants/icons';
import { NOTIFICATION_COMMENT } from 'constants/notifications';
import React from 'react';
import Page from 'component/page';
import Card from 'component/common/card';
import Spinner from 'component/spinner';
import Notification from 'component/notification';
import Yrbl from 'component/yrbl';
import Button from 'component/button';
import Yrbl from 'component/yrbl';
import usePrevious from 'effects/use-previous';
type Props = {
notifications: ?Array<Notification>,
notifications: Array<Notification>,
fetching: boolean,
unreadCount: number,
unseenCount: number,
doSeeAllNotifications: () => void,
doReadNotifications: () => void,
};
export default function NotificationsPage(props: Props) {
const { notifications, fetching, unreadCount, doReadNotifications } = props;
// Group sequential comment notifications if they are by the same author
let groupedCount = 1;
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];
}
}, []);
const { notifications, fetching, unreadCount, unseenCount, doSeeAllNotifications, doReadNotifications } = props;
const [hasFetched, setHasFetched] = React.useState(false);
const previousFetching = usePrevious(fetching);
const hasNotifications = notifications.length > 0;
React.useEffect(() => {
if (unreadCount > 0) {
@ -70,43 +30,64 @@ export default function NotificationsPage(props: Props) {
}
}, [unreadCount, doReadNotifications]);
React.useEffect(() => {
if ((fetching === false && previousFetching === true) || hasNotifications) {
setHasFetched(true);
}
}, [fetching, previousFetching, setHasFetched, hasNotifications]);
return (
<Page>
{fetching && (
{fetching && !hasNotifications && (
<div className="main--empty">
<Spinner delayed />
</div>
)}
{groupedNotifications && groupedNotifications.length > 0 ? (
<Card
isBodyList
title={__('Notifications')}
body={
<div className="notification_list">
{groupedNotifications.map((notification, index) => {
if (!notification) {
return null;
}
return <Notification key={notification.id} notification={notification} />;
})}
</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="/" />
{hasFetched && (
<>
{notifications && notifications.length > 0 ? (
<Card
isBodyList
title={
<span>
{__('Notifications')}
{fetching && <Spinner type="small" />}
</span>
}
titleActions={
unseenCount > 0 && (
<Button
icon={ICONS.EYE}
onClick={doSeeAllNotifications}
button="secondary"
label={__('Mark all as read')}
/>
)
}
body={
<div className="notification_list">
{notifications.map((notification, index) => {
return <Notification key={notification.id} notification={notification} />;
})}
</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>
);

View file

@ -3,6 +3,7 @@ import * as ACTIONS from 'constants/action_types';
import { Lbryio } from 'lbryinc';
import uuid from 'uuid/v4';
import { selectNotifications } from 'redux/selectors/notifications';
import { doResolveUris } from 'lbry-redux';
export function doToast(params: ToastParams) {
if (!params) {
@ -45,6 +46,20 @@ export function doNotificationList() {
return Lbryio.call('notification', 'list')
.then(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 } });
})
.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,
};
},
[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
[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;
});
export const selectUnseenNotificationCount = createSelector(selectNotifications, notifications => {
return notifications ? notifications.filter(notification => !notification.is_seen).length : 0;
});
export const selectToast = createSelector(selectState, state => {
if (state.toasts.length) {
const { id, params } = state.toasts[0];

View file

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

View file

@ -26,6 +26,26 @@
.channel-thumbnail {
@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 {
@ -33,23 +53,36 @@
flex: 1;
justify-content: space-between;
align-items: center;
@media (max-width: $breakpoint-small) {
align-items: flex-start;
}
}
.notification__title {
font-size: var(--font-small);
font-weight: bold;
color: var(--color-text);
margin-bottom: var(--spacing-s);
@media (max-width: $breakpoint-small) {
margin-bottom: 0;
}
}
.notification__text {
font-size: var(--font-body);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notification__time {
@extend .help;
margin-bottom: 0;
margin-top: 0;
margin-left: var(--spacing-s);
flex-shrink: 0;
}
.notification__bubble {

View file

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

View file

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

View file

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