Everything

This commit is contained in:
Nicholas Zuber 2018-11-04 23:32:30 -05:00
parent 704a61139b
commit a7cb5b7b1a
23 changed files with 1024 additions and 92 deletions

BIN
public/icon-black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/icon-purple.png">
<link rel="shortcut icon" href="%PUBLIC_URL%/icon-black.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta name="theme-color" content="#24292e">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>Meteorite — Smarter GitHub notifications</title>
</head>

View File

@ -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);

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill="#f68700" d="M22 5.72l-4.6-3.86-1.29 1.53 4.6 3.86L22 5.72zM7.88 3.39L6.6 1.86 2 5.71l1.29 1.53 4.59-3.85zM12.5 8H11v6l4.75 2.85.75-1.23-4-2.37V8zM12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill="#00cd94" d="M22 5.72l-4.6-3.86-1.29 1.53 4.6 3.86L22 5.72zM7.88 3.39L6.6 1.86 2 5.71l1.29 1.53 4.59-3.85zM12.5 8H11v6l4.75 2.85.75-1.23-4-2.37V8zM12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="#fff" d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2zm0 15l-5-2.18L7 18V5h10v13z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill="#00A0F5" d="M21 6h-2v9H6v2c0 .55.45 1 1 1h11l4 4V7c0-.55-.45-1-1-1zm-4 6V3c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1v14l4-4h10c.55 0 1-.45 1-1z"/></svg>

After

Width:  |  Height:  |  Size: 278 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M14 5h8v2h-8zm0 5.5h8v2h-8zm0 5.5h8v2h-8zM2 11.5C2 15.08 4.92 18 8.5 18H9v2l3-3-3-3v2h-.5C6.02 16 4 13.98 4 11.5S6.02 7 8.5 7H12V5H8.5C4.92 5 2 7.92 2 11.5z"/><path fill="none" d="M0 0h24v24H0z"/></svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/><path fill="none" d="M0 0h24v24H0V0z"/></svg>

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 194 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z"/><path fill="none" d="M0 0h24v24H0V0z"/></svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@ -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 (
<div style={{
background: `url(${loader}) center center no-repeat`,
background: `url(${(alt ? loaderAlt : loader)}) center center no-repeat`,
position: 'relative',
height: size || 100,
width: size || 100,

View File

@ -0,0 +1,17 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg width="22" height="22" viewBox="-1 -1 22 22" xmlns="http://www.w3.org/2000/svg" stroke="#e4e4e4">
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)" stroke-width="2.6">
<circle stroke="none" stroke-opacity=".5" cx="9" cy="9" r="9"/>
<path stroke="#24292e" d="M18,9 C18,4.03 13.97,0 9,0">
<animateTransform
attributeName="transform"
type="rotate"
from="0 9 9"
to="360 9 9"
dur=".5s"
repeatCount="indefinite"/>
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 727 B

View File

@ -3,7 +3,7 @@
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)" stroke-width="2.6">
<circle stroke-opacity=".5" cx="9" cy="9" r="9"/>
<path stroke="#6772e5" d="M18,9 C18,4.03 13.97,0 9,0">
<path stroke="#24292e" d="M18,9 C18,4.03 13.97,0 9,0">
<animateTransform
attributeName="transform"
type="rotate"

Before

Width:  |  Height:  |  Size: 713 B

After

Width:  |  Height:  |  Size: 713 B

View File

@ -3,5 +3,5 @@ export const Filters = {
REVIEW_REQUESTED: 'review_requested',
PARTICIPATING: 'participating',
SUBSCRIBED: 'subscribed',
HOT: 'hot',
COMMENT: 'comment'
};

15
src/constants/reasons.js Normal file
View File

@ -0,0 +1,15 @@
export const Reasons = {
MENTION: 'mention',
ASSIGN: 'assign',
REVIEW_REQUESTED: 'review_requested',
SUBSCRIBED: 'subscribed',
AUTHOR: 'author',
OTHER: 'other',
COMMENT: 'comment',
};
export const Badges = {
HOT: 'hot',
COMMENTS: 'comments',
OLD: 'old'
};

View File

@ -95,7 +95,7 @@ export default function Scene ({loggedIn, onLogout, ...props}) {
</div>
<BottomLinkContainer>
<SmallLink target="_blank" href="https://github.com/nickzuber/meteorite">View and contribute on GitHub</SmallLink>
<SmallText href="">Already have an account?</SmallText>
<SmallText>Totally free and open sourced</SmallText>
</BottomLinkContainer>
</LandingMessage>
<Curve />

View File

@ -56,7 +56,7 @@ export default function Scene ({ loading, error, loggedOut, ...props }) {
</ButtonsContainer>
</React.Fragment>
) : loading ? (
<LoadingIcon />
<LoadingIcon style={{marginTop: 50}} />
) : loggedOut ? (
<React.Fragment>
<p>Log in with GitHub and we'll start organizing and sorting all of your notifications.</p>

View File

@ -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 ({
<TableHeader>Queued</TableHeader>
{notificationsQueued.map(n => (
<NotificationRow key={n.id}>
<TableItem width={100}>
<InlineBlockContainer>
<Icon.Hot shrink={0.9} />
<Icon.Alarm shrink={0.9} />
</InlineBlockContainer>
</TableItem>
<TableItem>
<div style={{ float: 'left', marginTop: 2 }}>
{getPRIssueIcon(n.type, n.reasons)}
@ -464,11 +459,17 @@ export default function Scene ({
onStageThread(n.id)
}}>
<NotificationTitle>
<PRIssue alt="njksjnksdknjgf">{n.name}</PRIssue>
<PRIssue>{n.name}</PRIssue>
</NotificationTitle>
</TableItem>
<TableItem width={200}>
{/* <TableItem width={200}>
<Repository>{n.reasons.map(r => r.reason).join(', ')}</Repository>
</TableItem> */}
<TableItem width={100}>
<InlineBlockContainer>
<Icon.Hot shrink={0.75} />
<Icon.Alarm shrink={0.75} />
</InlineBlockContainer>
</TableItem>
<TableItem width={150}>
<Repository>{n.repository}</Repository>
@ -494,12 +495,6 @@ export default function Scene ({
<TableHeader style={{marginTop: 50}}>Staged</TableHeader>
{notificationsStaged.map(n => (
<NotificationRow key={n.id}>
<TableItem width={100}>
<InlineBlockContainer>
<Icon.Hot shrink={0.9} />
<Icon.Alarm shrink={0.9} />
</InlineBlockContainer>
</TableItem>
<TableItem>
<div style={{ float: 'left', marginTop: 2 }}>
{getPRIssueIcon(n.type, n.reasons)}

View File

@ -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 (
<Icon.PrMerged shrink={grow} />
);
case 'Issue':
return (
<Icon.IssueOpen shrink={grow} />
);
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 (
<div style={{marginTop: 60}}>
<NavigationContainer>
<div style={{
maxWidth: 1200,
textAlign: 'right',
margin: '0 auto',
padding: '0 20px 0 40px',
}}>
<Logo
size={36}
style={{
float: 'left',
marginRight: 48,
cursor: 'pointer'
}}
onClick={() => {
onSetActiveStatus(Status.QUEUED);
onSetActiveFilter(Filters.PARTICIPATING);
}}
/>
<SearchField>
<Icon.Search size={48} opacity={.45} />
<EnhancedSearchInput
disabled={isLoading}
type="text"
placeholder="Search for notifications"
onEnter={onSearch}
/>
{isSearching && <LoadingIcon alt={true} size={48} />}
</SearchField>
<div style={{display: 'inline-block'}} className="button-container-alt">
<Link style={{
marginRight: 15,
background: 'none',
color: '#fff',
height: 36,
padding: '0 12px'
}} to={routes.HOME}>home</Link>
</div>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#fff',
height: 36,
padding: '0 12px'
}} href="#" onClick={onLogout}>sign out</a>
</div>
</div>
</NavigationContainer>
<GeneralOptionsContainer>
<Tab disabled={isLoading}>
<Icon.Refresh
opacity={0.9}
onClick={!isLoading ? (() => onFetchNotifications()) : undefined}
/>
</Tab>
<Tab disabled={isLoading}>
<Icon.Trash
opacity={0.9}
onClick={!isLoading ? (() => onClearCache()) : undefined}
/>
</Tab>
{query ? (
<React.Fragment>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#202124',
textTransform: 'inherit',
boxShadow: '0 0 0',
fontWeight: 400,
height: 36,
padding: '0 12px',
}}
>
Showing results for '{query}'
</a>
</div>
<Tab disabled={isLoading}>
<Icon.X
opacity={0.9}
onClick={!isLoading ? (() => onClearQuery()) : undefined}
/>
</Tab>
</React.Fragment>
) : null}
<div style={{float: 'right'}}>
<div style={{display: 'inline-block'}} className="button-container-alt">
<a style={{
marginRight: 15,
background: 'none',
color: '#202124',
textTransform: 'inherit',
boxShadow: '0 0 0',
fontWeight: 400,
height: 36,
padding: '0 12px',
}}>
{first}-{last} of about {allNotificationsCount}
</a>
</div>
<Tab disabled={isLoading || isFirstPage}>
<Icon.Prev
opacity={0.9}
onClick={!isLoading && !isFirstPage ? (() => onChangePage(page - 1)) : undefined}
/>
</Tab>
<Tab disabled={isLoading || isLastPage}>
<Icon.Next
opacity={0.9}
onClick={!isLoading && !isLastPage ? (() => onChangePage(page + 1)) : undefined}
/>
</Tab>
</div>
</GeneralOptionsContainer>
<GeneralOptionsContainer style={{paddingTop: 4}}>
<NavTab
color="#00d19a"
active={activeStatus === Status.QUEUED}
onClick={() => onSetActiveStatus(Status.QUEUED)}
href="#">
Queued
</NavTab>
<NavTab
color="#009ef8"
active={activeStatus === Status.STAGED}
onClick={() => onSetActiveStatus(Status.STAGED)}
href="#">
Staged
</NavTab>
<NavTab
color="#f12c3f"
active={activeStatus === Status.CLOSED}
onClick={() => onSetActiveStatus(Status.CLOSED)}
href="#">
Closed
</NavTab>
</GeneralOptionsContainer>
<NotificationsContainer>
<Sidebar>
<FixedContainer>
<SidebarLink
active={activeFilter === Filters.ALL}
color="#6772e5"
onClick={() => onSetActiveFilter(Filters.ALL)}>
{activeFilter === Filters.ALL ? (
<Icon.InboxWhite shrink={.6} />
) : (
<Icon.Inbox shrink={.6} />
)}
all notifications
</SidebarLink>
<SidebarLink
active={activeFilter === Filters.PARTICIPATING}
color="#00d19a"
onClick={() => onSetActiveFilter(Filters.PARTICIPATING)}>
{activeFilter === Filters.PARTICIPATING ? (
<Icon.PeopleWhite shrink={.6} />
) : (
<Icon.People shrink={.6} />
)}
participating
</SidebarLink>
<SidebarLink
active={activeFilter === Filters.COMMENT}
color="#00A0F5"
onClick={() => onSetActiveFilter(Filters.COMMENT)}>
{activeFilter === Filters.COMMENT ? (
<Icon.BookmarkAltWhite shrink={.6} />
) : (
<Icon.BookmarkAlt shrink={.6} />
)}
commented
</SidebarLink>
</FixedContainer>
</Sidebar>
<Notifications>
{isFetchingNotifications ? (
<LoaderContainer>
<LoadingIcon />
</LoaderContainer>
) : notifications.length <= 0 ? (
<Message>
<p style={{
fontSize: 16,
fontWeight: 400,
}}>
No {activeStatus.toLowerCase()} notifications</p>
<p style={{
fontSize: 12,
fontWeight: 400,
color: '#5f6368'
}}>
🎉 You're all set here for the moment</p>
</Message>
) : (
<Table>
<tbody>
{notifications.map(n => (
<NotificationRow key={n.id}>
<TableItem>
<div style={{ float: 'left', marginTop: 2 }}>
{getPRIssueIcon(n.type, n.reasons)}
</div>
</TableItem>
<TableItem width={400} onClick={() => {
window.open(n.url);
onStageThread(n.id)
}}>
<NotificationTitle>
<PRIssue>{n.name}</PRIssue>
</NotificationTitle>
</TableItem>
{/* <TableItem width={200}>
<Repository>{n.reasons.map(r => r.reason).join(', ')}</Repository>
</TableItem> */}
<TableItem width={100}>
<InlineBlockContainer>
{n.badges.map(badge => {
switch (badge) {
case Badges.HOT:
// lots of `reasons` within short time frame
return <Icon.Hot shrink={0.75} />
break;
case Badges.OLD:
// old
return <Icon.Alarm shrink={0.75} />
break;
case Badges.COMMENTS:
// lots of `reasons`
return <Icon.Convo shrink={0.75} />
break;
default:
return null;
}
})}
</InlineBlockContainer>
</TableItem>
<TableItem width={250}>
<Repository>{n.repository}</Repository>
</TableItem>
<TableItem width={150} style={{textAlign: 'right'}}>
<NotificationTab>
{n.score}
</NotificationTab>
<NotificationTab>
<Icon.Check
opacity={0.9}
onClick={!isLoading ? (() => onStageThread(n.id)) : undefined}
/>
</NotificationTab>
<NotificationTab>
<Icon.X
opacity={0.9}
onClick={!isLoading ? (() => onMarkAsRead(n.id)) : undefined}
/>
</NotificationTab>
</TableItem>
{/* <p>Last read at {n.last_read_at ? moment(n.last_read_at).format('dddd h:mma') : 'never'}</p>
<p>Last updated at {moment(n.last_updated).format('dddd h:mma')}</p> */}
</NotificationRow>
))}
</tbody>
</Table>
)}
</Notifications>
</NotificationsContainer>
</div>
);
}

View File

@ -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 (
<Scene
notifications={notifications}
first={firstNumbered}
last={lastNumbered}
lastPage={lastPage}
allNotificationsCount={allNotificationsCount}
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={markAsRead}
onClearCache={clearCache}
@ -70,7 +256,6 @@ class NotificationsPage extends React.Component {
isFetchingNotifications={isFetchingNotifications}
fetchingNotificationsError={fetchingNotificationsError}
onSetActiveFilter={this.onSetActiveFilter}
activeFilter={this.state.activeFilter}
/>
);
}

View File

@ -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

View File

@ -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 => {

View File

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

View File

@ -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;
}