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:
parent
d0b4e092b3
commit
c828dfd596
7 changed files with 174 additions and 15 deletions
28
src/app/hooks/usePermission.js
Normal file
28
src/app/hooks/usePermission.js
Normal 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];
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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]?.();
|
||||||
|
|
Loading…
Reference in a new issue