Merge pull request #70 from Stremio/user-notifications-menu

User notifications menu
This commit is contained in:
Nikola Hristov 2019-10-17 09:37:48 +03:00 committed by GitHub
commit f9203087a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 611 additions and 2 deletions

View file

@ -17,6 +17,7 @@ const MainNavBar = ({ className }) => {
searchBar={true}
addonsButton={true}
fullscreenButton={true}
notificationsMenu={true}
navMenu={true}
/>
);

View file

@ -5,10 +5,11 @@ const NavTabButton = require('./NavTabButton');
const SearchBar = require('./SearchBar');
const AddonsButton = require('./AddonsButton');
const FullscreenButton = require('./FullscreenButton');
const NotificationsMenu = require('./NotificationsMenu');
const NavMenu = require('./NavMenu');
const styles = require('./styles');
const NavBar = React.memo(({ className, backButton, tabs, title, searchBar, addonsButton, fullscreenButton, navMenu }) => {
const NavBar = React.memo(({ className, backButton, tabs, title, searchBar, addonsButton, fullscreenButton, notificationsMenu, navMenu }) => {
const backButtonOnClick = React.useCallback(() => {
window.history.back();
}, []);
@ -62,6 +63,12 @@ const NavBar = React.memo(({ className, backButton, tabs, title, searchBar, addo
:
null
}
{
notificationsMenu ?
<NotificationsMenu className={styles['notifications-menu']} />
:
null
}
{
navMenu ?
<NavMenu className={styles['nav-menu']} />
@ -87,6 +94,7 @@ NavBar.propTypes = {
searchBar: PropTypes.bool,
addonsButton: PropTypes.bool,
fullscreenButton: PropTypes.bool,
notificationsMenu: PropTypes.bool,
navMenu: PropTypes.bool
};

View file

@ -0,0 +1,98 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const Button = require('stremio/common/Button');
const Image = require('stremio/common/Image');
const PlayIconCircleCentered = require('stremio/common/PlayIconCircleCentered');
const styles = require('./styles');
const ICON_FOR_TYPE = Object.assign(Object.create(null), {
'movie': 'ic_movies',
'series': 'ic_series',
'channel': 'ic_channels',
'tv': 'ic_tv',
'other': 'ic_movies'
});
const Notification = ({ className, id, type, name, poster, thumbnail, season, episode, released, onClick }) => {
const daysAgo = React.useMemo(() => {
if (released instanceof Date) {
return Math.floor(Math.abs((Date.now() - released) / (24 * 60 * 60 * 1000)));
}
}, [released]);
return (
<Button className={classnames(className, styles['notification-container'])} title={typeof name === 'string' && name.length > 0 ? name : id} data-id={id} onClick={onClick}>
<div className={styles['logo-container']}>
<div className={styles['logo-image-layer']}>
<Image
className={styles['logo-image']}
src={poster}
alt={' '}
renderFallback={() => (
<Icon
className={styles['placeholder-icon']}
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
/>
)}
/>
</div>
</div>
<div className={styles['info-container']}>
<div className={styles['episode-container']}>
{episode !== null && !isNaN(episode) ? `E${episode} ` : null}
{season !== null && !isNaN(season) ? `S${season} ` : null}
</div>
<div className={styles['name-container']}>
{typeof name === 'string' && name.length > 0 ? name : id}
</div>
{
daysAgo !== null && !isNaN(daysAgo) ?
<div className={styles['released-container']}>
{daysAgo === 0 ? 'today' : daysAgo + ' days ago'}
</div>
:
null
}
</div>
{
typeof thumbnail === 'string' && thumbnail.length > 0 ?
<div className={styles['poster-container']}>
<div className={styles['poster-image-layer']}>
<Image
className={styles['poster-image']}
src={thumbnail}
alt={' '}
renderFallback={() => (
<Icon
className={styles['placeholder-icon']}
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
/>
)}
/>
</div>
<div className={styles['play-icon-layer']}>
<PlayIconCircleCentered className={styles['play-icon']} />
</div>
</div>
:
null
}
</Button>
);
}
Notification.propTypes = {
className: PropTypes.string,
id: PropTypes.string,
type: PropTypes.string,
name: PropTypes.string,
poster: PropTypes.string,
thumbnail: PropTypes.string,
season: PropTypes.number,
episode: PropTypes.number,
released: PropTypes.instanceOf(Date),
onClick: PropTypes.func
};
module.exports = Notification;

View file

@ -0,0 +1,3 @@
const Notification = require('./Notification');
module.exports = Notification;

View file

@ -0,0 +1,135 @@
:import('~stremio/common/PlayIconCircleCentered/styles.less') {
play-icon-circle-centered-background: background;
play-icon-circle-centered-icon: icon;
}
.notification-container {
display: flex;
flex-direction: row;
padding: 1rem;
background-color: var(--color-surfacelighter);
&:hover, &:focus {
background-color: var(--color-surfacelight);
.poster-container {
.play-icon-layer {
display: block;
}
}
}
.logo-container, .poster-container {
position: relative;
z-index: 0;
height: 3.5rem;
background-color: var(--color-backgroundlight);
.logo-image-layer, .poster-image-layer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: -3;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.logo-image, .poster-image {
flex: none;
width: 100%;
height: 100%;
object-position: center;
object-fit: cover;
}
.placeholder-icon {
flex: none;
width: 80%;
height: 50%;
fill: var(--color-surfacelight20);
}
}
}
.logo-container {
width: 3.5rem;
border-radius: 50%;
}
.info-container {
flex: 1;
align-self: stretch;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
padding: 0 1rem;
&:first-child {
align-content: center;
}
.episode-container {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 100%;
white-space: nowrap;
text-overflow: ellipsis;
}
.name-container {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 100%;
max-height: 3.6em;
}
.released-container {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 100%;
margin-top: 0.5rem;
font-size: 0.9rem;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color-surfacedark);
}
}
.poster-container {
width: 6rem;
.play-icon-layer {
position: absolute;
top: 20%;
right: 0;
bottom: 20%;
left: 0;
z-index: -2;
display: none;
overflow: visible;
.play-icon {
display: block;
width: 100%;
height: 100%;
filter: drop-shadow(0 0 0.5rem var(--color-backgroundlight));
.play-icon-circle-centered-background {
fill: var(--color-surfacelighter);
}
.play-icon-circle-centered-icon {
fill: var(--color-primary);
}
}
}
}
&:not(:last-child) {
border-bottom: thin solid var(--color-surfacelight);
}
}

View file

@ -0,0 +1,25 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const styles = require('./styles');
const NotificationPlaceholder = ({ className }) => {
return (
<div className={classnames(className, styles['notification-placeholder-container'])}>
<div className={styles['logo-image-container']}>
<div className={styles['logo-image']} />
</div>
<div className={styles['info-container']}>
<div className={styles['name-container']} />
<div className={styles['released-container']} />
</div>
</div>
);
};
NotificationPlaceholder.propTypes = {
className: PropTypes.string
}
module.exports = NotificationPlaceholder;

View file

@ -0,0 +1,3 @@
const NotificationPlaceholder = require('./NotificationPlaceholder');
module.exports = NotificationPlaceholder;

View file

@ -0,0 +1,39 @@
.notification-placeholder-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 1rem;
.logo-image-container {
flex: none;
border-radius: 50%;
.logo-image {
width: 3.5rem;
height: 3.5rem;
background-color: var(--color-placeholder);
}
}
.info-container {
flex: 1;
padding: 0 1rem;
.name-container {
width: 100%;
height: 1.2rem;
background: var(--color-placeholder);
}
.released-container {
width: 50%;
height: 1.2rem;
margin-top: 0.5rem;
background: var(--color-placeholder);
}
}
&:not(:last-child) {
border-bottom: thin solid var(--color-surfacelight);
}
}

View file

@ -0,0 +1,67 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const Notification = require('./Notification');
const NotificationPlaceholder = require('./NotificationPlaceholder');
const styles = require('./styles');
const NotificationsList = ({ className, metaItems }) => {
return (
<div className={classnames(className, styles['notifications-list-scroll-container'])}>
{
metaItems.map(({ req, content }, index) => {
switch (content.type) {
case 'Ready':
return (
content.content.length > 0 ?
content.content.map((notification) => {
//notifications videos are not available in useCatalogs, but in useNotifications hook
notification.videos = notification.videos || [{
title: 'Demo title',
thumbnail: 'https://www.stremio.com/website/home-testimonials.jpg',
season: 1,
episode: 1,
released: new Date()
}];
return (
<Notification
{...notification}
key={`${index}${req.base}${content.type}${notification.id}`}
className={styles['notification']}
name={notification.videos.length === 1 ? notification.videos[0].title : notification.videos.length + ' new videos for ' + notification.name}
thumbnail={notification.videos.length === 1 ? notification.videos[0].thumbnail : null}
season={notification.videos.length === 1 ? notification.videos[0].season : null}
episode={notification.videos.length === 1 ? notification.videos[0].episode : null}
released={notification.videos.length === 1 ? notification.videos[0].released : notification.videos[notification.videos.length - 1].released}
/>
);
})
:
<div className={styles['no-notifications-container']}>
<Icon className={styles['icon']} icon={'ic_bell'} />
<div className={styles['label']}>No new notifications</div>
</div>
);
case 'Loading':
return (
<NotificationPlaceholder
key={`${index}${req.base}${content.type}`}
className={styles['notification']}
/>
);
}
})
}
</div>
);
}
NotificationsList.propTypes = {
className: PropTypes.string,
metaItems: PropTypes.arrayOf(PropTypes.object)
};
module.exports = NotificationsList;

View file

@ -0,0 +1,3 @@
const NotificationsList = require('./NotificationsList');
module.exports = NotificationsList;

View file

@ -0,0 +1,37 @@
.notifications-list-scroll-container {
flex: 1;
align-self: stretch;
overflow-y: auto;
background-color: var(--color-surfacelighter);
.notification {
width: var(--item-size);
}
.no-notifications-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: var(--item-size);
height: 100%;
padding: 1rem;
.icon {
width: 6rem;
height: 6rem;
margin-bottom: 2rem;
fill: var(--color-surfacedark);
}
.label {
font-size: 1.2rem;
text-align: center;
color: var(--color-surfacedark);
}
}
&:global(::-webkit-scrollbar-track) {
background-color: var(--color-surfacelighter);
}
}

View file

@ -0,0 +1,48 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const Button = require('stremio/common/Button');
const Popup = require('stremio/common/Popup');
const NotificationsList = require('./NotificationsList');
const useNotifications = require('./useNotifications');
const useCatalogs = require('stremio/routes/Board/useCatalogs');
const useBinaryState = require('stremio/common/useBinaryState');
const styles = require('./styles');
const NotificationsMenu = ({ className, onClearButtonClicked }) => {
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
//TODO use useNotifications hook instead of useCatalogs
const metaItems = useCatalogs();
return (
<Popup
open={menuOpen}
onCloseRequest={closeMenu}
renderLabel={({ref, className: popupLabelClassName, children}) => (
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['notifications-menu-label-container'], { 'active': menuOpen })} tabIndex={-1} onClick={toggleMenu}>
<Icon className={styles['icon']} icon={'ic_bell'} />
{children}
</Button>
)}
renderMenu={() => (
<div className={styles['notifications-menu-container']}>
<div className={styles['notifications-bar']}>
<div className={styles['notifications-label']}>Notifications</div>
<Button className={styles['button-container']} onClick={onClearButtonClicked}>
<Icon className={styles['icon']} icon={'ic_drawer'} />
</Button>
</div>
<NotificationsList className={styles['notifications-list']} metaItems={metaItems}/>
</div>
)}
/>
);
};
NotificationsMenu.propTypes = {
className: PropTypes.string,
onClearButtonClicked: PropTypes.func
};
module.exports = NotificationsMenu;

View file

@ -0,0 +1,3 @@
const NotificationsMenu = require('./NotificationsMenu');
module.exports = NotificationsMenu;

View file

@ -0,0 +1,68 @@
.notifications-menu-label-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
&:hover {
background-color: var(--color-secondary);
}
&:global(.active) {
background-color: var(--color-background);
}
.icon {
flex: none;
width: 50%;
height: 50%;
fill: var(--color-surfacelighter);
}
.notifications-menu-container {
display: flex;
flex-direction: column;
.notifications-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border-bottom: thin solid var(--color-surfacelight);
background-color: var(--color-surfacelighter);
.notifications-label {
padding-left: 0.5rem;
color: var(--color-surfacedark);
}
.button-container {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
&:hover, &:focus {
background-color: var(--color-surfacelight);
}
.icon {
width: 1.2rem;
fill: var(--color-surfacedark);
}
}
}
.notifications-list {
--item-size: 28rem;
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
height: 30rem;
align-self: stretch;
}
}
}

View file

@ -0,0 +1,26 @@
const React = require('react');
const { useServices } = require('stremio/services');
const useNotifications = () => {
const [notifications, setNotifications] = React.useState([]);
const { core } = useServices();
React.useEffect(() => {
const onNewState = () => {
const state = core.getState();
setNotifications(state.notifications.groups);
};
core.on('NewModel', onNewState);
core.dispatch({
action: 'Load',
args: {
load: 'Notifications'
}
});
return () => {
core.off('NewModel', onNewState);
};
}, []);
return notifications;
};
module.exports = useNotifications;

View file

@ -44,7 +44,7 @@
}
}
.addons-button, .fullscreen-button, .nav-menu {
.addons-button, .fullscreen-button, .notifications-menu, .nav-menu {
flex: none;
width: var(--nav-bar-size);
height: var(--nav-bar-size);

View file

@ -56,6 +56,7 @@ const Settings = () => {
backButton={true}
addonsButton={true}
fullscreenButton={true}
notificationsMenu={true}
navMenu={true} />
<div className={styles['settings-container']}>
<SectionsSelector className={styles['side-menu']} sections={sections} selectedSectionId={selectedSectionId} onSelectedSection={changeSection} />

View file

@ -0,0 +1,18 @@
const React = require('react');
const { storiesOf } = require('@storybook/react');
const { action } = require('@storybook/addon-actions');
const Notification = require('stremio/common/NavBar/NotificationsMenu/NotificationsList/Notification');
const styles = require('./styles');
storiesOf('Notification', module).add('MultipleVideos', () => (
<Notification
className={styles['multiple-videos-notification-container']}
id={'notification-id'}
type={'series'}
name={'Demo name'}
poster={'/images/intro_background.jpg'}
thumbnail={null}
released={new Date()}
onClick={action('Demo item clicked')}
/>
));

View file

@ -0,0 +1,20 @@
const React = require('react');
const { storiesOf } = require('@storybook/react');
const { action } = require('@storybook/addon-actions');
const Notification = require('stremio/common/NavBar/NotificationsMenu/NotificationsList/Notification');
const styles = require('./styles');
storiesOf('Notification', module).add('SingleVideo', () => (
<Notification
className={styles['single-video-notification-container']}
id={'notification-id'}
type={'series'}
name={'Demo name'}
poster={'/images/intro_background.jpg'}
thumbnail={'/images/intro_background.jpg'}
season={1}
episode={1}
released={new Date()}
onClick={action('Demo item clicked')}
/>
));

View file

@ -0,0 +1,2 @@
require('./SingleVideoNotification');
require('./MultipleVideosNotification');

View file

@ -0,0 +1,3 @@
.single-video-notification-container, .multiple-videos-notification-container {
width: 28rem;
}

View file

@ -2,3 +2,4 @@ require('./Addon');
require('./MetaItem');
require('./ColorPicker');
require('./SharePrompt');
require('./Notification');