about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch')
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js44
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js18
-rw-r--r--app/javascript/flavours/glitch/components/icon_with_badge.js4
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js7
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js39
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js8
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js10
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss11
-rw-r--r--app/javascript/flavours/glitch/util/main.js2
-rw-r--r--app/javascript/flavours/glitch/util/notifications.js29
14 files changed, 202 insertions, 18 deletions
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 7f311153b..eb7087027 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors';
 import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
 import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
+import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
+
 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' },
 });
@@ -327,3 +332,42 @@ export function 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/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index 01bd4a246..ccd0714f1 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/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 = {
@@ -88,7 +89,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', {
@@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent {
     }
 
     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;
diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js
index 219efc28c..a42ba4589 100644
--- a/app/javascript/flavours/glitch/components/icon_with_badge.js
+++ b/app/javascript/flavours/glitch/components/icon_with_badge.js
@@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon';
 
 const formatNumber = num => num > 40 ? '40+' : num;
 
-const IconWithBadge = ({ id, count, className }) => (
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
   <i className='icon-with-badge'>
     <Icon id={id} fixedWidth className={className} />
     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
   </i>
 );
 
 IconWithBadge.propTypes = {
   id: PropTypes.string.isRequired,
   count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
   className: PropTypes.string,
 };
 
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 8101be87e..762280bec 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent {
 
   componentDidMount() {
     this.disconnect = store.dispatch(connectUserStream());
-
-    // Desktop notifications
-    // Ask after 1 minute
-    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
-    }
-
     store.dispatch(showOnboardingOnce());
   }
 
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index e4d5d0eda..33ed139c7 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/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 = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@@ -31,8 +36,40 @@ export default class ColumnSettings extends React.PureComponent {
     const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
     const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
 
+    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 (
       <div>
+        {settingsIssues && (
+          <div className='column-settings__row'>
+            {settingsIssues}
+          </div>
+        )}
+
         <div className='column-settings__row'>
           <ClearColumnButton onClick={onClear} />
         </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
index 4b863712a..b4e309fce 100644
--- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/settings';
 import { setFilter } from 'flavours/glitch/actions/notifications';
-import { clearNotifications } from 'flavours/glitch/actions/notifications';
+import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications';
 import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { showAlert } from 'flavours/glitch/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/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 18db7e0d2..87fb3074b 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -62,6 +62,7 @@ const mapStateToProps = state => ({
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
   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',
 });
 
 /* glitch */
@@ -105,6 +106,7 @@ class Notifications extends React.PureComponent {
     onUnmount: PropTypes.func,
     lastReadId: PropTypes.string,
     canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -333,6 +335,7 @@ class Notifications extends React.PureComponent {
           multiColumn={multiColumn}
           localSettings={this.props.localSettings}
           extraButton={extraButtons}
+          collapseIssues={this.props.needsNotificationPermission}
           appendContent={notifCleaningDrawer}
         >
           <ColumnSettingsContainer />
diff --git a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
index 6b52ef9b4..ef195013c 100644
--- a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
+++ b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
@@ -3,6 +3,7 @@ import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
 
 const mapStateToProps = state => ({
   count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0,
+  issueBadge: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) !== 'granted',
   id: 'bell',
 });
 
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index 6daac229b..b4c5ef71a 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -17,6 +17,8 @@ import {
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
   NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
 } from 'flavours/glitch/actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -44,6 +46,8 @@ const initialState = ImmutableMap({
   isLoading: false,
   cleaningMode: false,
   isTabVisible: true,
+  browserSupport: false,
+  browserPermission: 'default',
   // notification removal mark of new notifs loaded whilst cleaningMode is true.
   markNewForDelete: false,
 });
@@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) {
     return action.timeline === 'home' ?
       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
       state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
 
   case NOTIFICATION_MARK_FOR_DELETE:
     return markForDelete(state, action.id, action.yes);
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index ef99ad552..3037c3885 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -33,12 +33,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/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 6b657660a..e0d506c9a 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -463,6 +463,15 @@
   flex: 1;
 }
 
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
 .column-header__icon {
   display: inline-block;
   margin-right: 5px;
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 04266c497..56d658d97 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -708,6 +708,17 @@
     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 {
diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js
index 1fdb9ff2b..6577b70c2 100644
--- a/app/javascript/flavours/glitch/util/main.js
+++ b/app/javascript/flavours/glitch/util/main.js
@@ -1,4 +1,5 @@
 import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
+import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
 import { default as Mastodon, store } from 'flavours/glitch/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(<Mastodon {...props} />, 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/flavours/glitch/util/notifications.js b/app/javascript/flavours/glitch/util/notifications.js
new file mode 100644
index 000000000..ab119c2e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/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));
+  }
+};