diff --git a/src/components/LoadingIcon/loader.svg b/src/components/LoadingIcon/loader.svg index 435ed96..6644b7e 100644 --- a/src/components/LoadingIcon/loader.svg +++ b/src/components/LoadingIcon/loader.svg @@ -3,7 +3,7 @@ - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/src/pages/Login/Scene.js b/src/pages/Login/Scene.js index 70ece9f..93c5158 100644 --- a/src/pages/Login/Scene.js +++ b/src/pages/Login/Scene.js @@ -13,7 +13,8 @@ const Container = styled('div')({ borderRadius: 4, margin: '0 auto', padding: '24px 48px 76px', - width: 300 + width: 300, + boxShadow: '0 50px 100px rgba(50,50,93,.1), 0 15px 35px rgba(50,50,93,.15), 0 5px 15px rgba(0,0,0,.1)' }); const ButtonsContainer = styled('div')({ diff --git a/src/pages/Notifications/Scene.js b/src/pages/Notifications/Scene.js index 8d13463..54340de 100644 --- a/src/pages/Notifications/Scene.js +++ b/src/pages/Notifications/Scene.js @@ -1,6 +1,5 @@ import React from 'react'; import { Link } from "@reach/router"; -import moment from 'moment'; import styled from 'react-emotion'; import Icon from '../../components/Icon'; import Logo from '../../components/Logo'; @@ -10,9 +9,12 @@ import { Filters } from '../../constants/filters'; import { withOnEnter } from '../../enhance'; import '../../styles/gradient.css'; +const FixedContainer = styled('div')({ + position: 'fixed' +}); + const NotificationsContainer = styled('div')({ position: 'relative', - boxSizing: 'border-box', background: '#fff', margin: '0 auto', padding: 0, @@ -36,41 +38,43 @@ const NavigationContainer = styled('div')({ const GeneralOptionsContainer = styled(NavigationContainer)({ background: '#fff', - padding: '8px 80px', - flex: '0 0 75px', + padding: '8px 16px', + paddingLeft: 260, + flex: '0 0 50px', 'button': { display: 'inline-flex', - margin: 0, - marginTop: 12 + margin: 0 } }); const Sidebar = styled('div')({ - flex: '0 0 180px', + flex: '0 0 200px', padding: '0 20px 20px', - marginTop: 15, - 'a': { - textAlign: 'left', - margin: '0 auto', - cursor: 'pointer', - borderRadius: '8px', - alignItems: 'center', - padding: '0 16px', - height: '48px', - fontSize: '12px', - fontWeight: '700', - textTransform: 'uppercase', - textDecoration: 'none', - transition: 'background 0.12s ease-in-out', - display: 'flex' - } + marginTop: 15 }); const SidebarLink = styled('a')({}, ({active, color}) => ({ + textAlign: 'left', + margin: '0 auto', + cursor: 'pointer', + borderRadius: '8px', + alignItems: 'center', + padding: '0 14px', + height: 40, + fontSize: '12px', + fontWeight: 600, + letterSpacing: 0.5, + textTransform: 'uppercase', + textDecoration: 'none', + transition: 'background 0.12s ease-in-out', + display: 'flex', background: active ? color : 'none', color: active ? '#fff' : '#1a1a1a', ':hover': { background: active ? color: 'rgba(200, 200, 200, .25)' + }, + 'div': { + marginRight: 5 } })); @@ -83,7 +87,6 @@ const Tab = styled('button')({ border: 0, outline: 'none', background: 'none', - display: 'block', height: 40, width: 40, borderRadius: '100%', @@ -108,7 +111,7 @@ const SearchField = styled('div')({ float: 'left', textAlign: 'left', width: '50%', - boxShadow: '0 1px 3px #4a4a4a5c', + boxShadow: '0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08)', margin: '0 auto', background: '#fff', borderRadius: '4px', @@ -147,23 +150,30 @@ const SearchInput = styled('input')({ }); const EnhancedSearchInput = withOnEnter(SearchInput); -const NotificationRow = styled('div')({ +const NotificationRow = styled('tr')({ + position: 'relative', cursor: 'pointer', + borderBottom: '1px solid #f2f2f2', display: 'block', textAlign: 'left', width: '100%', margin: '0 auto', background: '#fff', - padding: '16px 24px', + padding: '8px 16px', transition: 'all 0.12s ease-in-out', - borderRadius: 8, boxSizing: 'border-box', ':hover': { - background: '#f9f9f9' + // background: '#f9f9f9', + boxShadow: '0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08)', + zIndex: 10 } }); -const NotificationTitle = styled('div')({ +const NotificationTab = styled(Tab)({ + margin: 0, +}); + +const NotificationTitle = styled('span')({ position: 'relative', }, ({img}) => img && ({ paddingLeft: 20, @@ -180,15 +190,30 @@ const NotificationTitle = styled('div')({ })); const Repository = styled('span')({ - fontWeight: 600, + fontWeight: 500, marginLeft: 10, - fontSize: 16 + fontSize: 14 }); const PRIssue = styled(Repository)({ fontWeight: 400, }); +const Table = styled('table')({ + display: 'block', + 'td': { + display: 'inline-block' + } +}); + +const TableItem = styled('td')({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}, ({width}) => ({ + width +})); + function getPRIssueIcon (type, reason) { const grow = 1.2; @@ -231,14 +256,17 @@ export default function Scene ({ n.reason !== 'manual' && n.reason !== 'invitation' ); - break; + break; + case Filters.SUBSCRIBED: + filterMethod = n => n.reason === 'subscribed'; + break; case Filters.ALL: default: filterMethod = () => true; } notifications = notifications - .sort((a, b) => b.repository.name.localeCompare(a.repository.name)) + .sort((a, b) => a.repository.localeCompare(b.repository)) .filter(filterMethod); return ( @@ -276,13 +304,13 @@ export default function Scene ({ onFetchNotifications()) : undefined} /> onMarkAsRead('402658026'))} + onClick={!isLoading ? (() => onMarkAsRead('402658026')) : undefined} /> @@ -301,58 +329,60 @@ export default function Scene ({ - onSetActiveFilter(Filters.ALL)} - > - {activeFilter === Filters.ALL ? ( - - ) : ( - - )} - all notifications - - onSetActiveFilter(Filters.REVIEW_REQUESTED)} - > - {activeFilter === Filters.REVIEW_REQUESTED ? ( - - ) : ( - - )} - review requested - - onSetActiveFilter(Filters.PARTICIPATING)} - > - {activeFilter === Filters.PARTICIPATING ? ( - - ) : ( - - )} - participating - - onSetActiveFilter(Filters.SUBSCRIBED)} - > - - subscribed - - onSetActiveFilter(Filters.HOT)} - > - - hot - + + onSetActiveFilter(Filters.ALL)} + > + {activeFilter === Filters.ALL ? ( + + ) : ( + + )} + all notifications + + onSetActiveFilter(Filters.REVIEW_REQUESTED)} + > + {activeFilter === Filters.REVIEW_REQUESTED ? ( + + ) : ( + + )} + review requested + + onSetActiveFilter(Filters.PARTICIPATING)} + > + {activeFilter === Filters.PARTICIPATING ? ( + + ) : ( + + )} + participating + + onSetActiveFilter(Filters.SUBSCRIBED)} + > + + subscribed + + onSetActiveFilter(Filters.HOT)} + > + + hot + + {isFetchingNotifications ? ( @@ -364,24 +394,37 @@ export default function Scene ({

no notifications

) : ( -
- {notifications.map(n => ( - - -
- {getPRIssueIcon(n.subject.type, n.reason)} -
- {n.subject.title} ({n.reason}) - {/* {n.repository.name} */} -
- {/*

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')}

*/} -
- ))} -
+ + + {notifications.map(n => ( + + +
+ {getPRIssueIcon(n.type, n.reason)} +
+
+ window.open(n.url)}> + + {n.name} ({n.reason}) + + + + {n.repository} + + + + 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')}

*/} +
+ ))} + +
)}
diff --git a/src/providers/Notifications.js b/src/providers/Notifications.js index 528ab0a..9567f62 100644 --- a/src/providers/Notifications.js +++ b/src/providers/Notifications.js @@ -1,9 +1,17 @@ import React from 'react'; import {AuthConsumer} from './Auth'; +import {StorageProvider} from './Storage'; import {MockNotifications} from '../utils/mocks'; const BASE_GITHUB_API_URL = 'https://api.github.com'; +function subjectUrlToIssue (url) { + return url + .replace('api.github.com', 'github.com') + .replace('/repos/', '/') + .replace('/pulls/', '/pull/'); +} + function processHeadersAndBodyJson (response) { const entries = response.headers.entries(); const headers = {}; @@ -29,7 +37,19 @@ function processHeadersAndBodyJson (response) { // links.next.page headers['link'] = links; - return [headers, response.json()]; + // 304 will usually mean nothing has changed from our last fetch. + if (response.status === 304) { + return Promise.resolve({ + headers, + json: null + }); + } + + // I can't get marking a notification as read to get past here?? + return response.json().then(json => ({ + headers, + json + })); } class NotificationsProvider extends React.Component { @@ -41,17 +61,16 @@ class NotificationsProvider extends React.Component { state = { loading: false, - error: null, - notifications: MockNotifications + error: null } - requestPage = (page = 1) => { + requestPage = (page = 1, optimizePolling = true) => { const headers = { 'Authorization': `token ${this.props.token}`, 'Content-Type': 'application/json', }; - if (this.last_modified) { + if (optimizePolling && this.last_modified) { headers['If-Modified-Since'] = this.last_modified; } @@ -60,13 +79,16 @@ class NotificationsProvider extends React.Component { headers: headers }) .then(processHeadersAndBodyJson) - .then(([headers, body]) => { + .then(({headers, json}) => { // If there were updates, make sure we get the newest last-modified. if (headers['last-modified']) { this.last_modified = headers['last-modified']; } - return body; + return { + headers, + json + }; }); } @@ -77,24 +99,65 @@ class NotificationsProvider extends React.Component { }); } - fetchNotifications = () => { + fetchNotifications = (page = 1, optimizePolling = true) => { if (!this.props.token) { console.error('Unauthenitcated, aborting request.') return false; } this.setState({ loading: true }); - return this.mockRequestPage(1) - .then(notifications => this.processNotificationsChunk(notifications)) - .catch(error => this.setState({ error })) + return this.requestPage(page, optimizePolling) + .then(({headers, json}) => { + if (json === null) return; + let nextPage = null; + const links = headers['link']; + if (links && links.next && links.next.page) { + nextPage = links.next.page; + } + return this.processNotificationsChunk(nextPage, json); + }) + .catch(error => console.error(error) || this.setState({ error })) .finally(() => this.setState({ loading: false })); } - processNotificationsChunk = notificationsChunk => { - console.warn(notificationsChunk); - this.setState({ - notifications: notificationsChunk + processNotificationsChunk = (nextPage, notificationsChunk) => { + console.warn('chunk', notificationsChunk) + let everythingUpdated = true; + + 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)) { + // 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. + everythingUpdated = false; + return; + } + // Else, update the cache. + this.updateNotification(n); }); + + if (nextPage && everythingUpdated) { + // Still need to fetch more updates. + this.fetchNotifications(nextPage, false); + } else { + // All done fetching updates, let's trigger a sync. + this.props.refreshNotifications(); + } + } + + updateNotification = n => { + const value = { + id: n.id, // @TODO can prob remove this id since its the key + updated_at: n.updated_at, + reason: n.reason, + type: n.subject.type, + name: n.subject.title, + url: subjectUrlToIssue(n.subject.url), + repository: n.repository.name, + }; + this.props.setItemInStorage(n.id, value); } requestMarkAsRead = thread_id => { @@ -108,9 +171,12 @@ class NotificationsProvider extends React.Component { headers: headers }) .then(processHeadersAndBodyJson) - .then(([headers, body]) => { - console.warn(body) - return body; + .then(({headers, json}) => { + console.warn(headers, json); + console.warn('removing', thread_id); + this.props.removeItemFromStorage(thread_id); + this.props.refreshNotifications(); + return Promise.resolve(json); }); } @@ -130,6 +196,7 @@ class NotificationsProvider extends React.Component { render () { return this.props.children({ ...this.state, + notifications: this.props.notifications, fetchNotifications: this.fetchNotifications, markAsRead: this.markAsRead }); @@ -139,11 +206,22 @@ class NotificationsProvider extends React.Component { const withNotificationsProvider = WrappedComponent => props => ( {({ token }) => ( - - {(notificationsApi) => ( - + + {({ refreshNotifications, notifications, getItem, setItem, removeItem }) => ( + + {(notificationsApi) => ( + + )} + )} - + )} ); diff --git a/src/providers/Storage.js b/src/providers/Storage.js new file mode 100644 index 0000000..ec33c91 --- /dev/null +++ b/src/providers/Storage.js @@ -0,0 +1,79 @@ +import React from 'react'; + +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: {} + } + + // @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(); + } + + /** + * Loads up the notifications state with the cache. + */ + refreshNotifications = () => { + const notifications = []; + Object.keys(localStorage).forEach(key => { + if (key.indexOf(LOCAL_STORAGE_PREFIX) > -1) { + const n = JSON.parse(localStorage.getItem(key)); + notifications.push(n); + } + }); + this.setState({ + notifications + }); + } + + setItem = (id, value) => { + localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${id}`, JSON.stringify(value)); + } + + removeItem = id => { + localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${id}`); + } + + getItem = id => { + try { + return JSON.parse(localStorage.getItem(`${LOCAL_STORAGE_PREFIX}${id}`)); + } catch (e) { + return null; + } + } + + render () { + return this.props.children({ + ...this.state, + setItem: this.setItem, + getItem: this.getItem, + removeItem: this.removeItem, + refreshNotifications: this.refreshNotifications + }); + } +} + +const withStorageProvider = WrappedComponent => props => ( + + {(storageApi) => ( + + )} + +); + +export { + StorageProvider, + withStorageProvider +}; diff --git a/src/styles/fonts/Camphor-Heavy.ttf b/src/styles/fonts/Camphor-Heavy.ttf new file mode 100644 index 0000000..98df2b9 Binary files /dev/null and b/src/styles/fonts/Camphor-Heavy.ttf differ diff --git a/src/styles/fonts/Camphor-Light.ttf b/src/styles/fonts/Camphor-Light.ttf new file mode 100644 index 0000000..723b181 Binary files /dev/null and b/src/styles/fonts/Camphor-Light.ttf differ diff --git a/src/styles/fonts/Camphor-Regular.ttf b/src/styles/fonts/Camphor-Regular.ttf new file mode 100644 index 0000000..f3818e8 Binary files /dev/null and b/src/styles/fonts/Camphor-Regular.ttf differ diff --git a/src/styles/gradient.css b/src/styles/gradient.css index ae0c589..d3dd139 100644 --- a/src/styles/gradient.css +++ b/src/styles/gradient.css @@ -1,7 +1,7 @@ .container-gradient { /* background: radial-gradient(farthest-corner at -0% 100%, #7247ff 30%, #00ffbe 95%); */ - background: radial-gradient(farthest-corner at -0% 100%, #9065ff 30%, #58fff7 95%); + background: radial-gradient(farthest-corner at -0% 100%, #6772e5 30%, #00cfff 95%); background-size: 400% 400%; -webkit-animation: gradientTransition 20s ease infinite; @@ -11,10 +11,10 @@ .button-container a { text-align: center; - box-shadow: 0 1px 3px #4a4a4a5c; + box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08); margin: 0 auto; cursor: pointer; - color: #9065ff; + color: #6772e5; background: #fff; border-radius: 4px; -webkit-align-items: center; diff --git a/src/styles/index.css b/src/styles/index.css index 337f830..549c3c0 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1,14 +1,31 @@ @import url('https://rsms.me/inter/inter-ui.css'); +@font-face { + font-family: 'Camphor'; + font-weight: 500; + src: url('./fonts/Camphor-Regular.ttf'); +} +@font-face { + font-family: 'Camphor'; + font-weight: 300; + src: url('./fonts/Camphor-Light.ttf'); +} +@font-face { + font-family: 'Camphor'; + font-weight: 700; + src: url('./fonts/Camphor-Heavy.ttf'); +} + ::selection { color: #fff; - background: #9065ff; + background: #6772e5; } html, body, * { - font-family: 'Inter UI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + font-family: 'Camphor', 'Inter UI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + color: #1a1a1a; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }