diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index 95311a9..57672fa 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -16,7 +16,9 @@ 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 help from './svg/help.svg'; import inbox from './svg/inbox.svg'; +import info from './svg/info.svg'; import inboxWhite from './svg/inbox-white.svg'; import locked from './svg/locked.svg'; import lowPriority from './svg/low_priority.svg'; @@ -24,6 +26,8 @@ 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 peopleAlt from './svg/people-alt.svg'; +import peopleAltWhite from './svg/people-alt-white.svg'; import prev from './svg/prev.svg'; import refresh from './svg/refresh.svg'; import search from './svg/search.svg'; @@ -31,7 +35,10 @@ import settings from './svg/settings.svg'; import starAlt from './svg/star-alt.svg'; import star from './svg/star.svg'; import trash from './svg/trash.svg'; +import timer from './svg/timer.svg'; import unlocked from './svg/unlocked.svg'; +import undo from './svg/undo.svg'; +import user from './svg/user.svg'; import x from './svg/x.svg'; import issue_closed from './svg/github/issue-closed.svg'; @@ -76,7 +83,9 @@ Icon.Convo = createIcon(convo); Icon.DoneAll = createIcon(doneAll); Icon.Done = createIcon(done); Icon.Hot = createIcon(hot); +Icon.Help = createIcon(help); Icon.Inbox = createIcon(inbox); +Icon.Info = createIcon(info); Icon.InboxWhite = createIcon(inboxWhite); Icon.Locked = createIcon(locked); Icon.LowPriority = createIcon(lowPriority); @@ -84,6 +93,8 @@ Icon.Menu = createIcon(menu); Icon.Next = createIcon(next); Icon.People = createIcon(people); Icon.PeopleWhite = createIcon(peopleWhite); +Icon.PeopleAlt = createIcon(peopleAlt); +Icon.PeopleAltWhite = createIcon(peopleAltWhite); Icon.Prev = createIcon(prev); Icon.Refresh = createIcon(refresh); Icon.Search = createIcon(search); @@ -91,7 +102,10 @@ Icon.Settings = createIcon(settings); Icon.StarAlt = createIcon(starAlt); Icon.Star = createIcon(star); Icon.Trash = createIcon(trash); +Icon.Timer = createIcon(timer); Icon.Unlocked = createIcon(unlocked); +Icon.Undo = createIcon(undo); +Icon.User = createIcon(user); Icon.X = createIcon(x); Icon.IssueClosed = createIcon(issue_closed); diff --git a/src/components/Icon/svg/bolt.svg b/src/components/Icon/svg/bolt.svg index 2aac7c3..ed352d5 100644 --- a/src/components/Icon/svg/bolt.svg +++ b/src/components/Icon/svg/bolt.svg @@ -1,4 +1,4 @@ - + diff --git a/src/components/Icon/svg/help.svg b/src/components/Icon/svg/help.svg new file mode 100644 index 0000000..6320581 --- /dev/null +++ b/src/components/Icon/svg/help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/svg/info.svg b/src/components/Icon/svg/info.svg new file mode 100644 index 0000000..f8aba4c --- /dev/null +++ b/src/components/Icon/svg/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/svg/people-alt-white.svg b/src/components/Icon/svg/people-alt-white.svg new file mode 100644 index 0000000..b075526 --- /dev/null +++ b/src/components/Icon/svg/people-alt-white.svg @@ -0,0 +1 @@ + diff --git a/src/components/Icon/svg/people-alt.svg b/src/components/Icon/svg/people-alt.svg new file mode 100644 index 0000000..074db6b --- /dev/null +++ b/src/components/Icon/svg/people-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/svg/timer.svg b/src/components/Icon/svg/timer.svg new file mode 100644 index 0000000..4c11051 --- /dev/null +++ b/src/components/Icon/svg/timer.svg @@ -0,0 +1 @@ + diff --git a/src/components/Icon/svg/undo.svg b/src/components/Icon/svg/undo.svg new file mode 100644 index 0000000..fbc9edf --- /dev/null +++ b/src/components/Icon/svg/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/svg/user.svg b/src/components/Icon/svg/user.svg new file mode 100644 index 0000000..1da0081 --- /dev/null +++ b/src/components/Icon/svg/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/LoadingIcon/index.js b/src/components/LoadingIcon/index.js index 35a0308..b26c5c8 100644 --- a/src/components/LoadingIcon/index.js +++ b/src/components/LoadingIcon/index.js @@ -1,11 +1,19 @@ import React from 'react'; import loader from './loader.svg'; import loaderAlt from './loader-alt.svg'; +import loaderWhite from './loader-white.svg'; + +export default function LoadingIcon ({ style, size, alt, white, ...props }) { + let url = loader; + if (white) { + url = loaderWhite; + } else if (alt) { + url = loaderAlt; + } -export default function LoadingIcon ({ style, size, alt, ...props }) { return (
+ + + + + + + + + + diff --git a/src/enhance/index.js b/src/enhance/index.js index 92ad373..88181cf 100644 --- a/src/enhance/index.js +++ b/src/enhance/index.js @@ -10,3 +10,74 @@ export const withOnEnter = WrappedComponent => ({onEnter, ...props}) => ( }} /> ); + +class Tooltip extends React.Component { + constructor (props) { + super(props); + + this.id = ('tooltip-id-' + Math.random()).replace(/\./g, ''); + } + + static defaultProps = { + tooltipOffsetX: 0, + tooltipOffsetY: 0 + } + + getTooltipElement = () => document.querySelector(`#${this.id}`); + + onMouseEnter = event => { + if (this.getTooltipElement()) { + return; + } + + const {tooltipOffsetX, tooltipOffsetY} = this.props; + const {x, y, height} = event.target.getBoundingClientRect(); + const text = document.createTextNode(this.props.message); + const tooltipElement = document.createElement('div'); + + tooltipElement.setAttribute('id', this.id); + tooltipElement.setAttribute('class', 'react-tooltip'); + tooltipElement.setAttribute( + 'style', + `top: ${y + tooltipOffsetY}px; left: ${x + tooltipOffsetX}px;` + ); + tooltipElement.appendChild(text); + + document.querySelector('body').appendChild(tooltipElement); + + this.timeout = setTimeout(() => { + tooltipElement.setAttribute( + 'style', + `top: ${y + tooltipOffsetY}px; left: ${x + tooltipOffsetX}px; opacity: .83;` + ); + }, 500); + } + + onMouseLeave = () => { + clearTimeout(this.timeout); + const tooltipElement = this.getTooltipElement(); + if (tooltipElement) { + tooltipElement.parentNode.removeChild(tooltipElement); + } + } + + render () { + return this.props.children({ + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave, + }); + } +} + +export const withTooltip = WrappedComponent => ({tooltip, tooltipOffsetX, tooltipOffsetY, ...props}) => ( + + {mouseEvents => tooltip ? ( + + ) : ( + + )} + +); diff --git a/src/pages/Notifications/SceneAlt.js b/src/pages/Notifications/SceneAlt.js index 9456b17..5bced77 100644 --- a/src/pages/Notifications/SceneAlt.js +++ b/src/pages/Notifications/SceneAlt.js @@ -8,7 +8,7 @@ import Logo from '../../components/Logo'; import LoadingIcon from '../../components/LoadingIcon'; import {routes} from '../../constants'; import {Filters} from '../../constants/filters'; -import {withOnEnter} from '../../enhance'; +import {withOnEnter, withTooltip} from '../../enhance'; import {Status} from '../../constants/status'; import {Badges} from '../../constants/reasons'; import '../../styles/gradient.css'; @@ -405,6 +405,14 @@ const SmallLink = styled('a')({ } }); +const EnhancedTab = withTooltip(Tab); +const EnhancedNavTab = withTooltip(NavTab); +const EnhancedNotificationTab = withTooltip(NotificationTab); +const EnhancedSidebarLink = withTooltip(SidebarLink); +const EnhancedIconHot = withTooltip(Icon.Hot); +const EnhancedIconTimer = withTooltip(Icon.Timer); +const EnhancedIconConvo = withTooltip(Icon.Convo); + function getPRIssueIcon (type, reasons) { const grow = 1.0; switch (type) { @@ -443,6 +451,7 @@ export default function Scene ({ onFetchNotifications, onRefreshNotifications, onStageThread, + onRestoreThread, isSearching, isFetchingNotifications, onClearCache, @@ -450,7 +459,7 @@ export default function Scene ({ activeFilter, onSetActiveFilter, }) { - const isLoading = isSearching || isFetchingNotifications; + const loading = isSearching || isFetchingNotifications; const isFirstPage = page === 1; const isLastPage = page === lastPage; @@ -479,12 +488,12 @@ export default function Scene ({ - {isSearching && } + {isSearching && }
You've triaged {stagedTodayCount} notifications today
+ {/* + We shouldn't show all the notificaitons. Pointless and creates more noise. + - onSetActiveFilter(Filters.PARTICIPATING)}> {activeFilter === Filters.PARTICIPATING ? ( - + ) : ( - + )} - {/* participating */} - your triage - - + onSetActiveFilter(Filters.COMMENT)}> {activeFilter === Filters.COMMENT ? ( - + ) : ( - + )} - commented - + participating +
- Report bugs - Submit feedback - See source code + Report bugs + Submit feedback + See source code
@@ -603,23 +619,23 @@ export default function Scene ({ flex: 1 }}> - + onFetchNotifications()) : undefined} + onClick={!loading ? (() => onFetchNotifications()) : undefined} /> - - + + { + onClick={!loading ? (() => { const response = window.confirm('Are you sure you want to clear the cache?'); if (response) { onClearCache(); } }) : undefined} /> - + {query ? (
@@ -636,12 +652,12 @@ export default function Scene ({ Showing results for '{query}'
- + onClearQuery()) : undefined} + onClick={!loading ? (() => onClearQuery()) : undefined} /> - +
) : null}
@@ -659,45 +675,51 @@ export default function Scene ({ {first}-{last} of about {allNotificationsCount}
- + onChangePage(page - 1)) : undefined} + onClick={!loading && !isFirstPage ? (() => onChangePage(page - 1)) : undefined} /> - - + + onChangePage(page + 1)) : undefined} + onClick={!loading && !isLastPage ? (() => onChangePage(page + 1)) : undefined} /> - +
- onSetActiveStatus(Status.QUEUED)} href="javascript:void(0);"> - Queued - - + onSetActiveStatus(Status.STAGED)} href="javascript:void(0);"> - Staged - - + onSetActiveStatus(Status.CLOSED)} href="javascript:void(0);"> - Closed - + Resolved + @@ -738,12 +760,23 @@ export default function Scene ({ flex={.65} onClick={() => { window.open(n.url); - onStageThread(n.id) + onStageThread(n.id, n.repository) }}> {n.name} - {getRelativeTime(n.updated_at)} + + {getRelativeTime(n.updated_at)} + {n.isAuthor && ( + + )} + @@ -751,13 +784,34 @@ export default function Scene ({ switch (badge) { case Badges.HOT: // lots of `reasons` within short time frame - return + return ( + + ); case Badges.OLD: // old - return + return ( + + ); case Badges.COMMENTS: // lots of `reasons` - return + return ( + + ); default: return null; } @@ -771,21 +825,40 @@ export default function Scene ({ {n.repository} - + {n.score} - - - onStageThread(n.id, n.repository)) : undefined} - /> - - - onMarkAsRead(n.id)) : undefined} - /> - + + {activeStatus === Status.QUEUED ? ( + + onStageThread(n.id, n.repository)) : undefined} + /> + + ) : ( + + onRestoreThread(n.id)) : undefined} + /> + + )} + {activeStatus === Status.CLOSED ? ( + + {}) : undefined} + /> + + ) : ( + + onMarkAsRead(n.id)) : undefined} + /> + + )} ))} diff --git a/src/pages/Notifications/index.js b/src/pages/Notifications/index.js index 7163f19..c765480 100644 --- a/src/pages/Notifications/index.js +++ b/src/pages/Notifications/index.js @@ -69,10 +69,12 @@ function scoreOf (notification) { 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. + // 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) { + 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) { @@ -87,9 +89,8 @@ function badgesOf (notification) { } // 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. - // @TODO i changed this to 1 for testing, that's def too early. if (notification.reasons.some(r => r.reason === Reasons.REVIEW_REQUESTED) && - moment().diff(moment(notification.reasons[notification.reasons.length - 1].time).hours, 'hours') > 1) { + timeSinceLastUpdate > 60 * 4) { badges.push(Badges.OLD); } return badges; @@ -177,6 +178,11 @@ class NotificationsPage extends React.Component { this.props.notificationsApi.stageThread(thread_id); } + restoreThread = thread_id => { + console.warn('restoring thread'); + this.props.notificationsApi.restoreThread(thread_id); + } + render () { if (!this.props.authApi.token) { return @@ -286,6 +292,7 @@ class NotificationsPage extends React.Component { onMarkAsRead={markAsRead} onClearCache={clearCache} onStageThread={this.enhancedOnStageThread} + onRestoreThread={this.restoreThread} onRefreshNotifications={this.props.storageApi.refreshNotifications} isSearching={this.state.isSearching} isFetchingNotifications={isFetchingNotifications} diff --git a/src/providers/Notifications.js b/src/providers/Notifications.js index 7b01bad..3bdcf06 100644 --- a/src/providers/Notifications.js +++ b/src/providers/Notifications.js @@ -171,35 +171,6 @@ class NotificationsProvider extends React.Component { }); } - updateNotification = (n, prevReason = null) => { - let reasons = []; - const newReason = { - reason: n.reason, - time: n.updated_at - } - - if (prevReason) { - reasons = prevReason.concat(newReason); - console.warn('MULTIPLE REASONS', reasons) - } else { - reasons = [newReason]; - } - - const value = { - id: n.id, - updated_at: n.updated_at, - status: Status.QUEUED, - reasons: reasons, - type: n.subject.type, - name: n.subject.title, - 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); - } - requestMarkAsRead = thread_id => { const headers = { 'Authorization': `token ${this.props.token}`, @@ -270,6 +241,24 @@ class NotificationsProvider extends React.Component { }); } + requestRestoreThread = thread_id => { + return new Promise((resolve, reject) => { + console.warn('restoring thread', thread_id); + const cached_n = this.props.getItemFromStorage(thread_id); + if (cached_n) { + const newValue = { + ...cached_n, + status: Status.QUEUED + }; + this.props.setItemInStorage(thread_id, newValue); + this.props.refreshNotifications(); + return resolve(); + } else { + throw new Error(`Attempted to restore thread ${thread_id} that wasn't found in the cache.`); + } + }); + } + stageThread = thread_id => { this.setState({ loading: true }); return this.requestStageThread(thread_id) @@ -277,6 +266,44 @@ class NotificationsProvider extends React.Component { .finally(() => this.setState({ loading: false })); } + restoreThread = thread_id => { + this.setState({ loading: true }); + return this.requestRestoreThread(thread_id) + .catch(error => this.setState({ error })) + .finally(() => this.setState({ loading: false })); + } + + updateNotification = (n, prevReason = null) => { + let reasons = []; + const newReason = { + reason: n.reason, + time: n.updated_at + } + + if (prevReason) { + reasons = prevReason.concat(newReason); + console.warn('MULTIPLE REASONS', reasons) + } else { + reasons = [newReason]; + } + + // Notification model + const value = { + id: n.id, + isAuthor: reasons.some(r => r.reason === 'author'), + updated_at: n.updated_at, + status: Status.QUEUED, + reasons: reasons, + type: n.subject.type, + name: n.subject.title, + 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); + } + render () { return this.props.children({ ...this.state, @@ -285,7 +312,8 @@ class NotificationsProvider extends React.Component { fetchNotificationsSync: this.requestFetchNotifications, markAsRead: this.markAsRead, clearCache: this.clearCache, - stageThread: this.stageThread + stageThread: this.stageThread, + restoreThread: this.restoreThread, }); } } diff --git a/src/providers/Storage.js b/src/providers/Storage.js index aae107a..937e977 100644 --- a/src/providers/Storage.js +++ b/src/providers/Storage.js @@ -73,16 +73,17 @@ class StorageProvider extends React.Component { return acc; }, []); + // @TODO fix this // Document is out of focus, the we had notifications before this update, // and there was a change in notifications in the most recent update. - if (!document.hasFocus() && - this.state.notifications.length > 0 && - notifications.length !== this.state.notifications.length - ) { - this.setTitle('(1) ' + this.originalTitle); - } else { - this.setTitle(this.originalTitle); - } + // if (!document.hasFocus() && + // this.state.notifications.length > 0 && + // notifications.length !== this.state.notifications.length + // ) { + // this.setTitle('(1) ' + this.originalTitle); + // } else { + // this.setTitle(this.originalTitle); + // } this.setState({ notifications }); // this.setState({ notifications: mockNotifications }); diff --git a/src/styles/index.css b/src/styles/index.css index 9b68900..0740da5 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -77,3 +77,19 @@ p { -webkit-font-feature-settings: "calt" 1, "kern" 1, "liga" 1; font-feature-settings: "calt" 1, "kern" 1, "liga" 1; } + +.react-tooltip { + z-index: 999999; + pointer-events: none; + position: absolute; + background: #242a31; + color: #fff; + padding: 4px 8px; + font-weight: 600; + font-size: 11px; + border-radius: 4px; + white-space: nowrap; + transform: translateX(-35%) translateY(-15px); + opacity: 0; + transition: all 100ms ease-in; +}