Support pinning notifications

This commit is contained in:
Nicholas Zuber 2020-02-16 23:33:20 -05:00
parent 00460726a6
commit 9b4faf81cc
7 changed files with 222 additions and 46 deletions

View File

@ -7,4 +7,6 @@ export const Status = {
Unread: 'queued',
Read: 'staged',
Archived: 'closed',
Pinned: 'pinned', // Same idea as "PinnedUnread"
PinnedRead: 'pinned-read'
};

View File

@ -42,6 +42,14 @@ export const Mode = {
OLD: 3
};
function logNotificationPinned (value) {
amplitude.getInstance().logEvent('notification_pinned', {
event_category: 'notification',
event_label: 'Notification pinned',
value
});
}
function logNotificationRead (value) {
amplitude.getInstance().logEvent('notification_read', {
event_category: 'notification',
@ -266,6 +274,18 @@ class NotificationsPage extends React.Component {
}, 800);
}
enhancedOnMarkAsPinned = (thread_id) => {
logNotificationPinned(thread_id);
this.props.notificationsApi.pinThread(thread_id);
}
enhancedOnMarkAsReadPinned = (thread_id, repository) => {
logNotificationRead(thread_id);
this.props.storageApi.incrStat('stagedCount');
this.props.storageApi.incrStat(repository + '-stagedCount', '__REPO__');
this.props.notificationsApi.readPinThread(thread_id);
}
enhancedOnStageThread = (thread_id, repository) => {
logNotificationRead(thread_id);
this.props.storageApi.incrStat('stagedCount');
@ -388,7 +408,11 @@ class NotificationsPage extends React.Component {
.filter(item => item.badges.includes(Badges.OLD));
}
let notificationsQueued = filteredNotifications.filter(n => n.status === Status.QUEUED);
let notificationsQueued = filteredNotifications.filter(n => (
n.status === Status.QUEUED ||
n.status === Status.Pinned ||
n.status === Status.PinnedRead
));
let notificationsStaged = filteredNotifications.filter(n => n.status === Status.STAGED);
let notificationsClosed = filteredNotifications.filter(n => n.status === Status.CLOSED);
@ -477,6 +501,26 @@ class NotificationsPage extends React.Component {
}
}
// Final pinned sort
notificationsToRender.sort((a, b) => {
if (a.status === Status.Pinned && b.status === Status.Pinned) {
return a.score - a.score;
}
if (a.status === Status.Pinned) {
return -1;
}
if (b.status === Status.Pinned) {
return 1;
}
if (a.status === Status.PinnedRead) {
return -1;
}
if (b.status === Status.PinnedRead) {
return 1;
}
return 0;
});
return {
notifications: notificationsToRender,
queuedCount: notificationsQueued.length,
@ -596,6 +640,8 @@ class NotificationsPage extends React.Component {
request={this.props.notificationsApi.request}
getUserItem={this.props.storageApi.getUserItem}
setUserItem={this.props.storageApi.setUserItem}
onPinThread={this.enhancedOnMarkAsPinned}
onReadPinThread={this.enhancedOnMarkAsReadPinned}
/>
);
}

View File

@ -79,6 +79,7 @@ import {
optimized
} from './ui';
import {ToastProvider, useToasts} from 'react-toast-notifications';
import {Status} from '../../../constants/status';
export const AnimatedNotificationRow = animated(NotificationRow);
const hash = process.localEnv.GIT_HASH ? `#${process.localEnv.GIT_HASH}` : '';
@ -542,6 +543,8 @@ function Scene ({
setNotificationsPermission,
onStageThread,
onArchiveThread,
onPinThread,
onReadPinThread,
readStatistics,
readTodayCount,
reposReadCounts,
@ -1190,6 +1193,8 @@ function Scene ({
markAsRead={onStageThreadWithToast}
markAsArchived={onArchiveThreadWithToast}
markAsUnread={onRestoreThread}
markAsPinned={onPinThread}
markAsReadPinned={onReadPinThread}
user={user}
/>
)}
@ -1256,6 +1261,8 @@ function NotificationCollection ({
markAsRead,
markAsArchived,
markAsUnread,
markAsPinned,
markAsReadPinned,
user
}) {
const props = useSpring({
@ -1292,29 +1299,34 @@ function NotificationCollection ({
return (
<animated.tbody style={props} page={page}>
{notifications.map((item, xid) => {
const pinned = item.status === Status.Pinned || item.status === Status.PinnedRead;
const name = item.name;
const {title, tags} = extractJiraTags(name);
return (
<div css={css`position: relative;`}>
<AnimatedNotificationRow key={notifications.id || xid}>
<AnimatedNotificationRow
readPinned={item.status === Status.PinnedRead}
key={notifications.id || xid}
>
{/* Type */}
<NotificationCell width={60} css={css`@media (max-width: ${WIDTH_FOR_SMALL_SCREENS}) { flex: 50px 0 0; }`}>
{getPRIssueIcon(item.type, item.reasons, dark)}
{getPRIssueIcon({type: item.type, reasons: item.reasons, dark, pinned})}
</NotificationCell>
{/* Title */}
<NotificationCell
flex={4}
onClick={() => {
window.open(item.url);
markAsRead(item.id, item.repository);
if (item.status === Status.Pinned || item.status === Status.PinnedRead) {
markAsReadPinned(item.id, item.repository);
} else {
markAsRead(item.id, item.repository);
}
}}
css={css`
font-weight: 500;
`}>
css={css`font-weight: 500;`}>
<NotificationTitle css={css`
display: flex;
align-items: center;
display: block;
transition: all 200ms ease;
i {
font-size: 10px;
@ -1331,6 +1343,7 @@ function NotificationCollection ({
</NotificationTitle>
{/* Byline */}
<NotificationByline>
{item.isAuthor && <i className="fas fa-user-circle"></i>}
{getMessageFromReasons(item.reasons, item.type)}
{` ${getRelativeTime(item.updated_at).toLowerCase()}`}
</NotificationByline>
@ -1379,6 +1392,8 @@ function NotificationCollection ({
<ActionItems
item={item}
view={view}
markAsPinned={markAsPinned}
markAsReadPinned={markAsReadPinned}
markAsUnread={markAsUnread}
markAsRead={markAsRead}
markAsArchived={markAsArchived}
@ -1402,26 +1417,53 @@ function NotificationCollection ({
);
}
function ActionItems ({item, view, markAsRead, markAsArchived, markAsUnread}) {
function ActionItems ({
item,
view,
markAsRead,
markAsArchived,
markAsUnread,
markAsPinned,
markAsReadPinned
}) {
switch (view) {
case View.UNREAD:
return (
<>
<IconLink
tooltip="Mark as read"
onClick={() => markAsRead(item.id, item.repository)}
>
<i className="fas fa-check"></i>
</IconLink>
<IconLink
tooltip="Mark as archived"
onClick={() => markAsArchived(item.id, item.repository)}
>
{/* <i className="fas fa-thumbtack"></i> */}
<i className="fas fa-times"></i>
</IconLink>
</>
);
if (item.status === Status.Pinned || item.status === Status.PinnedRead) {
return (
<>
<IconLink
tooltip="Mark as read"
onClick={() => markAsReadPinned(item.id, item.repository)}
>
<i className="fas fa-check"></i>
</IconLink>
<IconLink
tooltip="Unpin notification"
onClick={() => markAsUnread(item.id)}
>
<i className="fas fa-map-pin"></i>
</IconLink>
</>
);
} else {
return (
<>
<IconLink
tooltip="Mark as read"
onClick={() => markAsRead(item.id, item.repository)}
>
<i className="fas fa-check"></i>
</IconLink>
<IconLink
tooltip="Pin to the top of your queue"
css={css`i { transform: rotate(45deg); }`}
onClick={() => markAsPinned(item.id)}
>
<i className="fas fa-map-pin"></i>
</IconLink>
</>
);
}
case View.READ:
return (
<>

View File

@ -509,9 +509,8 @@ export const NotificationRowHeader = enhance(styled('tr')`
box-sizing: border-box;
`);
export const NotificationRow = enhance(styled(NotificationRowHeader)`
export const NotificationRow = enhance(styled(NotificationRowHeader)(p => `
position: relative;
background: none;
z-index: 1;
border-radius: 4px;
padding: 6px 18px;
@ -520,7 +519,9 @@ export const NotificationRow = enhance(styled(NotificationRowHeader)`
border-radius: 6px;
cursor: pointer;
user-select: none;
opacity: ${p.readPinned ? 0.5 : 1};
transition: all 200ms ease;
background: none;
&:hover {
background: #757d8410;
};
@ -530,7 +531,7 @@ export const NotificationRow = enhance(styled(NotificationRowHeader)`
@media (max-width: ${WIDTH_FOR_SMALL_SCREENS}) {
padding: 6px 2px;
}
`);
`));
export const LoadingNotificationRow = enhance(styled(NotificationRowHeader)`
position: relative;
@ -613,7 +614,7 @@ export const NotificationTitle = enhance(styled('span')(p => `
`));
export const NotificationByline = enhance(styled('span')`
display: flex;
align-items: end;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: #8893a7cc;
@ -621,8 +622,8 @@ export const NotificationByline = enhance(styled('span')`
white-space: nowrap;
overflow: hidden;
i {
margin-right: 4px;
font-size: 10px;
margin-right: 6px;
font-size: 12px;
color: #8893a7cc;
}
span {
@ -809,6 +810,7 @@ export const NotificationIconWrapper = enhance(styled('div')`
align-items: center;
border-radius: 100%;
transform: scale(.65);
transition: all 100ms ease;
`);
export const IconLink = enhance(styled('span')(p => `
@ -922,6 +924,7 @@ export const LinkText = enhance(styled('div')(p => `
export const JiraTag = enhance(styled('span')(p => `
background: ${p.color || '#e2e2e2'}28;
color: ${p.color || '#e2e2e2'};
vertical-align: bottom;
font-size: 10px;
font-weight: 600;
border-radius: 4px;

View File

@ -97,7 +97,19 @@ export function stringOfError (errorText) {
}
}
export function getPRIssueIcon (type, _reasons, dark) {
const PinnedColor = '#fab005';
export function getPRIssueIcon ({type, reasons, dark, pinned}) {
if (pinned) {
return (
<NotificationIconWrapper css={css`background: ${PinnedColor}29;`}>
<i className="fas fa-star" css={css`
color: ${PinnedColor};
font-size: 18px;
`}></i>
</NotificationIconWrapper>
);
}
switch (type) {
case 'PullRequest':
return (

View File

@ -223,7 +223,7 @@ class NotificationsProvider extends React.Component {
}
if (this.state.loading) {
// Don't try to fetch if we're already fetching
// Don't try to fetch if we're already fetching.
return;
}
@ -251,7 +251,7 @@ class NotificationsProvider extends React.Component {
if (cached_n) {
// Something's changed, we want to push
if (cached_n.updated_at !== n.updated_at) {
return this.updateNotification(n, cached_n.reasons);;
return this.updateNotification(n, cached_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.
@ -355,6 +355,42 @@ class NotificationsProvider extends React.Component {
});
}
requestPinThread = 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.Pinned
};
this.props.setItemInStorage(thread_id, newValue);
this.props.refreshNotifications();
return resolve();
} else {
throw new Error(`Attempted to stage thread ${thread_id} that wasn't found in the cache.`);
}
});
}
requestReadPinThread = 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.PinnedRead
};
this.props.setItemInStorage(thread_id, newValue);
this.props.refreshNotifications();
return resolve();
} else {
throw new Error(`Attempted to stage thread ${thread_id} that wasn't found in the cache.`);
}
});
}
requestStageAll = () => {
return new Promise((resolve, reject) => {
Object.keys(localStorage).forEach(nKey => {
@ -403,22 +439,35 @@ class NotificationsProvider extends React.Component {
}
stageThread = thread_id => {
this.setState({ loading: true });
return this.requestStageThread(thread_id)
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
.catch(error => this.setState({error}));
}
pinThread = thread_id => {
return this.requestPinThread(thread_id)
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}));
}
readPinThread = thread_id => {
return this.requestReadPinThread(thread_id)
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}));
}
restoreThread = thread_id => {
this.setState({ loading: true });
return this.requestRestoreThread(thread_id)
.then(() => this.setState({error: null}))
.catch(error => this.setState({error}))
.finally(() => this.setState({ loading: false }));
.catch(error => this.setState({error}));
}
updateNotification = (n, prevReason = null) => {
updateNotification = (n, cachedNotification = null) => {
const prevReason = cachedNotification ? cachedNotification.reasons : null;
const isPinned = cachedNotification ? (
cachedNotification.status === Status.Pinned ||
cachedNotification.status === Status.PinnedRead
) : false;
let reasons = [];
const newReason = {
reason: n.reason,
@ -439,13 +488,30 @@ class NotificationsProvider extends React.Component {
? transformUrlFromResponse(n.subject.url) + '#issuecomment-' + commentNumber
: transformUrlFromResponse(n.subject.url);
// Notification model
let nextStatus = null;
if (n.unread) {
// If the notification is unread.
if (isPinned) {
nextStatus = Status.Pinned;
} else {
nextStatus = Status.Unread;
}
} else {
// If the notification is read.
if (isPinned) {
nextStatus = Status.PinnedRead;
} else {
nextStatus = Status.Read;
}
}
// Notification model.
const value = {
id: n.id,
pullRequestURL: n.subject.url,
isAuthor: reasons.some(r => r.reason === 'author'),
updated_at: n.updated_at,
status: n.unread ? Status.QUEUED : Status.STAGED,
status: nextStatus,
reasons: reasons,
type: n.subject.type,
name: n.subject.title,
@ -471,6 +537,8 @@ class NotificationsProvider extends React.Component {
clearCache: this.clearCache,
stageThread: this.stageThread,
restoreThread: this.restoreThread,
pinThread: this.pinThread,
readPinThread: this.readPinThread,
setNotificationsPermission: this.setNotificationsPermission,
});
}

View File

@ -96,9 +96,12 @@ class StorageProvider extends React.Component {
this.deleteItem(notification.id);
}
return true;
case Status.Pinned:
case Status.PinnedRead:
return true;
}
// Fallback, if there's no status.
// Fallback, if there's no valid status.
return false;
});