mirror of
https://github.com/nickzuber/meteorite.git
synced 2024-10-05 15:47:33 +03:00
Support pinning notifications
This commit is contained in:
parent
00460726a6
commit
9b4faf81cc
@ -7,4 +7,6 @@ export const Status = {
|
||||
Unread: 'queued',
|
||||
Read: 'staged',
|
||||
Archived: 'closed',
|
||||
Pinned: 'pinned', // Same idea as "PinnedUnread"
|
||||
PinnedRead: 'pinned-read'
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user