mirror of
https://github.com/nickzuber/meteorite.git
synced 2024-11-29 09:31:15 +03:00
Web notifications support
This commit is contained in:
parent
d85dcb639e
commit
cd5f8b6485
BIN
src/images/issue-bg.png
Normal file
BIN
src/images/issue-bg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
BIN
src/images/issue.png
Normal file
BIN
src/images/issue.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
BIN
src/images/pr-bg.png
Normal file
BIN
src/images/pr-bg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
BIN
src/images/pr.png
Normal file
BIN
src/images/pr.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@ -27,7 +27,7 @@ function stringOfType (type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMessageFromReasons (reasons, type) {
|
export function getMessageFromReasons (reasons, type) {
|
||||||
switch (reasons[reasons.length - 1].reason) {
|
switch (reasons[reasons.length - 1].reason) {
|
||||||
case Reasons.ASSIGN:
|
case Reasons.ASSIGN:
|
||||||
return 'You were assigned this ' + stringOfType(type);
|
return 'You were assigned this ' + stringOfType(type);
|
||||||
@ -516,9 +516,9 @@ export default function Scene ({
|
|||||||
? Icon.NotificationsOn
|
? Icon.NotificationsOn
|
||||||
: Icon.NotificationsOff;
|
: Icon.NotificationsOff;
|
||||||
|
|
||||||
console.log('notifications', notifications)
|
// console.log('notifications', notifications)
|
||||||
console.log('isFirstTimeUser', isFirstTimeUser)
|
// console.log('isFirstTimeUser', isFirstTimeUser)
|
||||||
console.log('notificationsPermission', notificationsPermission)
|
// console.log('notificationsPermission', notificationsPermission)
|
||||||
|
|
||||||
if (isFirstTimeUser && notifications.length > 5) {
|
if (isFirstTimeUser && notifications.length > 5) {
|
||||||
// alert('hello, clear ur shit');
|
// alert('hello, clear ur shit');
|
||||||
@ -722,7 +722,13 @@ export default function Scene ({
|
|||||||
}) : undefined}
|
}) : undefined}
|
||||||
/>
|
/>
|
||||||
</EnhancedTab>
|
</EnhancedTab>
|
||||||
<EnhancedTab tooltip={!loading ? "Toggle web notifications" : null} disabled={loading}>
|
<EnhancedTab
|
||||||
|
tooltip={!loading ? (
|
||||||
|
notificationsPermission === 'granted'
|
||||||
|
? "Turn off web notifications"
|
||||||
|
: "Turn on web notifications"
|
||||||
|
) : null}
|
||||||
|
disabled={loading}>
|
||||||
<NotificationsIcon
|
<NotificationsIcon
|
||||||
opacity={0.9}
|
opacity={0.9}
|
||||||
onClick={!loading ? (() => {
|
onClick={!loading ? (() => {
|
||||||
|
@ -11,7 +11,9 @@ import { routes } from '../../constants';
|
|||||||
import { Filters } from '../../constants/filters';
|
import { Filters } from '../../constants/filters';
|
||||||
import { Status } from '../../constants/status';
|
import { Status } from '../../constants/status';
|
||||||
import { Reasons, Badges } from '../../constants/reasons';
|
import { Reasons, Badges } from '../../constants/reasons';
|
||||||
import Scene from './Scene';
|
import Scene, { getMessageFromReasons } from './Scene';
|
||||||
|
import issueIcon from '../../images/issue-bg.png';
|
||||||
|
import prIcon from '../../images/pr-bg.png';
|
||||||
|
|
||||||
const PER_PAGE = 10;
|
const PER_PAGE = 10;
|
||||||
|
|
||||||
@ -113,9 +115,15 @@ const decorateWithScore = notification => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
class NotificationsPage extends React.Component {
|
class NotificationsPage extends React.Component {
|
||||||
|
constructor (props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.notificationSent = false;
|
||||||
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
currentTime: moment(),
|
currentTime: moment(),
|
||||||
prevNotifications: [],
|
notificationSent: false,
|
||||||
isFirstTimeUser: false,
|
isFirstTimeUser: false,
|
||||||
isSearching: false,
|
isSearching: false,
|
||||||
query: null,
|
query: null,
|
||||||
@ -139,6 +147,15 @@ class NotificationsPage extends React.Component {
|
|||||||
}, 8 * 1000);
|
}, 8 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// we don't want to trigger a rerender.
|
||||||
|
return nextState.prevNotifications === this.state.prevNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
clearInterval(this.syncer);
|
clearInterval(this.syncer);
|
||||||
}
|
}
|
||||||
@ -200,31 +217,44 @@ class NotificationsPage extends React.Component {
|
|||||||
this.props.notificationsApi.setNotificationsPermission(...args);
|
this.props.notificationsApi.setNotificationsPermission(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWebNotification = () => {
|
sendWebNotification = newNotifcations => {
|
||||||
var img = '../images/icon.png';
|
if (this.notificationSent || newNotifcations.length === 0) {
|
||||||
var text = 'HEY! Your task "null" is now overdue.';
|
return;
|
||||||
var notification = new Notification('Meteorite', { body: text, icon: img });
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
if (!this.props.authApi.token) {
|
|
||||||
return <Redirect noThrow to={routes.LOGIN} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
// Set this even if we don't actually send the notification due to permissions.
|
||||||
fetchNotifications,
|
this.notificationSent = true;
|
||||||
markAsRead,
|
|
||||||
markAllAsStaged,
|
// No permission, no notification.
|
||||||
clearCache,
|
if (this.props.notificationsApi.notificationsPermission !== 'granted') {
|
||||||
notificationsPermission,
|
return;
|
||||||
notifications,
|
}
|
||||||
loading: isFetchingNotifications,
|
|
||||||
error: fetchingNotificationsError,
|
const n = newNotifcations[0];
|
||||||
} = this.props.notificationsApi;
|
const reasonByline = getMessageFromReasons(n.reasons, n.type);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.addEventListener('click', () => {
|
||||||
|
this.enhancedOnStageThread(n.id, n.repository);
|
||||||
|
window.open(n.url);
|
||||||
|
})
|
||||||
|
|
||||||
|
// Manually close for legacy browser support.
|
||||||
|
setTimeout(notification.close.bind(notification), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilteredNotifications = () => {
|
||||||
|
const {notifications} = this.props.notificationsApi;
|
||||||
|
|
||||||
// @TODO Move all this out of the render method.
|
|
||||||
// nick, do this ^ so you can fire off a web noti when the filtered/final
|
|
||||||
// notifications have a diff
|
|
||||||
let filterMethod = () => true;
|
let filterMethod = () => true;
|
||||||
switch (this.state.activeFilter) {
|
switch (this.state.activeFilter) {
|
||||||
case Filters.PARTICIPATING:
|
case Filters.PARTICIPATING:
|
||||||
@ -287,6 +317,45 @@ class NotificationsPage extends React.Component {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.props.notificationsApi.newChanges) {
|
||||||
|
// we shouldn't do it like this. instead, we should have an additional state called
|
||||||
|
// "new changes" or something that the notifications api knows about.
|
||||||
|
// this will be whatever we get in the syncing/fetching response
|
||||||
|
console.log('send', this.props.notificationsApi.newChanges)
|
||||||
|
this.sendWebNotification(this.props.notificationsApi.newChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(this.props.notificationsApi);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: scoredAndSortedNotifications,
|
||||||
|
queuedCount: notificationsQueued.length,
|
||||||
|
stagedCount: notificationsStaged.length,
|
||||||
|
closedCount: notificationsClosed.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
if (!this.props.authApi.token) {
|
||||||
|
return <Redirect noThrow to={routes.LOGIN} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchNotifications,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsStaged,
|
||||||
|
clearCache,
|
||||||
|
notificationsPermission,
|
||||||
|
loading: isFetchingNotifications,
|
||||||
|
error: fetchingNotificationsError,
|
||||||
|
} = this.props.notificationsApi;
|
||||||
|
const {
|
||||||
|
notifications: scoredAndSortedNotifications,
|
||||||
|
queuedCount,
|
||||||
|
stagedCount,
|
||||||
|
closedCount,
|
||||||
|
} = this.getFilteredNotifications();
|
||||||
|
|
||||||
let firstIndex = (this.state.currentPage - 1) * PER_PAGE;
|
let firstIndex = (this.state.currentPage - 1) * PER_PAGE;
|
||||||
let lastIndex = (this.state.currentPage * PER_PAGE);
|
let lastIndex = (this.state.currentPage * PER_PAGE);
|
||||||
let notificationsOnPage = scoredAndSortedNotifications.slice(firstIndex, lastIndex);
|
let notificationsOnPage = scoredAndSortedNotifications.slice(firstIndex, lastIndex);
|
||||||
@ -311,9 +380,9 @@ class NotificationsPage extends React.Component {
|
|||||||
isFirstTimeUser={this.state.isFirstTimeUser}
|
isFirstTimeUser={this.state.isFirstTimeUser}
|
||||||
setNotificationsPermission={this.setNotificationsPermission}
|
setNotificationsPermission={this.setNotificationsPermission}
|
||||||
notificationsPermission={notificationsPermission}
|
notificationsPermission={notificationsPermission}
|
||||||
queuedCount={notificationsQueued.length}
|
queuedCount={queuedCount}
|
||||||
stagedCount={notificationsStaged.length}
|
stagedCount={stagedCount}
|
||||||
closedCount={notificationsClosed.length}
|
closedCount={closedCount}
|
||||||
stagedTodayCount={stagedTodayCount || 0}
|
stagedTodayCount={stagedTodayCount || 0}
|
||||||
first={firstNumbered}
|
first={firstNumbered}
|
||||||
last={lastNumbered}
|
last={lastNumbered}
|
||||||
|
@ -4,6 +4,7 @@ import {StorageProvider, LOCAL_STORAGE_PREFIX} from './Storage';
|
|||||||
import {Status} from '../constants/status';
|
import {Status} from '../constants/status';
|
||||||
|
|
||||||
const BASE_GITHUB_API_URL = 'https://api.github.com';
|
const BASE_GITHUB_API_URL = 'https://api.github.com';
|
||||||
|
const PER_PAGE = 50;
|
||||||
|
|
||||||
function cleanResponseUrl (url) {
|
function cleanResponseUrl (url) {
|
||||||
return url
|
return url
|
||||||
@ -62,12 +63,17 @@ class NotificationsProvider extends React.Component {
|
|||||||
syncing: false,
|
syncing: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
newChanges: null,
|
||||||
notificationsPermission:
|
notificationsPermission:
|
||||||
this.props.getUserItem('notificationsPermission') ||
|
this.props.getUserItem('notificationsPermission') ||
|
||||||
'default',
|
'default',
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate (nextProps, nextState) {
|
shouldComponentUpdate (nextProps, nextState) {
|
||||||
|
// Don't try to rerender if we're just setting the new changes.
|
||||||
|
if (this.state.newChanges !== nextState.newChanges) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Update if our state changes.
|
// Update if our state changes.
|
||||||
if ((this.state.loading !== nextState.loading) ||
|
if ((this.state.loading !== nextState.loading) ||
|
||||||
(this.state.error !== nextState.error)) {
|
(this.state.error !== nextState.error)) {
|
||||||
@ -102,7 +108,7 @@ class NotificationsProvider extends React.Component {
|
|||||||
headers['If-Modified-Since'] = this.last_modified;
|
headers['If-Modified-Since'] = this.last_modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(`${BASE_GITHUB_API_URL}/notifications?page=${page}`, {
|
return fetch(`${BASE_GITHUB_API_URL}/notifications?page=${page}&per_page=${PER_PAGE}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: headers
|
headers: headers
|
||||||
})
|
})
|
||||||
@ -129,7 +135,11 @@ class NotificationsProvider extends React.Component {
|
|||||||
this.setState({syncing: true});
|
this.setState({syncing: true});
|
||||||
return this.requestPage(page, optimizePolling)
|
return this.requestPage(page, optimizePolling)
|
||||||
.then(({headers, json}) => {
|
.then(({headers, json}) => {
|
||||||
if (json === null) return [];
|
// This means that we got a response where nothing changed.
|
||||||
|
if (json === null) {
|
||||||
|
this.setState({newChanges: null});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
let nextPage = null;
|
let nextPage = null;
|
||||||
const links = headers['link'];
|
const links = headers['link'];
|
||||||
if (links && links.next && links.next.page) {
|
if (links && links.next && links.next.page) {
|
||||||
@ -148,7 +158,7 @@ class NotificationsProvider extends React.Component {
|
|||||||
|
|
||||||
if (this.state.loading) {
|
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 Promise.reject();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
@ -167,26 +177,33 @@ class NotificationsProvider extends React.Component {
|
|||||||
// Apparently this means that a user has no notifications (makes sense).
|
// Apparently this means that a user has no notifications (makes sense).
|
||||||
// So I guess we should purge our cache? This brings up the great point
|
// So I guess we should purge our cache? This brings up the great point
|
||||||
// of us having stale cache. How can we detect that a notifcation was seen?
|
// of us having stale cache. How can we detect that a notifcation was seen?
|
||||||
|
// Update ^ we can't so we'll provide the tools for users to easily handle this.
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationsChunk.forEach(n => {
|
const processedNotifications = notificationsChunk.map(n => {
|
||||||
const cached_n = this.props.getItemFromStorage(n.id);
|
const cached_n = this.props.getItemFromStorage(n.id);
|
||||||
// If we've seen this notification before.
|
// If we've seen this notification before.
|
||||||
if (cached_n) {
|
if (cached_n) {
|
||||||
// Something's changed, we want to push
|
// Something's changed, we want to push
|
||||||
if (cached_n.updated_at !== n.updated_at) {
|
if (cached_n.updated_at !== n.updated_at) {
|
||||||
this.updateNotification(n, cached_n.reasons);
|
return this.updateNotification(n, cached_n.reasons);;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// This means that something didn't update, which means the page we're
|
// 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.
|
// currently processing has stale data so we don't need to fetch the next page.
|
||||||
everythingUpdated = false;
|
everythingUpdated = false;
|
||||||
|
return cached_n;
|
||||||
} else {
|
} else {
|
||||||
// Else, update the cache.
|
// Else, update the cache.
|
||||||
this.updateNotification(n);
|
return this.updateNotification(n);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.last_modified && notificationsChunk.length < PER_PAGE) {
|
||||||
|
// `this.last_modified` being set means this isn't our first fetch.
|
||||||
|
// We don't want to send notifications for the first time the page loads.
|
||||||
|
this.setState({newChanges: processedNotifications});
|
||||||
|
}
|
||||||
|
|
||||||
if (nextPage && everythingUpdated) {
|
if (nextPage && everythingUpdated) {
|
||||||
// Still need to fetch more updates.
|
// Still need to fetch more updates.
|
||||||
this.fetchNotifications(nextPage, false);
|
this.fetchNotifications(nextPage, false);
|
||||||
@ -228,7 +245,7 @@ class NotificationsProvider extends React.Component {
|
|||||||
|
|
||||||
if (this.state.loading) {
|
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 Promise.reject();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
@ -346,6 +363,14 @@ class NotificationsProvider extends React.Component {
|
|||||||
reasons = [newReason];
|
reasons = [newReason];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentNumber = n.subject.latest_comment_url
|
||||||
|
? n.subject.latest_comment_url.split('/').pop()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const url = commentNumber
|
||||||
|
? cleanResponseUrl(n.subject.url) + '#issuecomment-' + commentNumber
|
||||||
|
: cleanResponseUrl(n.subject.url);
|
||||||
|
|
||||||
// Notification model
|
// Notification model
|
||||||
const value = {
|
const value = {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@ -355,12 +380,13 @@ class NotificationsProvider extends React.Component {
|
|||||||
reasons: reasons,
|
reasons: reasons,
|
||||||
type: n.subject.type,
|
type: n.subject.type,
|
||||||
name: n.subject.title,
|
name: n.subject.title,
|
||||||
url: cleanResponseUrl(n.subject.url),
|
url: url,
|
||||||
repository: n.repository.full_name,
|
repository: n.repository.full_name,
|
||||||
number: n.subject.url.split('/').pop(),
|
number: n.subject.url.split('/').pop(),
|
||||||
repositoryUrl: cleanResponseUrl(n.repository.url)
|
repositoryUrl: cleanResponseUrl(n.repository.url)
|
||||||
};
|
};
|
||||||
this.props.setItemInStorage(n.id, value);
|
this.props.setItemInStorage(n.id, value);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
Loading…
Reference in New Issue
Block a user