From 9ee4b256fb8fe93b914f7b3ebea3a8809c0c7bc4 Mon Sep 17 00:00:00 2001 From: Sean Yesmunt Date: Fri, 21 Aug 2020 15:44:54 -0400 Subject: [PATCH] add mark as seen to notifications --- flow-typed/notification.js | 2 + ui/component/notification/index.js | 5 +- ui/component/notification/view.jsx | 68 ++++++--- .../notificationHeaderButton/view.jsx | 50 +------ ui/constants/action_types.js | 3 + ui/page/notifications/index.js | 6 +- ui/page/notifications/view.jsx | 139 ++++++++---------- ui/redux/actions/notifications.js | 44 ++++++ ui/redux/reducers/notifications.js | 16 ++ ui/redux/selectors/notifications.js | 4 + ui/scss/component/_card.scss | 4 + ui/scss/component/_notification.scss | 35 ++++- ui/scss/component/menu-button.scss | 8 + ui/scss/init/_vars.scss | 2 +- ui/scss/themes/light.scss | 2 +- 15 files changed, 236 insertions(+), 152 deletions(-) diff --git a/flow-typed/notification.js b/flow-typed/notification.js index f3ccfd49f..c46c554a0 100644 --- a/flow-typed/notification.js +++ b/flow-typed/notification.js @@ -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: {}, }, diff --git a/ui/component/notification/index.js b/ui/component/notification/index.js index a8fc6e0f3..3ac1bbe26 100644 --- a/ui/component/notification/index.js +++ b/ui/component/notification/index.js @@ -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); diff --git a/ui/component/notification/view.jsx b/ui/component/notification/view.jsx index c6cd177d3..d0f0c92c4 100644 --- a/ui/component/notification/view.jsx +++ b/ui/component/notification/view.jsx @@ -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 = ; break; case NOTIFICATION_COMMENT: - icon = ; + icon = ; break; default: icon = ; } + function handleNotificationClick() { + if (!is_seen) { + doSeeNotifications([id]); + } + + if (notificationLink) { + push(notificationLink); + } + } + const Wrapper = menuButton ? (props: { children: any }) => ( - push(notificationLink)}> + {props.children} ) - : (props: { children: any }) => ( - push(notificationLink)}> + : notificationLink + ? (props: { children: any }) => ( + {props.children} + ) + : (props: { children: any }) => ( + + {props.children} + ); return ( -
+
{icon}
- {notification.notification_rule !== NOTIFICATION_COMMENT && ( -
{notification.notification_parameters.device.title}
+ {notification_rule !== NOTIFICATION_COMMENT && ( +
{notification_parameters.device.title}
)} -
- {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' - )} -
+ {notification_rule === NOTIFICATION_COMMENT && commentText ? ( + <> +
{notification_parameters.device.title}
+
{commentText}
+ + ) : ( + <> +
{notification_parameters.device.text}
+ + )}
diff --git a/ui/component/notificationHeaderButton/view.jsx b/ui/component/notificationHeaderButton/view.jsx index bf1002e84..0174105ac 100644 --- a/ui/component/notificationHeaderButton/view.jsx +++ b/ui/component/notificationHeaderButton/view.jsx @@ -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, 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 ( ); - - // Below is disabled until scroll style issues are resolved - // return ( - // - // - // - // {unreadCount > 0 && {unreadCount}} - // - - // {notifications && notifications.length > 0 ? ( - // - // {notifications.slice(0, 7).map((notification, index) => ( - // - // ))} - - // push(`/$/${PAGES.NOTIFICATIONS}`)}> - // - // {__('View All')} - // - // - // ) : ( - // - //
No notifications yet.
- // {/* Below is needed because MenuPopover isn't meant to be used this way */} - // - // {}} /> - // - //
- // )} - //
- // ); } diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 050a0ecfd..2a228ae05 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -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'; diff --git a/ui/page/notifications/index.js b/ui/page/notifications/index.js index c9e9e238a..d4b1d98a2 100644 --- a/ui/page/notifications/index.js +++ b/ui/page/notifications/index.js @@ -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); diff --git a/ui/page/notifications/view.jsx b/ui/page/notifications/view.jsx index d4ed8349d..ff556754c 100644 --- a/ui/page/notifications/view.jsx +++ b/ui/page/notifications/view.jsx @@ -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, + notifications: Array, 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 ( - {fetching && ( + {fetching && !hasNotifications && (
)} - {groupedNotifications && groupedNotifications.length > 0 ? ( - - {groupedNotifications.map((notification, index) => { - if (!notification) { - return null; - } - - return ; - })} -
- } - /> - ) : ( -
- -

{__("You don't have any notifications yet, but they will be here when you do!")}

-
-
- } - /> -
+ } + /> + ) : ( +
+ +

{__("You don't have any notifications yet, but they will be here when you do!")}

+
+
+
+ } + /> +
+ )} + )} ); diff --git a/ui/redux/actions/notifications.js b/ui/redux/actions/notifications.js index 2a6b947f1..072a028ec 100644 --- a/ui/redux/actions/notifications.js +++ b/ui/redux/actions/notifications.js @@ -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) { + 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)); + }; +} diff --git a/ui/redux/reducers/notifications.js b/ui/redux/reducers/notifications.js index cd1aa6098..fca02c474 100644 --- a/ui/redux/reducers/notifications.js +++ b/ui/redux/reducers/notifications.js @@ -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) => { diff --git a/ui/redux/selectors/notifications.js b/ui/redux/selectors/notifications.js index ecf1f50cd..211d6800b 100644 --- a/ui/redux/selectors/notifications.js +++ b/ui/redux/selectors/notifications.js @@ -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]; diff --git a/ui/scss/component/_card.scss b/ui/scss/component/_card.scss index e8253190d..98c70b15e 100644 --- a/ui/scss/component/_card.scss +++ b/ui/scss/component/_card.scss @@ -213,6 +213,10 @@ & > *:not(:last-child) { margin-right: 0; } + + @media (max-width: $breakpoint-small) { + align-items: center; + } } .card__media--nsfw { diff --git a/ui/scss/component/_notification.scss b/ui/scss/component/_notification.scss index efb93e577..05b4c5d28 100644 --- a/ui/scss/component/_notification.scss +++ b/ui/scss/component/_notification.scss @@ -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 { diff --git a/ui/scss/component/menu-button.scss b/ui/scss/component/menu-button.scss index 4935608c6..d62088d3e 100644 --- a/ui/scss/component/menu-button.scss +++ b/ui/scss/component/menu-button.scss @@ -108,6 +108,14 @@ } } +.menu__link--notification-nolink { + @extend .menu__link--notification; + + &:hover { + cursor: default; + } +} + .menu__link--all-notifications { @extend .button--alt; width: auto; diff --git a/ui/scss/init/_vars.scss b/ui/scss/init/_vars.scss index d2a0b7ad9..de243f365 100644 --- a/ui/scss/init/_vars.scss +++ b/ui/scss/init/_vars.scss @@ -97,6 +97,6 @@ $breakpoint-large: 1600px; @media (max-width: $breakpoint-small) { :root { - --font-base: 16px; + --font-body: 0.8rem; } } diff --git a/ui/scss/themes/light.scss b/ui/scss/themes/light.scss index 0182cc920..aeecffd00 100644 --- a/ui/scss/themes/light.scss +++ b/ui/scss/themes/light.scss @@ -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);