mirror of
https://github.com/nickzuber/meteorite.git
synced 2024-10-05 15:47:33 +03:00
Most of filtering and suggestion work
This commit is contained in:
parent
c72f85a4c9
commit
332e1f1e35
@ -1,19 +1,19 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import amplitude from 'amplitude-js';
|
||||
import { Redirect } from "@reach/router";
|
||||
import { compose } from 'recompose';
|
||||
import { withNotificationsProvider } from '../../providers/Notifications';
|
||||
import { withAuthProvider } from '../../providers/Auth';
|
||||
import { withCookiesProvider } from '../../providers/Cookies';
|
||||
import { withStorageProvider } from '../../providers/Storage';
|
||||
import { OAUTH_TOKEN_COOKIE } from '../../constants/cookies';
|
||||
import { routes } from '../../constants';
|
||||
import { Filters } from '../../constants/filters';
|
||||
import { Status } from '../../constants/status';
|
||||
import { Reasons, Badges } from '../../constants/reasons';
|
||||
import {Redirect} from '@reach/router';
|
||||
import {compose} from 'recompose';
|
||||
import {withNotificationsProvider} from '../../providers/Notifications';
|
||||
import {withAuthProvider} from '../../providers/Auth';
|
||||
import {withCookiesProvider} from '../../providers/Cookies';
|
||||
import {withStorageProvider} from '../../providers/Storage';
|
||||
import {OAUTH_TOKEN_COOKIE} from '../../constants/cookies';
|
||||
import {routes} from '../../constants';
|
||||
import {Filters} from '../../constants/filters';
|
||||
import {Status} from '../../constants/status';
|
||||
import {Reasons, Badges} from '../../constants/reasons';
|
||||
import Scene from './Scene';
|
||||
import { getMessageFromReasons } from './redesign/utils';
|
||||
import {getMessageFromReasons} from './redesign/utils';
|
||||
import issueIcon from '../../images/issue-bg.png';
|
||||
import prIcon from '../../images/pr-bg.png';
|
||||
import tabIcon from '../../images/iconCircle.png';
|
||||
@ -42,7 +42,7 @@ export const Mode = {
|
||||
OLD: 3
|
||||
};
|
||||
|
||||
function logNotificationPinned (value) {
|
||||
function logNotificationPinned(value) {
|
||||
amplitude.getInstance().logEvent('notification_pinned', {
|
||||
event_category: 'notification',
|
||||
event_label: 'Notification pinned',
|
||||
@ -50,7 +50,7 @@ function logNotificationPinned (value) {
|
||||
});
|
||||
}
|
||||
|
||||
function logNotificationRead (value) {
|
||||
function logNotificationRead(value) {
|
||||
amplitude.getInstance().logEvent('notification_read', {
|
||||
event_category: 'notification',
|
||||
event_label: 'Notification read',
|
||||
@ -58,7 +58,7 @@ function logNotificationRead (value) {
|
||||
});
|
||||
}
|
||||
|
||||
function logNotificationArchived (value) {
|
||||
function logNotificationArchived(value) {
|
||||
amplitude.getInstance().logEvent('notification_archived', {
|
||||
event_category: 'notification',
|
||||
event_label: 'Notification archived',
|
||||
@ -98,7 +98,7 @@ function logNotificationArchived (value) {
|
||||
* @param {Object} notification Some notification to score.
|
||||
* @return {number} The score.
|
||||
*/
|
||||
function scoreOf (notification) {
|
||||
function scoreOf(notification) {
|
||||
const {reasons} = notification;
|
||||
let score = 0;
|
||||
let prevReason = null;
|
||||
@ -113,12 +113,15 @@ function scoreOf (notification) {
|
||||
prevReason = reason;
|
||||
}
|
||||
return score;
|
||||
};
|
||||
}
|
||||
|
||||
function badgesOf (notification) {
|
||||
function badgesOf(notification) {
|
||||
const badges = [];
|
||||
const len = notification.reasons.length;
|
||||
const timeSinceLastUpdate = moment().diff(moment(notification.reasons[len - 1].time), 'minutes');
|
||||
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. The last update should be within the past 30 minutes.
|
||||
@ -138,12 +141,14 @@ function badgesOf (notification) {
|
||||
}
|
||||
// If you've been tagged in for review and the most recent update happened over
|
||||
// 3 days ago – but that specific time is subject to change here if we want.
|
||||
if (notification.reasons.some(r => r.reason === Reasons.REVIEW_REQUESTED) &&
|
||||
timeSinceLastUpdate > 60 * 24 * 3) {
|
||||
if (
|
||||
notification.reasons.some(r => r.reason === Reasons.REVIEW_REQUESTED) &&
|
||||
timeSinceLastUpdate > 60 * 24 * 3
|
||||
) {
|
||||
badges.push(Badges.OLD);
|
||||
}
|
||||
return badges;
|
||||
};
|
||||
}
|
||||
|
||||
const scoreOfReason = {
|
||||
[Reasons.ASSIGN]: 21,
|
||||
@ -154,7 +159,7 @@ const scoreOfReason = {
|
||||
[Reasons.REVIEW_REQUESTED]: 29,
|
||||
[Reasons.SUBSCRIBED]: 3,
|
||||
[Reasons.COMMENT]: 6,
|
||||
[Reasons.STATE_CHANGE]: 5,
|
||||
[Reasons.STATE_CHANGE]: 5
|
||||
};
|
||||
|
||||
const decorateWithScore = notification => ({
|
||||
@ -164,7 +169,7 @@ const decorateWithScore = notification => ({
|
||||
});
|
||||
|
||||
class NotificationsPage extends React.Component {
|
||||
constructor (props) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.notificationSent = false;
|
||||
@ -185,9 +190,9 @@ class NotificationsPage extends React.Component {
|
||||
sort: Sort.SCORE,
|
||||
descending: false,
|
||||
user: null
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
componentDidMount() {
|
||||
const isFirstTimeUser = !this.props.storageApi.getUserItem('hasOnboarded');
|
||||
|
||||
if (isFirstTimeUser) {
|
||||
@ -201,7 +206,9 @@ class NotificationsPage extends React.Component {
|
||||
amplitude.getInstance().setUserProperties({
|
||||
username: user.login,
|
||||
full_name: user.name,
|
||||
version: process.localEnv.GIT_HASH ? process.localEnv.GIT_HASH : 'unknown'
|
||||
version: process.localEnv.GIT_HASH
|
||||
? process.localEnv.GIT_HASH
|
||||
: 'unknown'
|
||||
});
|
||||
this.setState({user});
|
||||
});
|
||||
@ -213,15 +220,19 @@ class NotificationsPage extends React.Component {
|
||||
}, 2 * 1000);
|
||||
|
||||
this.syncer = setInterval(() => {
|
||||
this.props.notificationsApi.fetchNotificationsSync()
|
||||
this.props.notificationsApi
|
||||
.fetchNotificationsSync()
|
||||
.then(() => this.setState({error: null}))
|
||||
.catch(error => this.setState({error}));
|
||||
this.setState({currentTime: moment()});
|
||||
}, 8 * 1000);
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
if (this.props.notificationsApi.newChanges !== nextProps.notificationsApi.newChanges) {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
if (
|
||||
this.props.notificationsApi.newChanges !==
|
||||
nextProps.notificationsApi.newChanges
|
||||
) {
|
||||
this.notificationSent = false;
|
||||
}
|
||||
// The idea here is if we've just updated the prevNotifications state, then
|
||||
@ -229,88 +240,88 @@ class NotificationsPage extends React.Component {
|
||||
return nextState.prevNotifications === this.state.prevNotifications;
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.syncer);
|
||||
clearInterval(this.tabSyncer);
|
||||
}
|
||||
|
||||
onChangePage = page => {
|
||||
this.setState({ currentPage: page });
|
||||
}
|
||||
this.setState({currentPage: page});
|
||||
};
|
||||
|
||||
onSetActiveFilter = filter => {
|
||||
this.setState({ activeFilter: filter, currentPage: 1 });
|
||||
}
|
||||
this.setState({activeFilter: filter, currentPage: 1});
|
||||
};
|
||||
|
||||
onSetActiveStatus = status => {
|
||||
this.setState({ activeStatus: status, currentPage: 1 });
|
||||
}
|
||||
this.setState({activeStatus: status, currentPage: 1});
|
||||
};
|
||||
|
||||
onClearQuery = () => {
|
||||
this.setState({ query: null });
|
||||
}
|
||||
this.setState({query: null});
|
||||
};
|
||||
|
||||
onLogout = () => {
|
||||
// Remove cookie and invalidate token on client.
|
||||
this.props.cookiesApi.removeCookie(OAUTH_TOKEN_COOKIE);
|
||||
this.props.authApi.invalidateToken();
|
||||
}
|
||||
|
||||
onSearch = event => {
|
||||
const text = event.target.value;
|
||||
};
|
||||
|
||||
onSearch = text => {
|
||||
// Ignore empty queries.
|
||||
if (text.length <= 0) {
|
||||
this.onClearQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isSearching: true });
|
||||
this.setState({isSearching: true});
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
query: text,
|
||||
isSearching: false
|
||||
});
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
enhancedOnMarkAsPinned = (thread_id) => {
|
||||
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');
|
||||
this.props.storageApi.incrStat(repository + '-stagedCount', '__REPO__');
|
||||
this.props.notificationsApi.stageThread(thread_id);
|
||||
}
|
||||
};
|
||||
|
||||
enhancedOnMarkAsRead = (thread_id, repository) => {
|
||||
logNotificationArchived(thread_id);
|
||||
this.props.storageApi.incrStat('stagedCount');
|
||||
this.props.storageApi.incrStat(repository + '-stagedCount', '__REPO__');
|
||||
this.props.notificationsApi.markAsRead(thread_id);
|
||||
}
|
||||
};
|
||||
|
||||
restoreThread = thread_id => {
|
||||
this.props.notificationsApi.restoreThread(thread_id);
|
||||
}
|
||||
};
|
||||
|
||||
setNotificationsPermission = (...args) => {
|
||||
this.props.notificationsApi.setNotificationsPermission(...args);
|
||||
}
|
||||
};
|
||||
|
||||
updateTabIcon (hasUnread = false) {
|
||||
updateTabIcon(hasUnread = false) {
|
||||
this.isUnreadTab = hasUnread;
|
||||
var link = document.querySelector("link[rel*='icon']") || document.createElement('link');
|
||||
var link =
|
||||
document.querySelector("link[rel*='icon']") ||
|
||||
document.createElement('link');
|
||||
link.rel = 'shortcut icon';
|
||||
link.href = hasUnread ? tabDotIcon : tabIcon;
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
@ -338,26 +349,25 @@ class NotificationsPage extends React.Component {
|
||||
const n = newNotifcations[0];
|
||||
const reasonByline = getMessageFromReasons(n.reasons, n.type);
|
||||
|
||||
const additionalInfo = newNotifcations.length > 1
|
||||
? ` (+${newNotifcations.length} more)`
|
||||
: '';
|
||||
const additionalInfo =
|
||||
newNotifcations.length > 1 ? ` (+${newNotifcations.length} more)` : '';
|
||||
|
||||
const notification = new Notification(n.name + additionalInfo, {
|
||||
body: reasonByline,
|
||||
icon: n.type === "Issue" ? issueIcon : prIcon,
|
||||
badge: n.type === "Issue" ? issueIcon : prIcon,
|
||||
requireInteraction: true,
|
||||
icon: n.type === 'Issue' ? issueIcon : prIcon,
|
||||
badge: n.type === 'Issue' ? issueIcon : prIcon,
|
||||
requireInteraction: true
|
||||
});
|
||||
|
||||
notification.addEventListener('click', () => {
|
||||
this.updateTabIcon(false);
|
||||
this.enhancedOnStageThread(n.id, n.repository);
|
||||
window.open(n.url);
|
||||
})
|
||||
});
|
||||
|
||||
// Manually close for legacy browser support.
|
||||
setTimeout(notification.close.bind(notification), 10000);
|
||||
}
|
||||
};
|
||||
|
||||
getFilteredNotifications = () => {
|
||||
const {notifications} = this.props.notificationsApi;
|
||||
@ -365,29 +375,26 @@ class NotificationsPage extends React.Component {
|
||||
let filterMethod = () => true;
|
||||
switch (this.state.activeFilter) {
|
||||
case Filters.PARTICIPATING:
|
||||
filterMethod = n => (
|
||||
n.reasons.some(({ reason }) => (
|
||||
reason === Reasons.REVIEW_REQUESTED ||
|
||||
reason === Reasons.ASSIGN ||
|
||||
reason === Reasons.MENTION ||
|
||||
reason === Reasons.AUTHOR
|
||||
))
|
||||
);
|
||||
filterMethod = n =>
|
||||
n.reasons.some(
|
||||
({reason}) =>
|
||||
reason === Reasons.REVIEW_REQUESTED ||
|
||||
reason === Reasons.ASSIGN ||
|
||||
reason === Reasons.MENTION ||
|
||||
reason === Reasons.AUTHOR
|
||||
);
|
||||
break;
|
||||
case Filters.ASSIGNED:
|
||||
filterMethod = n => (
|
||||
n.reasons.some(({ reason }) => reason === Reasons.ASSIGN)
|
||||
);
|
||||
filterMethod = n =>
|
||||
n.reasons.some(({reason}) => reason === Reasons.ASSIGN);
|
||||
break;
|
||||
case Filters.REVIEW_REQUESTED:
|
||||
filterMethod = n => (
|
||||
n.reasons.some(({ reason }) => reason === Reasons.REVIEW_REQUESTED)
|
||||
);
|
||||
filterMethod = n =>
|
||||
n.reasons.some(({reason}) => reason === Reasons.REVIEW_REQUESTED);
|
||||
break;
|
||||
case Filters.COMMENT:
|
||||
filterMethod = n => (
|
||||
n.reasons.some(({ reason }) => reason === Reasons.COMMENT)
|
||||
);
|
||||
filterMethod = n =>
|
||||
n.reasons.some(({reason}) => reason === Reasons.COMMENT);
|
||||
break;
|
||||
default:
|
||||
filterMethod = () => true;
|
||||
@ -398,23 +405,31 @@ class NotificationsPage extends React.Component {
|
||||
.map(decorateWithScore);
|
||||
|
||||
if (this.state.mode === Mode.HOT) {
|
||||
filteredNotifications = filteredNotifications
|
||||
.filter(item => item.badges.includes(Badges.HOT));
|
||||
filteredNotifications = filteredNotifications.filter(item =>
|
||||
item.badges.includes(Badges.HOT)
|
||||
);
|
||||
} else if (this.state.mode === Mode.COMMENTS) {
|
||||
filteredNotifications = filteredNotifications
|
||||
.filter(item => item.badges.includes(Badges.COMMENTS));
|
||||
filteredNotifications = filteredNotifications.filter(item =>
|
||||
item.badges.includes(Badges.COMMENTS)
|
||||
);
|
||||
} else if (this.state.mode === Mode.OLD) {
|
||||
filteredNotifications = filteredNotifications
|
||||
.filter(item => item.badges.includes(Badges.OLD));
|
||||
filteredNotifications = filteredNotifications.filter(item =>
|
||||
item.badges.includes(Badges.OLD)
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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
|
||||
);
|
||||
|
||||
let notificationsToRender = [];
|
||||
switch (this.state.activeStatus) {
|
||||
@ -447,16 +462,12 @@ class NotificationsPage extends React.Component {
|
||||
if (this.state.descending) {
|
||||
notificationsToRender.sort((a, b) => {
|
||||
const diff = a.repository.localeCompare(b.repository);
|
||||
return diff === 0
|
||||
? b.score - a.score
|
||||
: diff;
|
||||
return diff === 0 ? b.score - a.score : diff;
|
||||
});
|
||||
} else {
|
||||
notificationsToRender.sort((a, b) => {
|
||||
const diff = b.repository.localeCompare(a.repository);
|
||||
return diff === 0
|
||||
? b.score - a.score
|
||||
: diff;
|
||||
return diff === 0 ? b.score - a.score : diff;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -469,33 +480,37 @@ class NotificationsPage extends React.Component {
|
||||
}
|
||||
if (this.state.sort === Sort.DATE) {
|
||||
if (this.state.descending) {
|
||||
notificationsToRender.sort((a, b) => moment(a.updated_at).diff(b.updated_at));
|
||||
notificationsToRender.sort((a, b) =>
|
||||
moment(a.updated_at).diff(b.updated_at)
|
||||
);
|
||||
} else {
|
||||
notificationsToRender.sort((a, b) => moment(b.updated_at).diff(a.updated_at));
|
||||
notificationsToRender.sort((a, b) =>
|
||||
moment(b.updated_at).diff(a.updated_at)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// We gotta make sure to search notifications before we paginate.
|
||||
// Otherwise we'd just end up searching on the current page, which is bad.
|
||||
if (this.state.query) {
|
||||
notificationsToRender = notificationsToRender.filter(n => (
|
||||
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
|
||||
notificationsToRender = notificationsToRender.filter(
|
||||
n => n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1
|
||||
);
|
||||
notificationsQueued = notificationsQueued.filter(n => (
|
||||
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
|
||||
notificationsQueued = notificationsQueued.filter(
|
||||
n => n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1
|
||||
);
|
||||
notificationsStaged = notificationsStaged.filter(n => (
|
||||
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
|
||||
notificationsStaged = notificationsStaged.filter(
|
||||
n => n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1
|
||||
);
|
||||
notificationsClosed = notificationsClosed.filter(n => (
|
||||
n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1)
|
||||
notificationsClosed = notificationsClosed.filter(
|
||||
n => n.name.toLowerCase().indexOf(this.state.query.toLowerCase()) > -1
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.notificationsApi.newChanges) {
|
||||
const filteredNewChanges = this.props.notificationsApi.newChanges.filter(n => (
|
||||
notificationsToRender.some(fn => fn.id === n.id)
|
||||
));
|
||||
const filteredNewChanges = this.props.notificationsApi.newChanges.filter(
|
||||
n => notificationsToRender.some(fn => fn.id === n.id)
|
||||
);
|
||||
if (filteredNewChanges.length > 0) {
|
||||
this.sendWebNotification(filteredNewChanges);
|
||||
}
|
||||
@ -522,16 +537,17 @@ class NotificationsPage extends React.Component {
|
||||
});
|
||||
|
||||
return {
|
||||
filteredNotifications: filteredNotifications,
|
||||
notifications: notificationsToRender,
|
||||
queuedCount: notificationsQueued.length,
|
||||
stagedCount: notificationsStaged.length,
|
||||
closedCount: notificationsClosed.length,
|
||||
closedCount: notificationsClosed.length
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
if (!this.props.authApi.token) {
|
||||
return <Redirect noThrow to={routes.LOGIN} />
|
||||
return <Redirect noThrow to={routes.LOGIN} />;
|
||||
}
|
||||
|
||||
const {
|
||||
@ -540,25 +556,32 @@ class NotificationsPage extends React.Component {
|
||||
clearCache,
|
||||
notificationsPermission,
|
||||
loading: isFetchingNotifications,
|
||||
error: fetchingNotificationsError,
|
||||
error: fetchingNotificationsError
|
||||
} = this.props.notificationsApi;
|
||||
|
||||
const {
|
||||
filteredNotifications,
|
||||
notifications: scoredAndSortedNotifications,
|
||||
queuedCount,
|
||||
stagedCount,
|
||||
closedCount,
|
||||
closedCount
|
||||
} = this.getFilteredNotifications();
|
||||
|
||||
const [highestScore, lowestScore] = scoredAndSortedNotifications.reduce(([h, l], notification) => {
|
||||
h = Math.max(notification.score, h);
|
||||
l = Math.min(notification.score, l);
|
||||
return [h, l];
|
||||
}, [0, Infinity]);
|
||||
const [highestScore, lowestScore] = scoredAndSortedNotifications.reduce(
|
||||
([h, l], notification) => {
|
||||
h = Math.max(notification.score, h);
|
||||
l = Math.min(notification.score, l);
|
||||
return [h, l];
|
||||
},
|
||||
[0, Infinity]
|
||||
);
|
||||
|
||||
let firstIndex = (this.state.currentPage - 1) * PER_PAGE;
|
||||
let lastIndex = (this.state.currentPage * PER_PAGE);
|
||||
let notificationsOnPage = scoredAndSortedNotifications.slice(firstIndex, lastIndex);
|
||||
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);
|
||||
@ -583,12 +606,16 @@ class NotificationsPage extends React.Component {
|
||||
)[0];
|
||||
const stagedStatistics = this.props.storageApi.getStat(
|
||||
'stagedCount',
|
||||
this.state.currentTime.clone().startOf('week').subtract(1, 'week'),
|
||||
this.state.currentTime
|
||||
.clone()
|
||||
.startOf('week')
|
||||
.subtract(1, 'week'),
|
||||
this.state.currentTime.clone().endOf('week')
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
allNotifications={filteredNotifications}
|
||||
currentTime={this.state.currentTime}
|
||||
readStatistics={stagedStatistics}
|
||||
isFirstTimeUser={this.state.isFirstTimeUser}
|
||||
@ -623,7 +650,9 @@ class NotificationsPage extends React.Component {
|
||||
onRefreshNotifications={this.props.storageApi.refreshNotifications}
|
||||
isSearching={this.state.isSearching}
|
||||
isFetchingNotifications={isFetchingNotifications}
|
||||
fetchingNotificationsError={fetchingNotificationsError || this.state.error}
|
||||
fetchingNotificationsError={
|
||||
fetchingNotificationsError || this.state.error
|
||||
}
|
||||
highestScore={highestScore}
|
||||
lowestScore={lowestScore}
|
||||
hasUnread={this.isUnreadTab}
|
||||
@ -645,7 +674,7 @@ class NotificationsPage extends React.Component {
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const enhance = compose(
|
||||
withStorageProvider,
|
||||
|
@ -1,11 +1,22 @@
|
||||
/** @jsx jsx */
|
||||
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import Typed from 'typed.js';
|
||||
import moment from 'moment';
|
||||
import {css, jsx} from '@emotion/core';
|
||||
import LoadingIcon from '../../../components/LoadingIcon';
|
||||
import {colorOfString, extractJiraTags} from './utils';
|
||||
import {SearchField, EnhancedSearchInput, Dropdown} from './ui';
|
||||
import {colorOfString, colorOfTag, extractJiraTags} from './utils';
|
||||
import {
|
||||
withTheme,
|
||||
DarkTheme,
|
||||
WHITE,
|
||||
SearchField,
|
||||
EnhancedSearchInput,
|
||||
Dropdown,
|
||||
FilterItem,
|
||||
JiraTag
|
||||
} from './ui';
|
||||
|
||||
function TypedSpan({source, toString, options = {}}) {
|
||||
const spanRef = React.useRef();
|
||||
@ -33,16 +44,20 @@ function TypedSpan({source, toString, options = {}}) {
|
||||
return () => typed.current.destroy();
|
||||
}, [source]);
|
||||
|
||||
return <span ref={spanRef} />;
|
||||
}
|
||||
|
||||
// @TODO will be used in main filter as well
|
||||
function filterFromQuery({query, items, toString}) {
|
||||
query = query.toLowerCase();
|
||||
return items.filter(item =>
|
||||
toString(item)
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
return (
|
||||
<span
|
||||
css={css`
|
||||
display: inline-block;
|
||||
text-transform: initial;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
`}
|
||||
ref={spanRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -58,18 +73,18 @@ function FilterTagInline({type}) {
|
||||
return (
|
||||
<span
|
||||
css={css`
|
||||
background: ${color}28 !important;
|
||||
color: ${color} !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 4px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: capitalize !important;
|
||||
margin-left: 0 !important;
|
||||
position: absolute !important;
|
||||
left: 40px !important;
|
||||
width: 32px !important;
|
||||
text-align: center !important;
|
||||
background: ${color}28;
|
||||
color: ${color};
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
margin-left: 0;
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
`}
|
||||
>
|
||||
{type}
|
||||
@ -83,14 +98,14 @@ function FilterTag({type}) {
|
||||
return (
|
||||
<span
|
||||
css={css`
|
||||
background: ${color}28 !important;
|
||||
color: ${color} !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 4px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: capitalize !important;
|
||||
margin-left: 0 !important;
|
||||
background: ${color}28;
|
||||
color: ${color};
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
margin-left: 0;
|
||||
`}
|
||||
>
|
||||
{type}
|
||||
@ -98,6 +113,46 @@ function FilterTag({type}) {
|
||||
);
|
||||
}
|
||||
|
||||
const Suggestion = withTheme(
|
||||
styled('div')(
|
||||
p => `
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
|
||||
&:hover {
|
||||
background: ${p.dark ? '#273947' : '#eff0f2'};
|
||||
}
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const SuggestionTitle = withTheme(
|
||||
styled('p')(
|
||||
p => `
|
||||
display: inline-block;
|
||||
text-transform: initial;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: ${p.dark ? WHITE : 'inherit'};
|
||||
font-size: 13px;
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
const SuggestionRepo = styled('p')`
|
||||
text-transform: initial;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: #8893a7cc;
|
||||
`;
|
||||
|
||||
function validateFilter(filter) {
|
||||
const exists = Object.values(SearchFilters).includes(filter);
|
||||
return exists ? filter : null;
|
||||
@ -121,13 +176,24 @@ function parseTextForFilter(input, currentFilter) {
|
||||
};
|
||||
}
|
||||
|
||||
// @TODO will be used in main filter as well
|
||||
function filterFromQuery({query, items, compare}) {
|
||||
query = query.toLowerCase();
|
||||
return items.filter(item => compare(item, query));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
export function FilterSearch({
|
||||
isSearching,
|
||||
activeQuery,
|
||||
dark,
|
||||
notifications,
|
||||
view,
|
||||
loading,
|
||||
onSearch
|
||||
}) {
|
||||
const downdownRef = React.useRef();
|
||||
const searchRef = React.useRef();
|
||||
const containerRef = React.useRef();
|
||||
const [searchMenuOpened, setSearchMenuOpened] = React.useState(false);
|
||||
@ -141,7 +207,7 @@ export function FilterSearch({
|
||||
},
|
||||
{
|
||||
name:
|
||||
'Update innerRef to allow React.createRef and React.forwardRef api usage ',
|
||||
'Update innerRef to allow React.createRef and React.forwardRef api usage',
|
||||
repository: 'robinpowered/glamorous-native',
|
||||
score: 78
|
||||
},
|
||||
@ -153,12 +219,19 @@ export function FilterSearch({
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (notifications.length > 3) {
|
||||
if (notifications.length >= 3) {
|
||||
const examples = notifications.slice(0, 5);
|
||||
setExampleNotifications(examples);
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeQuery) {
|
||||
setSearchInput('');
|
||||
setActiveFilter(null);
|
||||
}
|
||||
}, [activeQuery]);
|
||||
|
||||
function smartSetSearchInput(input) {
|
||||
const {filter, text} = parseTextForFilter(input, activeFilter);
|
||||
setSearchInput(text);
|
||||
@ -176,15 +249,24 @@ export function FilterSearch({
|
||||
};
|
||||
React.useEffect(() => () => clearInterval(timer.current), []);
|
||||
|
||||
// Global event listeners for things like the dropdowns & popups.
|
||||
React.useEffect(() => {
|
||||
const body = window.document.querySelector('body');
|
||||
const hideSearchFocused = () => setSearchMenuOpened(false);
|
||||
const eventType = 'click'; // isMobile ? 'touchend' : 'click';
|
||||
body.addEventListener(eventType, hideSearchFocused);
|
||||
return () => body.removeEventListener(eventType, hideSearchFocused);
|
||||
const hideSearchFocused = event => {
|
||||
const dropdown = downdownRef.current;
|
||||
if (dropdown && !dropdown.contains(event.target)) {
|
||||
setSearchMenuOpened(false);
|
||||
}
|
||||
};
|
||||
body.addEventListener('click', hideSearchFocused);
|
||||
return () => body.removeEventListener('click', hideSearchFocused);
|
||||
}, []);
|
||||
|
||||
function onSuggestionSelect(text) {
|
||||
setSearchInput(text);
|
||||
onSearch(text);
|
||||
setSearchMenuOpened(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchField innerRef={containerRef}>
|
||||
<i className="fas fa-search"></i>
|
||||
@ -197,7 +279,7 @@ export function FilterSearch({
|
||||
onChange={e => smartSetSearchInput(e.target.value)}
|
||||
value={searchInput}
|
||||
placeholder="Search for notifications"
|
||||
onEnter={onSearch}
|
||||
onEnter={() => onSearch(searchInput)}
|
||||
css={css`
|
||||
${activeFilter &&
|
||||
`
|
||||
@ -207,7 +289,10 @@ export function FilterSearch({
|
||||
`}
|
||||
/>
|
||||
<DropdownSection
|
||||
forwardRef={downdownRef}
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
searchMenuOpened={searchMenuOpened}
|
||||
activeFilter={activeFilter}
|
||||
searchInput={searchInput}
|
||||
notifications={notifications}
|
||||
exampleNotifications={exampleNotifications}
|
||||
@ -221,7 +306,7 @@ export function FilterSearch({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
transform: 'scale(0.8)',
|
||||
backgroundColor: 'transparent'
|
||||
backgroundColor: dark ? DarkTheme.SecondaryAlt : WHITE
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -230,7 +315,10 @@ export function FilterSearch({
|
||||
}
|
||||
|
||||
function DropdownSection({
|
||||
forwardRef,
|
||||
onSuggestionSelect,
|
||||
searchMenuOpened,
|
||||
activeFilter,
|
||||
searchInput,
|
||||
notifications,
|
||||
exampleNotifications,
|
||||
@ -241,22 +329,73 @@ function DropdownSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown innerRef={forwardRef}>
|
||||
{searchInput !== '' ? (
|
||||
// Previews
|
||||
<span>
|
||||
<React.Fragment>
|
||||
{filterFromQuery({
|
||||
query: searchInput,
|
||||
items: notifications,
|
||||
toString: item => item.name
|
||||
}).map(n => (
|
||||
<span>{n.name}</span>
|
||||
))}
|
||||
</span>
|
||||
compare: (item, query) => {
|
||||
switch (activeFilter) {
|
||||
case SearchFilters.TITLE: {
|
||||
const words = query.split(' ');
|
||||
const itemString = item.name.toLowerCase();
|
||||
return words.every(word => itemString.includes(word));
|
||||
}
|
||||
case SearchFilters.REPO: {
|
||||
const words = query.split(' ');
|
||||
const itemString = item.repository.toLowerCase();
|
||||
return words.every(word => itemString.includes(word));
|
||||
}
|
||||
default:
|
||||
const words = query.split(' ');
|
||||
const itemString = `${item.name} ${item.repository}`.toLowerCase();
|
||||
return words.every(word => itemString.includes(word));
|
||||
}
|
||||
}
|
||||
})
|
||||
.sort((a, b) => moment(b.updated_at).diff(a.updated_at))
|
||||
.slice(0, 10)
|
||||
.map(notification => {
|
||||
const {title, tags} = extractJiraTags(notification.name);
|
||||
return (
|
||||
<Suggestion
|
||||
onClick={() => {
|
||||
switch (activeFilter) {
|
||||
case SearchFilters.TITLE:
|
||||
return onSuggestionSelect(notification.name);
|
||||
case SearchFilters.REPO:
|
||||
return onSuggestionSelect(notification.repository);
|
||||
default:
|
||||
return onSuggestionSelect(notification.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SuggestionTitle>
|
||||
{tags.map(tag => (
|
||||
<JiraTag
|
||||
key={tag}
|
||||
css={css`
|
||||
padding: 0px 4px;
|
||||
vertical-align: text-bottom;
|
||||
`}
|
||||
color={colorOfTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</JiraTag>
|
||||
))}
|
||||
{title}
|
||||
</SuggestionTitle>
|
||||
<SuggestionRepo>{`@${notification.repository}`}</SuggestionRepo>
|
||||
</Suggestion>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
// Filter Suggestion Menu
|
||||
<React.Fragment>
|
||||
<span onMouseDown={() => setSearchInput('[title] ')}>
|
||||
<FilterItem onClick={() => setSearchInput('[title] ')}>
|
||||
<FilterTag type={SearchFilters.TITLE} />
|
||||
<TypedSpan
|
||||
source={exampleNotifications}
|
||||
@ -270,16 +409,16 @@ function DropdownSection({
|
||||
}}
|
||||
/>
|
||||
<p>{'Search for specific titles'}</p>
|
||||
</span>
|
||||
<span onMouseDown={() => setSearchInput('[repo] ')}>
|
||||
</FilterItem>
|
||||
<FilterItem onClick={() => setSearchInput('[repo] ')}>
|
||||
<FilterTag type={SearchFilters.REPO} />
|
||||
<TypedSpan
|
||||
source={exampleNotifications}
|
||||
toString={n => n.repository.split('/')[1]}
|
||||
/>
|
||||
<p>{'Search for specific repositories'}</p>
|
||||
</span>
|
||||
<span onMouseDown={() => setSearchInput('[score] ')}>
|
||||
</FilterItem>
|
||||
{/* <FilterItem onClick={() => setSearchInput('[score] ')}>
|
||||
<FilterTag type={SearchFilters.SCORE} />
|
||||
<TypedSpan
|
||||
source={exampleNotifications}
|
||||
@ -289,7 +428,7 @@ function DropdownSection({
|
||||
}}
|
||||
/>
|
||||
<p>{'Search for specific score ranges'}</p>
|
||||
</span>
|
||||
</FilterItem> */}
|
||||
<h5>
|
||||
{'Not including a filter will search everything across all fields'}
|
||||
</h5>
|
||||
|
@ -597,7 +597,7 @@ function Scene({
|
||||
onRestoreThread,
|
||||
onLogout,
|
||||
mode,
|
||||
setMode,
|
||||
allNotifications,
|
||||
activeFilter,
|
||||
onSetActiveFilter,
|
||||
getUserItem,
|
||||
@ -764,8 +764,10 @@ function Scene({
|
||||
`}
|
||||
>
|
||||
<FilterSearch
|
||||
notifications={notifications}
|
||||
notifications={allNotifications}
|
||||
activeQuery={query}
|
||||
view={view}
|
||||
dark={darkMode}
|
||||
loading={loading}
|
||||
onSearch={onSearch}
|
||||
isSearching={isSearching}
|
||||
@ -1216,7 +1218,17 @@ function Scene({
|
||||
`}
|
||||
>
|
||||
{'Showing results for '}
|
||||
<span>{query}</span>
|
||||
<span
|
||||
css={css`
|
||||
max-width: 250px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
`}
|
||||
>
|
||||
{query}
|
||||
</span>
|
||||
</span>
|
||||
<IconLink
|
||||
onClick={!loading ? () => onClearQuery() : undefined}
|
||||
|
@ -381,9 +381,40 @@ const SearchInput = enhance(
|
||||
)
|
||||
);
|
||||
|
||||
export const FilterItem = enhance(
|
||||
styled('span')(
|
||||
p => `
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: ${p.dark ? WHITE : 'inherit'};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
text-transform: lowercase;
|
||||
|
||||
&:hover {
|
||||
background: ${p.dark ? '#273947' : '#eff0f2'};
|
||||
}
|
||||
|
||||
p {
|
||||
text-transform: initial;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8893a7cc;
|
||||
}
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
export const Dropdown = enhance(
|
||||
styled('div')(
|
||||
p => `
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: red;
|
||||
width: 388px;
|
||||
@media (max-width: ${WIDTH_FOR_SMALL_SCREENS}) {
|
||||
@ -404,6 +435,17 @@ export const Dropdown = enhance(
|
||||
: 'rgba(84,70,35,0) 0px 2px 8px, rgba(84,70,35,0.15) 0px 1px 3px'
|
||||
};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
background-color: ${p.dark ? DarkTheme.SecondaryAlt : '#fffefc'};
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: ${p.dark ? DarkTheme.Alpha.Dark : '#bfc5d1ab'};
|
||||
border: 4px solid ${p.dark ? DarkTheme.SecondaryAlt : '#fffefc'};
|
||||
border-top-width: 2px;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
border-top: 1px solid ${p.dark ? DarkTheme.Secondary : '#bfc5d155'};
|
||||
margin: 0;
|
||||
@ -414,43 +456,6 @@ export const Dropdown = enhance(
|
||||
line-height: 16px;
|
||||
color: #8893a7cc;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: ${p.dark ? WHITE : 'inherit'};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
text-transform: lowercase;
|
||||
|
||||
&:hover {
|
||||
background: ${p.dark ? '#273947' : '#eff0f2'};
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
text-transform: initial;
|
||||
// background: #ffeb3b66;
|
||||
// border-radius: 4px;
|
||||
// padding: 2px 6px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
text-transform: initial;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8893a7cc;
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user