Notification menu (#1652)

* Save notification menu prototype

* Add dynamic links

* Add timestamps

* Mark as seen on click

* Fix guest mode

* Fix discussion links & channel thumbnails

* Adjust some details

* Adjust theme

* Replaxe Menu with MuiMenu

* Fix Mui behavior & transitions

* Adjust Mui menu behavior

* Adjust some padding

* Fix read & see

* Clean code

* Adjust border on top notification

* Add case for comment replies

* Save

* Make alignment pixel perfect

* Clean code

* Adjust gif avatars, stickers & tips

* Add delete function

* Add delete icon hover effect

* Add outline to delete icon

* Fix seeNotification call

* Add case for empty notification listä
This commit is contained in:
Rave | 図書館猫 2022-06-09 16:29:56 +02:00 committed by GitHub
parent 70695dfd3f
commit 54ee4ee94a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 506 additions and 58 deletions

View file

@ -1,14 +1,26 @@
import { connect } from 'react-redux';
import { selectUnseenNotificationCount } from 'redux/selectors/notifications';
import { doSeeAllNotifications } from 'redux/actions/notifications';
import { selectUser } from 'redux/selectors/user';
import { selectNotifications, selectUnseenNotificationCount } from 'redux/selectors/notifications';
import {
doReadNotifications,
doSeeNotifications,
doDeleteNotification,
doSeeAllNotifications,
} from 'redux/actions/notifications';
import { selectUser, selectUserVerifiedEmail } from 'redux/selectors/user';
import NotificationHeaderButton from './view';
const select = (state) => ({
notifications: selectNotifications(state),
unseenCount: selectUnseenNotificationCount(state),
user: selectUser(state),
authenticated: selectUserVerifiedEmail(state),
});
export default connect(select, {
doSeeAllNotifications,
})(NotificationHeaderButton);
const perform = (dispatch, ownProps) => ({
readNotification: ([id]) => dispatch(doReadNotifications([id])),
seeNotification: ([id]) => dispatch(doSeeNotifications([id])),
deleteNotification: (id) => dispatch(doDeleteNotification(id)),
doSeeAllNotifications: doSeeAllNotifications,
});
export default connect(select, perform)(NotificationHeaderButton);

View file

@ -5,37 +5,229 @@ import { ENABLE_UI_NOTIFICATIONS } from 'config';
import { useHistory } from 'react-router';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import Button from 'component/button';
import Icon from 'component/common/icon';
import NotificationBubble from 'component/notificationBubble';
import React from 'react';
import Tooltip from 'component/common/tooltip';
import { formatLbryUrlForWeb } from 'util/url';
import Notification from 'component/notification';
import DateTime from 'component/dateTime';
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu as MuiMenu } from '@mui/material';
import Button from 'component/button';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import { RULE } from 'constants/notifications';
import UriIndicator from 'component/uriIndicator';
import { generateNotificationTitle } from '../notification/helpers/title';
import { generateNotificationText } from '../notification/helpers/text';
import { parseURI } from 'util/lbryURI';
type Props = {
notifications: Array<Notification>,
unseenCount: number,
user: ?User,
authenticated: boolean,
readNotification: (Array<number>) => void,
seeNotification: (Array<number>) => void,
deleteNotification: (number) => void,
doSeeAllNotifications: () => void,
};
export default function NotificationHeaderButton(props: Props) {
const { unseenCount, user, doSeeAllNotifications } = props;
const {
notifications,
unseenCount,
user,
authenticated,
readNotification,
seeNotification,
deleteNotification,
doSeeAllNotifications,
} = props;
const list = notifications.slice(0, 5);
const { push } = useHistory();
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const notificationsEnabled = authenticated && (ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui));
const [anchorEl, setAnchorEl] = React.useState(null);
const [clicked, setClicked] = React.useState(false);
const open = Boolean(anchorEl);
const handleClick = (event) => setAnchorEl(!anchorEl ? event.currentTarget : null);
const handleClose = () => setAnchorEl(null);
const menuProps = {
id: 'notification-menu',
anchorEl,
open,
onClose: handleClose,
MenuListProps: {
'aria-labelledby': 'basic-button',
sx: { padding: 'var(--spacing-xs)' },
},
className: 'menu__list--header menu__list--notifications',
sx: { 'z-index': 2 },
PaperProps: { className: 'MuiMenu-list--paper' },
disableScrollLock: true,
};
const handleClickAway = () => {
if (!clicked) {
if (open) setClicked(true);
} else {
setAnchorEl(null);
setClicked(false);
}
};
const creatorIcon = (channelUrl, channelThumbnail) => (
<UriIndicator uri={channelUrl} link showAtSign channelInfo={{ uri: channelUrl, name: '' }}>
<ChannelThumbnail small thumbnailPreview={channelThumbnail} uri={channelThumbnail ? undefined : channelUrl} />
</UriIndicator>
);
function handleMenuClick() {
if (unseenCount > 0) doSeeAllNotifications();
push(`/$/${PAGES.NOTIFICATIONS}`);
}
function handleNotificationDelete(e, id) {
e.stopPropagation();
deleteNotification(id);
}
React.useEffect(() => {
if (!open) setClicked(false);
}, [open]);
if (!notificationsEnabled) return null;
function handleNotificationClick(notification) {
const { id, notification_parameters, is_read } = notification;
if (!is_read) {
seeNotification([id]);
readNotification([id]);
}
let notificationLink = formatLbryUrlForWeb(notification_parameters.device.target);
if (notification_parameters.dynamic.hash) {
notificationLink += '?lc=' + notification_parameters.dynamic.hash + '&view=discussion';
}
push(notificationLink);
}
function menuEntry(notification) {
const { id, active_at, notification_rule, notification_parameters, is_read, type } = notification;
let channelUrl;
let icon;
switch (notification_rule) {
case RULE.CREATOR_SUBSCRIBER:
icon = <Icon icon={ICONS.SUBSCRIBE} sectionIcon />;
break;
case RULE.COMMENT:
case RULE.CREATOR_COMMENT:
channelUrl = notification_parameters.dynamic.comment_author;
icon = creatorIcon(channelUrl, notification_parameters?.dynamic?.comment_author_thumbnail);
break;
case RULE.COMMENT_REPLY:
channelUrl = notification_parameters.dynamic.reply_author;
icon = creatorIcon(channelUrl, notification_parameters?.dynamic?.comment_author_thumbnail);
break;
case RULE.NEW_CONTENT:
channelUrl = notification_parameters.dynamic.channel_url;
icon = creatorIcon(channelUrl, notification_parameters?.dynamic?.channel_thumbnail);
break;
case RULE.NEW_LIVESTREAM:
channelUrl = notification_parameters.dynamic.channel_url;
icon = creatorIcon(channelUrl, notification_parameters?.dynamic?.channel_thumbnail);
break;
case RULE.WEEKLY_WATCH_REMINDER:
case RULE.DAILY_WATCH_AVAILABLE:
case RULE.DAILY_WATCH_REMIND:
case RULE.MISSED_OUT:
case RULE.REWARDS_APPROVAL_PROMPT:
icon = <Icon icon={ICONS.LBC} sectionIcon />;
break;
case RULE.FIAT_TIP:
icon = <Icon icon={ICONS.FINANCE} sectionIcon />;
break;
default:
icon = <Icon icon={ICONS.NOTIFICATION} sectionIcon />;
}
let channelName;
if (channelUrl) {
try {
({ claimName: channelName } = parseURI(channelUrl));
} catch (e) {}
}
return (
<>
<a onClick={() => handleNotificationClick(notification)}>
<div
className={
is_read ? 'menu__list--notification' : 'menu__list--notification menu__list--notification-unread'
}
key={id}
>
<div className="notification__icon">{icon}</div>
<div className="menu__list--notification-info">
<div className="menu__list--notification-type">
{generateNotificationTitle(notification_rule, notification_parameters, channelName)}
</div>
<div
className={
type === 'comments' ? 'menu__list--notification-title blockquote' : 'menu__list--notification-title'
}
>
{generateNotificationText(notification_rule, notification_parameters)}
</div>
{!is_read && <span></span>}
<DateTime timeAgo date={active_at} />
</div>
<div className="delete-notification" onClick={(e) => handleNotificationDelete(e, id)}>
<Icon icon={ICONS.DELETE} sectionIcon />
</div>
</div>
</a>
</>
);
}
return (
<Tooltip title={__('Notifications')}>
<Button onClick={handleMenuClick} className="header__navigationItem--icon">
<Icon size={18} icon={ICONS.NOTIFICATION} aria-hidden />
<NotificationBubble />
</Button>
</Tooltip>
notificationsEnabled && (
<>
<Tooltip title={__('Notifications')}>
<Button className="header__navigationItem--icon" onClick={handleClick}>
<Icon size={18} icon={ICONS.NOTIFICATION} aria-hidden />
<NotificationBubble />
</Button>
</Tooltip>
<ClickAwayListener onClickAway={handleClickAway}>
<MuiMenu {...menuProps}>
<div className="menu__list--notifications-header" />
<div className="menu__list--notifications-list">
{list.map((notification) => {
return menuEntry(notification);
})}
{list.length === 0 && (
<div className="menu__list--notification-empty">
<div className="menu__list--notification-empty-title">{__('No notifications')}</div>
<div className="menu__list--notification-empty-text">
{__("You don't have any notifications yet, but they will be here when you do!")}
</div>
</div>
)}
</div>
<a onClick={handleMenuClick}>
<div className="menu__list--notifications-more">{__('View all')}</div>
</a>
</MuiMenu>
</ClickAwayListener>
</>
)
);
}

View file

@ -15,6 +15,7 @@ import React from 'react';
import Skeleton from '@mui/material/Skeleton';
import ChannelSelector from 'component/channelSelector';
import Button from 'component/button';
import ClickAwayListener from '@mui/material/ClickAwayListener';
type HeaderMenuButtonProps = {
myChannelClaimIds: ?Array<string>,
@ -28,6 +29,7 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
const { myChannelClaimIds, activeChannelClaim, authenticated, email, signOut } = props;
const [anchorEl, setAnchorEl] = React.useState(null);
const [clicked, setClicked] = React.useState(false);
const open = Boolean(anchorEl);
const handleClick = (event) => setAnchorEl(!anchorEl ? event.currentTarget : null);
const handleClose = () => setAnchorEl(null);
@ -37,6 +39,19 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
const noActiveChannel = activeChannelUrl === null;
const pendingChannelFetch = !noActiveChannel && myChannelClaimIds === undefined;
const handleClickAway = () => {
if (!clicked) {
if (open) setClicked(true);
} else {
setAnchorEl(null);
setClicked(false);
}
};
React.useEffect(() => {
if (!open) setClicked(false);
}, [open]);
const menuProps = {
id: 'basic-menu',
anchorEl,
@ -49,6 +64,7 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
className: 'menu__list--header',
sx: { 'z-index': 2 },
PaperProps: { className: 'MuiMenu-list--paper' },
disableScrollLock: true,
};
return (
@ -87,46 +103,50 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
</Button>
)}
{authenticated ? (
<MuiMenu {...menuProps}>
<ChannelSelector storeSelection isHeaderMenu />
<ClickAwayListener onClickAway={handleClickAway}>
<MuiMenu {...menuProps}>
<ChannelSelector storeSelection isHeaderMenu />
<hr className="menu__separator" />
<HeaderMenuLink useMui page={PAGES.UPLOADS} icon={ICONS.PUBLISH} name={__('Uploads')} />
<HeaderMenuLink useMui page={PAGES.CHANNELS} icon={ICONS.CHANNEL} name={__('Channels')} />
<HeaderMenuLink
useMui
page={PAGES.CREATOR_DASHBOARD}
icon={ICONS.ANALYTICS}
name={__('Creator Analytics')}
/>
<hr className="menu__separator" />
<HeaderMenuLink useMui page={PAGES.UPLOADS} icon={ICONS.PUBLISH} name={__('Uploads')} />
<HeaderMenuLink useMui page={PAGES.CHANNELS} icon={ICONS.CHANNEL} name={__('Channels')} />
<HeaderMenuLink
useMui
page={PAGES.CREATOR_DASHBOARD}
icon={ICONS.ANALYTICS}
name={__('Creator Analytics')}
/>
<hr className="menu__separator" />
<HeaderMenuLink useMui page={PAGES.REWARDS} icon={ICONS.REWARDS} name={__('Rewards')} />
<HeaderMenuLink useMui page={PAGES.INVITE} icon={ICONS.INVITE} name={__('Invites')} />
<HeaderMenuLink useMui page={PAGES.ODYSEE_MEMBERSHIP} icon={ICONS.UPGRADE} name={__('Odysee Premium')} />
<hr className="menu__separator" />
<HeaderMenuLink useMui page={PAGES.REWARDS} icon={ICONS.REWARDS} name={__('Rewards')} />
<HeaderMenuLink useMui page={PAGES.INVITE} icon={ICONS.INVITE} name={__('Invites')} />
<HeaderMenuLink useMui page={PAGES.ODYSEE_MEMBERSHIP} icon={ICONS.UPGRADE} name={__('Odysee Premium')} />
<hr className="menu__separator" />
<HeaderMenuLink useMui page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} />
<HeaderMenuLink useMui page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} />
<hr className="menu__separator" />
<HeaderMenuLink useMui page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} />
<HeaderMenuLink useMui page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} />
<hr className="menu__separator" />
<MuiMenuItem onClick={signOut}>
<div className="menu__link" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<div className="menu__link-label">
<Icon aria-hidden icon={ICONS.SIGN_OUT} />
{__('Sign Out')}
<hr className="menu__separator" />
<MuiMenuItem onClick={signOut}>
<div className="menu__link" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<div className="menu__link-label">
<Icon aria-hidden icon={ICONS.SIGN_OUT} />
{__('Sign Out')}
</div>
<span className="menu__link-help">{email}</span>
</div>
<span className="menu__link-help">{email}</span>
</div>
</MuiMenuItem>
</MuiMenu>
</MuiMenuItem>
</MuiMenu>
</ClickAwayListener>
) : (
<MuiMenu {...menuProps}>
<HeaderMenuLink useMui page={PAGES.AUTH_SIGNIN} icon={ICONS.SIGN_IN} name={__('Log In')} />
<HeaderMenuLink useMui page={PAGES.AUTH} icon={ICONS.SIGN_UP} name={__('Sign Up')} />
<HeaderMenuLink useMui page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} />
<HeaderMenuLink useMui page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} />
</MuiMenu>
<ClickAwayListener onClickAway={handleClickAway}>
<MuiMenu {...menuProps}>
<HeaderMenuLink useMui page={PAGES.AUTH_SIGNIN} icon={ICONS.SIGN_IN} name={__('Log In')} />
<HeaderMenuLink useMui page={PAGES.AUTH} icon={ICONS.SIGN_UP} name={__('Sign Up')} />
<HeaderMenuLink useMui page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} />
<HeaderMenuLink useMui page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} />
</MuiMenu>
</ClickAwayListener>
)}
</div>
</>

View file

@ -243,9 +243,9 @@ $contentMaxWidth: 60rem;
.notification__title {
position: relative;
font-size: var(--font-small);
color: var(--color-text);
margin-bottom: var(--spacing-xxs);
font-size: var(--font-xsmall);
color: var(--color-text-subtitle);
// margin-bottom: var(--spacing-xxs);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
@ -265,8 +265,8 @@ $contentMaxWidth: 60rem;
}
.notification__text {
font-size: var(--font-xsmall);
color: var(--color-text-subtitle);
font-size: var(--font-small);
color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
@ -284,6 +284,7 @@ $contentMaxWidth: 60rem;
blockquote {
padding-left: var(--spacing-xs);
border-left: 2px solid;
color: var(--color-text) !important;
}
}

View file

@ -156,9 +156,10 @@ reach-portal {
}
.MuiPaper-root {
top: calc(var(--header-height) - 11px) !important;
top: calc(var(--header-height) - 10px) !important;
transition: none !important;
@media (max-width: $breakpoint-small) {
top: calc(var(--header-height-mobile) - 11px) !important;
top: calc(var(--header-height-mobile) - 10px) !important;
}
}
@ -190,6 +191,230 @@ reach-portal {
@extend .menu__list;
}
.menu__list--notifications {
.MuiMenu-list {
padding: 0;
backdrop-filter: blur(4px) !important;
background-color: unset !important;
}
.MuiMenu-list--paper {
border-radius: 0 0 var(--border-radius) var(--border-radius);
background-color: unset !important;
}
.menu__list--notifications-header {
height: var(--spacing-xs);
background-color: rgba(var(--color-header-background-base), 0.9);
backdrop-filter: blur(4px) !important;
}
a:nth-child(1) {
.menu__list--notification {
border-top: 1px solid rgba(var(--color-header-button-base), 0.95);
}
}
.menu__list--notifications-list {
overflow: hidden !important;
.menu__list--notification {
position: relative;
overflow: hidden !important;
width: 440px;
padding: var(--spacing-s);
display: flex;
background-color: rgba(var(--color-header-button-base), 0.95);
border-top: 1px solid var(--color-header-background);
transition: border-left 0.4s;
.notification__icon {
margin-top: 0;
margin-left: 0;
.channel-thumbnail {
margin-right: var(--spacing-s);
width: 3rem;
height: 3rem;
img,
canvas {
width: 3rem;
height: 3rem;
border-radius: 50%;
}
}
}
.menu__list--notification-channel-unread {
border: 2px solid var(--color-primary);
}
.menu__list--notification-info {
overflow: hidden;
width: 100%;
.menu__list--notification-type {
display: flex;
flex-flow: wrap;
gap: var(--spacing-xxxs);
width: 100%;
color: rgba(var(--color-text-base), 0.6);
font-size: var(--font-xsmall);
.notification__claim-title {
width: 100%;
color: rgba(var(--color-text-base), 0.6);
font-size: var(--font-xsmall);
font-weight: 400;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
}
.button__content {
display: flex;
margin: 0;
.channel-name {
margin: 0;
color: rgba(var(--color-text-base), 0.6);
font-size: var(--font-xsmall);
font-weight: 400;
}
}
span {
margin-right: 0;
.credit-amount {
color: rgba(var(--color-text-base), 0.6);
font-size: var(--font-xsmall);
font-weight: 400;
svg {
margin-bottom: 0;
}
}
}
}
.menu__list--notification-title {
color: var(--color-text);
flex-grow: 1;
margin-bottom: -3px;
width: 100%;
.notification__text {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
blockquote {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
padding-right: 0;
}
}
}
.date_time {
color: rgba(var(--color-text-base), 0.6);
}
span {
color: var(--color-primary);
display: inline-block;
font-size: var(--font-xxsmall);
margin-right: var(--spacing-xxxs);
}
.sticker__comment {
margin-left: 0;
padding: var(--spacing-xxs);
padding-left: 0;
height: 50px;
}
}
&:hover {
cursor: pointer;
background-color: rgba(var(--color-header-background-base), 1);
.menu__list--notification-info {
.menu__list--notification-title {
.notification__text {
color: var(--color-primary);
blockquote {
color: var(--color-primary) !important;
}
}
}
}
.delete-notification {
opacity: 1;
}
}
}
.menu__list--notification-unread {
border-left: 2px solid var(--color-primary);
}
.menu__list--notification-empty {
display: block;
word-wrap: break-word;
white-space: normal;
width: 440px;
padding: var(--spacing-m) var(--spacing-s);
background-color: rgba(var(--color-header-button-base), 0.95);
color: var(--color-text);
text-align: center;
.menu__list--notification-empty-title {
font-weight: bold;
font-size: var(--font-body);
}
.menu__list--notification-empty-text {
color: rgba(var(--color-text-base), 0.6);
font-size: var(--font-xsmall);
font-weight: 400;
}
}
.delete-notification {
position: absolute;
top: var(--spacing-xs);
right: var(--spacing-xxs);
opacity: 0;
.icon__wrapper {
width: 20px;
height: 20px;
padding: 0;
background-color: rgba(var(--color-header-button-base), 0.95);
outline: 4px solid rgba(var(--color-header-background-base), 1);
svg {
width: 12px;
height: 12px;
}
}
&:hover {
.icon__wrapper {
background-color: var(--color-primary);
color: var(--color-primary-contrast);
}
}
}
}
.menu__list--notifications-more {
color: var(--color-text);
background-color: rgba(var(--color-header-background-base), 1);
padding: var(--spacing-s) var(--spacing-xs);
text-align: center;
border-top: 1px solid rgba(var(--color-header-button-base), 0.95);
&:hover {
cursor: pointer;
color: var(--color-primary);
}
}
}
.MuiMenuItem-root {
margin-left: 0px !important;
font-size: var(--font-small) !important;
@ -199,8 +424,6 @@ reach-portal {
.menu__link {
display: flex;
align-items: center;
// padding: var(--spacing-s);
// padding-right: var(--spacing-l);
padding: var(--spacing-xs) var(--spacing-s) var(--spacing-xs) var(--spacing-s);
height: var(--button-height);
color: var(--color-text);