diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index cd03a1d784..c4fa664282 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 a1dd3a731e..90f1da7bd4 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 1bb583583a..236e922969 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 7851eb4be9..4214eccfde 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 8bd03fbdab..be88df6d6d 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 a67f262953..664c379806 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 68afe593d2..5e87faa082 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 da553cd9f0..b8932704b5 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 91c86b505a..c6df49a5fb 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 da4884fd3d..bda51f692b 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 216876134d..1d48747176 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 efef2ad9a5..886353de3d 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 0000000000..ab119c2e34
--- /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 ec49ae120a..8a8f20baa5 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;