+
go back
-
+
diff --git a/src/pages/Notifications/Scene.js b/src/pages/Notifications/Scene.js
index 54340de..31cfcfd 100644
--- a/src/pages/Notifications/Scene.js
+++ b/src/pages/Notifications/Scene.js
@@ -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 (
-
+
);
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 (
- onMarkAsRead('402658026')) : undefined}
+ onClick={!isLoading ? (() => onClearCache()) : undefined}
/>
@@ -342,18 +419,6 @@ export default function Scene ({
)}
all notifications
- onSetActiveFilter(Filters.REVIEW_REQUESTED)}
- >
- {activeFilter === Filters.REVIEW_REQUESTED ? (
-
- ) : (
-
- )}
- review requested
-
- onSetActiveFilter(Filters.SUBSCRIBED)}
- >
-
- subscribed
-
- onSetActiveFilter(Filters.HOT)}
- >
-
- hot
-
@@ -396,24 +445,80 @@ export default function Scene ({
) : (
- {notifications.map(n => (
+ Queued
+ {notificationsQueued.map(n => (
+
+
+
+
+
+
-
- {getPRIssueIcon(n.type, n.reason)}
+
+ {getPRIssueIcon(n.type, n.reasons)}
-
window.open(n.url)}>
+ {
+ window.open(n.url);
+ onStageThread(n.id)
+ }}>
- {n.name} ({n.reason})
+ {n.name}
+ {n.reasons.map(r => r.reason).join(', ')}
+
+
{n.repository}
-
+
- onStageThread(n.id)) : undefined}
+ />
+
+
+ onMarkAsRead(n.id)) : undefined}
+ />
+
+
+ {/* Last read at {n.last_read_at ? moment(n.last_read_at).format('dddd h:mma') : 'never'}
+ Last updated at {moment(n.last_updated).format('dddd h:mma')}
*/}
+
+ ))}
+ Staged
+ {notificationsStaged.map(n => (
+
+
+
+
+
+
+
+
+
+ {getPRIssueIcon(n.type, n.reasons)}
+
+
+ window.open(n.url)}>
+
+ {n.name}
+
+
+
+ {n.reasons.map(r => r.reason).join(', ')}
+
+
+ {n.repository}
+
+
+
+ onMarkAsRead(n.id)) : undefined}
/>
@@ -427,13 +532,6 @@ export default function Scene ({
)}
-
- onSetActiveFilter(Filters.HOT)}
- >hot
-
);
diff --git a/src/pages/Notifications/index.js b/src/pages/Notifications/index.js
index ae4dec9..2724f55 100644
--- a/src/pages/Notifications/index.js
+++ b/src/pages/Notifications/index.js
@@ -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
diff --git a/src/providers/Notifications.js b/src/providers/Notifications.js
index 9567f62..e6f9882 100644
--- a/src/providers/Notifications.js
+++ b/src/providers/Notifications.js
@@ -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 => (
{({ token }) => (
- {({ refreshNotifications, notifications, getItem, setItem, removeItem }) => (
+ {({
+ refreshNotifications,
+ notifications,
+ getItem,
+ setItem,
+ clearCache,
+ removeItem
+ }) => (
diff --git a/src/providers/Storage.js b/src/providers/Storage.js
index ec33c91..65552b0 100644
--- a/src/providers/Storage.js
+++ b/src/providers/Storage.js
@@ -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
});
}
diff --git a/src/styles/gradient.css b/src/styles/gradient.css
index d3dd139..4de0542 100644
--- a/src/styles/gradient.css
+++ b/src/styles/gradient.css
@@ -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%}
diff --git a/src/styles/index.css b/src/styles/index.css
index 549c3c0..d00a2b9 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -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;