Add Desktop notifications (#252)
authorginnyTheCat <ginnythecat@lelux.net>
Sat, 29 Jan 2022 14:20:51 +0000 (15:20 +0100)
committerGitHub <noreply@github.com>
Sat, 29 Jan 2022 14:20:51 +0000 (19:50 +0530)
* Add notifications

* Abide push actions

* Handle browsers not having notification support

* Ask for notification permission after loading

* Make usePermission work without live permission support

* Focus message when clicking the notification

* make const all caps

* Fix usePermission error in Safari

* Fix live permissions

* Remove userActivity and use document.visibilityState instead

* Change setting label to "desktop notifications"

* Check for notification permissions in the settings.js

src/app/hooks/usePermission.js [new file with mode: 0644]
src/app/organisms/settings/Settings.jsx
src/app/organisms/settings/Settings.scss
src/client/action/settings.js
src/client/state/Notifications.js
src/client/state/cons.js
src/client/state/settings.js

diff --git a/src/app/hooks/usePermission.js b/src/app/hooks/usePermission.js
new file mode 100644 (file)
index 0000000..5dc7607
--- /dev/null
@@ -0,0 +1,28 @@
+/* eslint-disable import/prefer-default-export */
+
+import { useEffect, useState } from 'react';
+
+export function usePermission(name, initial) {
+  const [state, setState] = useState(initial);
+
+  useEffect(() => {
+    let descriptor;
+
+    const update = () => setState(descriptor.state);
+
+    if (navigator.permissions?.query) {
+      navigator.permissions.query({ name }).then((_descriptor) => {
+        descriptor = _descriptor;
+
+        update();
+        descriptor.addEventListener('change', update);
+      });
+    }
+
+    return () => {
+      if (descriptor) descriptor.removeEventListener('change', update);
+    };
+  }, []);
+
+  return [state, setState];
+}
index 9a3db0fddaaaa7e93965c1bf54c8ba0709335148..0c3e1aaf22bb222e6a543352b334dec8f99186d0 100644 (file)
@@ -5,8 +5,12 @@ import './Settings.scss';
 import initMatrix from '../../../client/initMatrix';
 import cons from '../../../client/state/cons';
 import settings from '../../../client/state/settings';
-import { toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents } from '../../../client/action/settings';
+import {
+  toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents,
+  toggleNotifications,
+} from '../../../client/action/settings';
 import logout from '../../../client/action/logout';
+import { usePermission } from '../../hooks/usePermission';
 
 import Text from '../../atoms/text/Text';
 import IconButton from '../../atoms/button/IconButton';
@@ -24,6 +28,7 @@ import ProfileEditor from '../profile-editor/ProfileEditor';
 import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
 import SunIC from '../../../../public/res/ic/outlined/sun.svg';
 import LockIC from '../../../../public/res/ic/outlined/lock.svg';
+import BellIC from '../../../../public/res/ic/outlined/bell.svg';
 import InfoIC from '../../../../public/res/ic/outlined/info.svg';
 import PowerIC from '../../../../public/res/ic/outlined/power.svg';
 import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
@@ -60,21 +65,23 @@ function AppearanceSection() {
       />
       {(() => {
         if (!settings.useSystemTheme) {
-          return <SettingTile
-            title="Theme"
-            content={(
-              <SegmentedControls
-                selected={settings.getThemeIndex()}
-                segments={[
-                  { text: 'Light' },
-                  { text: 'Silver' },
-                  { text: 'Dark' },
-                  { text: 'Butter' },
-                ]}
-                onSelect={(index) => settings.setTheme(index)}
-              />
+          return (
+            <SettingTile
+              title="Theme"
+              content={(
+                <SegmentedControls
+                  selected={settings.getThemeIndex()}
+                  segments={[
+                    { text: 'Light' },
+                    { text: 'Silver' },
+                    { text: 'Dark' },
+                    { text: 'Butter' },
+                  ]}
+                  onSelect={(index) => settings.setTheme(index)}
+                />
             )}
-          />
+            />
+          );
         }
       })()}
       <SettingTile
@@ -111,6 +118,50 @@ function AppearanceSection() {
   );
 }
 
+function NotificationsSection() {
+  const [permission, setPermission] = usePermission('notifications', window.Notification?.permission);
+
+  const [, updateState] = useState({});
+
+  const renderOptions = () => {
+    if (window.Notification === undefined) {
+      return <Text className="set-notifications__not-supported">Not supported in this browser.</Text>;
+    }
+
+    if (permission === 'granted') {
+      return (
+        <Toggle
+          isActive={settings._showNotifications}
+          onToggle={() => {
+            toggleNotifications();
+            setPermission(window.Notification?.permission);
+            updateState({});
+          }}
+        />
+      );
+    }
+
+    return (
+      <Button
+        variant="primary"
+        onClick={() => window.Notification.requestPermission().then(setPermission)}
+      >
+        Request permission
+      </Button>
+    );
+  };
+
+  return (
+    <div className="set-notifications settings-content">
+      <SettingTile
+        title="Show desktop notifications"
+        options={renderOptions()}
+        content={<Text variant="b3">Show notifications when new messages arrive.</Text>}
+      />
+    </div>
+  );
+}
+
 function SecuritySection() {
   return (
     <div className="set-security settings-content">
@@ -178,6 +229,12 @@ function Settings({ isOpen, onRequestClose }) {
     render() {
       return <AppearanceSection />;
     },
+  }, {
+    name: 'Notifications',
+    iconSrc: BellIC,
+    render() {
+      return <NotificationsSection />;
+    },
   }, {
     name: 'Security & Privacy',
     iconSrc: LockIC,
index 9b42c66ee706dc595f68a34df7ea39d050ed0f90..cb8d139a10d3de751838bb8d10f27358e905e7fe 100644 (file)
   }
 }
 
+.set-notifications {
+  &__not-supported {
+    padding: 0 var(--sp-ultra-tight);
+  }
+}
+
 .set-about {
   &__branding {
     margin-top: var(--sp-extra-tight);
index e849702b8e54766b382f0199cdb792bcb59a91a3..11923418e50b3d1e9a57e18daafe595a6f54c3c1 100644 (file)
@@ -30,3 +30,9 @@ export function toggleNickAvatarEvents() {
     type: cons.actions.settings.TOGGLE_NICKAVATAR_EVENT,
   });
 }
+
+export function toggleNotifications() {
+  appDispatcher.dispatch({
+    type: cons.actions.settings.TOGGLE_NOTIFICATIONS,
+  });
+}
index e41dc8f3c894a58753cb70a8d2a6870115f4d401..94626649f50d243fecb9022231413edf26fdd5cf 100644 (file)
@@ -1,5 +1,8 @@
 import EventEmitter from 'events';
+import { selectRoom } from '../action/navigation';
 import cons from './cons';
+import navigation from './navigation';
+import settings from './settings';
 
 function isNotifEvent(mEvent) {
   const eType = mEvent.getType();
@@ -24,6 +27,9 @@ class Notifications extends EventEmitter {
     this._initNoti();
     this._listenEvents();
 
+    // Ask for permission by default after loading
+    window.Notification?.requestPermission();
+
     // TODO:
     window.notifications = this;
   }
@@ -158,6 +164,32 @@ class Notifications extends EventEmitter {
     [...parentIds].forEach((parentId) => this._deleteNoti(parentId, total, highlight, roomId));
   }
 
+  async _displayPopupNoti(mEvent, room) {
+    if (!settings.showNotifications) return;
+
+    const actions = this.matrixClient.getPushActionsForEvent(mEvent);
+    if (!actions?.notify) return;
+
+    if (navigation.selectedRoomId === room.roomId && document.visibilityState === 'visible') return;
+
+    if (mEvent.isEncrypted()) {
+      await mEvent.attemptDecryption(this.matrixClient.crypto);
+    }
+
+    let title;
+    if (!mEvent.sender || room.name === mEvent.sender.name) {
+      title = room.name;
+    } else if (mEvent.sender) {
+      title = `${mEvent.sender.name} (${room.name})`;
+    }
+
+    const noti = new window.Notification(title, {
+      body: mEvent.getContent().body,
+      icon: mEvent.sender?.getAvatarUrl(this.matrixClient.baseUrl, 36, 36, 'crop'),
+    });
+    noti.onclick = () => selectRoom(room.roomId, mEvent.getId());
+  }
+
   _listenEvents() {
     this.matrixClient.on('Room.timeline', (mEvent, room) => {
       if (!isNotifEvent(mEvent)) return;
@@ -172,6 +204,10 @@ class Notifications extends EventEmitter {
 
       const noti = this.getNoti(room.roomId);
       this._setNoti(room.roomId, total - noti.total, highlight - noti.highlight);
+
+      if (this.matrixClient.getSyncState() === 'SYNCING') {
+        this._displayPopupNoti(mEvent, room);
+      }
     });
 
     this.matrixClient.on('Room.receipt', (mEvent, room) => {
index c0f8687f9dfdce304c67b727376a685322d71599..2516b2ae76179f6d053c1ac24be1991d1dc2f5cc 100644 (file)
@@ -60,6 +60,7 @@ const cons = {
       TOGGLE_PEOPLE_DRAWER: 'TOGGLE_PEOPLE_DRAWER',
       TOGGLE_MEMBERSHIP_EVENT: 'TOGGLE_MEMBERSHIP_EVENT',
       TOGGLE_NICKAVATAR_EVENT: 'TOGGLE_NICKAVATAR_EVENT',
+      TOGGLE_NOTIFICATIONS: 'TOGGLE_NOTIFICATIONS',
     },
   },
   events: {
@@ -118,6 +119,7 @@ const cons = {
       PEOPLE_DRAWER_TOGGLED: 'PEOPLE_DRAWER_TOGGLED',
       MEMBERSHIP_EVENTS_TOGGLED: 'MEMBERSHIP_EVENTS_TOGGLED',
       NICKAVATAR_EVENTS_TOGGLED: 'NICKAVATAR_EVENTS_TOGGLED',
+      NOTIFICATIONS_TOGGLED: 'NOTIFICATIONS_TOGGLED',
     },
   },
 };
index 84d269acaef0c0e42ef47f05fac03cae84225085..011d0bd96e7e1f613b3379ed06ce29579ec7ec81 100644 (file)
@@ -28,6 +28,7 @@ class Settings extends EventEmitter {
     this.isPeopleDrawer = this.getIsPeopleDrawer();
     this.hideMembershipEvents = this.getHideMembershipEvents();
     this.hideNickAvatarEvents = this.getHideNickAvatarEvents();
+    this._showNotifications = this.getShowNotifications();
 
     this.isTouchScreenDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
   }
@@ -110,6 +111,20 @@ class Settings extends EventEmitter {
     return settings.isPeopleDrawer;
   }
 
+  get showNotifications() {
+    if (window.Notification?.permission !== 'granted') return false;
+    return this._showNotifications;
+  }
+
+  getShowNotifications() {
+    if (typeof this._showNotifications === 'boolean') return this._showNotifications;
+
+    const settings = getSettings();
+    if (settings === null) return true;
+    if (typeof settings.showNotifications === 'undefined') return true;
+    return settings.showNotifications;
+  }
+
   setter(action) {
     const actions = {
       [cons.actions.settings.TOGGLE_SYSTEM_THEME]: () => {
@@ -140,6 +155,15 @@ class Settings extends EventEmitter {
         setSettings('hideNickAvatarEvents', this.hideNickAvatarEvents);
         this.emit(cons.events.settings.NICKAVATAR_EVENTS_TOGGLED, this.hideNickAvatarEvents);
       },
+      [cons.actions.settings.TOGGLE_NOTIFICATIONS]: async () => {
+        if (window.Notification?.permission !== 'granted') {
+          this._showNotifications = false;
+        } else {
+          this._showNotifications = !this._showNotifications;
+        }
+        setSettings('showNotifications', this._showNotifications);
+        this.emit(cons.events.settings.NOTIFICATIONS_TOGGLED, this._showNotifications);
+      },
     };
 
     actions[action.type]?.();