Add snooze

This commit is contained in:
Nicholas Zuber 2020-02-11 18:33:29 -05:00
parent d45ce4c070
commit 3f36ccf5bd
7 changed files with 296 additions and 124 deletions

View File

@ -7,4 +7,5 @@ export const Status = {
Unread: 'queued',
Read: 'staged',
Archived: 'closed',
Snoozed: 'snoozed'
};

View File

@ -39,7 +39,8 @@ export const Mode = {
ALL: 0,
HOT: 1,
COMMENTS: 2,
OLD: 3
OLD: 3,
SNOOZED: 4
};
function logNotificationRead (value) {
@ -50,6 +51,14 @@ function logNotificationRead (value) {
});
}
function logNotificationSnoozed (value) {
amplitude.getInstance().logEvent('notification_snoozed', {
event_category: 'notification',
event_label: 'Notification snoozed',
value
});
}
function logNotificationArchived (value) {
amplitude.getInstance().logEvent('notification_archived', {
event_category: 'notification',
@ -273,6 +282,13 @@ class NotificationsPage extends React.Component {
this.props.notificationsApi.stageThread(thread_id);
}
enhancedOnSnoozeThread = (thread_id, repository) => {
logNotificationSnoozed(thread_id);
// this.props.storageApi.incrStat('stagedCount');
// this.props.storageApi.incrStat(repository + '-stagedCount', '__REPO__');
this.props.notificationsApi.snoozeThread(thread_id);
}
enhancedOnMarkAsRead = (thread_id, repository) => {
logNotificationArchived(thread_id);
this.props.storageApi.incrStat('stagedCount');
@ -392,17 +408,24 @@ class NotificationsPage extends React.Component {
let notificationsStaged = filteredNotifications.filter(n => n.status === Status.STAGED);
let notificationsClosed = filteredNotifications.filter(n => n.status === Status.CLOSED);
const notificationsSnoozed = filteredNotifications.filter(n => n.status === Status.Snoozed);
let notificationsToRender = [];
switch (this.state.activeStatus) {
case View.ARCHIVED:
notificationsToRender = notificationsClosed;
break;
case View.READ:
notificationsToRender = notificationsStaged;
break;
case View.UNREAD:
default:
notificationsToRender = notificationsQueued;
if (this.state.mode !== Mode.SNOOZED) {
switch (this.state.activeStatus) {
case View.ARCHIVED:
notificationsToRender = notificationsClosed;
break;
case View.READ:
notificationsToRender = notificationsStaged;
break;
case View.UNREAD:
default:
notificationsToRender = notificationsQueued;
}
} else {
// Viewing snoozed notifications.
notificationsToRender = notificationsSnoozed;
}
if (this.state.sort === Sort.TITLE) {
@ -479,6 +502,7 @@ class NotificationsPage extends React.Component {
return {
notifications: notificationsToRender,
snoozedNotifications: notificationsSnoozed,
queuedCount: notificationsQueued.length,
stagedCount: notificationsStaged.length,
closedCount: notificationsClosed.length,
@ -501,6 +525,7 @@ class NotificationsPage extends React.Component {
const {
notifications: scoredAndSortedNotifications,
snoozedNotifications,
queuedCount,
stagedCount,
closedCount,
@ -548,6 +573,7 @@ class NotificationsPage extends React.Component {
currentTime={this.state.currentTime}
readStatistics={stagedStatistics}
isFirstTimeUser={this.state.isFirstTimeUser}
snoozedNotifications={snoozedNotifications}
setNotificationsPermission={this.setNotificationsPermission}
notificationsPermission={notificationsPermission}
unreadCount={queuedCount}
@ -574,6 +600,7 @@ class NotificationsPage extends React.Component {
onMarkAllAsStaged={markAllAsStaged}
onClearCache={clearCache}
onStageThread={this.enhancedOnStageThread}
onSnoozeThread={this.enhancedOnSnoozeThread}
onRestoreThread={this.restoreThread}
onRefreshNotifications={this.props.storageApi.refreshNotifications}
isSearching={this.state.isSearching}

View File

@ -94,12 +94,21 @@ function BasePageItem ({children, onChange, ...props}) {
const PageItem = withTooltip(BasePageItem);
function MenuIconItem ({children, onChange, selected, alwaysActive, noBorder, ...props}) {
function MenuIconItem ({
children,
onChange,
selected,
alwaysActive,
noBorder,
count,
...props
}) {
return (
<IconContainer
onClick={() => onChange(props.mode)}
selected={alwaysActive || selected}
noBorder={noBorder}
count={count}
{...props}
>
{children}
@ -303,6 +312,7 @@ function ReadCountGraph ({data, onHover, onExit, dark}) {
export default function Scene ({
notifications,
snoozedNotifications,
notificationsPermission,
currentTime,
highestScore,
@ -339,6 +349,7 @@ export default function Scene ({
reposReadCounts,
readTodayLastWeekCount,
onRestoreThread,
onSnoozeThread,
onLogout,
mode,
setMode,
@ -490,11 +501,23 @@ export default function Scene ({
selected={mode === Mode.ALL}
onChange={setMode}
open={menuOpen}
count={unreadCount}
>
<span>{titleOfMode(Mode.ALL)}</span>
<i className="fas fa-leaf"></i>
</MenuIconItem>
<MenuIconItem
mode={Mode.SNOOZED}
primary="#e91e63"
selected={mode === Mode.SNOOZED}
onChange={setMode}
open={menuOpen}
count={snoozedNotifications.length}
>
<span>{titleOfMode(Mode.SNOOZED)}</span>
<i className="far fa-clock"></i>
</MenuIconItem>
{/* <MenuIconItem
mode={Mode.HOT}
primary="#e91e63"
selected={mode === Mode.HOT}
@ -523,7 +546,7 @@ export default function Scene ({
>
<span>{titleOfMode(Mode.OLD)}</span>
<i className="fas fa-stopwatch"></i>
</MenuIconItem>
</MenuIconItem> */}
</MenuContainerItem>
<ContentItem>
<CardSection>
@ -687,97 +710,129 @@ export default function Scene ({
<h4>{subtitleOfMode(mode)}</h4>
</SubTitleSection>
<PageSelection>
<PageItem
view={View.UNREAD}
selected={view === View.UNREAD}
primary={ThemeColor(darkMode)}
onChange={setView}
mark={hasUnread}
dark={darkMode}
tooltip="View your active unread notifications"
tooltipOffsetY={-72}
>
{'Unread'}
{unreadCount > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${view === View.UNREAD
? ThemeColor(darkMode)
: darkMode ? DarkTheme.Gray : '#bfc5d1'
};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{unreadCount}
</span>
)}
</PageItem>
<PageItem
view={View.READ}
selected={view === View.READ}
primary={ThemeColor(darkMode)}
onChange={setView}
dark={darkMode}
tooltip="View notifications you have already read"
tooltipOffsetY={-72}
>
{'Read'}
{readCount > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${view === View.READ
? ThemeColor(darkMode)
: darkMode ? DarkTheme.Gray : '#bfc5d1'
};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{readCount}
</span>
)}
</PageItem>
<PageItem
view={View.ARCHIVED}
selected={view === View.ARCHIVED}
primary={ThemeColor(darkMode)}
onChange={setView}
dark={darkMode}
tooltip="View notifications that are considered completed"
tooltipOffsetY={-72}
>
{'Archived'}
{archivedCount > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${view === View.ARCHIVED
? ThemeColor(darkMode)
: darkMode ? DarkTheme.Gray : '#bfc5d1'
};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{archivedCount}
</span>
)}
</PageItem>
{mode !== Mode.SNOOZED ? (
<React.Fragment>
<PageItem
view={View.UNREAD}
selected={view === View.UNREAD}
primary={ThemeColor(darkMode)}
onChange={setView}
mark={hasUnread}
dark={darkMode}
tooltip="View your active unread notifications"
tooltipOffsetY={-72}
>
{'Unread'}
{unreadCount > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${view === View.UNREAD
? ThemeColor(darkMode)
: darkMode ? DarkTheme.Gray : '#bfc5d1'
};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{unreadCount}
</span>
)}
</PageItem>
<PageItem
view={View.READ}
selected={view === View.READ}
primary={ThemeColor(darkMode)}
onChange={setView}
dark={darkMode}
tooltip="View notifications you have already read"
tooltipOffsetY={-72}
>
{'Read'}
{readCount > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${view === View.READ
? ThemeColor(darkMode)
: darkMode ? DarkTheme.Gray : '#bfc5d1'
};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{readCount}
</span>
)}
</PageItem>
<PageItem
view={View.ARCHIVED}
selected={view === View.ARCHIVED}
primary={ThemeColor(darkMode)}
onChange={setView}
dark={darkMode}
tooltip="View notifications that are considered completed"
tooltipOffsetY={-72}
>
{'Archived'}
{archivedCount > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${view === View.ARCHIVED
? ThemeColor(darkMode)
: darkMode ? DarkTheme.Gray : '#bfc5d1'
};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{archivedCount}
</span>
)}
</PageItem>
</React.Fragment>
) : (
<PageItem
view={View.UNREAD}
selected={true}
primary={ThemeColor(darkMode)}
onChange={setView}
dark={darkMode}
tooltip="View your snoozed unread notifications"
tooltipOffsetY={-72}
>
{'Snoozed'}
{snoozedNotifications.length > 0 && (
<span css={css`
transition: all 200ms ease;
background: ${ThemeColor(darkMode)};
color: ${WHITE};
transition: background 200ms ease;
font-size: 9px;
margin: 0 6px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
vertical-align: middle;
`}>
{snoozedNotifications.length}
</span>
)}
</PageItem>
)}
<div css={css`
height: auto;
position: absolute;
@ -918,19 +973,46 @@ export default function Scene ({
<span onClick={() => onFetchNotifications()}>{'Try loading again'}</span>
</ErrorContainer>
) : (
<NotificationCollection
dark={darkMode}
isLastPage={isLastPage}
page={page}
view={view}
fact={fact}
notifications={notifications}
colorOfScore={createColorOfScore(lowestScore, highestScore)}
markAsRead={onStageThread}
markAsArchived={onArchiveThread}
markAsUnread={onRestoreThread}
user={user}
/>
<React.Fragment>
{/* {mode !== Mode.SNOOZED && snoozedNotifications.length > 0 && (
<div onClick={() => setMode(Mode.SNOOZED)} css={css`
background: ${darkMode ? '#4b5662' : '#d3d3d3'};
color: ${darkMode ? WHITE : '#2f343e'};
font-size: 12px;
font-weight: 500;
opacity: 0.35;
padding: 4px 18px;
margin: 0 auto 12px;
text-align: center;
border-radius: 20px;
user-select: none;
cursor: pointer;
transition: all 200ms ease;
&:hover {
opacity: .4;
}
&:active {
opacity: .5;
}
`}>
{`${snoozedNotifications.length} snoozed notifications`}
</div>
)} */}
<NotificationCollection
dark={darkMode}
isLastPage={isLastPage}
page={page}
view={view}
fact={fact}
notifications={notifications}
colorOfScore={createColorOfScore(lowestScore, highestScore)}
markAsRead={onStageThread}
markAsArchived={onArchiveThread}
markAsUnread={onRestoreThread}
markAsSnoozed={onSnoozeThread}
user={user}
/>
</React.Fragment>
)}
</NotificationsTable>
</NotificationsSection>
@ -995,6 +1077,7 @@ function NotificationCollection ({
markAsRead,
markAsArchived,
markAsUnread,
markAsSnoozed,
user
}) {
const props = useSpring({
@ -1121,6 +1204,7 @@ function NotificationCollection ({
markAsUnread={markAsUnread}
markAsRead={markAsRead}
markAsArchived={markAsArchived}
markAsSnoozed={markAsSnoozed}
/>
</NotificationCell>
</AnimatedNotificationRow>
@ -1141,7 +1225,7 @@ function NotificationCollection ({
);
}
function ActionItems ({item, view, markAsRead, markAsArchived, markAsUnread}) {
function ActionItems ({item, view, markAsRead, markAsArchived, markAsUnread, markAsSnoozed}) {
switch (view) {
case View.UNREAD:
return (
@ -1153,10 +1237,10 @@ function ActionItems ({item, view, markAsRead, markAsArchived, markAsUnread}) {
<i className="fas fa-check"></i>
</IconLink>
<IconLink
tooltip="Mark as archived"
onClick={() => markAsArchived(item.id, item.repository)}
tooltip="Snooze, save for later"
onClick={() => markAsSnoozed(item.id, item.repository)}
>
<i className="fas fa-times"></i>
<i className="far fa-clock"></i>
</IconLink>
</>
);

View File

@ -204,6 +204,7 @@ export const IconContainer = enhance(styled('div')`
user-select: none;
transition: all 200ms ease;
i {
position: relative;
transition: all 200ms ease;
color: ${props => props.selected ? props.primary : '#bfc5d15e'}
}
@ -222,6 +223,26 @@ export const IconContainer = enhance(styled('div')`
&:hover {
background: ${props => props.selected ? 'rgba(255, 255, 255, 0)' : 'rgba(233, 233, 233, .1)'};
}
${({count, selected}) => count && `
i::after {
content: "${count}";
color: ${WHITE};
bottom: -10px;
right: -14px;
position: absolute;
background: #E91356;
display: flex;
min-width: 12px;
border-radius: 4px;
font-family: 'Inter UI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-weight: 700;
font-size: 10px;
padding: 2px 4px;
align-items: center;
justify-content: center;
}
`}
`);
export const NotificationsSection = enhance(styled('div')`
@ -564,7 +585,7 @@ export const NotificationBlock = enhance(styled('tbody')`
transition: all 200ms ease;
`);
export const ErrorContainer = enhance(styled('div')`
export const ErrorContainer = enhance(styled('div')(p => `
display: flex;
justify-content: center;
align-items: center;
@ -575,16 +596,21 @@ export const ErrorContainer = enhance(styled('div')`
font-weight: 500;
margin-bottom: 0;
text-align: center;
color: ${p.dark ? WHITE : 'inherit'};
}
p {
margin-bottom: 6px;
opacity: 0.5;
color: ${p.dark ? WHITE : 'inherit'};
}
span {
text-decoration: underline;
text-underline-position: under;
cursor: pointer;
opacity: 0.5;
color: ${p.dark ? WHITE : 'inherit'};
}
`);
`));
export const NotificationCell = enhance(styled('td')`
white-space: nowrap;

View File

@ -216,6 +216,8 @@ export function titleOfMode (mode) {
switch (mode) {
case Mode.ALL:
return 'All Relevent Threads';
case Mode.SNOOZED:
return 'Saved for later';
case Mode.HOT:
return 'Hot Threads';
case Mode.COMMENTS:
@ -231,6 +233,8 @@ export function subtitleOfMode (mode) {
switch (mode) {
case Mode.ALL:
return 'All of the notifications that matter to you';
case Mode.SNOOZED:
return 'Notifications that you\'ve saved to look at later';
case Mode.HOT:
return 'Some currently very active threads you care about';
case Mode.COMMENTS:

View File

@ -355,6 +355,24 @@ class NotificationsProvider extends React.Component {
});
}
requestSnoozeThread = thread_id => {
return new Promise((resolve, reject) => {
const cached_n = this.props.getItemFromStorage(thread_id);
if (cached_n) {
const newValue = {
...cached_n,
status_last_changed: moment(),
status: Status.Snoozed
};
this.props.setItemInStorage(thread_id, newValue);
this.props.refreshNotifications();
return resolve();
} else {
throw new Error(`Attempted to snooze thread ${thread_id} that wasn't found in the cache.`);
}
});
}
requestStageAll = () => {
return new Promise((resolve, reject) => {
Object.keys(localStorage).forEach(nKey => {
@ -410,6 +428,14 @@ class NotificationsProvider extends React.Component {
.finally(() => this.setState({ loading: false }));
}
snoozeThread = thread_id => {
this.setState({ loading: true });
return this.requestSnoozeThread(thread_id)
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
}
restoreThread = thread_id => {
this.setState({ loading: true });
return this.requestRestoreThread(thread_id)
@ -470,6 +496,7 @@ class NotificationsProvider extends React.Component {
markAllAsStaged: this.markAllAsStaged,
clearCache: this.clearCache,
stageThread: this.stageThread,
snoozeThread: this.snoozeThread,
restoreThread: this.restoreThread,
setNotificationsPermission: this.setNotificationsPermission,
});

View File

@ -68,6 +68,9 @@ class StorageProvider extends React.Component {
const daysOld = moment().diff(lastUpdated, 'days');
switch (notification.status) {
// Do nothing with snoozed.
case Status.Snoozed:
return true;
case Status.Unread:
// Mark as unread
if (daysOld > TriageLimit.Unread) {
@ -98,7 +101,7 @@ class StorageProvider extends React.Component {
return true;
}
// Fallback, if there's no status.
// Fallback, if there's no valid status.
return false;
});