Make backwards compat with current route

This commit is contained in:
Nicholas Zuber 2019-04-02 18:08:33 -04:00
parent cba0a00041
commit e4fc9dddaa
11 changed files with 1198 additions and 622 deletions

View File

@ -6,6 +6,7 @@ import {
Home,
Login,
Notifications,
NotificationsRedesign,
} from './pages';
class App extends Component {
@ -16,6 +17,7 @@ class App extends Component {
<Home path={routes.HOME} />
<Login path={routes.LOGIN} />
<Notifications path={routes.NOTIFICATIONS} />
<NotificationsRedesign path={routes.REDESIGN_NOTIFICATIONS} />
</Router>
</AuthProvider>
);

View File

@ -1,10 +1,11 @@
import React from 'react';
import meteoriteLogo from './icon-gray.png';
import meteoriteLogoWhite from './logo-white.png';
export default function LoadingIcon ({ style, size, ...props }) {
export default function LoadingIcon ({ style, size, white = false, ...props }) {
return (
<div style={{
background: `url(${meteoriteLogo}) center center no-repeat`,
background: `url(${white ? meteoriteLogoWhite : meteoriteLogo}) center center no-repeat`,
backgroundSize: 'contain',
position: 'relative',
cursor: props.onClick ? 'pointer' : 'default',

View File

@ -1,5 +1,6 @@
export default {
HOME: '/',
NOTIFICATIONS: 'notifications',
LOGIN: 'login'
NOTIFICATIONS: '/notifications',
REDESIGN_NOTIFICATIONS: '/notifications-redesign',
LOGIN: '/login'
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 375 KiB

View File

@ -363,7 +363,7 @@ const ImageContainer = styled('div')({
background: `url(${screenshot}) center center no-repeat`,
backgroundSize: 'cover',
backgroundColor: '#fff',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.51)',
boxShadow: 'rgba(84,70,35,0) 0px 2px 8px, rgba(68, 58, 32, 0.16) 0px 1px 9px 4px',
marginLeft: 100,
borderRadius: 8,
display: 'block',
@ -395,7 +395,7 @@ const Section = styled('div')({
margin: '28px auto 0',
padding: '60px 0'
}, ({alt}) => alt && ({
background: '#24292e',
background: '#457cff',
'p': {
color: '#fff'
},
@ -534,7 +534,7 @@ const BottomLinkContainer = styled(LandingHeader)({
margin: '32px 20px 0',
});
const LinkButton = styled('a')({});
const LinkButton = styled('a')({boxShadow: '0 0 0 transparent'});
const U = styled('span')({
color: 'inherit',});
// background: '#009cfb',
@ -571,8 +571,8 @@ export default function Scene ({loggedIn, onLogout, ...props}) {
paddingBottom: 50
}}>
<LandingHeader style={{paddingLeft: '5%'}}>
<Logo size={75}>
<UnofficialReleaseTag>beta</UnofficialReleaseTag>
<Logo white size={75}>
{/* <UnofficialReleaseTag>beta</UnofficialReleaseTag> */}
</Logo>
{loggedIn ? (
<div className="button-container-alt">
@ -788,7 +788,8 @@ export default function Scene ({loggedIn, onLogout, ...props}) {
style={{
marginLeft: 15,
color: '#fff',
background: 'none'
background: 'none',
boxShadow: '0 0 0 transparent'
}} href="https://github.com/nickzuber/meteorite/">check out the github</LinkButton>
</div>
</Section>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
import React from 'react';
import {default as RedesignScene} from './redesign/Scene';
/* eslint-disable jsx-a11y/anchor-is-valid */
/* eslint-disable no-script-url */
export default function Scene ({
onMarkAsRead,
...props
}) {
const loading = props.isSearching || props.isFetchingNotifications;
const isFirstPage = props.page === 1;
const isLastPage = props.page === props.lastPage;
return (
<RedesignScene
loading={loading}
isFirstPage={isFirstPage}
isLastPage={isLastPage}
onArchiveThread={onMarkAsRead}
{...props}
/>
);
}

View File

@ -0,0 +1,550 @@
import React from 'react';
import moment from 'moment';
import { Redirect } from "@reach/router";
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';
import { Status } from '../../constants/status';
import { Reasons, Badges } from '../../constants/reasons';
import Scene from './Scene';
import { getMessageFromReasons } from './redesign/Scene';
import issueIcon from '../../images/issue-bg.png';
import prIcon from '../../images/pr-bg.png';
import tabIcon from '../../images/iconCircle.png';
import tabDotIcon from '../../images/iconCircleDotAlt.png';
export const PER_PAGE = 10;
export const Sort = {
TYPE: 1,
TITLE: 0,
REPOSITORY: 2,
SCORE: 3,
DATE: 4
};
export const View = {
UNREAD: 1,
READ: 0,
ARCHIVED: 2
};
// @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 -> 30
* - SUBSCRIBED -> 3
* - COMMENT -> 3
* - AUTHOR -> 10
* - 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;
if (prevReason && reason === prevReason) {
const degradedScore = Math.ceil(scoreOfReason[reason] / 3);
score += Math.max(degradedScore, 2);
} else {
score += scoreOfReason[reason];
}
prevReason = reason;
}
return score;
};
function badgesOf (notification) {
const badges = [];
const len = notification.reasons.length;
const timeSinceLastUpdate = moment().diff(moment(notification.reasons[len - 1].time), 'minutes');
// If there are more than 4 reasons, and the last 4 reasons have happened within
// an hour of each other. The last update should be within the past 30 minutes.
// The specific time frame and reasons count is subject to change.
if (len >= 4 && timeSinceLastUpdate < 30) {
const oldestReference = moment(notification.reasons[len - 4].time);
const newestReference = moment(notification.reasons[len - 1].time);
if (newestReference.diff(oldestReference, 'hours') <= 1) {
badges.push(Badges.HOT);
}
}
// If there's a lot of activity going on within the thread in general.
// The specific nunmber should be relative to average number of thread lengths.
// We can track a running statistic as we see notifications update.
if (len > 6) {
badges.push(Badges.COMMENTS);
}
// If you've been tagged in for review and the most recent update happened over
// 4 hours ago, that specific time is subject to change.
if (notification.reasons.some(r => r.reason === Reasons.REVIEW_REQUESTED) &&
timeSinceLastUpdate > 60 * 4) {
badges.push(Badges.OLD);
}
return badges;
};
const scoreOfReason = {
[Reasons.ASSIGN]: 21,
[Reasons.AUTHOR]: 11,
[Reasons.MENTION]: 17,
[Reasons.TEAM_MENTION]: 11,
[Reasons.OTHER]: 4,
[Reasons.REVIEW_REQUESTED]: 29,
[Reasons.SUBSCRIBED]: 3,
[Reasons.COMMENT]: 6,
[Reasons.STATE_CHANGE]: 5,
};
const decorateWithScore = notification => ({
...notification,
score: scoreOf(notification),
badges: badgesOf(notification)
});
class NotificationsPage extends React.Component {
constructor (props) {
super(props);
this.notificationSent = false;
this.isUnreadTab = false;
}
state = {
currentTime: moment(),
error: null,
notificationSent: false,
isFirstTimeUser: false,
isSearching: false,
query: null,
activeFilter: Filters.PARTICIPATING,
activeStatus: View.UNREAD,
currentPage: 1,
sort: Sort.SCORE,
descending: false,
user: null
}
componentDidMount () {
const isFirstTimeUser = !this.props.storageApi.getUserItem('hasOnboarded');
if (isFirstTimeUser) {
this.setState({isFirstTimeUser: true});
// this.props.storageApi.setUserItem('hasOnboarded', true);
}
this.props.notificationsApi.fetchNotifications();
this.props.notificationsApi.requestUser().then(user => {
this.setState({user});
});
this.tabSyncer = setInterval(() => {
if (!document.hidden && this.isUnreadTab) {
this.updateTabIcon(false);
}
}, 2000);
this.syncer = setInterval(() => {
this.props.notificationsApi.fetchNotificationsSync()
.then(error => this.setState({error: null}))
.catch(error => this.setState({error}));
this.setState({currentTime: moment()});
}, 8 * 1000);
}
shouldComponentUpdate (nextProps, nextState) {
if (this.props.notificationsApi.newChanges !== nextProps.notificationsApi.newChanges) {
this.notificationSent = false;
}
// The idea here is if we've just updated the prevNotifications state, then
// we don't want to trigger a rerender.
return nextState.prevNotifications === this.state.prevNotifications;
}
componentWillUnmount () {
clearInterval(this.syncer);
clearInterval(this.tabSyncer);
}
onChangePage = page => {
this.setState({ currentPage: page });
}
onSetActiveFilter = filter => {
this.setState({ activeFilter: filter, currentPage: 1 });
}
onSetActiveStatus = status => {
this.setState({ activeStatus: status, currentPage: 1 });
}
onClearQuery = () => {
this.setState({ query: null });
}
onLogout = () => {
// Remove cookie and invalidate token on client.
this.props.cookiesApi.removeCookie(OAUTH_TOKEN_COOKIE);
this.props.authApi.invalidateToken();
}
onSearch = event => {
const text = event.target.value;
// Ignore empty queries.
if (text.length <= 0) {
this.onClearQuery();
return;
}
this.setState({ isSearching: true });
setTimeout(() => {
this.setState({
query: text,
isSearching: false
});
}, 800);
}
enhancedOnStageThread = (thread_id, repository) => {
this.props.storageApi.incrStat('stagedCount');
this.props.storageApi.incrStat(repository + '-stagedCount');
this.props.notificationsApi.stageThread(thread_id);
}
enhancedOnMarkAsRead = (thread_id, repository) => {
this.props.storageApi.incrStat('stagedCount');
this.props.storageApi.incrStat(repository + '-stagedCount');
this.props.notificationsApi.markAsRead(thread_id);
}
restoreThread = thread_id => {
this.props.notificationsApi.restoreThread(thread_id);
}
setNotificationsPermission = (...args) => {
this.props.notificationsApi.setNotificationsPermission(...args);
}
updateTabIcon (hasUnread = true) {
this.isUnreadTab = hasUnread;
var link = document.querySelector("link[rel*='icon']") || document.createElement('link');
link.rel = 'shortcut icon';
link.href = hasUnread ? tabDotIcon : tabIcon;
document.getElementsByTagName('head')[0].appendChild(link);
}
sendWebNotification = newNotifcations => {
if (this.notificationSent || newNotifcations.length === 0) {
return;
}
// Only show these notifications and title change if the tab is out of focus.
if (!document.hidden) {
return;
}
// Set this even if we don't actually send the notification due to permissions.
this.notificationSent = true;
this.updateTabIcon();
// No permission, no notification.
if (this.props.notificationsApi.notificationsPermission !== 'granted') {
return;
}
const n = newNotifcations[0];
const reasonByline = getMessageFromReasons(n.reasons, n.type);
const additionalInfo = newNotifcations.length > 1
? ` (+${newNotifcations.length} more)`
: '';
const notification = new Notification(n.name + additionalInfo, {
body: reasonByline,
icon: n.type === "Issue" ? issueIcon : prIcon,
badge: n.type === "Issue" ? issueIcon : prIcon,
requireInteraction: true,
});
notification.addEventListener('click', () => {
this.updateTabIcon(false);
this.enhancedOnStageThread(n.id, n.repository);
window.open(n.url);
})
// Manually close for legacy browser support.
setTimeout(notification.close.bind(notification), 10000);
}
getFilteredNotifications = () => {
const {notifications} = this.props.notificationsApi;
let filterMethod = () => true;
switch (this.state.activeFilter) {
case Filters.PARTICIPATING:
filterMethod = n => (
n.reasons.some(({ reason }) => (
reason === Reasons.REVIEW_REQUESTED ||
reason === Reasons.ASSIGN ||
reason === Reasons.MENTION ||
reason === Reasons.AUTHOR
))
);
break;
case Filters.ASSIGNED:
filterMethod = n => (
n.reasons.some(({ reason }) => reason === Reasons.ASSIGN)
);
break;
case Filters.REVIEW_REQUESTED:
filterMethod = n => (
n.reasons.some(({ reason }) => reason === Reasons.REVIEW_REQUESTED)
);
break;
case Filters.COMMENT:
filterMethod = n => (
n.reasons.some(({ reason }) => reason === Reasons.COMMENT)
);
break;
default:
filterMethod = () => true;
}
const filteredNotifications = notifications.filter(filterMethod);
let notificationsQueued = filteredNotifications.filter(n => n.status === Status.QUEUED);
let notificationsStaged = filteredNotifications.filter(n => n.status === Status.STAGED);
let notificationsClosed = filteredNotifications.filter(n => n.status === Status.CLOSED);
let notificationsToRender = [];
switch (this.state.activeStatus) {
case View.ARCHIVED:
notificationsToRender = notificationsClosed;
break;
case View.READ:
notificationsToRender = notificationsStaged;
break;
case View.UNREAD:
default:
notificationsToRender = notificationsQueued;
}
let scoredAndSortedNotifications = notificationsToRender
.map(decorateWithScore);
if (this.state.sort === Sort.TITLE) {
if (this.state.descending) {
scoredAndSortedNotifications.sort((a, b) => a.name.localeCompare(b.name));
} else {
scoredAndSortedNotifications.sort((a, b) => b.name.localeCompare(a.name));
}
}
if (this.state.sort === Sort.SCORE) {
if (this.state.descending) {
scoredAndSortedNotifications.sort((a, b) => a.score - b.score);
} else {
scoredAndSortedNotifications.sort((a, b) => b.score - a.score);
}
}
if (this.state.sort === Sort.REPOSITORY) {
if (this.state.descending) {
scoredAndSortedNotifications.sort((a, b) => a.repository.localeCompare(b.repository));
} else {
scoredAndSortedNotifications.sort((a, b) => b.repository.localeCompare(a.repository));
}
}
if (this.state.sort === Sort.TYPE) {
if (this.state.descending) {
scoredAndSortedNotifications.sort((a, b) => a.type.localeCompare(b.type));
} else {
scoredAndSortedNotifications.sort((a, b) => b.type.localeCompare(a.type));
}
}
if (this.state.sort === Sort.DATE) {
if (this.state.descending) {
scoredAndSortedNotifications.sort((a, b) => moment(a.updated_at).diff(b.updated_at));
} else {
scoredAndSortedNotifications.sort((a, b) => moment(b.updated_at).diff(a.updated_at));
}
}
// We gotta make sure to search notifications before we paginate.
// Otherwise we'd just end up searching on the current page, which is bad.
if (this.state.query) {
scoredAndSortedNotifications = scoredAndSortedNotifications.filter(n => (
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
);
notificationsQueued = notificationsQueued.filter(n => (
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
);
notificationsStaged = notificationsStaged.filter(n => (
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
);
notificationsClosed = notificationsClosed.filter(n => (
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
);
}
if (this.props.notificationsApi.newChanges) {
const filteredNewChanges = this.props.notificationsApi.newChanges.filter(n => (
scoredAndSortedNotifications.some(fn => fn.id === n.id)
));
if (filteredNewChanges.length > 0) {
this.sendWebNotification(filteredNewChanges);
}
}
return {
notifications: scoredAndSortedNotifications,
queuedCount: notificationsQueued.length,
stagedCount: notificationsStaged.length,
closedCount: notificationsClosed.length,
};
}
render () {
if (!this.props.authApi.token) {
return <Redirect noThrow to={routes.LOGIN} />
}
const {
fetchNotifications,
markAllAsStaged,
clearCache,
notificationsPermission,
loading: isFetchingNotifications,
error: fetchingNotificationsError,
} = this.props.notificationsApi;
const {
notifications: scoredAndSortedNotifications,
queuedCount,
stagedCount,
closedCount,
} = this.getFilteredNotifications();
const [highestScore, lowestScore] = scoredAndSortedNotifications.reduce(([h, l], notification) => {
h = Math.max(notification.score, h);
l = Math.min(notification.score, l);
return [h, l];
}, [0, Infinity]);
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;
}
const todayLastWeek = this.state.currentTime.clone().subtract(1, 'week');
const stagedTodayCount = this.props.storageApi.getStat('stagedCount')[0];
const stagedTodayLastWeekCount = this.props.storageApi.getStat(
'stagedCount',
todayLastWeek,
todayLastWeek.clone().add(1, 'day')
)[0];
const stagedStatistics = this.props.storageApi.getStat(
'stagedCount',
this.state.currentTime.clone().startOf('week').subtract(1, 'week'),
this.state.currentTime.clone().endOf('week')
);
return (
<Scene
currentTime={this.state.currentTime}
readStatistics={stagedStatistics}
isFirstTimeUser={this.state.isFirstTimeUser}
setNotificationsPermission={this.setNotificationsPermission}
notificationsPermission={notificationsPermission}
unreadCount={queuedCount}
readCount={stagedCount}
archivedCount={closedCount}
readTodayCount={parseInt(stagedTodayCount, 10) || 0}
readTodayLastWeekCount={parseInt(stagedTodayLastWeekCount, 10) || 0}
first={firstNumbered}
last={lastNumbered}
lastPage={lastPage}
allNotificationsCount={scoredAndSortedNotifications.length}
notifications={notificationsOnPage}
query={this.state.query}
page={this.state.currentPage}
activeStatus={this.state.activeStatus}
activeFilter={this.state.activeFilter}
onChangePage={this.onChangePage}
onLogout={this.onLogout}
onSetActiveStatus={this.onSetActiveStatus}
onSearch={this.onSearch}
onClearQuery={this.onClearQuery}
onFetchNotifications={fetchNotifications}
onMarkAsRead={this.enhancedOnMarkAsRead}
onMarkAllAsStaged={markAllAsStaged}
onClearCache={clearCache}
onStageThread={this.enhancedOnStageThread}
onRestoreThread={this.restoreThread}
onRefreshNotifications={this.props.storageApi.refreshNotifications}
isSearching={this.state.isSearching}
isFetchingNotifications={isFetchingNotifications}
fetchingNotificationsError={fetchingNotificationsError || this.state.error}
onSetActiveFilter={this.onSetActiveFilter}
highestScore={highestScore}
lowestScore={lowestScore}
hasUnread={this.isUnreadTab}
sort={this.state.sort}
setSort={sort => this.setState({sort})}
descending={this.state.descending}
setDescending={descending => this.setState({descending})}
view={this.state.activeStatus}
setView={this.onSetActiveStatus}
user={this.state.user}
/>
);
}
};
const enhance = compose(
withStorageProvider,
withAuthProvider,
withCookiesProvider,
withNotificationsProvider
);
export default enhance(NotificationsPage);

View File

@ -91,7 +91,7 @@ function getRelativeTime (time) {
return 'Over a week ago';
}
function getMessageFromReasons (reasons, type) {
export function getMessageFromReasons (reasons, type) {
switch (reasons[reasons.length - 1].reason) {
case Reasons.ASSIGN:
return 'You were assigned';
@ -451,7 +451,7 @@ const InteractionSection = styled('ul')`
`;
const InteractionMenu = styled('div')`
height: ${props => props.show ? 338 : 0}px;
height: ${props => props.show ? 345 : 0}px;
opacity: ${props => props.show ? 1 : 0};
top: 32px;
left: 32px;
@ -1334,8 +1334,6 @@ export default function Scene ({
<LoadingNotificationRow />
<LoadingNotificationRow />
<LoadingNotificationRow />
<LoadingNotificationRow />
<LoadingNotificationRow />
</NotificationBlock>
) : (
<NotificationCollection

View File

@ -1,3 +1,4 @@
export {default as Home} from './Home';
export {default as Notifications} from './Notifications';
export {default as NotificationsRedesign} from './NotificationsRedesign';
export {default as Login} from './Login';

View File

@ -2,7 +2,7 @@
.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%, #24292e 30%, #213a54 95%);
background: radial-gradient(farthest-corner at -0% 100%, #457cff 30%, #3e71e7 95%);
background-size: 400% 400%;
-webkit-animation: gradientTransition 20s ease infinite;
@ -18,7 +18,7 @@
user-select: none;
cursor: pointer;
/* color: #6772e5; */
color: #24292e;
color: #457cff;
background: #fff;
border-radius: 4px;
-webkit-align-items: center;
@ -64,11 +64,11 @@
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);
/* 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: #24292e;
color: #457cff;
background: #fff;
border-radius: 4px;
-webkit-align-items: center;