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
This commit is contained in:
ginnyTheCat 2022-01-29 15:20:51 +01:00 committed by GitHub
parent d0b4e092b3
commit c828dfd596
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 174 additions and 15 deletions

View file

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

View file

@ -5,8 +5,12 @@ import './Settings.scss';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import settings from '../../../client/state/settings'; 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 logout from '../../../client/action/logout';
import { usePermission } from '../../hooks/usePermission';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton'; 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 SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import SunIC from '../../../../public/res/ic/outlined/sun.svg'; import SunIC from '../../../../public/res/ic/outlined/sun.svg';
import LockIC from '../../../../public/res/ic/outlined/lock.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 InfoIC from '../../../../public/res/ic/outlined/info.svg';
import PowerIC from '../../../../public/res/ic/outlined/power.svg'; import PowerIC from '../../../../public/res/ic/outlined/power.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
@ -60,7 +65,8 @@ function AppearanceSection() {
/> />
{(() => { {(() => {
if (!settings.useSystemTheme) { if (!settings.useSystemTheme) {
return <SettingTile return (
<SettingTile
title="Theme" title="Theme"
content={( content={(
<SegmentedControls <SegmentedControls
@ -75,6 +81,7 @@ function AppearanceSection() {
/> />
)} )}
/> />
);
} }
})()} })()}
<SettingTile <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() { function SecuritySection() {
return ( return (
<div className="set-security settings-content"> <div className="set-security settings-content">
@ -178,6 +229,12 @@ function Settings({ isOpen, onRequestClose }) {
render() { render() {
return <AppearanceSection />; return <AppearanceSection />;
}, },
}, {
name: 'Notifications',
iconSrc: BellIC,
render() {
return <NotificationsSection />;
},
}, { }, {
name: 'Security & Privacy', name: 'Security & Privacy',
iconSrc: LockIC, iconSrc: LockIC,

View file

@ -26,6 +26,12 @@
} }
} }
.set-notifications {
&__not-supported {
padding: 0 var(--sp-ultra-tight);
}
}
.set-about { .set-about {
&__branding { &__branding {
margin-top: var(--sp-extra-tight); margin-top: var(--sp-extra-tight);

View file

@ -30,3 +30,9 @@ export function toggleNickAvatarEvents() {
type: cons.actions.settings.TOGGLE_NICKAVATAR_EVENT, type: cons.actions.settings.TOGGLE_NICKAVATAR_EVENT,
}); });
} }
export function toggleNotifications() {
appDispatcher.dispatch({
type: cons.actions.settings.TOGGLE_NOTIFICATIONS,
});
}

View file

@ -1,5 +1,8 @@
import EventEmitter from 'events'; import EventEmitter from 'events';
import { selectRoom } from '../action/navigation';
import cons from './cons'; import cons from './cons';
import navigation from './navigation';
import settings from './settings';
function isNotifEvent(mEvent) { function isNotifEvent(mEvent) {
const eType = mEvent.getType(); const eType = mEvent.getType();
@ -24,6 +27,9 @@ class Notifications extends EventEmitter {
this._initNoti(); this._initNoti();
this._listenEvents(); this._listenEvents();
// Ask for permission by default after loading
window.Notification?.requestPermission();
// TODO: // TODO:
window.notifications = this; window.notifications = this;
} }
@ -158,6 +164,32 @@ class Notifications extends EventEmitter {
[...parentIds].forEach((parentId) => this._deleteNoti(parentId, total, highlight, roomId)); [...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() { _listenEvents() {
this.matrixClient.on('Room.timeline', (mEvent, room) => { this.matrixClient.on('Room.timeline', (mEvent, room) => {
if (!isNotifEvent(mEvent)) return; if (!isNotifEvent(mEvent)) return;
@ -172,6 +204,10 @@ class Notifications extends EventEmitter {
const noti = this.getNoti(room.roomId); const noti = this.getNoti(room.roomId);
this._setNoti(room.roomId, total - noti.total, highlight - noti.highlight); 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) => { this.matrixClient.on('Room.receipt', (mEvent, room) => {

View file

@ -60,6 +60,7 @@ const cons = {
TOGGLE_PEOPLE_DRAWER: 'TOGGLE_PEOPLE_DRAWER', TOGGLE_PEOPLE_DRAWER: 'TOGGLE_PEOPLE_DRAWER',
TOGGLE_MEMBERSHIP_EVENT: 'TOGGLE_MEMBERSHIP_EVENT', TOGGLE_MEMBERSHIP_EVENT: 'TOGGLE_MEMBERSHIP_EVENT',
TOGGLE_NICKAVATAR_EVENT: 'TOGGLE_NICKAVATAR_EVENT', TOGGLE_NICKAVATAR_EVENT: 'TOGGLE_NICKAVATAR_EVENT',
TOGGLE_NOTIFICATIONS: 'TOGGLE_NOTIFICATIONS',
}, },
}, },
events: { events: {
@ -118,6 +119,7 @@ const cons = {
PEOPLE_DRAWER_TOGGLED: 'PEOPLE_DRAWER_TOGGLED', PEOPLE_DRAWER_TOGGLED: 'PEOPLE_DRAWER_TOGGLED',
MEMBERSHIP_EVENTS_TOGGLED: 'MEMBERSHIP_EVENTS_TOGGLED', MEMBERSHIP_EVENTS_TOGGLED: 'MEMBERSHIP_EVENTS_TOGGLED',
NICKAVATAR_EVENTS_TOGGLED: 'NICKAVATAR_EVENTS_TOGGLED', NICKAVATAR_EVENTS_TOGGLED: 'NICKAVATAR_EVENTS_TOGGLED',
NOTIFICATIONS_TOGGLED: 'NOTIFICATIONS_TOGGLED',
}, },
}, },
}; };

View file

@ -28,6 +28,7 @@ class Settings extends EventEmitter {
this.isPeopleDrawer = this.getIsPeopleDrawer(); this.isPeopleDrawer = this.getIsPeopleDrawer();
this.hideMembershipEvents = this.getHideMembershipEvents(); this.hideMembershipEvents = this.getHideMembershipEvents();
this.hideNickAvatarEvents = this.getHideNickAvatarEvents(); this.hideNickAvatarEvents = this.getHideNickAvatarEvents();
this._showNotifications = this.getShowNotifications();
this.isTouchScreenDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0); this.isTouchScreenDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0);
} }
@ -110,6 +111,20 @@ class Settings extends EventEmitter {
return settings.isPeopleDrawer; 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) { setter(action) {
const actions = { const actions = {
[cons.actions.settings.TOGGLE_SYSTEM_THEME]: () => { [cons.actions.settings.TOGGLE_SYSTEM_THEME]: () => {
@ -140,6 +155,15 @@ class Settings extends EventEmitter {
setSettings('hideNickAvatarEvents', this.hideNickAvatarEvents); setSettings('hideNickAvatarEvents', this.hideNickAvatarEvents);
this.emit(cons.events.settings.NICKAVATAR_EVENTS_TOGGLED, 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]?.(); actions[action.type]?.();