New homepage, working on more native notification support

This commit is contained in:
Nicholas Zuber 2018-11-10 15:27:17 -05:00
parent 35bc45c3a3
commit d85dcb639e
16 changed files with 397 additions and 143 deletions

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> -->
<meta name="theme-color" content="#24292e">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>Meteorite — Smarter GitHub notifications</title>

View File

@ -54,6 +54,9 @@ import tagWhite from './svg/tag-white.svg';
import sync from './svg/sync.svg';
import noPhone from './svg/nophone.svg';
import noMusic from './svg/nomusic.svg';
import leftArrow from './svg/left-arrow.svg';
import notificationsOn from './svg/notifications-on.svg';
import notificationsOff from './svg/notifications-off.svg';
import issue_closed from './svg/github/issue-closed.svg';
import issue_open from './svg/github/issue-open.svg';
@ -135,6 +138,9 @@ Icon.TagWhite = createIcon(tagWhite);
Icon.Sync = createIcon(sync);
Icon.NoPhone = createIcon(noPhone);
Icon.NoMusic = createIcon(noMusic);
Icon.LeftArrow = createIcon(leftArrow);
Icon.NotificationsOn = createIcon(notificationsOn);
Icon.NotificationsOff = createIcon(notificationsOff);
Icon.IssueClosed = createIcon(issue_closed);
Icon.IssueOpen = createIcon(issue_open);

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 11H6.83l2.88-2.88c.39-.39.39-1.02 0-1.41-.39-.39-1.02-.39-1.41 0L3.71 11.3c-.39.39-.39 1.02 0 1.41L8.3 17.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L6.83 13H20c.55 0 1-.45 1-1s-.45-1-1-1z"/></svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm0-15.5c2.49 0 4 2.02 4 4.5v.1l2 2V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.24.06-.47.15-.69.23l1.64 1.64c.18-.02.36-.05.55-.05zM5.41 3.35L4 4.76l2.81 2.81C6.29 8.57 6 9.74 6 11v5l-2 2v1h14.24l1.74 1.74 1.41-1.41L5.41 3.35zM16 17H8v-6c0-.68.12-1.32.34-1.9L16 16.76V17z"/></svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6zM7.58 4.08L6.15 2.65C3.75 4.48 2.17 7.3 2.03 10.5h2c.15-2.65 1.51-4.97 3.55-6.42zm12.39 6.42h2c-.15-3.2-1.73-6.02-4.12-7.85l-1.42 1.43c2.02 1.45 3.39 3.77 3.54 6.42z"/></svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -1,12 +1,11 @@
import React from 'react';
const CLIENT_ID = '9478c90e57ef3d546ef0';
const REDIRECT_URI = 'https://meteorite.surge.sh/login';
const SCOPES = 'notifications';
const AuthenticationButton = props => (
<a
href={`https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&scope=${SCOPES}&redirect_uri=${REDIRECT_URI}`}
href={`https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&scope=${SCOPES}`}
{...props}
>
Authorize with GitHub

View File

@ -6,6 +6,8 @@ export const Reasons = {
AUTHOR: 'author',
OTHER: 'other',
COMMENT: 'comment',
STATE_CHANGE: 'state_change',
TEAM_MENTION: 'team_mention'
};
export const Badges = {

BIN
src/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
src/images/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

View File

@ -5,6 +5,7 @@ import { routes } from '../../constants';
import Curve from '../../components/Curve';
import Icon from '../../components/Icon';
import Logo from '../../components/Logo';
import screenshot from '../../images/screenshot.png';
import '../../styles/gradient.css';
function createImagePlaceholder (highlight) {
@ -326,13 +327,42 @@ function createImagePlaceholder (highlight) {
);
}
const ImageContainer = styled('div')({
position: 'absolute',
height: 390,
width: 685,
top: 155,
left: '50%',
background: `url(${screenshot}) center center no-repeat`,
backgroundSize: 'cover',
backgroundColor: '#fff',
boxShadow: '0 2px 8px rgba(179, 179, 179, 0.25)',
marginLeft: 100,
borderRadius: 8,
display: 'block',
'@media (max-width: 1000px)': {
display: 'none'
}
});
const WidthContainer = styled('div')({
margin: '0 auto',
maxWidth: 1500,
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
'@media (max-width: 1400px)': {
flexDirection: 'column'
}
});
const Section = styled('div')({
position: 'relative',
width: '100%',
minHeight: 300,
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
flexDirection: 'column',
margin: '28px auto 0',
padding: '60px 0'
}, ({alt}) => alt && ({
@ -341,10 +371,12 @@ const Section = styled('div')({
color: '#fff'
},
'h2': {
color: '#fff'
},
'@media (max-width: 1000px)': {
flexDirection: 'column'
color: '#fff',
marginTop: 0,
marginLeft: 15,
fontSize: 42,
textAlign: 'left',
fontWeight: 600
}
}));
@ -393,8 +425,9 @@ const ImagePlaceholder = styled('div')({
const Header = styled('h1')({
color: '#fff',
padding: '0 20px',
margin: '0 auto 48px',
letterSpacing: '-1.0px'
margin: '0 0 24px',
letterSpacing: '-1.0px',
width: '50%',
});
const SubHeader = styled(Header)({
@ -408,19 +441,32 @@ const SubHeader = styled(Header)({
const LandingHeader = styled('div')({
position: 'relative',
width: '100%',
margin: '54px 20px 78px',
maxWidth: 1000,
width: '90%',
margin: '22px 20px 54px',
maxWidth: 1500,
display: 'flex',
justifyContent: 'space-between',
});
const LandingMessage = styled(LandingHeader)({
marginLeft: '5%',
flexDirection: 'column',
textAlign: 'center',
maxWidth: 1000,
textAlign: 'left',
maxWidth: 1500,
'h1': {
display: 'block'
},
'@media (max-width: 1000px)': {
textAlign: 'center',
'h1': {
marginLeft: 'auto',
marginRight: 'auto',
width: 500
},
'div': {
marginLeft: 'auto !important',
marginRight: 'auto !important',
},
}
});
@ -443,16 +489,24 @@ const SmallText = styled('span')({
const BottomLinkContainer = styled(LandingHeader)({
maxWidth: 350,
width: '100%',
margin: '32px auto 0',
margin: '32px 20px 0',
});
const LinkButton = styled('a')({});
const U = styled('span')({
color: 'inherit',});
// background: '#009cfb',
// padding: '0 6px 2px',
// borderRadius: 4,
// }, ({color}) => ({
// background: color
// }));
const UnofficialReleaseTag = styled('span')({
color: 'white',
position: 'absolute',
left: '44px',
bottom: '7px',
bottom: '9px',
fontSize: '11px',
background: '#f42839',
fontWeight: '800',
@ -471,27 +525,61 @@ export default function Scene ({loggedIn, onLogout, ...props}) {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflow: 'hidden'
overflow: 'hidden',
paddingBottom: 50
}}>
<LandingHeader>
<Logo size={75} />
<UnofficialReleaseTag>beta</UnofficialReleaseTag>
<LandingHeader style={{paddingLeft: '5%'}}>
<Logo size={75}>
<UnofficialReleaseTag>beta</UnofficialReleaseTag>
</Logo>
{loggedIn ? (
<div className="button-container">
<RouterLink style={{marginRight: 15}} to={routes.NOTIFICATIONS}>notifications</RouterLink>
<LinkButton style={{marginRight: 15}} href="#" onClick={onLogout}>sign out</LinkButton>
<div className="button-container-alt">
<RouterLink
style={{
marginRight: 15,
color: '#fff',
background: 'none'
}} to={routes.NOTIFICATIONS}>notifications</RouterLink>
<LinkButton
style={{
marginRight: 15,
color: '#fff',
background: 'none'
}} href="#" onClick={onLogout}>sign out</LinkButton>
</div>
) : (
<div className="button-container">
<RouterLink style={{marginRight: 15}} to={routes.LOGIN}>sign in</RouterLink>
<div className="button-container-alt">
<RouterLink
style={{
marginRight: 15,
color: '#fff',
background: 'none'
}} to={routes.LOGIN}>sign in</RouterLink>
</div>
)}
</LandingHeader>
<LandingMessage>
<Header>Control your GitHub notifications</Header>
<SubHeader>Prioritize the tasks that keep you and your team most productive</SubHeader>
<div className="button-container">
<div className="button-container-alt" style={{marginLeft: 20}}>
<RouterLink to={routes.LOGIN}>let's get started</RouterLink>
<LinkButton
onClick={() => {
const section = document.querySelector('#learnMore');
const y = section.getBoundingClientRect().top + window.scrollY;
window.scroll({
top: y,
behavior: 'smooth'
});
}}
style={{
marginLeft: 20,
color: '#fff',
background: 'none'
}}>
learn more
<Icon.LeftArrow shrink={0.6} style={{marginLeft: 5, filter: 'invert(1)', transform: 'rotateY(180deg)'}} />
</LinkButton>
</div>
<BottomLinkContainer>
<SmallLink target="_blank" href="https://github.com/nickzuber/meteorite">View and contribute on GitHub</SmallLink>
@ -508,31 +596,34 @@ export default function Scene ({loggedIn, onLogout, ...props}) {
</SmallText>
</BottomLinkContainer>
</LandingMessage>
<ImageContainer />
<Curve />
</div>
<Section id="section">
<Item style={{flex: '0 0 2.5%', padding: 0}} />
<Item>
{createImagePlaceholder('badges')}
</Item>
<Item id="item-text">
<h2>Surface the things that matter the most.</h2>
<ItemText>
<Icon.Ring />
<p>The most important issues and pull requests that require your presence are called out and brought to your attention.</p>
</ItemText>
<ItemText>
<Icon.Ear />
<p>We listen for updates with your notifications and let you know <i>why</i> and <i>when</i> things change.</p>
</ItemText>
<ItemText>
<Icon.Zap />
<p>Super charge your day by focusing on getting things done, rather than sifting through notifications or emails.</p>
</ItemText>
</Item>
<Item style={{flex: '0 0 2.5%', padding: 0}} />
<Section className="section">
<WidthContainer>
<Item style={{flex: '0 0 2.5%', padding: 0}} />
<Item>
{createImagePlaceholder('badges')}
</Item>
<Item className="item-text">
<h2>Surface the things that matter the most.</h2>
<ItemText>
<Icon.Ring />
<p>The most important issues and pull requests that require your presence are called out and brought to your attention.</p>
</ItemText>
<ItemText>
<Icon.Ear />
<p>We listen for updates with your notifications and let you know <i>why</i> and <i>when</i> things change.</p>
</ItemText>
<ItemText>
<Icon.Zap />
<p>Super charge your day by focusing on getting things done, rather than sifting through notifications or emails.</p>
</ItemText>
</Item>
<Item style={{flex: '0 0 2.5%', padding: 0}} />
</WidthContainer>
</Section>
<Section id="section" alt={true} style={{paddingTop: 140, overflowX: 'hidden'}}>
<Section className="section" alt={true} style={{paddingTop: 140, overflowX: 'hidden'}}>
<Curve style={{
bottom: 'auto',
marginBottom: 0,
@ -540,26 +631,63 @@ export default function Scene ({loggedIn, onLogout, ...props}) {
top: 0,
transform: 'translateX(-50%) rotate(180deg)'
}} />
<Item style={{flex: '0 0 2.5%', padding: 0}} />
<Item id="item-text">
<h2>Your time matters, so<br />we keep things simple.</h2>
<ItemText>
<Icon.CloudOffWhite />
<p>All of the information we use to make your notifications more useful is kept offline and kept on your own computer.</p>
</ItemText>
<ItemText>
<Icon.NoPhone />
<p>Simply sign in and start working no complicated or intrusive set up needed.</p>
</ItemText>
<ItemText>
<Icon.NoMusic />
<p>No distractions we only show you updates on things that matter to you.</p>
</ItemText>
</Item>
<Item>
{createImagePlaceholder('reason')}
</Item>
<Item style={{flex: '0 0 2.5%', padding: 0}} />
<WidthContainer>
<Item style={{flex: '0 0 2.5%', padding: 0}} />
<Item className="item-text">
<h2>Your time matters, so<br />we keep things simple.</h2>
<ItemText>
<Icon.CloudOffWhite />
<p>All of the information we use to make your notifications more useful is kept offline and kept on your own computer.</p>
</ItemText>
<ItemText>
<Icon.NoPhone />
<p>Simply sign in and start working no complicated or intrusive set up needed.</p>
</ItemText>
<ItemText>
<Icon.NoMusic />
<p>No distractions we only show you updates on things that matter to you.</p>
</ItemText>
</Item>
<Item>
{createImagePlaceholder('reason')}
</Item>
<Item style={{flex: '0 0 2.5%', padding: 0}} />
</WidthContainer>
</Section>
<Section id="learnMore" className="section" alt={true} style={{marginTop: 0}}>
<h2 style={{textAlign: 'center', maxWidth: 900, color: '#fff'}}>
Meteorite is the assistant for your <br />GitHub notifications.
</h2>
<WidthContainer>
<Item className="item-text">
<ItemText>
<Icon.CloudOffWhite />
<p>All of the information we use to make your notifications more useful is kept offline and kept on your own computer.</p>
</ItemText>
<ItemText>
<Icon.NoPhone />
<p>Simply sign in and start working no complicated or intrusive set up needed.</p>
</ItemText>
<ItemText>
<Icon.NoMusic />
<p>No distractions we only show you updates on things that matter to you.</p>
</ItemText>
</Item>
<Item className="item-text">
<ItemText>
<Icon.CloudOffWhite />
<p>All of the information we use to make your notifications more useful is kept offline and kept on your own computer.</p>
</ItemText>
<ItemText>
<Icon.NoPhone />
<p>Simply sign in and start working no complicated or intrusive set up needed.</p>
</ItemText>
<ItemText>
<Icon.NoMusic />
<p>No distractions we only show you updates on things that matter to you.</p>
</ItemText>
</Item>
</WidthContainer>
</Section>
</div>
);

View File

@ -16,18 +16,29 @@ import '../../styles/gradient.css';
/* eslint-disable jsx-a11y/anchor-is-valid */
/* eslint-disable no-script-url */
function getMessageFromReasons (reasons) {
function stringOfType (type) {
switch (type) {
case 'PullRequest':
return 'pull request';
case 'Issue':
return 'issue';
default:
return 'task';
}
}
function getMessageFromReasons (reasons, type) {
switch (reasons[reasons.length - 1].reason) {
case Reasons.ASSIGN:
return 'You were just assigned a task';
return 'You were assigned this ' + stringOfType(type);
case Reasons.AUTHOR:
return 'There was activity on a thread you created';
return 'There was activity on this thread you created';
case Reasons.COMMENT:
return 'Somebody just left a comment';
return 'Somebody left a comment';
case Reasons.MENTION:
return 'You were just @mentioned';
return 'You were @mentioned';
case Reasons.REVIEW_REQUESTED:
return 'Your review was just requested';
return 'Your review was requested for this ' + stringOfType(type);
case Reasons.SUBSCRIBED:
return 'There was an update and you\'re subscribed';
case Reasons.OTHER:
@ -86,11 +97,9 @@ const InlineBlockContainer = styled('div')({
const NotificationsContainer = styled('div')({
position: 'relative',
background: '#fff',
margin: '0 auto',
padding: 0,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'row',
overflowX: 'hidden',
@ -117,7 +126,6 @@ const GeneralOptionsContainer = styled(NavigationContainer)({
minHeight: 60,
width: '95%',
margin: 0,
background: '#fff',
padding: '8px 16px',
paddingTop: 18,
flex: '0 0 50px',
@ -350,7 +358,6 @@ const NotificationRow = styled('tr')({
width: '100%',
borderRadius: 4,
margin: '0 auto',
background: '#fff',
padding: '8px 16px',
transition: 'all 0.1s ease-in-out',
boxSizing: 'border-box',
@ -468,6 +475,9 @@ function getPRIssueIcon (type, reasons) {
}
export default function Scene ({
currentTime,
isFirstTimeUser,
notificationsPermission,
queuedCount,
stagedCount,
closedCount,
@ -496,12 +506,23 @@ export default function Scene ({
fetchingNotificationsError,
activeFilter,
onSetActiveFilter,
setNotificationsPermission
}) {
const loading = isSearching || isFetchingNotifications;
const isFirstPage = page === 1;
const isLastPage = page === lastPage;
const NotificationsIcon = notificationsPermission === 'granted'
? Icon.NotificationsOn
: Icon.NotificationsOff;
console.log('notifications', notifications)
console.log('isFirstTimeUser', isFirstTimeUser)
console.log('notificationsPermission', notificationsPermission)
if (isFirstTimeUser && notifications.length > 5) {
// alert('hello, clear ur shit');
}
return (
<div style={{marginTop: 60}}>
@ -578,14 +599,14 @@ export default function Scene ({
marginRight: '5px',
top: '-3px',
}} />
{moment().format('h:mma')}
{currentTime.format('h:mma')}
</h3>
<span style={{
display: 'block',
padding: '6px 0px',
fontSize: 15,
opacity: 0.7,
}}>{moment().format('dddd, MMMM Do')}</span>
}}>{currentTime.format('dddd, MMMM Do')}</span>
<span style={{
display: 'block',
padding: '6px 0 8px',
@ -701,6 +722,23 @@ export default function Scene ({
}) : undefined}
/>
</EnhancedTab>
<EnhancedTab tooltip={!loading ? "Toggle web notifications" : null} disabled={loading}>
<NotificationsIcon
opacity={0.9}
onClick={!loading ? (() => {
switch(notificationsPermission) {
case 'granted':
return setNotificationsPermission('denied');
case 'denied':
case 'default':
default:
Notification.requestPermission().then(result => {
return setNotificationsPermission(result);
});
}
}) : undefined}
/>
</EnhancedTab>
{query ? (
<React.Fragment>
<div style={{display: 'inline-block'}} className="button-container-alt">
@ -856,7 +894,7 @@ export default function Scene ({
}}
shrink={.5}
/>
{getMessageFromReasons(n.reasons)}
{getMessageFromReasons(n.reasons, n.type)}
</ReasonMessage>
</TableItem>
<TableItem width={100}>

View File

@ -13,7 +13,7 @@ import { Status } from '../../constants/status';
import { Reasons, Badges } from '../../constants/reasons';
import Scene from './Scene';
const PER_PAGE = 15;
const PER_PAGE = 10;
// @TODO Move these functions.
@ -64,7 +64,6 @@ function scoreOf (notification) {
return score;
};
// @TODO implement this
function badgesOf (notification) {
const badges = [];
const len = notification.reasons.length;
@ -96,13 +95,15 @@ function badgesOf (notification) {
};
const scoreOfReason = {
[Reasons.ASSIGN]: 18,
[Reasons.AUTHOR]: 10,
[Reasons.MENTION]: 12,
[Reasons.OTHER]: 2,
[Reasons.REVIEW_REQUESTED]: 30,
[Reasons.ASSIGN]: 21,
[Reasons.AUTHOR]: 11,
[Reasons.MENTION]: 17,
[Reasons.TEAM_MENTION]: 11,
[Reasons.OTHER]: 4,
[Reasons.REVIEW_REQUESTED]: 29,
[Reasons.SUBSCRIBED]: 3,
[Reasons.COMMENT]: 6,
[Reasons.STATE_CHANGE]: 5,
};
const decorateWithScore = notification => ({
@ -113,6 +114,9 @@ const decorateWithScore = notification => ({
class NotificationsPage extends React.Component {
state = {
currentTime: moment(),
prevNotifications: [],
isFirstTimeUser: false,
isSearching: false,
query: null,
activeFilter: Filters.PARTICIPATING,
@ -121,10 +125,17 @@ class NotificationsPage extends React.Component {
}
componentDidMount () {
this.props.notificationsApi.fetchNotifications();
const isFirstTimeUser = !this.props.storageApi.getUserItem('hasOnboarded');
if (isFirstTimeUser) {
this.setState({isFirstTimeUser: true});
// this.props.storageApi.setUserItem('hasOnboarded', true);
}
this.props.notificationsApi.fetchNotifications();
this.syncer = setInterval(() => {
this.props.notificationsApi.fetchNotificationsSync();
this.setState({currentTime: moment()});
}, 8 * 1000);
}
@ -132,6 +143,10 @@ class NotificationsPage extends React.Component {
clearInterval(this.syncer);
}
diffForWebNotification (nextProps, nextState) {
this.sendWebNotification();
}
onChangePage = page => {
this.setState({ currentPage: page });
}
@ -181,6 +196,16 @@ class NotificationsPage extends React.Component {
this.props.notificationsApi.restoreThread(thread_id);
}
setNotificationsPermission = (...args) => {
this.props.notificationsApi.setNotificationsPermission(...args);
}
sendWebNotification = () => {
var img = '../images/icon.png';
var text = 'HEY! Your task "null" is now overdue.';
var notification = new Notification('Meteorite', { body: text, icon: img });
}
render () {
if (!this.props.authApi.token) {
return <Redirect noThrow to={routes.LOGIN} />
@ -191,37 +216,40 @@ class NotificationsPage extends React.Component {
markAsRead,
markAllAsStaged,
clearCache,
notificationsPermission,
notifications,
loading: isFetchingNotifications,
error: fetchingNotificationsError,
} = this.props.notificationsApi;
// @TODO Move all this out of the render method.
// nick, do this ^ so you can fire off a web noti when the filtered/final
// notifications have a diff
let filterMethod = () => true;
switch (this.state.activeFilter) {
case Filters.PARTICIPATING:
filterMethod = n => (
n.reasons.some(({ reason }) => (
reason === 'review_requested' ||
reason === 'assign' ||
reason === 'mention' ||
reason === 'author'
reason === Reasons.REVIEW_REQUESTED ||
reason === Reasons.ASSIGN ||
reason === Reasons.MENTION ||
reason === Reasons.AUTHOR
))
);
break;
case Filters.ASSIGNED:
filterMethod = n => (
n.reasons.some(({ reason }) => reason === 'assign')
n.reasons.some(({ reason }) => reason === Reasons.ASSIGN)
);
break;
case Filters.REVIEW_REQUESTED:
filterMethod = n => (
n.reasons.some(({ reason }) => reason === 'review_requested')
n.reasons.some(({ reason }) => reason === Reasons.REVIEW_REQUESTED)
);
break;
case Filters.COMMENT:
filterMethod = n => (
n.reasons.some(({ reason }) => reason === 'comment')
n.reasons.some(({ reason }) => reason === Reasons.COMMENT)
);
break;
default:
@ -279,6 +307,10 @@ class NotificationsPage extends React.Component {
return (
<Scene
currentTime={this.state.currentTime}
isFirstTimeUser={this.state.isFirstTimeUser}
setNotificationsPermission={this.setNotificationsPermission}
notificationsPermission={notificationsPermission}
queuedCount={notificationsQueued.length}
stagedCount={notificationsStaged.length}
closedCount={notificationsClosed.length}

View File

@ -59,8 +59,12 @@ class NotificationsProvider extends React.Component {
}
state = {
syncing: false,
loading: false,
error: null
error: null,
notificationsPermission:
this.props.getUserItem('notificationsPermission') ||
'default',
}
shouldComponentUpdate (nextProps, nextState) {
@ -78,6 +82,16 @@ class NotificationsProvider extends React.Component {
return this.props.notifications !== nextProps.notifications;
}
// The web notificaitons API doesn't let users revoke notifications permission
// after they already grant it, for a reason I can only assume that was pure evil.
// So, if a user wants to stop getting notifications we set that in their local
// storage, leaning towards it being revoked.
setNotificationsPermission = permission => {
this.setState({notificationsPermission: permission});
this.props.setUserItem('notificationsPermission', permission);
this.forceUpdate();
}
requestPage = (page = 1, optimizePolling = true) => {
const headers = {
'Authorization': `token ${this.props.token}`,
@ -107,16 +121,23 @@ class NotificationsProvider extends React.Component {
}
requestFetchNotifications = (page = 1, optimizePolling = true) => {
if (this.state.syncing) {
// Don't try to send off another request if we're already trying to get one.
return Promise.reject();
}
this.setState({syncing: true});
return this.requestPage(page, optimizePolling)
.then(({headers, json}) => {
if (json === null) return;
if (json === null) return [];
let nextPage = null;
const links = headers['link'];
if (links && links.next && links.next.page) {
nextPage = links.next.page;
}
return this.processNotificationsChunk(nextPage, json);
});
})
.finally(() => this.setState({syncing: false}));
}
fetchNotifications = (page = 1, optimizePolling = true) => {
@ -125,9 +146,15 @@ class NotificationsProvider extends React.Component {
return false;
}
if (this.state.loading) {
// Don't try to fetch if we're already fetching
return Promise.reject();
}
this.setState({ loading: true });
return this.requestFetchNotifications(page, optimizePolling)
.catch(error => this.setState({ error }))
.then(() => this.setState({error: null}))
.catch(error =>this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
@ -199,9 +226,15 @@ class NotificationsProvider extends React.Component {
return false;
}
if (this.state.loading) {
// Don't try to fetch if we're already fetching
return Promise.reject();
}
this.setState({ loading: true });
return this.requestMarkAsRead(thread_id)
.catch(error => this.setState({ error }))
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
@ -217,7 +250,8 @@ class NotificationsProvider extends React.Component {
clearCache = () => {
this.setState({ loading: true });
return this.requestClearCache()
.catch(error => this.setState({ error }))
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
@ -278,21 +312,24 @@ class NotificationsProvider extends React.Component {
markAllAsStaged = () => {
this.setState({ loading: true });
return this.requestStageAll()
.catch(error => this.setState({ error }))
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
stageThread = thread_id => {
this.setState({ loading: true });
return this.requestStageThread(thread_id)
.catch(error => this.setState({ error }))
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
restoreThread = thread_id => {
this.setState({ loading: true });
return this.requestRestoreThread(thread_id)
.catch(error => this.setState({ error }))
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
@ -337,6 +374,7 @@ class NotificationsProvider extends React.Component {
clearCache: this.clearCache,
stageThread: this.stageThread,
restoreThread: this.restoreThread,
setNotificationsPermission: this.setNotificationsPermission,
});
}
}
@ -350,6 +388,8 @@ const withNotificationsProvider = WrappedComponent => props => (
notifications,
getItem,
setItem,
getUserItem,
setUserItem,
clearCache,
removeItem
}) => (
@ -358,6 +398,8 @@ const withNotificationsProvider = WrappedComponent => props => (
notifications={notifications}
getItemFromStorage={getItem}
setItemInStorage={setItem}
getUserItem={getUserItem}
setUserItem={setUserItem}
clearStorageCache={clearCache}
removeItemFromStorage={removeItem}
token={token}

View File

@ -1,38 +1,14 @@
import React from 'react';
import moment from 'moment';
import {Status} from '../constants/status';
import {Reasons} from '../constants/reasons';
import {createMockNotifications} from '../utils/mocks';
const mockNotifications = createMockNotifications(100);
export const LOCAL_STORAGE_PREFIX = '__meteorite_noti_cache__';
export const LOCAL_STORAGE_USER_PREFIX = '__meteorite_user_cache__';
export const LOCAL_STORAGE_STATISTIC_PREFIX = '__meteorite_statistic_cache__';
const getMockReasons = n => {
const reasons = Object.values(Reasons);
const len = reasons.length;
return new Array(n).fill(0).map(_ => ({
reason: reasons[Math.floor(Math.random() * len)],
time: moment().format()
}));
};
const getMockNotification = randomNumber => ({
id: randomNumber,
updated_at: moment().format(),
status: (randomNumber > 0.8 ? Status.STAGED : Status.QUEUED),
reasons: getMockReasons(Math.ceil(randomNumber * 10)),
type: ['Issue', 'PullRequest'][Math.floor(randomNumber * 2)],
name: 'Mock - Fake notification name',
url: 'https://github.com/test/repo/pull',
repository: 'test/mock',
number: Math.ceil(randomNumber * 1000),
repositoryUrl: 'https://github.com/test/repo',
});
const mockNotifications = new Array(1000);
for (let i = 0; i < mockNotifications.length; i++) {
mockNotifications[i] = getMockNotification(Math.random());
}
class StorageProvider extends React.Component {
constructor (props) {
super(props);
@ -142,10 +118,22 @@ class StorageProvider extends React.Component {
try {
return JSON.parse(window.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`));
} catch (e) {
return null;
return window.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`);
}
}
getUserItem = id => {
try {
return JSON.parse(window.localStorage.getItem(`${LOCAL_STORAGE_USER_PREFIX}${id}`));
} catch (e) {
return window.localStorage.getItem(`${LOCAL_STORAGE_USER_PREFIX}${id}`);
}
}
setUserItem = (id, value) => {
window.localStorage.setItem(`${LOCAL_STORAGE_USER_PREFIX}${id}`, JSON.stringify(value));
}
removeItem = id => {
// We never really want to purge anything from the cache if we can help it,
// since there's always a chance that a read notification can be resurrected.
@ -169,6 +157,8 @@ class StorageProvider extends React.Component {
...this.state,
setItem: this.setItem,
getItem: this.getItem,
getUserItem: this.getUserItem,
setUserItem: this.setUserItem,
removeItem: this.removeItem,
clearCache: this.clearCache,
refreshNotifications: this.refreshNotifications,

View File

@ -11,6 +11,7 @@
}
.button-container a {
position: relative;
text-align: center;
box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08);
margin: 0 auto;
@ -38,13 +39,25 @@
display: -ms-inline-flexbox;
display: inline-flex;
}
.button-container a:hover {
background: #f9f9f9;
box-shadow: 0 2px 6px #4a4a4a5c;
.button-container a:before {
content: "";
transition: all 150ms ease;
background: rgba(190, 197, 208, 0.25);
border-radius: 4px;
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
transform: scale(0);
}
.button-container a:active {
background: #e4e4e4;
box-shadow: 0 0 0 #4a4a4a5c;
.button-container a:hover:before {
transform: scale(1);
}
.button-container a:active:before {
background: rgba(190, 197, 208, 0.5)
}
.button-container-alt a {
@ -114,10 +127,10 @@
}
@media only screen and (max-width: 1400px) {
#section {
.section {
flex-direction: column !important;
}
#item-text {
.item-text {
width: 600px;
}
}