Few more icons, notification statuses

This commit is contained in:
Nicholas Zuber 2018-11-04 11:05:24 -05:00
parent 4be84327ce
commit 704a61139b
14 changed files with 366 additions and 110 deletions

View File

@ -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);

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 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

View File

@ -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

View File

@ -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

View File

@ -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

View 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
View File

@ -0,0 +1,5 @@
export const Status = {
QUEUED: 'queued',
STAGED: 'staged',
CLOSED: 'closed'
};

View File

@ -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>

View File

@ -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>
);

View File

@ -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

View File

@ -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}
>

View File

@ -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
});
}

View File

@ -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%}

View File

@ -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;