Fix browser notification permission request logic (#13543)

* Add notification permission handling code

* Request notification permission when enabling any notification setting

* Add badge to notification settings when permissions insufficient

* Disable alerts by default, requesting permission and enable them on onboarding
This commit is contained in:
ThibG 2020-10-13 00:37:21 +02:00 committed by GitHub
parent 5e1364c448
commit f54ca3d08e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 215 additions and 15 deletions

View File

@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id'; import compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
import { requestNotificationPermission } from '../utils/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; 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_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; 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({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@ -235,6 +240,46 @@ export const unmountNotifications = () => ({
type: NOTIFICATIONS_UNMOUNT, type: NOTIFICATIONS_UNMOUNT,
}); });
export const markNotificationsAsRead = () => ({ export const markNotificationsAsRead = () => ({
type: NOTIFICATIONS_MARK_AS_READ, 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,
};
}

View File

@ -1,8 +1,20 @@
import { changeSetting, saveSettings } from './settings'; import { changeSetting, saveSettings } from './settings';
import { requestBrowserPermission } from './notifications';
export const INTRODUCTION_VERSION = 20181216044202; export const INTRODUCTION_VERSION = 20181216044202;
export const closeOnboarding = () => dispatch => { export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings()); 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());
}
}));
}; };

View File

@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
onMove: PropTypes.func, onMove: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
appendContent: PropTypes.node, appendContent: PropTypes.node,
collapseIssues: PropTypes.bool,
}; };
state = { state = {
@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent {
} }
render () { 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 { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent {
} }
if (children || (multiColumn && this.props.onPin)) { if (children || (multiColumn && this.props.onPin)) {
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; collapseButton = (
<button
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
);
} }
const hasTitle = icon && title; const hasTitle = icon && title;

View File

@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon';
const formatNumber = num => num > 40 ? '40+' : num; const formatNumber = num => num > 40 ? '40+' : num;
const IconWithBadge = ({ id, count, className }) => ( const IconWithBadge = ({ id, count, issueBadge, className }) => (
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} /> <Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i> </i>
); );
IconWithBadge.propTypes = { IconWithBadge.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
issueBadge: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
}; };

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ClearColumnButton from './clear_column_button'; import ClearColumnButton from './clear_column_button';
import SettingToggle from './setting_toggle'; import SettingToggle from './setting_toggle';
import Icon from 'mastodon/components/icon';
export default class ColumnSettings extends React.PureComponent { export default class ColumnSettings extends React.PureComponent {
@ -12,6 +13,10 @@ export default class ColumnSettings extends React.PureComponent {
pushSettings: ImmutablePropTypes.map.isRequired, pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
onRequestNotificationPermission: PropTypes.func.isRequired,
alertsEnabled: PropTypes.bool,
browserSupport: PropTypes.bool,
browserPermission: PropTypes.bool,
}; };
onPushChange = (path, checked) => { onPushChange = (path, checked) => {
@ -19,7 +24,7 @@ export default class ColumnSettings extends React.PureComponent {
} }
render () { render () {
const { settings, pushSettings, onChange, onClear } = this.props; const { settings, pushSettings, onChange, onClear, onRequestNotificationPermission, alertsEnabled, browserSupport, browserPermission } = this.props;
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@ -30,8 +35,40 @@ export default class ColumnSettings extends React.PureComponent {
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
const settingsIssues = [];
if (alertsEnabled && browserSupport && browserPermission !== 'granted') {
if (browserPermission === 'denied') {
settingsIssues.push(
<button
className='text-btn column-header__issue-btn'
tabIndex='0'
onClick={onRequestNotificationPermission}
>
<Icon id='exclamation-circle' /> <FormattedMessage id='notifications.permission_denied' defaultMessage='Mastodon cannot show notifications because the permission has been denied' />
</button>
);
} else if (browserPermission === 'default') {
settingsIssues.push(
<button
className='text-btn column-header__issue-btn'
tabIndex='0'
onClick={onRequestNotificationPermission}
>
<Icon id='exclamation-circle' /> <FormattedMessage id='notifications.request_permission' defaultMessage='Enable browser notifications' />
</button>
);
}
}
return ( return (
<div> <div>
{settingsIssues && (
<div className='column-settings__row'>
{settingsIssues}
</div>
)}
<div className='column-settings__row'> <div className='column-settings__row'>
<ClearColumnButton onClick={onClear} /> <ClearColumnButton onClick={onClear} />
</div> </div>

View File

@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
import { changeSetting } from '../../../actions/settings'; import { changeSetting } from '../../../actions/settings';
import { setFilter } from '../../../actions/notifications'; 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 { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { showAlert } from '../../../actions/alerts';
const messages = defineMessages({ const messages = defineMessages({
clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear 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 => ({ const mapStateToProps = state => ({
settings: state.getIn(['settings', 'notifications']), settings: state.getIn(['settings', 'notifications']),
pushSettings: state.get('push_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 }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (path, checked) { onChange (path, checked) {
if (path[0] === 'push') { 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') { } else if (path[0] === 'quickFilter') {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
dispatch(setFilter('all')); 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 { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
})); }));
}, },
onRequestNotificationPermission () {
dispatch(requestBrowserPermission());
},
}); });
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));

View File

@ -55,6 +55,7 @@ const mapStateToProps = state => ({
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
lastReadId: state.getIn(['notifications', 'readMarkerId']), 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), 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) export default @connect(mapStateToProps)
@ -75,6 +76,7 @@ class Notifications extends React.PureComponent {
numPending: PropTypes.number, numPending: PropTypes.number,
lastReadId: PropTypes.string, lastReadId: PropTypes.string,
canMarkAsRead: PropTypes.bool, canMarkAsRead: PropTypes.bool,
needsNotificationPermission: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -250,6 +252,7 @@ class Notifications extends React.PureComponent {
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={extraButton} extraButton={extraButton}
collapseIssues={this.props.needsNotificationPermission}
> >
<ColumnSettingsContainer /> <ColumnSettingsContainer />
</ColumnHeader> </ColumnHeader>

View File

@ -3,6 +3,7 @@ import IconWithBadge from 'mastodon/components/icon_with_badge';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
count: state.getIn(['notifications', 'unread']), count: state.getIn(['notifications', 'unread']),
issueBadge: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) !== 'granted',
id: 'bell', id: 'bell',
}); });

View File

@ -366,10 +366,6 @@ class UI extends React.PureComponent {
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); 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(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());

View File

@ -1,4 +1,5 @@
import * as registerPushNotifications from './actions/push_notifications'; import * as registerPushNotifications from './actions/push_notifications';
import { setupBrowserNotifications } from './actions/notifications';
import { default as Mastodon, store } from './containers/mastodon'; import { default as Mastodon, store } from './containers/mastodon';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
@ -22,6 +23,7 @@ function main() {
const props = JSON.parse(mountNode.getAttribute('data-props')); const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
store.dispatch(setupBrowserNotifications());
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug // avoid offline in dev mode because it's harder to debug
require('offline-plugin/runtime').install(); require('offline-plugin/runtime').install();

View File

@ -10,6 +10,8 @@ import {
NOTIFICATIONS_MOUNT, NOTIFICATIONS_MOUNT,
NOTIFICATIONS_UNMOUNT, NOTIFICATIONS_UNMOUNT,
NOTIFICATIONS_MARK_AS_READ, NOTIFICATIONS_MARK_AS_READ,
NOTIFICATIONS_SET_BROWSER_SUPPORT,
NOTIFICATIONS_SET_BROWSER_PERMISSION,
} from '../actions/notifications'; } from '../actions/notifications';
import { import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
@ -40,6 +42,8 @@ const initialState = ImmutableMap({
readMarkerId: '0', readMarkerId: '0',
isTabVisible: true, isTabVisible: true,
isLoading: false, isLoading: false,
browserSupport: false,
browserPermission: 'default',
}); });
const notificationToMap = notification => ImmutableMap({ const notificationToMap = notification => ImmutableMap({
@ -242,6 +246,10 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_MARK_AS_READ: case NOTIFICATIONS_MARK_AS_READ:
const lastNotification = state.get('items').find(item => item !== null); const lastNotification = state.get('items').find(item => item !== null);
return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; 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: default:
return state; return state;
} }

View File

@ -29,12 +29,12 @@ const initialState = ImmutableMap({
notifications: ImmutableMap({ notifications: ImmutableMap({
alerts: ImmutableMap({ alerts: ImmutableMap({
follow: true, follow: false,
follow_request: false, follow_request: false,
favourite: true, favourite: false,
reblog: true, reblog: false,
mention: true, mention: false,
poll: true, poll: false,
}), }),
quickFilter: ImmutableMap({ quickFilter: ImmutableMap({

View File

@ -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));
}
};

View File

@ -2418,6 +2418,17 @@ a.account__display-name {
line-height: 14px; line-height: 14px;
color: $primary-text-color; 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 { .column-link--transparent .icon-with-badge__badge {
@ -3453,6 +3464,15 @@ a.status-card.compact:hover {
cursor: pointer; cursor: pointer;
} }
.column-header__issue-btn {
color: $warning-red;
&:hover {
color: $error-red;
text-decoration: underline;
}
}
.column-header__icon { .column-header__icon {
display: inline-block; display: inline-block;
margin-right: 5px; margin-right: 5px;