diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index cd03a1d78..c4fa66428 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; +import { requestNotificationPermission } from '../utils/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; + export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, @@ -235,6 +240,46 @@ export const unmountNotifications = () => ({ type: NOTIFICATIONS_UNMOUNT, }); + export const markNotificationsAsRead = () => ({ type: NOTIFICATIONS_MARK_AS_READ, }); + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js index a1dd3a731..90f1da7bd 100644 --- a/app/javascript/mastodon/actions/onboarding.js +++ b/app/javascript/mastodon/actions/onboarding.js @@ -1,8 +1,20 @@ import { changeSetting, saveSettings } from './settings'; +import { requestBrowserPermission } from './notifications'; export const INTRODUCTION_VERSION = 20181216044202; export const closeOnboarding = () => dispatch => { dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(saveSettings()); + + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); + dispatch(saveSettings()); + } + })); }; diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 1bb583583..236e92296 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { onMove: PropTypes.func, onClick: PropTypes.func, appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, }; state = { @@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent { } if (children || (multiColumn && this.props.onPin)) { - collapseButton = ; + collapseButton = ( + + ); } const hasTitle = icon && title; diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js index 7851eb4be..4214eccfd 100644 --- a/app/javascript/mastodon/components/icon_with_badge.js +++ b/app/javascript/mastodon/components/icon_with_badge.js @@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon'; const formatNumber = num => num > 40 ? '40+' : num; -const IconWithBadge = ({ id, count, className }) => ( +const IconWithBadge = ({ id, count, issueBadge, className }) => ( {count > 0 && {formatNumber(count)}} + {issueBadge && } ); IconWithBadge.propTypes = { id: PropTypes.string.isRequired, count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, className: PropTypes.string, }; diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 8bd03fbda..be88df6d6 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import ClearColumnButton from './clear_column_button'; import SettingToggle from './setting_toggle'; +import Icon from 'mastodon/components/icon'; export default class ColumnSettings extends React.PureComponent { @@ -12,6 +13,10 @@ export default class ColumnSettings extends React.PureComponent { pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, + onRequestNotificationPermission: PropTypes.func.isRequired, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, }; onPushChange = (path, checked) => { @@ -19,7 +24,7 @@ export default class ColumnSettings extends React.PureComponent { } render () { - const { settings, pushSettings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear, onRequestNotificationPermission, alertsEnabled, browserSupport, browserPermission } = this.props; const filterShowStr = ; const filterAdvancedStr = ; @@ -30,8 +35,40 @@ export default class ColumnSettings extends React.PureComponent { const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const pushStr = showPushSettings && ; + const settingsIssues = []; + + if (alertsEnabled && browserSupport && browserPermission !== 'granted') { + if (browserPermission === 'denied') { + settingsIssues.push( + + ); + } else if (browserPermission === 'default') { + settingsIssues.push( + + ); + } + } + return (
+ {settingsIssues && ( +
+ {settingsIssues} +
+ )} +
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index a67f26295..664c37980 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from '../../../actions/settings'; import { setFilter } from '../../../actions/notifications'; -import { clearNotifications } from '../../../actions/notifications'; +import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; +import { showAlert } from '../../../actions/alerts'; const messages = defineMessages({ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied', defaultMessage: 'Cannot enable desktop notifications as permission has been denied.' }, }); const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { - dispatch(changePushNotifications(path.slice(1), checked)); + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } } else if (path[0] === 'quickFilter') { dispatch(changeSetting(['notifications', ...path], checked)); dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } } else { dispatch(changeSetting(['notifications', ...path], checked)); } @@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + }); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 68afe593d..5e87faa08 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -55,6 +55,7 @@ const mapStateToProps = state => ({ numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, lastReadId: state.getIn(['notifications', 'readMarkerId']), canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) !== 'granted', }); export default @connect(mapStateToProps) @@ -75,6 +76,7 @@ class Notifications extends React.PureComponent { numPending: PropTypes.number, lastReadId: PropTypes.string, canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, }; static defaultProps = { @@ -250,6 +252,7 @@ class Notifications extends React.PureComponent { pinned={pinned} multiColumn={multiColumn} extraButton={extraButton} + collapseIssues={this.props.needsNotificationPermission} > diff --git a/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js b/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js index da553cd9f..b8932704b 100644 --- a/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js +++ b/app/javascript/mastodon/features/ui/components/notifications_counter_icon.js @@ -3,6 +3,7 @@ import IconWithBadge from 'mastodon/components/icon_with_badge'; const mapStateToProps = state => ({ count: state.getIn(['notifications', 'unread']), + issueBadge: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) !== 'granted', id: 'bell', }); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 91c86b505..c6df49a5f 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -366,10 +366,6 @@ class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 120 * 1000); - } - this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index da4884fd3..bda51f692 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -1,4 +1,5 @@ import * as registerPushNotifications from './actions/push_notifications'; +import { setupBrowserNotifications } from './actions/notifications'; import { default as Mastodon, store } from './containers/mastodon'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -22,6 +23,7 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(, mountNode); + store.dispatch(setupBrowserNotifications()); if (process.env.NODE_ENV === 'production') { // avoid offline in dev mode because it's harder to debug require('offline-plugin/runtime').install(); diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 216876134..1d4874717 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -10,6 +10,8 @@ import { NOTIFICATIONS_MOUNT, NOTIFICATIONS_UNMOUNT, NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -40,6 +42,8 @@ const initialState = ImmutableMap({ readMarkerId: '0', isTabVisible: true, isLoading: false, + browserSupport: false, + browserPermission: 'default', }); const notificationToMap = notification => ImmutableMap({ @@ -242,6 +246,10 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_MARK_AS_READ: const lastNotification = state.get('items').find(item => item !== null); return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); default: return state; } diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index efef2ad9a..886353de3 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -29,12 +29,12 @@ const initialState = ImmutableMap({ notifications: ImmutableMap({ alerts: ImmutableMap({ - follow: true, + follow: false, follow_request: false, - favourite: true, - reblog: true, - mention: true, - poll: true, + favourite: false, + reblog: false, + mention: false, + poll: false, }), quickFilter: ImmutableMap({ diff --git a/app/javascript/mastodon/utils/notifications.js b/app/javascript/mastodon/utils/notifications.js new file mode 100644 index 000000000..ab119c2e3 --- /dev/null +++ b/app/javascript/mastodon/utils/notifications.js @@ -0,0 +1,29 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ec49ae120..8a8f20baa 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2418,6 +2418,17 @@ a.account__display-name { line-height: 14px; color: $primary-text-color; } + + &__issue-badge { + position: absolute; + left: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } } .column-link--transparent .icon-with-badge__badge { @@ -3453,6 +3464,15 @@ a.status-card.compact:hover { cursor: pointer; } +.column-header__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + .column-header__icon { display: inline-block; margin-right: 5px;