diff --git a/public/icon-black.png b/public/icon-black.png new file mode 100644 index 0000000..b0ceef2 Binary files /dev/null and b/public/icon-black.png differ diff --git a/public/index.html b/public/index.html index 7507ef6..706ab2d 100644 --- a/public/index.html +++ b/public/index.html @@ -2,9 +2,9 @@ - + - + Meteorite — Smarter GitHub notifications diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index 754436e..4af470c 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -7,19 +7,23 @@ import back from './svg/back.svg'; import bolt from './svg/bolt.svg'; import boltWhite from './svg/bolt-white.svg'; import bookmarkAlt from './svg/bookmark-alt.svg'; +import bookmarkAltWhite from './svg/bookmark-alt-white.svg'; import bookmark from './svg/bookmark.svg'; import bookmarks from './svg/bookmarks.svg'; import check from './svg/check.svg'; +import convo from './svg/convo.svg'; import doneAll from './svg/done-all.svg'; import done from './svg/done.svg'; import hot from './svg/hot.svg'; import inbox from './svg/inbox.svg'; import inboxWhite from './svg/inbox-white.svg'; import locked from './svg/locked.svg'; +import lowPriority from './svg/low_priority.svg'; import menu from './svg/menu.svg'; import next from './svg/next.svg'; import people from './svg/people.svg'; import peopleWhite from './svg/people-white.svg'; +import prev from './svg/prev.svg'; import refresh from './svg/refresh.svg'; import search from './svg/search.svg'; import settings from './svg/settings.svg'; @@ -62,19 +66,23 @@ Icon.Back = createIcon(back); Icon.Bolt = createIcon(bolt); Icon.BoltWhite = createIcon(boltWhite); Icon.BookmarkAlt = createIcon(bookmarkAlt); +Icon.BookmarkAltWhite = createIcon(bookmarkAltWhite); Icon.Bookmark = createIcon(bookmark); Icon.Bookmarks = createIcon(bookmarks); Icon.Check = createIcon(check); +Icon.Convo = createIcon(convo); Icon.DoneAll = createIcon(doneAll); Icon.Done = createIcon(done); Icon.Hot = createIcon(hot); Icon.Inbox = createIcon(inbox); Icon.InboxWhite = createIcon(inboxWhite); Icon.Locked = createIcon(locked); +Icon.LowPriority = createIcon(lowPriority); Icon.Menu = createIcon(menu); Icon.Next = createIcon(next); Icon.People = createIcon(people); Icon.PeopleWhite = createIcon(peopleWhite); +Icon.Prev = createIcon(prev); Icon.Refresh = createIcon(refresh); Icon.Search = createIcon(search); Icon.Settings = createIcon(settings); diff --git a/src/components/Icon/svg/alarm.svg b/src/components/Icon/svg/alarm.svg index 36963b1..f00083e 100644 --- a/src/components/Icon/svg/alarm.svg +++ b/src/components/Icon/svg/alarm.svg @@ -1 +1 @@ - + diff --git a/src/components/Icon/svg/bookmark-alt-white.svg b/src/components/Icon/svg/bookmark-alt-white.svg new file mode 100644 index 0000000..44c9f9e --- /dev/null +++ b/src/components/Icon/svg/bookmark-alt-white.svg @@ -0,0 +1 @@ + diff --git a/src/components/Icon/svg/convo.svg b/src/components/Icon/svg/convo.svg new file mode 100644 index 0000000..54f31cd --- /dev/null +++ b/src/components/Icon/svg/convo.svg @@ -0,0 +1 @@ + diff --git a/src/components/Icon/svg/low_priority.svg b/src/components/Icon/svg/low_priority.svg new file mode 100644 index 0000000..2eb455f --- /dev/null +++ b/src/components/Icon/svg/low_priority.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/svg/next.svg b/src/components/Icon/svg/next.svg index de05024..d30802d 100644 --- a/src/components/Icon/svg/next.svg +++ b/src/components/Icon/svg/next.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/Icon/svg/prev.svg b/src/components/Icon/svg/prev.svg new file mode 100644 index 0000000..47192be --- /dev/null +++ b/src/components/Icon/svg/prev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/LoadingIcon/index.js b/src/components/LoadingIcon/index.js index 672bf62..35a0308 100644 --- a/src/components/LoadingIcon/index.js +++ b/src/components/LoadingIcon/index.js @@ -1,10 +1,11 @@ import React from 'react'; import loader from './loader.svg'; +import loaderAlt from './loader-alt.svg'; -export default function LoadingIcon ({ style, size, ...props }) { +export default function LoadingIcon ({ style, size, alt, ...props }) { return (
+ + + + + + + + + + diff --git a/src/components/LoadingIcon/loader.svg b/src/components/LoadingIcon/loader.svg index 6644b7e..13da2fa 100644 --- a/src/components/LoadingIcon/loader.svg +++ b/src/components/LoadingIcon/loader.svg @@ -3,7 +3,7 @@ - + View and contribute on GitHub - Already have an account? + Totally free and open sourced diff --git a/src/pages/Login/Scene.js b/src/pages/Login/Scene.js index 7bbfea4..58fbd8e 100644 --- a/src/pages/Login/Scene.js +++ b/src/pages/Login/Scene.js @@ -56,7 +56,7 @@ export default function Scene ({ loading, error, loggedOut, ...props }) { ) : loading ? ( - + ) : loggedOut ? (

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

diff --git a/src/pages/Notifications/Scene.js b/src/pages/Notifications/Scene.js index 31cfcfd..3576beb 100644 --- a/src/pages/Notifications/Scene.js +++ b/src/pages/Notifications/Scene.js @@ -23,20 +23,21 @@ import '../../styles/gradient.css'; * - MENTION -> 8 * - ASSIGN -> 14 * - REVIEW_REQUESTED -> 20 - * - SUBSCRIBED -> 6 + * - SUBSCRIBED -> 3 * - 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: + * a degraded score of min(ceil(n/3), 2). 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 + * - null, MENTION, MENTION -> 0, 8, 3 + * - null, ASSIGN, ASSIGN, REVIEW_REQUESTED, -> 0, 14, 5, 20 + * - null, SUBSCRIBED, SUBSCRIBED, SUBSCRIBED -> 0, 3, 2, 2 * - * @param {Object} notification Some notification to sort. + * @param {Object} notification Some notification to score. + * @return {number} The score. */ function scoreOf (notification) { return notification.reasons.length @@ -113,7 +114,7 @@ const SidebarLink = styled('a')({}, ({active, color}) => ({ transition: 'background 0.12s ease-in-out', display: 'flex', background: active ? color : 'none', - color: active ? '#fff' : '#1a1a1a', + color: active ? '#fff' : '#202124', ':hover': { background: active ? color: 'rgba(200, 200, 200, .25)' }, @@ -248,7 +249,7 @@ const NotificationTitle = styled('span')({ const Repository = styled('span')({ fontWeight: 500, marginLeft: 10, - fontSize: 15 + fontSize: 14 }); const PRIssue = styled(Repository)({ @@ -279,7 +280,7 @@ const TableItem = styled('td')({ })); function getPRIssueIcon (type, reasons) { - const grow = 1.2; + const grow = 1.0; switch (type) { case 'PullRequest': @@ -448,12 +449,6 @@ export default function Scene ({ Queued {notificationsQueued.map(n => ( - - - - - -
{getPRIssueIcon(n.type, n.reasons)} @@ -464,11 +459,17 @@ export default function Scene ({ onStageThread(n.id) }}> - {n.name} + {n.name} - + {/* {n.reasons.map(r => r.reason).join(', ')} + */} + + + + + {n.repository} @@ -494,12 +495,6 @@ export default function Scene ({ Staged {notificationsStaged.map(n => ( - - - - - -
{getPRIssueIcon(n.type, n.reasons)} diff --git a/src/pages/Notifications/SceneAlt.js b/src/pages/Notifications/SceneAlt.js new file mode 100644 index 0000000..2d35cf2 --- /dev/null +++ b/src/pages/Notifications/SceneAlt.js @@ -0,0 +1,655 @@ +import React from 'react'; +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 {Status} from '../../constants/status'; +import {Reasons, Badges} from '../../constants/reasons'; +import '../../styles/gradient.css'; + +const FixedContainer = styled('div')({ + position: 'fixed' +}); + +const InlineBlockContainer = styled('div')({ + 'div': { + display: 'inline-block' + } +}); + +const NotificationsContainer = styled('div')({ + position: 'relative', + background: '#fff', + margin: '0 auto', + padding: 0, + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'row', + overflowX: 'hidden', + boxSizing: 'border-box' +}); + +const NavigationContainer = styled('div')({ + position: 'fixed', + top: 0, + boxSizing: 'border-box', + margin: '0 auto', + width: '100%', + background: 'none', + height: 60, + backgroundColor: '#24292e', + color: 'hsla(0,0%,100%,.75)', + paddingBottom: '12px', + paddingTop: '12px', + zIndex: '100', +}); + +const GeneralOptionsContainer = styled(NavigationContainer)({ + position: 'relative', + zIndex: '1', + height: 'initial', + minHeight: 60, + width: '100%', + margin: 0, + marginLeft: 230, + maxWidth: 1000, + background: '#fff', + padding: '8px 16px', + paddingTop: 18, + flex: '0 0 50px', + 'button': { + display: 'inline-flex', + margin: 0 + } +}); + +const Sidebar = styled('div')({ + flex: '0 0 200px', + padding: '0 20px 20px', + display: 'flex', + justifyContent: 'center', +}); + +const SidebarLink = styled('a')({}, ({active, color}) => ({ + textAlign: 'left', + userSelect: 'none', + margin: '0 auto', + position: 'relative', + cursor: 'pointer', + borderRadius: 4, + alignItems: 'center', + padding: '0 14px', + height: 40, + fontSize: '12px', + fontWeight: 600, + letterSpacing: 0.5, + textTransform: 'capitalize', + textDecoration: 'none', + transition: 'background 0.12s ease-in-out', + display: 'flex', + background: active ? color : 'none', + color: active ? '#fff' : '#202124', + ':before': { + content: '""', + transition: 'all 150ms ease', + background: 'rgba(190, 197, 208, 0.25)', + borderRadius: 4, + display: 'block', + top: 0, + bottom: 0, + right: 0, + left: 0, + position: 'absolute', + transform: 'scale(0)' + }, + ':hover:before': { + transform: active ? 'scale(0)' : 'scale(1)', + }, + ':active:before': { + background: 'rgba(190, 197, 208, 0.5)' + }, + 'div': { + marginRight: 5 + } +})); + +const Notifications = styled('div')({ + flex: 1, +}); + +const NavTab = styled('a')({ + position: 'relative', + textTransform: 'capitalize', + userSelect: 'none', + borderRadius: 4, + textDecoration: 'none', + fontWeight: '500', + fontSize: '14px', + textAlign: 'left', + opacity: 0.6, + padding: '20px 32px', + paddingLeft: '16px', + width: '150px', + display: 'inline-block', + margin: 0, + transition: 'all 150ms ease', + ':hover': { + background: 'rgba(190, 197, 208, 0.25)', + }, +}, ({ active, color }) => active && ({ + color, + opacity: 1, + ':after': { + content: '""', + position: 'absolute', + background: color, + height: '3px', + width: '90%', + bottom: '0', + left: '5%', + borderTopLeftRadius: '4px', + borderTopRightRadius: '4px', + } +})); + +const Tab = styled('button')({ + position: 'relative', + userSelect: 'none', + cursor: 'pointer', + border: 0, + outline: 'none', + background: 'none', + height: 40, + width: 40, + borderRadius: '100%', + margin: '0 auto', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + ':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)' + }, + ':hover:before': { + transform: 'scale(1)', + }, + ':active:before': { + background: 'rgba(190, 197, 208, 0.5)' + } +}, ({disabled}) => disabled && ({ + background: 'none !important', + opacity: 0.35, + cursor: 'default', + ':hover:before': { + transform: 'scale(0) !important', + }, + ':active:before': { + background: 'none !important' + } +})); + +const SearchField = styled('div')({ + float: 'left', + textAlign: 'left', + width: '50%', + boxShadow: '0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08)', + margin: '0 auto', + background: 'hsla(0,0%,100%,.125)', + borderRadius: '4px', + alignItems: 'center', + padding: 0, + height: '36px', + fontSize: '13px', + textDecoration: 'none', + transition: 'all 0.06s ease-in-out', + display: 'inline-flex', + ':focus-within': { + background: '#fff' + } +}); + +const Message = styled('div')({ + display: 'block', + textAlign: 'center', + marginTop: 96, + 'p': { + paddingTop: 24, + userSelect: 'none', + display: 'block', + margin: 0 + } +}); + +const LoaderContainer = styled('div')({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%' +}); + +const SearchInput = styled('input')({ + flex: 1, + textAlign: 'left', + margin: '0 auto', + background: 'none', + padding: 0, + height: '36px', + color: '#fff', + fontSize: '13px', + textDecoration: 'none', + display: 'inline-flex', + border: '0', + outline: 'none', + ':focus': { + color: '#202124' + } +}); +const EnhancedSearchInput = withOnEnter(SearchInput); + +const NotificationRow = styled('tr')({ + position: 'relative', + cursor: 'pointer', + // borderBottom: '1px solid #f2f2f2', + display: 'block', + textAlign: 'left', + width: '100%', + margin: '0 auto', + background: '#fff', + padding: '8px 16px', + 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)', + zIndex: 10 + } +}); + +const NotificationTab = styled(Tab)({ + display: 'inline-flex', + margin: 0, +}); + +const NotificationTitle = styled('span')({ + position: 'relative', +}, ({img}) => img && ({ + paddingLeft: 20, + '::before': { + content: "''", + position: 'absolute', + display: 'block', + background: `url(${img}) center center no-repeat`, + backgroundSize: 'cover', + left: 0, + height: 20, + width: 20, + } +})); + +const Repository = styled('span')({ + fontWeight: 500, + marginLeft: 10, + fontSize: 14 +}); + +const PRIssue = styled(Repository)({ + fontWeight: 400, +}); + +const Table = styled('table')({ + width: '100%', + maxWidth: 970, + minWidth: 970, + display: 'block', + 'td': { + display: 'inline-block' + } +}); + +const TableItem = styled('td')({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}, ({width}) => ({ + width +})); + +function getPRIssueIcon (type, reasons) { + const grow = 1.0; + + switch (type) { + case 'PullRequest': + return ( + + ); + case 'Issue': + return ( + + ); + default: + return null; + } +} + +export default function Scene ({ + first, + last, + lastPage, + page, + notifications, + query, + activeStatus, + allNotificationsCount, + onChangePage, + onSetActiveStatus, + onClearQuery, + onLogout, + onSearch, + onMarkAsRead, + onFetchNotifications, + onRefreshNotifications, + onStageThread, + isSearching, + isFetchingNotifications, + onClearCache, + fetchingNotificationsError, + activeFilter, + onSetActiveFilter, +}) { + const isLoading = isSearching || isFetchingNotifications; + const isFirstPage = page === 1; + const isLastPage = page === lastPage; + + console.warn('before render in scene', notifications) + + if (query) { + notifications = notifications.filter(n => ( + n.name.toLowerCase().indexOf(query.toLowerCase()) > -1) + ) + } + + return ( +
+ +
+ { + onSetActiveStatus(Status.QUEUED); + onSetActiveFilter(Filters.PARTICIPATING); + }} + /> + + + + {isSearching && } + +
+ home +
+
+ sign out +
+
+
+ + + onFetchNotifications()) : undefined} + /> + + + onClearCache()) : undefined} + /> + + {query ? ( + + + + onClearQuery()) : undefined} + /> + + + ) : null} +
+ + + onChangePage(page - 1)) : undefined} + /> + + + onChangePage(page + 1)) : undefined} + /> + +
+
+ + onSetActiveStatus(Status.QUEUED)} + href="#"> + Queued + + onSetActiveStatus(Status.STAGED)} + href="#"> + Staged + + onSetActiveStatus(Status.CLOSED)} + href="#"> + Closed + + + + + + onSetActiveFilter(Filters.ALL)}> + {activeFilter === Filters.ALL ? ( + + ) : ( + + )} + all notifications + + onSetActiveFilter(Filters.PARTICIPATING)}> + {activeFilter === Filters.PARTICIPATING ? ( + + ) : ( + + )} + participating + + onSetActiveFilter(Filters.COMMENT)}> + {activeFilter === Filters.COMMENT ? ( + + ) : ( + + )} + commented + + + + + {isFetchingNotifications ? ( + + + + ) : notifications.length <= 0 ? ( + +

+ No {activeStatus.toLowerCase()} notifications

+

+ 🎉 You're all set here for the moment

+
+ ) : ( + + + {notifications.map(n => ( + + +
+ {getPRIssueIcon(n.type, n.reasons)} +
+
+ { + window.open(n.url); + onStageThread(n.id) + }}> + + {n.name} + + + {/* + {n.reasons.map(r => r.reason).join(', ')} + */} + + + {n.badges.map(badge => { + switch (badge) { + case Badges.HOT: + // lots of `reasons` within short time frame + return + break; + case Badges.OLD: + // old + return + break; + case Badges.COMMENTS: + // lots of `reasons` + return + break; + default: + return null; + } + })} + + + + {n.repository} + + + + {n.score} + + + 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')}

*/} +
+ ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/pages/Notifications/index.js b/src/pages/Notifications/index.js index 2724f55..d9c67d3 100644 --- a/src/pages/Notifications/index.js +++ b/src/pages/Notifications/index.js @@ -8,16 +8,127 @@ import { withStorageProvider } from '../../providers/Storage'; import { OAUTH_TOKEN_COOKIE } from '../../constants/cookies'; import { routes } from '../../constants'; import { Filters } from '../../constants/filters'; -import Scene from './Scene'; +import { Status } from '../../constants/status'; +import { Reasons, Badges } from '../../constants/reasons'; +import Scene from './SceneAlt'; + +// @TODO Move these functions. + +/** + * 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 -> 3 + * - COMMENT -> 3 + * - AUTHOR -> 6 + * - 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 + * a degraded score of min(ceil(n/3), 2). For example: + * + * - null, MENTION, MENTION -> 0, 8, 3 + * - null, ASSIGN, ASSIGN, REVIEW_REQUESTED, -> 0, 14, 5, 20 + * - null, SUBSCRIBED, SUBSCRIBED, SUBSCRIBED -> 0, 3, 2, 2 + * + * @param {Object} notification Some notification to score. + * @return {number} The score. + */ +function scoreOf (notification) { + const {reasons} = notification; + let score = 0; + let prevReason = null; + for (let i = 0; i < reasons.length; i++) { + const reason = reasons[i].reason; + console.log(reason) + if (prevReason && reason === prevReason) { + const degradedScore = Math.ceil(scoreOfReason[reason] / 3); + score += Math.max(degradedScore, 2); + } else { + score += scoreOfReason[reason]; + } + prevReason = reason; + } + return score; +}; + +// @TODO implement this +function badgesOf (notification) { + const badges = []; + if (notification.reasons.length > 7) { + badges.push(Badges.HOT); + } + if (notification.reasons.length > 3) { + badges.push(Badges.COMMENTS); + } + if (notification.reasons.length <= 2) { + badges.push(Badges.OLD); + } + return badges; +}; + +const scoreOfReason = { + [Reasons.ASSIGN]: 14, + [Reasons.AUTHOR]: 6, + [Reasons.MENTION]: 8, + [Reasons.OTHER]: 2, + [Reasons.REVIEW_REQUESTED]: 20, + [Reasons.SUBSCRIBED]: 3, + [Reasons.COMMENT]: 3, +}; + +const decorateWithScore = notification => ({ + ...notification, + score: scoreOf(notification), + badges: badgesOf(notification) +}); + +const PER_PAGE = 10; class NotificationsPage extends React.Component { state = { isSearching: false, - activeFilter: Filters.PARTICIPATING + query: null, + activeFilter: Filters.PARTICIPATING, + activeStatus: Status.QUEUED, + currentPage: 1 + } + + componentDidMount () { + this.syncer = setInterval(() => { + console.warn('sync'); + this.props.notificationsApi.fetchNotificationsSync(); + }, 15 * 1000); + } + + componentWillUnmount () { + clearInterval(this.syncer); + } + + onChangePage = page => { + this.setState({ currentPage: page }); } onSetActiveFilter = filter => { - this.setState({ activeFilter: filter }); + this.setState({ activeFilter: filter, currentPage: 1 }); + } + + onSetActiveStatus = status => { + this.setState({ activeStatus: status, currentPage: 1 }); + } + + onClearQuery = () => { + this.setState({ query: null }); } onLogout = () => { @@ -36,9 +147,11 @@ class NotificationsPage extends React.Component { this.setState({ isSearching: true }); setTimeout(() => { - console.warn(`searched for '${text}'`); - this.setState({ isSearching: false }); - }, 2000); + this.setState({ + query: text, + isSearching: false + }); + }, 500); } render () { @@ -56,11 +169,84 @@ class NotificationsPage extends React.Component { error: fetchingNotificationsError, } = this.props.notificationsApi; + // @TODO Move all this out of the render method. + let filterMethod = () => true; + switch (this.state.activeFilter) { + case Filters.PARTICIPATING: + filterMethod = n => ( + n.reasons.some(({ reason }) => ( + reason === 'review_requested' || + reason === 'assign' || + reason === 'mention' || + reason === 'author' + )) + ); + break; + case Filters.COMMENT: + filterMethod = n => ( + n.reasons.some(({ reason }) => reason === 'comment') + ); + break; + default: + filterMethod = () => true; + } + + const filteredNotifications = notifications.filter(filterMethod); + const allNotificationsCount = filteredNotifications.length; + + const notificationsQueued = filteredNotifications.filter(n => n.status === Status.QUEUED); + const notificationsStaged = filteredNotifications.filter(n => n.status === Status.STAGED); + const notificationsClosed = filteredNotifications.filter(n => n.status === Status.CLOSED); + + let notificationsToRender = []; + switch (this.state.activeStatus) { + case Status.CLOSED: + notificationsToRender = notificationsClosed; + break; + case Status.STAGED: + notificationsToRender = notificationsStaged; + break; + case Status.QUEUED: + default: + notificationsToRender = notificationsQueued; + } + + const scoredAndSortedNotifications = notificationsToRender + .map(decorateWithScore) + .sort((a, b) => b.score - a.score); + + let firstIndex = (this.state.currentPage - 1) * PER_PAGE; + let lastIndex = (this.state.currentPage * PER_PAGE); + let notificationsOnPage = scoredAndSortedNotifications.slice(firstIndex, lastIndex); + let lastPage = Math.ceil(scoredAndSortedNotifications.length / PER_PAGE); + let firstNumbered = firstIndex + 1; + let lastNumbered = Math.min(lastIndex, scoredAndSortedNotifications.length); + + if (scoredAndSortedNotifications.length === 0) { + firstIndex = 0; + lastIndex = 0; + notificationsOnPage = []; + lastPage = 1; + firstNumbered = 0; + lastNumbered = 0; + } + return ( ); } diff --git a/src/providers/Notifications.js b/src/providers/Notifications.js index e6f9882..8d50387 100644 --- a/src/providers/Notifications.js +++ b/src/providers/Notifications.js @@ -6,7 +6,7 @@ import {Status} from '../constants/status'; const BASE_GITHUB_API_URL = 'https://api.github.com'; -function subjectUrlToIssue (url) { +function cleanResponseUrl (url) { return url .replace('api.github.com', 'github.com') .replace('/repos/', '/') @@ -64,6 +64,17 @@ class NotificationsProvider extends React.Component { error: null } + shouldComponentUpdate (nextProps, nextState) { + // Update if our state changes + if ((this.state.loading !== nextState.loading) || + (this.state.error !== nextState.error)) { + return true; + } + // Only update if our notifications prop changes. + // All other props "changing" should NOT trigger a rerender. + return this.props.notifications !== nextProps.notifications; + } + requestPage = (page = 1, optimizePolling = true) => { const headers = { 'Authorization': `token ${this.props.token}`, @@ -102,13 +113,7 @@ class NotificationsProvider extends React.Component { }); } - fetchNotifications = (page = 1, optimizePolling = true) => { - if (!this.props.token) { - console.error('Unauthenitcated, aborting request.') - return false; - } - - this.setState({ loading: true }); + requestFetchNotifications = (page = 1, optimizePolling = true) => { return this.requestPage(page, optimizePolling) .then(({headers, json}) => { if (json === null) return; @@ -118,46 +123,59 @@ class NotificationsProvider extends React.Component { nextPage = links.next.page; } return this.processNotificationsChunk(nextPage, json); - }) - .catch(error => console.error(error) || this.setState({ error })) + }); + } + + fetchNotifications = (page = 1, optimizePolling = true) => { + if (!this.props.token) { + console.error('Unauthenitcated, aborting request.') + return false; + } + + this.setState({ loading: true }); + return this.requestFetchNotifications(page, optimizePolling) + .catch(error => this.setState({ error })) .finally(() => this.setState({ loading: false })); } processNotificationsChunk = (nextPage, notificationsChunk) => { - console.log('chunk', notificationsChunk) - let everythingUpdated = true; + return new Promise((resolve, reject) => { + 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? - } + 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. - 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; + notificationsChunk.forEach(n => { + const cached_n = this.props.getItemFromStorage(n.id); + // 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. + everythingUpdated = false; + } else { + // Else, update the cache. + this.updateNotification(n); } - // 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; + }); + + if (nextPage && everythingUpdated) { + // Still need to fetch more updates. + this.fetchNotifications(nextPage, false); } else { - // Else, update the cache. - this.updateNotification(n); + // All done fetching updates, let's trigger a sync. + this.props.refreshNotifications(); + resolve(); } }); - - 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, prevReason = null) => { @@ -181,8 +199,10 @@ class NotificationsProvider extends React.Component { reasons: reasons, type: n.subject.type, name: n.subject.title, - url: subjectUrlToIssue(n.subject.url), - repository: n.repository.name, + url: cleanResponseUrl(n.subject.url), + repository: n.repository.full_name, + number: n.subject.url.split('/').pop(), + repositoryUrl: cleanResponseUrl(n.repository.url) }; this.props.setItemInStorage(n.id, value); } @@ -269,6 +289,7 @@ class NotificationsProvider extends React.Component { ...this.state, notifications: this.props.notifications, fetchNotifications: this.fetchNotifications, + fetchNotificationsSync: this.requestFetchNotifications, markAsRead: this.markAsRead, clearCache: this.clearCache, stageThread: this.stageThread diff --git a/src/providers/Storage.js b/src/providers/Storage.js index 65552b0..17dea85 100644 --- a/src/providers/Storage.js +++ b/src/providers/Storage.js @@ -1,13 +1,38 @@ import React from 'react'; +import moment from 'moment'; import {Status} from '../constants/status'; +import {Reasons} from '../constants/reasons'; const LOCAL_STORAGE_PREFIX = '__meteorite_noti_cache__'; -class StorageProvider extends React.Component { - constructor (props) { - super(props); - } +const getMockReasons = n => { + const reasons = Object.values(Reasons); + const len = reasons.length; + return new Array(n).fill(0).map(_ => ({ + reason: reasons[Math.floor(Math.random() * len)], + time: moment().format() + })); +}; +const getMockNotification = randomNumber => ({ + id: randomNumber, + updated_at: moment().format(), + status: (randomNumber > 0.8 ? Status.STAGED : Status.QUEUED), + reasons: getMockReasons(Math.ceil(randomNumber * 10)), + type: ['Issue', 'PullRequest'][Math.floor(randomNumber * 2)], + name: 'Mock - Fake notification name', + url: 'https://github.com/test/repo/pull', + repository: 'test/mock', + number: Math.ceil(randomNumber * 1000), + repositoryUrl: 'https://github.com/test/repo', +}); + +const mockNotifications = new Array(1000); +for (let i = 0; i < mockNotifications.length; i++) { + mockNotifications[i] = getMockNotification(Math.random()); +} + +class StorageProvider extends React.Component { state = { loading: false, error: null, @@ -28,6 +53,7 @@ class StorageProvider extends React.Component { } }); this.setState({ notifications }); + // this.setState({ notifications: mockNotifications }); } // val value : Object @@ -42,11 +68,11 @@ class StorageProvider extends React.Component { // // window.localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${id}`); const cached_n = this.getItem(id); - cached_n = { + const closed_cached_n = { ...cached_n, status: Status.CLOSED }; - this.setItem(id, cached_n); + this.setItem(id, closed_cached_n); } getItem = id => { diff --git a/src/styles/gradient.css b/src/styles/gradient.css index 4de0542..b29581d 100644 --- a/src/styles/gradient.css +++ b/src/styles/gradient.css @@ -1,7 +1,8 @@ .container-gradient { /* background: radial-gradient(farthest-corner at -0% 100%, #7247ff 30%, #00ffbe 95%); */ - background: radial-gradient(farthest-corner at -0% 100%, #6772e5 30%, #00cfff 95%); + /* background: radial-gradient(farthest-corner at -0% 100%, #6772e5 30%, #00cfff 95%); */ + background: radial-gradient(farthest-corner at -0% 100%, #24292e 30%, #213a54 95%); background-size: 400% 400%; -webkit-animation: gradientTransition 20s ease infinite; @@ -13,8 +14,10 @@ text-align: center; box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08); margin: 0 auto; + user-select: none; cursor: pointer; - color: #6772e5; + /* color: #6772e5; */ + color: #24292e; background: #fff; border-radius: 4px; -webkit-align-items: center; @@ -47,10 +50,12 @@ .button-container-alt a { position: relative; text-align: center; + user-select: none; 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; + /* color: #6772e5; */ + color: #24292e; background: #fff; border-radius: 4px; -webkit-align-items: center; diff --git a/src/styles/index.css b/src/styles/index.css index d00a2b9..9b68900 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -18,14 +18,14 @@ ::selection { color: #fff; - background: #6772e5; + background: #24292e; } html, body, * { font-family: 'Inter UI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - color: #1a1a1a; + color: #202124; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }