diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index c5633fb..754436e 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -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.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); diff --git a/src/components/Icon/svg/alarm.svg b/src/components/Icon/svg/alarm.svg new file mode 100644 index 0000000..36963b1 --- /dev/null +++ b/src/components/Icon/svg/alarm.svg @@ -0,0 +1 @@ + diff --git a/src/components/Icon/svg/bolt.svg b/src/components/Icon/svg/bolt.svg index d732c6f..2aac7c3 100644 --- a/src/components/Icon/svg/bolt.svg +++ b/src/components/Icon/svg/bolt.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/src/components/Icon/svg/github/issue-open.svg b/src/components/Icon/svg/github/issue-open.svg index 9f2cb00..94cd88b 100644 --- a/src/components/Icon/svg/github/issue-open.svg +++ b/src/components/Icon/svg/github/issue-open.svg @@ -1 +1 @@ - + diff --git a/src/components/Icon/svg/hot.svg b/src/components/Icon/svg/hot.svg index c349b09..7beac83 100644 --- a/src/components/Icon/svg/hot.svg +++ b/src/components/Icon/svg/hot.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/components/Icon/svg/trash.svg b/src/components/Icon/svg/trash.svg new file mode 100644 index 0000000..55f0cf5 --- /dev/null +++ b/src/components/Icon/svg/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/constants/status.js b/src/constants/status.js new file mode 100644 index 0000000..e68888d --- /dev/null +++ b/src/constants/status.js @@ -0,0 +1,5 @@ +export const Status = { + QUEUED: 'queued', + STAGED: 'staged', + CLOSED: 'closed' +}; diff --git a/src/pages/Login/Scene.js b/src/pages/Login/Scene.js index 93c5158..7bbfea4 100644 --- a/src/pages/Login/Scene.js +++ b/src/pages/Login/Scene.js @@ -47,10 +47,10 @@ export default function Scene ({ loading, error, loggedOut, ...props }) {

Log in with GitHub and we'll start organizing and sorting all of your notifications.

Oops, looks like something went wrong. Try again? -
+
go back
-
+
@@ -61,10 +61,10 @@ export default function Scene ({ loading, error, loggedOut, ...props }) {

Log in with GitHub and we'll start organizing and sorting all of your notifications.

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