Few more icons, notification statuses
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import styled from 'react-emotion';
|
||||
|
||||
import alarm from './svg/alarm.svg';
|
||||
import allInbox from './svg/all_inbox.svg';
|
||||
import back from './svg/back.svg';
|
||||
import bolt from './svg/bolt.svg';
|
||||
@ -24,6 +25,7 @@ import search from './svg/search.svg';
|
||||
import settings from './svg/settings.svg';
|
||||
import starAlt from './svg/star-alt.svg';
|
||||
import star from './svg/star.svg';
|
||||
import trash from './svg/trash.svg';
|
||||
import unlocked from './svg/unlocked.svg';
|
||||
import x from './svg/x.svg';
|
||||
|
||||
@ -54,6 +56,7 @@ export default function Icon ({src, ...props}) {
|
||||
|
||||
const createIcon = src => props => <Icon {...props} src={src} />;
|
||||
|
||||
Icon.Alarm = createIcon(alarm);
|
||||
Icon.AllInbox = createIcon(allInbox);
|
||||
Icon.Back = createIcon(back);
|
||||
Icon.Bolt = createIcon(bolt);
|
||||
@ -77,6 +80,7 @@ Icon.Search = createIcon(search);
|
||||
Icon.Settings = createIcon(settings);
|
||||
Icon.StarAlt = createIcon(starAlt);
|
||||
Icon.Star = createIcon(star);
|
||||
Icon.Trash = createIcon(trash);
|
||||
Icon.Unlocked = createIcon(unlocked);
|
||||
Icon.X = createIcon(x);
|
||||
|
||||
|
1
src/components/Icon/svg/alarm.svg
Normal 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 fill="#f68700" d="M22 5.72l-4.6-3.86-1.29 1.53 4.6 3.86L22 5.72zM7.88 3.39L6.6 1.86 2 5.71l1.29 1.53 4.59-3.85zM12.5 8H11v6l4.75 2.85.75-1.23-4-2.37V8zM12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>
|
After Width: | Height: | Size: 411 B |
@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 2.02c-5.51 0-9.98 4.47-9.98 9.98s4.47 9.98 9.98 9.98 9.98-4.47 9.98-9.98S17.51 2.02 12 2.02zM11.48 20v-6.26H8L13 4v6.26h3.35L11.48 20z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="none" d="M0 0h24v24H0z" />
|
||||
<path fill="#2cbf73" d="M12 2.02c-5.51 0-9.98 4.47-9.98 9.98s4.47 9.98 9.98 9.98 9.98-4.47 9.98-9.98S17.51 2.02 12 2.02zM11.48 20v-6.26H8L13 4v6.26h3.35L11.48 20z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 301 B |
@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true"><path fill="#2cbf73" fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 339 B |
@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#ee3044" d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
|
||||
|
Before Width: | Height: | Size: 443 B After Width: | Height: | Size: 459 B |
1
src/components/Icon/svg/trash.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/><path fill="none" d="M0 0h24v24H0V0z"/></svg>
|
After Width: | Height: | Size: 227 B |
5
src/constants/status.js
Normal file
@ -0,0 +1,5 @@
|
||||
export const Status = {
|
||||
QUEUED: 'queued',
|
||||
STAGED: 'staged',
|
||||
CLOSED: 'closed'
|
||||
};
|
@ -47,10 +47,10 @@ export default function Scene ({ loading, error, loggedOut, ...props }) {
|
||||
<p>Log in with GitHub and we'll start organizing and sorting all of your notifications.</p>
|
||||
<ErrorMessage>Oops, looks like something went wrong. Try again?</ErrorMessage>
|
||||
<ButtonsContainer>
|
||||
<div className="button-container">
|
||||
<div className="button-container-alt">
|
||||
<Link style={{boxShadow: '0 0 0'}} to={routes.HOME}>go back</Link>
|
||||
</div>
|
||||
<div className="button-container">
|
||||
<div className="button-container-alt">
|
||||
<AuthenticationButton style={{boxShadow: '0 0 0'}} />
|
||||
</div>
|
||||
</ButtonsContainer>
|
||||
@ -61,10 +61,10 @@ export default function Scene ({ loading, error, loggedOut, ...props }) {
|
||||
<React.Fragment>
|
||||
<p>Log in with GitHub and we'll start organizing and sorting all of your notifications.</p>
|
||||
<ButtonsContainer>
|
||||
<div className="button-container">
|
||||
<div className="button-container-alt">
|
||||
<Link style={{boxShadow: '0 0 0'}} to={routes.HOME}>go back</Link>
|
||||
</div>
|
||||
<div className="button-container">
|
||||
<div className="button-container-alt">
|
||||
<AuthenticationButton style={{boxShadow: '0 0 0'}} />
|
||||
</div>
|
||||
</ButtonsContainer>
|
||||
|
@ -1,18 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Link } from "@reach/router";
|
||||
import {Link} from "@reach/router";
|
||||
import styled from 'react-emotion';
|
||||
import Icon from '../../components/Icon';
|
||||
import Logo from '../../components/Logo';
|
||||
import LoadingIcon from '../../components/LoadingIcon';
|
||||
import { routes } from '../../constants';
|
||||
import { Filters } from '../../constants/filters';
|
||||
import { withOnEnter } from '../../enhance';
|
||||
import {routes} from '../../constants';
|
||||
import {Filters} from '../../constants/filters';
|
||||
import {withOnEnter} from '../../enhance';
|
||||
import {Status} from '../../constants/status';
|
||||
import '../../styles/gradient.css';
|
||||
|
||||
/**
|
||||
* Given a notification, give it a score based on its importance.
|
||||
*
|
||||
* There are some interesting workarounds that go into this algorithm to account
|
||||
* for GitHub's broken notifications API -- but we will get to that later. First,
|
||||
* let's start off with the basics of scoring.
|
||||
*
|
||||
* There are a few "reasons" that we can be getting a notification, each having
|
||||
* an initial weight of importance:
|
||||
*
|
||||
* - MENTION -> 8
|
||||
* - ASSIGN -> 14
|
||||
* - REVIEW_REQUESTED -> 20
|
||||
* - SUBSCRIBED -> 6
|
||||
* - AUTHOR -> 8
|
||||
* - OTHER -> 2
|
||||
*
|
||||
* There are some rules that go to giving out these scores, primarily being the
|
||||
* first time we see one of these unique reasons, we award the notification with
|
||||
* the respective score, but a reason that transitions into itself will be awarded
|
||||
* the score of `OTHER`. For example:
|
||||
*
|
||||
* - null, MENTION, MENTION -> 0, 8, 2
|
||||
* - null, ASSIGN, ASSIGN, REVIEW_REQUESTED, -> 0, 14, 2, 20
|
||||
* - null, SUBSCRIBED, SUBSCRIBED, SUBSCRIBED -> 0, 6, 2, 2
|
||||
*
|
||||
* @param {Object} notification Some notification to sort.
|
||||
*/
|
||||
function scoreOf (notification) {
|
||||
return notification.reasons.length
|
||||
}
|
||||
|
||||
const decorateWithScore = notification => ({
|
||||
...notification,
|
||||
score: scoreOf(notification)
|
||||
});
|
||||
|
||||
const FixedContainer = styled('div')({
|
||||
position: 'fixed'
|
||||
});
|
||||
|
||||
const InlineBlockContainer = styled('div')({
|
||||
'div': {
|
||||
display: 'inline-block'
|
||||
}
|
||||
});
|
||||
|
||||
const NotificationsContainer = styled('div')({
|
||||
position: 'relative',
|
||||
background: '#fff',
|
||||
@ -83,6 +127,7 @@ const Notifications = styled('div')({
|
||||
});
|
||||
|
||||
const Tab = styled('button')({
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
border: 0,
|
||||
outline: 'none',
|
||||
@ -94,11 +139,21 @@ const Tab = styled('button')({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transition: 'all 250ms ease',
|
||||
':hover': {
|
||||
background: 'rgba(190, 197, 208, 0.25)'
|
||||
':before': {
|
||||
content: "''",
|
||||
transition: 'all 150ms ease',
|
||||
background: 'rgba(190, 197, 208, 0.25)',
|
||||
borderRadius: '100%',
|
||||
display: 'block',
|
||||
height: 40,
|
||||
width: 40,
|
||||
position: 'absolute',
|
||||
transform: 'scale(0)'
|
||||
},
|
||||
':active': {
|
||||
':hover:before': {
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
':active:before': {
|
||||
background: 'rgba(190, 197, 208, 0.5)'
|
||||
}
|
||||
}, ({disabled}) => disabled && ({
|
||||
@ -160,16 +215,17 @@ const NotificationRow = styled('tr')({
|
||||
margin: '0 auto',
|
||||
background: '#fff',
|
||||
padding: '8px 16px',
|
||||
transition: 'all 0.12s ease-in-out',
|
||||
transition: 'all 0.1s ease-in-out',
|
||||
boxSizing: 'border-box',
|
||||
':hover': {
|
||||
// background: '#f9f9f9',
|
||||
boxShadow: '0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08)',
|
||||
background: '#f9f9f9',
|
||||
// boxShadow: '0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08)',
|
||||
zIndex: 10
|
||||
}
|
||||
});
|
||||
|
||||
const NotificationTab = styled(Tab)({
|
||||
display: 'inline-flex',
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
@ -192,7 +248,7 @@ const NotificationTitle = styled('span')({
|
||||
const Repository = styled('span')({
|
||||
fontWeight: 500,
|
||||
marginLeft: 10,
|
||||
fontSize: 14
|
||||
fontSize: 15
|
||||
});
|
||||
|
||||
const PRIssue = styled(Repository)({
|
||||
@ -206,6 +262,14 @@ const Table = styled('table')({
|
||||
}
|
||||
});
|
||||
|
||||
const TableHeader = styled('h2')({
|
||||
fontWeight: 500,
|
||||
fontSize: 34,
|
||||
color: 'rgba(0, 0, 0, 0.86)',
|
||||
letterSpacing: '-0.05px',
|
||||
margin: '20px 15px 0',
|
||||
});
|
||||
|
||||
const TableItem = styled('td')({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
@ -214,13 +278,13 @@ const TableItem = styled('td')({
|
||||
width
|
||||
}));
|
||||
|
||||
function getPRIssueIcon (type, reason) {
|
||||
function getPRIssueIcon (type, reasons) {
|
||||
const grow = 1.2;
|
||||
|
||||
switch (type) {
|
||||
case 'PullRequest':
|
||||
return (
|
||||
<Icon.PrOpen shrink={grow} />
|
||||
<Icon.PrMerged shrink={grow} />
|
||||
);
|
||||
case 'Issue':
|
||||
return (
|
||||
@ -237,8 +301,11 @@ export default function Scene ({
|
||||
onSearch,
|
||||
onMarkAsRead,
|
||||
onFetchNotifications,
|
||||
onRefreshNotifications,
|
||||
onStageThread,
|
||||
isSearching,
|
||||
isFetchingNotifications,
|
||||
onClearCache,
|
||||
fetchingNotificationsError,
|
||||
activeFilter,
|
||||
onSetActiveFilter,
|
||||
@ -248,17 +315,20 @@ export default function Scene ({
|
||||
let filterMethod = () => true;
|
||||
switch (activeFilter) {
|
||||
case Filters.REVIEW_REQUESTED:
|
||||
filterMethod = n => n.reason === 'review_requested';
|
||||
filterMethod = n => n.reasons[0].reason === 'review_requested';
|
||||
break;
|
||||
case Filters.PARTICIPATING:
|
||||
filterMethod = n => (
|
||||
n.reason !== 'subscribed' &&
|
||||
n.reason !== 'manual' &&
|
||||
n.reason !== 'invitation'
|
||||
n.reasons.some(({ reason }) => (
|
||||
reason === 'review_requested' ||
|
||||
reason === 'assign' ||
|
||||
reason === 'mention' ||
|
||||
reason === 'author'
|
||||
))
|
||||
);
|
||||
break;
|
||||
case Filters.SUBSCRIBED:
|
||||
filterMethod = n => n.reason === 'subscribed';
|
||||
filterMethod = n => n.reasons[0].reason === 'subscribed';
|
||||
break;
|
||||
case Filters.ALL:
|
||||
default:
|
||||
@ -266,8 +336,15 @@ export default function Scene ({
|
||||
}
|
||||
|
||||
notifications = notifications
|
||||
.filter(filterMethod)
|
||||
.sort((a, b) => a.repository.localeCompare(b.repository))
|
||||
.filter(filterMethod);
|
||||
.map(decorateWithScore)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
console.warn(notifications)
|
||||
|
||||
const notificationsQueued = notifications.filter(n => n.status === Status.QUEUED);
|
||||
const notificationsStaged = notifications.filter(n => n.status === Status.STAGED);
|
||||
|
||||
return (
|
||||
<div className="container-gradient" style={{
|
||||
@ -308,9 +385,9 @@ export default function Scene ({
|
||||
/>
|
||||
</Tab>
|
||||
<Tab disabled={isLoading}>
|
||||
<Icon.DoneAll
|
||||
<Icon.Trash
|
||||
opacity={0.9}
|
||||
onClick={!isLoading ? (() => onMarkAsRead('402658026')) : undefined}
|
||||
onClick={!isLoading ? (() => onClearCache()) : undefined}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab>
|
||||
@ -342,18 +419,6 @@ export default function Scene ({
|
||||
)}
|
||||
all notifications
|
||||
</SidebarLink>
|
||||
<SidebarLink
|
||||
active={activeFilter === Filters.REVIEW_REQUESTED}
|
||||
color="#2cbf73"
|
||||
onClick={() => onSetActiveFilter(Filters.REVIEW_REQUESTED)}
|
||||
>
|
||||
{activeFilter === Filters.REVIEW_REQUESTED ? (
|
||||
<Icon.BoltWhite shrink={.6} />
|
||||
) : (
|
||||
<Icon.Bolt shrink={.6} />
|
||||
)}
|
||||
review requested
|
||||
</SidebarLink>
|
||||
<SidebarLink
|
||||
active={activeFilter === Filters.PARTICIPATING}
|
||||
color="#00A0F5"
|
||||
@ -366,22 +431,6 @@ export default function Scene ({
|
||||
)}
|
||||
participating
|
||||
</SidebarLink>
|
||||
<SidebarLink
|
||||
active={activeFilter === Filters.SUBSCRIBED}
|
||||
color="#f68700"
|
||||
onClick={() => onSetActiveFilter(Filters.SUBSCRIBED)}
|
||||
>
|
||||
<Icon.Bookmark shrink={.6} />
|
||||
subscribed
|
||||
</SidebarLink>
|
||||
<SidebarLink
|
||||
active={activeFilter === Filters.HOT}
|
||||
color="#ee3044"
|
||||
onClick={() => onSetActiveFilter(Filters.HOT)}
|
||||
>
|
||||
<Icon.Hot shrink={.6} />
|
||||
hot
|
||||
</SidebarLink>
|
||||
</FixedContainer>
|
||||
</Sidebar>
|
||||
<Notifications>
|
||||
@ -396,24 +445,80 @@ export default function Scene ({
|
||||
) : (
|
||||
<Table>
|
||||
<tbody>
|
||||
{notifications.map(n => (
|
||||
<TableHeader>Queued</TableHeader>
|
||||
{notificationsQueued.map(n => (
|
||||
<NotificationRow key={n.id}>
|
||||
<TableItem width={100}>
|
||||
<InlineBlockContainer>
|
||||
<Icon.Hot shrink={0.9} />
|
||||
<Icon.Alarm shrink={0.9} />
|
||||
</InlineBlockContainer>
|
||||
</TableItem>
|
||||
<TableItem>
|
||||
<div style={{ float: 'left', marginTop: -3 }}>
|
||||
{getPRIssueIcon(n.type, n.reason)}
|
||||
<div style={{ float: 'left', marginTop: 2 }}>
|
||||
{getPRIssueIcon(n.type, n.reasons)}
|
||||
</div>
|
||||
</TableItem>
|
||||
<TableItem width={500} onClick={() => window.open(n.url)}>
|
||||
<TableItem width={400} onClick={() => {
|
||||
window.open(n.url);
|
||||
onStageThread(n.id)
|
||||
}}>
|
||||
<NotificationTitle>
|
||||
<PRIssue>{n.name} ({n.reason})</PRIssue>
|
||||
<PRIssue alt="njksjnksdknjgf">{n.name}</PRIssue>
|
||||
</NotificationTitle>
|
||||
</TableItem>
|
||||
<TableItem width={200}>
|
||||
<Repository>{n.reasons.map(r => r.reason).join(', ')}</Repository>
|
||||
</TableItem>
|
||||
<TableItem width={150}>
|
||||
<Repository>{n.repository}</Repository>
|
||||
</TableItem>
|
||||
<TableItem>
|
||||
<TableItem width={100} style={{textAlign: 'right'}}>
|
||||
<NotificationTab>
|
||||
<Icon.Done
|
||||
<Icon.Check
|
||||
opacity={0.9}
|
||||
onClick={!isLoading ? (() => onStageThread(n.id)) : undefined}
|
||||
/>
|
||||
</NotificationTab>
|
||||
<NotificationTab>
|
||||
<Icon.X
|
||||
opacity={0.9}
|
||||
onClick={!isLoading ? (() => onMarkAsRead(n.id)) : undefined}
|
||||
/>
|
||||
</NotificationTab>
|
||||
</TableItem>
|
||||
{/* <p>Last read at {n.last_read_at ? moment(n.last_read_at).format('dddd h:mma') : 'never'}</p>
|
||||
<p>Last updated at {moment(n.last_updated).format('dddd h:mma')}</p> */}
|
||||
</NotificationRow>
|
||||
))}
|
||||
<TableHeader style={{marginTop: 50}}>Staged</TableHeader>
|
||||
{notificationsStaged.map(n => (
|
||||
<NotificationRow key={n.id}>
|
||||
<TableItem width={100}>
|
||||
<InlineBlockContainer>
|
||||
<Icon.Hot shrink={0.9} />
|
||||
<Icon.Alarm shrink={0.9} />
|
||||
</InlineBlockContainer>
|
||||
</TableItem>
|
||||
<TableItem>
|
||||
<div style={{ float: 'left', marginTop: 2 }}>
|
||||
{getPRIssueIcon(n.type, n.reasons)}
|
||||
</div>
|
||||
</TableItem>
|
||||
<TableItem width={400} onClick={() => window.open(n.url)}>
|
||||
<NotificationTitle>
|
||||
<PRIssue>{n.name}</PRIssue>
|
||||
</NotificationTitle>
|
||||
</TableItem>
|
||||
<TableItem width={200}>
|
||||
<Repository>{n.reasons.map(r => r.reason).join(', ')}</Repository>
|
||||
</TableItem>
|
||||
<TableItem width={150}>
|
||||
<Repository>{n.repository}</Repository>
|
||||
</TableItem>
|
||||
<TableItem width={100} style={{textAlign: 'right'}}>
|
||||
<NotificationTab>
|
||||
<Icon.X
|
||||
opacity={0.9}
|
||||
onClick={!isLoading ? (() => onMarkAsRead(n.id)) : undefined}
|
||||
/>
|
||||
@ -427,13 +532,6 @@ export default function Scene ({
|
||||
</Table>
|
||||
)}
|
||||
</Notifications>
|
||||
<Sidebar>
|
||||
<SidebarLink
|
||||
active={activeFilter === Filters.HOT}
|
||||
color="#ee3044"
|
||||
onClick={() => onSetActiveFilter(Filters.HOT)}
|
||||
>hot</SidebarLink>
|
||||
</Sidebar>
|
||||
</NotificationsContainer>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,6 +4,7 @@ import { compose } from 'recompose';
|
||||
import { withNotificationsProvider } from '../../providers/Notifications';
|
||||
import { withAuthProvider } from '../../providers/Auth';
|
||||
import { withCookiesProvider } from '../../providers/Cookies';
|
||||
import { withStorageProvider } from '../../providers/Storage';
|
||||
import { OAUTH_TOKEN_COOKIE } from '../../constants/cookies';
|
||||
import { routes } from '../../constants';
|
||||
import { Filters } from '../../constants/filters';
|
||||
@ -12,7 +13,7 @@ import Scene from './Scene';
|
||||
class NotificationsPage extends React.Component {
|
||||
state = {
|
||||
isSearching: false,
|
||||
activeFilter: Filters.ALL
|
||||
activeFilter: Filters.PARTICIPATING
|
||||
}
|
||||
|
||||
onSetActiveFilter = filter => {
|
||||
@ -47,7 +48,9 @@ class NotificationsPage extends React.Component {
|
||||
|
||||
const {
|
||||
fetchNotifications,
|
||||
stageThread,
|
||||
markAsRead,
|
||||
clearCache,
|
||||
notifications,
|
||||
loading: isFetchingNotifications,
|
||||
error: fetchingNotificationsError,
|
||||
@ -60,6 +63,9 @@ class NotificationsPage extends React.Component {
|
||||
onSearch={this.onSearch}
|
||||
onFetchNotifications={fetchNotifications}
|
||||
onMarkAsRead={markAsRead}
|
||||
onClearCache={clearCache}
|
||||
onStageThread={stageThread}
|
||||
onRefreshNotifications={this.props.storageApi.refreshNotifications}
|
||||
isSearching={this.state.isSearching}
|
||||
isFetchingNotifications={isFetchingNotifications}
|
||||
fetchingNotificationsError={fetchingNotificationsError}
|
||||
@ -71,6 +77,7 @@ class NotificationsPage extends React.Component {
|
||||
};
|
||||
|
||||
const enhance = compose(
|
||||
withStorageProvider,
|
||||
withAuthProvider,
|
||||
withCookiesProvider,
|
||||
withNotificationsProvider
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import {AuthConsumer} from './Auth';
|
||||
import {StorageProvider} from './Storage';
|
||||
import {MockNotifications} from '../utils/mocks';
|
||||
import {Status} from '../constants/status';
|
||||
|
||||
const BASE_GITHUB_API_URL = 'https://api.github.com';
|
||||
|
||||
@ -45,7 +46,6 @@ function processHeadersAndBodyJson (response) {
|
||||
});
|
||||
}
|
||||
|
||||
// I can't get marking a notification as read to get past here??
|
||||
return response.json().then(json => ({
|
||||
headers,
|
||||
json
|
||||
@ -95,7 +95,10 @@ class NotificationsProvider extends React.Component {
|
||||
// @TODO remove this mock when ready
|
||||
mockRequestPage = page => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => resolve(MockNotifications), 1000)
|
||||
setTimeout(() => resolve({
|
||||
headers: {},
|
||||
json: MockNotifications
|
||||
}), 1000)
|
||||
});
|
||||
}
|
||||
|
||||
@ -121,21 +124,31 @@ class NotificationsProvider extends React.Component {
|
||||
}
|
||||
|
||||
processNotificationsChunk = (nextPage, notificationsChunk) => {
|
||||
console.warn('chunk', notificationsChunk)
|
||||
console.log('chunk', notificationsChunk)
|
||||
let everythingUpdated = true;
|
||||
|
||||
if (notificationsChunk.length === 0) {
|
||||
// Apparently this means that a user has no notifications (makes sense).
|
||||
// So I guess we should purge our cache? This brings up the great point
|
||||
// of us having stale cache. How can we detect that a notifcation was seen?
|
||||
}
|
||||
|
||||
notificationsChunk.forEach(n => {
|
||||
const cached_n = this.props.getItemFromStorage(n.id);
|
||||
// If we've seen this notification before and it hasn't updated, skip it.
|
||||
if (cached_n && (cached_n.updated_at === n.updated_at)) {
|
||||
// If we've seen this notification before.
|
||||
if (cached_n) {
|
||||
// Something's changed, we want to push
|
||||
if (cached_n.updated_at !== n.updated_at) {
|
||||
this.updateNotification(n, cached_n.reasons);
|
||||
return;
|
||||
}
|
||||
// This means that something didn't update, which means the page we're
|
||||
// currently processing has stale data so we don't need to fetch the
|
||||
// next page.
|
||||
// currently processing has stale data so we don't need to fetch the next page.
|
||||
everythingUpdated = false;
|
||||
return;
|
||||
} else {
|
||||
// Else, update the cache.
|
||||
this.updateNotification(n);
|
||||
}
|
||||
// Else, update the cache.
|
||||
this.updateNotification(n);
|
||||
});
|
||||
|
||||
if (nextPage && everythingUpdated) {
|
||||
@ -147,11 +160,25 @@ class NotificationsProvider extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
updateNotification = n => {
|
||||
const value = {
|
||||
id: n.id, // @TODO can prob remove this id since its the key
|
||||
updated_at: n.updated_at,
|
||||
updateNotification = (n, prevReason = null) => {
|
||||
let reasons = [];
|
||||
const newReason = {
|
||||
reason: n.reason,
|
||||
time: n.updated_at
|
||||
}
|
||||
|
||||
if (prevReason) {
|
||||
reasons = prevReason.concat(newReason);
|
||||
console.warn('MULTIPLE REASONS', reasons)
|
||||
} else {
|
||||
reasons = [newReason];
|
||||
}
|
||||
|
||||
const value = {
|
||||
id: n.id,
|
||||
updated_at: n.updated_at,
|
||||
status: Status.QUEUED,
|
||||
reasons: reasons,
|
||||
type: n.subject.type,
|
||||
name: n.subject.title,
|
||||
url: subjectUrlToIssue(n.subject.url),
|
||||
@ -170,13 +197,16 @@ class NotificationsProvider extends React.Component {
|
||||
method: 'PATCH',
|
||||
headers: headers
|
||||
})
|
||||
.then(processHeadersAndBodyJson)
|
||||
.then(({headers, json}) => {
|
||||
console.warn(headers, json);
|
||||
.then(response => {
|
||||
return response.status === 205
|
||||
? Promise.resolve()
|
||||
: Promise.reject();
|
||||
})
|
||||
.then(() => {
|
||||
console.warn('removing', thread_id);
|
||||
this.props.removeItemFromStorage(thread_id);
|
||||
this.props.refreshNotifications();
|
||||
return Promise.resolve(json);
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@ -188,7 +218,48 @@ class NotificationsProvider extends React.Component {
|
||||
|
||||
this.setState({ loading: true });
|
||||
return this.requestMarkAsRead(thread_id)
|
||||
.then(response => console.warn('response', response))
|
||||
.catch(error => this.setState({ error }))
|
||||
.finally(() => this.setState({ loading: false }));
|
||||
}
|
||||
|
||||
requestClearCache = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.warn('clearing cache');
|
||||
this.props.clearStorageCache();
|
||||
this.props.refreshNotifications();
|
||||
this.last_modified = null;
|
||||
return resolve();
|
||||
});
|
||||
}
|
||||
|
||||
clearCache = () => {
|
||||
this.setState({ loading: true });
|
||||
return this.requestClearCache()
|
||||
.catch(error => this.setState({ error }))
|
||||
.finally(() => this.setState({ loading: false }));
|
||||
}
|
||||
|
||||
requestStageThread = thread_id => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.warn('staging thread', thread_id);
|
||||
const cached_n = this.props.getItemFromStorage(thread_id);
|
||||
if (cached_n) {
|
||||
const newValue = {
|
||||
...cached_n,
|
||||
status: Status.STAGED
|
||||
};
|
||||
this.props.setItemInStorage(thread_id, newValue);
|
||||
this.props.refreshNotifications();
|
||||
return resolve();
|
||||
} else {
|
||||
throw new Error(`Attempted to stage thread ${thread_id} that wasn't found in the cache.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stageThread = thread_id => {
|
||||
this.setState({ loading: true });
|
||||
return this.requestStageThread(thread_id)
|
||||
.catch(error => this.setState({ error }))
|
||||
.finally(() => this.setState({ loading: false }));
|
||||
}
|
||||
@ -198,7 +269,9 @@ class NotificationsProvider extends React.Component {
|
||||
...this.state,
|
||||
notifications: this.props.notifications,
|
||||
fetchNotifications: this.fetchNotifications,
|
||||
markAsRead: this.markAsRead
|
||||
markAsRead: this.markAsRead,
|
||||
clearCache: this.clearCache,
|
||||
stageThread: this.stageThread
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -207,12 +280,20 @@ const withNotificationsProvider = WrappedComponent => props => (
|
||||
<AuthConsumer>
|
||||
{({ token }) => (
|
||||
<StorageProvider>
|
||||
{({ refreshNotifications, notifications, getItem, setItem, removeItem }) => (
|
||||
{({
|
||||
refreshNotifications,
|
||||
notifications,
|
||||
getItem,
|
||||
setItem,
|
||||
clearCache,
|
||||
removeItem
|
||||
}) => (
|
||||
<NotificationsProvider
|
||||
refreshNotifications={refreshNotifications}
|
||||
notifications={notifications}
|
||||
getItemFromStorage={getItem}
|
||||
setItemInStorage={setItem}
|
||||
clearStorageCache={clearCache}
|
||||
removeItemFromStorage={removeItem}
|
||||
token={token}
|
||||
>
|
||||
|
@ -1,23 +1,19 @@
|
||||
import React from 'react';
|
||||
import {Status} from '../constants/status';
|
||||
|
||||
const LOCAL_STORAGE_PREFIX = '__meteorite_noti_cache__';
|
||||
|
||||
class StorageProvider extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.last_modified = null;
|
||||
}
|
||||
|
||||
state = {
|
||||
loading: false,
|
||||
error: null,
|
||||
notifications: {}
|
||||
notifications: []
|
||||
}
|
||||
|
||||
// @TODO move all this storage stuff to its own provider
|
||||
// this guy is concerned about updating the cache and syncing
|
||||
// the storage provider will be concerned about providing the notifications.
|
||||
componentWillMount () {
|
||||
this.refreshNotifications();
|
||||
}
|
||||
@ -26,40 +22,52 @@ class StorageProvider extends React.Component {
|
||||
* Loads up the notifications state with the cache.
|
||||
*/
|
||||
refreshNotifications = () => {
|
||||
const notifications = [];
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
const notifications = Object.keys(window.localStorage).map(key => {
|
||||
if (key.indexOf(LOCAL_STORAGE_PREFIX) > -1) {
|
||||
const n = JSON.parse(localStorage.getItem(key));
|
||||
notifications.push(n);
|
||||
return JSON.parse(window.localStorage.getItem(key));
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
notifications
|
||||
});
|
||||
this.setState({ notifications });
|
||||
}
|
||||
|
||||
// val value : Object
|
||||
setItem = (id, value) => {
|
||||
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${id}`, JSON.stringify(value));
|
||||
window.localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${id}`, JSON.stringify(value));
|
||||
}
|
||||
|
||||
removeItem = id => {
|
||||
localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${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.
|
||||
// Instead, let's "remove" a thread by closing it.
|
||||
//
|
||||
// window.localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${id}`);
|
||||
const cached_n = this.getItem(id);
|
||||
cached_n = {
|
||||
...cached_n,
|
||||
status: Status.CLOSED
|
||||
};
|
||||
this.setItem(id, cached_n);
|
||||
}
|
||||
|
||||
getItem = id => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`));
|
||||
return JSON.parse(window.localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
clearCache = () => {
|
||||
window.localStorage.clear();
|
||||
}
|
||||
|
||||
render () {
|
||||
return this.props.children({
|
||||
...this.state,
|
||||
setItem: this.setItem,
|
||||
getItem: this.getItem,
|
||||
removeItem: this.removeItem,
|
||||
clearCache: this.clearCache,
|
||||
refreshNotifications: this.refreshNotifications
|
||||
});
|
||||
}
|
||||
|
@ -44,6 +44,54 @@
|
||||
box-shadow: 0 0 0 #4a4a4a5c;
|
||||
}
|
||||
|
||||
.button-container-alt 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;
|
||||
cursor: pointer;
|
||||
color: #6772e5;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
-webkit-text-decoration: none;
|
||||
text-decoration: none;
|
||||
-webkit-transition: all 0.04s ease-in-out;
|
||||
transition: all 0.04s ease-in-out;
|
||||
display: -webkit-inline-box;
|
||||
display: -webkit-inline-flex;
|
||||
display: -ms-inline-flexbox;
|
||||
display: inline-flex;
|
||||
}
|
||||
.button-container-alt 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-alt a:hover:before {
|
||||
transform: scale(1);
|
||||
}
|
||||
.button-container-alt a:active:before {
|
||||
background: rgba(190, 197, 208, 0.5)
|
||||
}
|
||||
|
||||
|
||||
@-webkit-keyframes gradientTransition {
|
||||
0% {background-position: 0% 50%}
|
||||
50% {background-position: 60% 50%}
|
||||
|
@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
html, body, * {
|
||||
font-family: 'Camphor', 'Inter UI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
font-family: 'Inter UI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
color: #1a1a1a;
|
||||
|