From c828dfd596d973a9f05a53166d1fff6afd2862a9 Mon Sep 17 00:00:00 2001 From: ginnyTheCat Date: Sat, 29 Jan 2022 15:20:51 +0100 Subject: [PATCH] Add Desktop notifications (#252) * 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 | 28 ++++++++ src/app/organisms/settings/Settings.jsx | 87 ++++++++++++++++++++---- src/app/organisms/settings/Settings.scss | 6 ++ src/client/action/settings.js | 6 ++ src/client/state/Notifications.js | 36 ++++++++++ src/client/state/cons.js | 2 + src/client/state/settings.js | 24 +++++++ 7 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 src/app/hooks/usePermission.js diff --git a/src/app/hooks/usePermission.js b/src/app/hooks/usePermission.js new file mode 100644 index 0000000..5dc7607 --- /dev/null +++ b/src/app/hooks/usePermission.js @@ -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]; +} diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index 9a3db0f..0c3e1aa 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -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 settings.setTheme(index)} - /> + return ( + settings.setTheme(index)} + /> )} - /> + /> + ); } })()} { + if (window.Notification === undefined) { + return Not supported in this browser.; + } + + if (permission === 'granted') { + return ( + { + toggleNotifications(); + setPermission(window.Notification?.permission); + updateState({}); + }} + /> + ); + } + + return ( + + ); + }; + + return ( +
+ Show notifications when new messages arrive.} + /> +
+ ); +} + function SecuritySection() { return (
@@ -178,6 +229,12 @@ function Settings({ isOpen, onRequestClose }) { render() { return ; }, + }, { + name: 'Notifications', + iconSrc: BellIC, + render() { + return ; + }, }, { name: 'Security & Privacy', iconSrc: LockIC, diff --git a/src/app/organisms/settings/Settings.scss b/src/app/organisms/settings/Settings.scss index 9b42c66..cb8d139 100644 --- a/src/app/organisms/settings/Settings.scss +++ b/src/app/organisms/settings/Settings.scss @@ -26,6 +26,12 @@ } } +.set-notifications { + &__not-supported { + padding: 0 var(--sp-ultra-tight); + } +} + .set-about { &__branding { margin-top: var(--sp-extra-tight); diff --git a/src/client/action/settings.js b/src/client/action/settings.js index e849702..1192341 100644 --- a/src/client/action/settings.js +++ b/src/client/action/settings.js @@ -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, + }); +} diff --git a/src/client/state/Notifications.js b/src/client/state/Notifications.js index e41dc8f..9462664 100644 --- a/src/client/state/Notifications.js +++ b/src/client/state/Notifications.js @@ -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) => { diff --git a/src/client/state/cons.js b/src/client/state/cons.js index c0f8687..2516b2a 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -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', }, }, }; diff --git a/src/client/state/settings.js b/src/client/state/settings.js index 84d269a..011d0bd 100644 --- a/src/client/state/settings.js +++ b/src/client/state/settings.js @@ -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]?.();