Notification settings (#807)

* Add account data hook

* Add options to change global notification

* Add options to add keywords

* Fix wrong notification count

* Show unread badge in favicon (#251)

* Fix favicon badge

* Fix bug

* Fix favicon not updating

* Add favicon svg's

* remove dead code
This commit is contained in:
Ajay Bura 2022-09-04 13:10:07 +05:30 committed by GitHub
parent ac155bbf4c
commit 584fa87bbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 587 additions and 39 deletions

View file

@ -0,0 +1,13 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_1961)">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
</g>
<defs>
<clipPath id="clip0_2707_1961">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,13 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2707_2015)">
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
</g>
<defs>
<clipPath id="clip0_2707_2015">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,8 +1,4 @@
import { avatarInitials } from '../../../util/common'; import { avatarInitials, cssVar } from '../../../util/common';
function cssVar(name) {
return getComputedStyle(document.body).getPropertyValue(name);
}
// renders the avatar and returns it as an URL // renders the avatar and returns it as an URL
export default async function renderAvatar({ export default async function renderAvatar({

View file

@ -0,0 +1,22 @@
/* eslint-disable import/prefer-default-export */
import { useState, useEffect } from 'react';
import initMatrix from '../../client/initMatrix';
export function useAccountData(eventType) {
const mx = initMatrix.matrixClient;
const [event, setEvent] = useState(mx.getAccountData(eventType)?.getContent());
useEffect(() => {
const handleChange = (mEvent) => {
if (mEvent.getType() !== eventType) return;
setEvent(mEvent.getContent());
};
mx.on('accountData', handleChange);
return () => {
mx.removeListener('accountData', handleChange);
};
}, [eventType]);
return event;
}

View file

@ -0,0 +1,174 @@
import React from 'react';
import initMatrix from '../../../client/initMatrix';
import { openReusableContextMenu } from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import SettingTile from '../setting-tile/SettingTile';
import NotificationSelector from './NotificationSelector';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import { useAccountData } from '../../hooks/useAccountData';
export const notifType = {
ON: 'on',
OFF: 'off',
NOISY: 'noisy',
};
export const typeToLabel = {
[notifType.ON]: 'On',
[notifType.OFF]: 'Off',
[notifType.NOISY]: 'Noisy',
};
Object.freeze(notifType);
const DM = '.m.rule.room_one_to_one';
const ENC_DM = '.m.rule.encrypted_room_one_to_one';
const ROOM = '.m.rule.message';
const ENC_ROOM = '.m.rule.encrypted';
export function getActionType(rule) {
const { actions } = rule;
if (actions.find((action) => action?.set_tweak === 'sound')) return notifType.NOISY;
if (actions.find((action) => action?.set_tweak === 'highlight')) return notifType.ON;
if (actions.find((action) => action === 'dont_notify')) return notifType.OFF;
return notifType.OFF;
}
export function getTypeActions(type, highlightValue = false) {
if (type === notifType.OFF) return ['dont_notify'];
const highlight = { set_tweak: 'highlight' };
if (typeof highlightValue === 'boolean') highlight.value = highlightValue;
if (type === notifType.ON) return ['notify', highlight];
const sound = { set_tweak: 'sound', value: 'default' };
return ['notify', sound, highlight];
}
function useGlobalNotif() {
const mx = initMatrix.matrixClient;
const pushRules = useAccountData('m.push_rules');
const underride = pushRules?.global?.underride ?? [];
const rulesToType = {
[DM]: notifType.ON,
[ENC_DM]: notifType.ON,
[ROOM]: notifType.NOISY,
[ENC_ROOM]: notifType.NOISY,
};
const getRuleCondition = (rule) => {
const condition = [];
if (rule === DM || rule === ENC_DM) {
condition.push({ kind: 'room_member_count', is: '2' });
}
condition.push({
kind: 'event_match',
key: 'type',
pattern: [ENC_DM, ENC_ROOM].includes(rule) ? 'm.room.encrypted' : 'm.room.message',
});
return condition;
};
const setRule = (rule, type) => {
const content = pushRules ?? {};
if (!content.global) content.global = {};
if (!content.global.underride) content.global.underride = [];
const ur = content.global.underride;
let ruleContent = ur.find((action) => action?.rule_id === rule);
if (!ruleContent) {
ruleContent = {
conditions: getRuleCondition(type),
actions: [],
rule_id: rule,
default: true,
enabled: true,
};
ur.push(ruleContent);
}
ruleContent.actions = getTypeActions(type);
mx.setAccountData('m.push_rules', content);
};
const dmRule = underride.find((rule) => rule.rule_id === DM);
const encDmRule = underride.find((rule) => rule.rule_id === ENC_DM);
const roomRule = underride.find((rule) => rule.rule_id === ROOM);
const encRoomRule = underride.find((rule) => rule.rule_id === ENC_ROOM);
if (dmRule) rulesToType[DM] = getActionType(dmRule);
if (encDmRule) rulesToType[ENC_DM] = getActionType(encDmRule);
if (roomRule) rulesToType[ROOM] = getActionType(roomRule);
if (encRoomRule) rulesToType[ENC_ROOM] = getActionType(encRoomRule);
return [rulesToType, setRule];
}
function GlobalNotification() {
const [rulesToType, setRule] = useGlobalNotif();
const onSelect = (evt, rule) => {
openReusableContextMenu(
'bottom',
getEventCords(evt, '.btn-surface'),
(requestClose) => (
<NotificationSelector
value={rulesToType[rule]}
onSelect={(value) => {
if (rulesToType[rule] !== value) setRule(rule, value);
requestClose();
}}
/>
),
);
};
return (
<div className="global-notification">
<MenuHeader>Global Notifications</MenuHeader>
<SettingTile
title="Direct messages"
options={(
<Button onClick={(evt) => onSelect(evt, DM)} iconSrc={ChevronBottomIC}>
{ typeToLabel[rulesToType[DM]] }
</Button>
)}
content={<Text variant="b3">Default notification settings for all direct message.</Text>}
/>
<SettingTile
title="Encrypted direct messages"
options={(
<Button onClick={(evt) => onSelect(evt, ENC_DM)} iconSrc={ChevronBottomIC}>
{typeToLabel[rulesToType[ENC_DM]]}
</Button>
)}
content={<Text variant="b3">Default notification settings for all encrypted direct message.</Text>}
/>
<SettingTile
title="Rooms messages"
options={(
<Button onClick={(evt) => onSelect(evt, ROOM)} iconSrc={ChevronBottomIC}>
{typeToLabel[rulesToType[ROOM]]}
</Button>
)}
content={<Text variant="b3">Default notification settings for all room message.</Text>}
/>
<SettingTile
title="Encrypted rooms messages"
options={(
<Button onClick={(evt) => onSelect(evt, ENC_ROOM)} iconSrc={ChevronBottomIC}>
{typeToLabel[rulesToType[ENC_ROOM]]}
</Button>
)}
content={<Text variant="b3">Default notification settings for all encrypted room message.</Text>}
/>
</div>
);
}
export default GlobalNotification;

View file

@ -0,0 +1,239 @@
import React from 'react';
import './KeywordNotification.scss';
import initMatrix from '../../../client/initMatrix';
import { openReusableContextMenu } from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
import Text from '../../atoms/text/Text';
import Chip from '../../atoms/chip/Chip';
import Input from '../../atoms/input/Input';
import Button from '../../atoms/button/Button';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import SettingTile from '../setting-tile/SettingTile';
import NotificationSelector from './NotificationSelector';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useAccountData } from '../../hooks/useAccountData';
import {
notifType, typeToLabel, getActionType, getTypeActions,
} from './GlobalNotification';
const DISPLAY_NAME = '.m.rule.contains_display_name';
const ROOM_PING = '.m.rule.roomnotif';
const USERNAME = '.m.rule.contains_user_name';
const KEYWORD = 'keyword';
function useKeywordNotif() {
const mx = initMatrix.matrixClient;
const pushRules = useAccountData('m.push_rules');
const override = pushRules?.global?.override ?? [];
const content = pushRules?.global?.content ?? [];
const rulesToType = {
[DISPLAY_NAME]: notifType.NOISY,
[ROOM_PING]: notifType.NOISY,
[USERNAME]: notifType.NOISY,
};
const setRule = (rule, type) => {
const evtContent = pushRules ?? {};
if (!evtContent.global) evtContent.global = {};
if (!evtContent.global.override) evtContent.global.override = [];
if (!evtContent.global.content) evtContent.global.content = [];
const or = evtContent.global.override;
const ct = evtContent.global.content;
if (rule === DISPLAY_NAME || rule === ROOM_PING) {
let orRule = or.find((r) => r?.rule_id === rule);
if (!orRule) {
orRule = {
conditions: [],
actions: [],
rule_id: rule,
default: true,
enabled: true,
};
or.push(orRule);
}
if (rule === DISPLAY_NAME) {
orRule.conditions = [{ kind: 'contains_display_name' }];
orRule.actions = getTypeActions(type, true);
} else {
orRule.conditions = [
{ kind: 'event_match', key: 'content.body', pattern: '@room' },
{ kind: 'sender_notification_permission', key: 'room' },
];
orRule.actions = getTypeActions(type, true);
}
} else if (rule === USERNAME) {
let usernameRule = ct.find((r) => r?.rule_id === rule);
if (!usernameRule) {
const userId = mx.getUserId();
const username = userId.match(/^@?(\S+):(\S+)$/)?.[1] ?? userId;
usernameRule = {
actions: [],
default: true,
enabled: true,
pattern: username,
rule_id: rule,
};
ct.push(usernameRule);
}
usernameRule.actions = getTypeActions(type, true);
} else {
const keyRules = ct.filter((r) => r.rule_id !== USERNAME);
keyRules.forEach((r) => {
// eslint-disable-next-line no-param-reassign
r.actions = getTypeActions(type, true);
});
}
mx.setAccountData('m.push_rules', evtContent);
};
const addKeyword = (keyword) => {
if (content.find((r) => r.rule_id === keyword)) return;
content.push({
rule_id: keyword,
pattern: keyword,
enabled: true,
default: false,
actions: getTypeActions(rulesToType[KEYWORD] ?? notifType.NOISY, true),
});
mx.setAccountData('m.push_rules', pushRules);
};
const removeKeyword = (rule) => {
pushRules.global.content = content.filter((r) => r.rule_id !== rule.rule_id);
mx.setAccountData('m.push_rules', pushRules);
};
const dsRule = override.find((rule) => rule.rule_id === DISPLAY_NAME);
const roomRule = override.find((rule) => rule.rule_id === ROOM_PING);
const usernameRule = content.find((rule) => rule.rule_id === USERNAME);
const keywordRule = content.find((rule) => rule.rule_id !== USERNAME);
if (dsRule) rulesToType[DISPLAY_NAME] = getActionType(dsRule);
if (roomRule) rulesToType[ROOM_PING] = getActionType(roomRule);
if (usernameRule) rulesToType[USERNAME] = getActionType(usernameRule);
if (keywordRule) rulesToType[KEYWORD] = getActionType(keywordRule);
return {
rulesToType,
pushRules,
setRule,
addKeyword,
removeKeyword,
};
}
function GlobalNotification() {
const {
rulesToType,
pushRules,
setRule,
addKeyword,
removeKeyword,
} = useKeywordNotif();
const keywordRules = pushRules?.global?.content.filter((r) => r.rule_id !== USERNAME) ?? [];
const onSelect = (evt, rule) => {
openReusableContextMenu(
'bottom',
getEventCords(evt, '.btn-surface'),
(requestClose) => (
<NotificationSelector
value={rulesToType[rule]}
onSelect={(value) => {
if (rulesToType[rule] !== value) setRule(rule, value);
requestClose();
}}
/>
),
);
};
const handleSubmit = (evt) => {
evt.preventDefault();
const { keywordInput } = evt.target.elements;
const value = keywordInput.value.trim();
if (value === '') return;
addKeyword(value);
keywordInput.value = '';
};
return (
<div className="keyword-notification">
<MenuHeader>Mentions & keywords</MenuHeader>
<SettingTile
title="Message containing my display name"
options={(
<Button onClick={(evt) => onSelect(evt, DISPLAY_NAME)} iconSrc={ChevronBottomIC}>
{ typeToLabel[rulesToType[DISPLAY_NAME]] }
</Button>
)}
content={<Text variant="b3">Default notification settings for all message containing your display name.</Text>}
/>
<SettingTile
title="Message containing my username"
options={(
<Button onClick={(evt) => onSelect(evt, USERNAME)} iconSrc={ChevronBottomIC}>
{ typeToLabel[rulesToType[USERNAME]] }
</Button>
)}
content={<Text variant="b3">Default notification settings for all message containing your username.</Text>}
/>
<SettingTile
title="Message containing @room"
options={(
<Button onClick={(evt) => onSelect(evt, ROOM_PING)} iconSrc={ChevronBottomIC}>
{typeToLabel[rulesToType[ROOM_PING]]}
</Button>
)}
content={<Text variant="b3">Default notification settings for all messages containing @room.</Text>}
/>
{ rulesToType[KEYWORD] && (
<SettingTile
title="Message containing keywords"
options={(
<Button onClick={(evt) => onSelect(evt, KEYWORD)} iconSrc={ChevronBottomIC}>
{typeToLabel[rulesToType[KEYWORD]]}
</Button>
)}
content={<Text variant="b3">Default notification settings for all message containing keywords.</Text>}
/>
)}
<SettingTile
title="Keywords"
content={(
<div className="keyword-notification__keyword">
<Text variant="b3">Get notification when a message contains keyword.</Text>
<form onSubmit={handleSubmit}>
<Input name="keywordInput" required />
<Button variant="primary" type="submit">Add</Button>
</form>
{keywordRules.length > 0 && (
<div>
{keywordRules.map((rule) => (
<Chip
iconSrc={CrossIC}
key={rule.rule_id}
text={rule.pattern}
iconColor={CrossIC}
onClick={() => removeKeyword(rule)}
/>
))}
</div>
)}
</div>
)}
/>
</div>
);
}
export default GlobalNotification;

View file

@ -0,0 +1,16 @@
.keyword-notification {
&__keyword {
& form,
& > div:last-child {
display: flex;
gap: var(--sp-tight);
}
& form {
margin: var(--sp-ultra-tight) 0 var(--sp-normal);
.input-container {
flex-grow: 1;
}
}
}
}

View file

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import CheckIC from '../../../../public/res/ic/outlined/check.svg';
function NotificationSelector({
value, onSelect,
}) {
return (
<div>
<MenuHeader>Notification</MenuHeader>
<MenuItem iconSrc={value === 'off' ? CheckIC : null} variant={value === 'off' ? 'positive' : 'surface'} onClick={() => onSelect('off')}>Off</MenuItem>
<MenuItem iconSrc={value === 'on' ? CheckIC : null} variant={value === 'on' ? 'positive' : 'surface'} onClick={() => onSelect('on')}>On</MenuItem>
<MenuItem iconSrc={value === 'noisy' ? CheckIC : null} variant={value === 'noisy' ? 'positive' : 'surface'} onClick={() => onSelect('noisy')}>Noisy</MenuItem>
</div>
);
}
NotificationSelector.propTypes = {
value: PropTypes.oneOf(['off', 'on', 'noisy']).isRequired,
onSelect: PropTypes.func.isRequired,
};
export default NotificationSelector;

View file

@ -56,7 +56,9 @@ function Drawer() {
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0; scrollRef.current.scrollTop = 0;
}
}); });
}, [selectedTab]); }, [selectedTab]);

View file

@ -168,7 +168,7 @@ function Search() {
} }
}; };
const notifs = initMatrix.notifications; const noti = initMatrix.notifications;
const renderRoomSelector = (item) => { const renderRoomSelector = (item) => {
let imageSrc = null; let imageSrc = null;
let iconSrc = null; let iconSrc = null;
@ -178,9 +178,6 @@ function Search() {
iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space'); iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
} }
const isUnread = notifs.hasNoti(item.roomId);
const noti = notifs.getNoti(item.roomId);
return ( return (
<RoomSelector <RoomSelector
key={item.roomId} key={item.roomId}
@ -189,9 +186,9 @@ function Search() {
roomId={item.roomId} roomId={item.roomId}
imageSrc={imageSrc} imageSrc={imageSrc}
iconSrc={iconSrc} iconSrc={iconSrc}
isUnread={isUnread} isUnread={noti.hasNoti(item.roomId)}
notificationCount={noti.total} notificationCount={noti.getTotalNoti(item.roomId)}
isAlert={noti.highlight > 0} isAlert={noti.getHighlightNoti(item.roomId) > 0}
onClick={() => openItem(item.roomId, item.type)} onClick={() => openItem(item.roomId, item.type)}
/> />
); );
@ -207,7 +204,7 @@ function Search() {
size="small" size="small"
> >
<div className="search-dialog"> <div className="search-dialog">
<form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult()}}> <form className="search-dialog__input" onSubmit={(e) => { e.preventDefault(); openFirstResult(); }}>
<RawIcon src={SearchIC} size="small" /> <RawIcon src={SearchIC} size="small" />
<Input <Input
onChange={handleOnChange} onChange={handleOnChange}

View file

@ -25,6 +25,8 @@ import SettingTile from '../../molecules/setting-tile/SettingTile';
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys'; import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys'; import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack'; import { ImagePackUser, ImagePackGlobal } from '../../molecules/image-pack/ImagePack';
import GlobalNotification from '../../molecules/global-notification/GlobalNotification';
import KeywordNotification from '../../molecules/global-notification/KeywordNotification';
import ProfileEditor from '../profile-editor/ProfileEditor'; import ProfileEditor from '../profile-editor/ProfileEditor';
import CrossSigning from './CrossSigning'; import CrossSigning from './CrossSigning';
@ -150,6 +152,7 @@ function NotificationsSection() {
}; };
return ( return (
<>
<div className="settings-notifications"> <div className="settings-notifications">
<MenuHeader>Notification & Sound</MenuHeader> <MenuHeader>Notification & Sound</MenuHeader>
<SettingTile <SettingTile
@ -168,6 +171,9 @@ function NotificationsSection() {
content={<Text variant="b3">Play sound when new messages arrive.</Text>} content={<Text variant="b3">Play sound when new messages arrive.</Text>}
/> />
</div> </div>
<GlobalNotification />
<KeywordNotification />
</>
); );
} }

View file

@ -38,6 +38,8 @@
} }
.settings-appearance__card, .settings-appearance__card,
.settings-notifications, .settings-notifications,
.global-notification,
.keyword-notification,
.settings-security__card, .settings-security__card,
.settings-security .device-manage, .settings-security .device-manage,
.settings-about__card, .settings-about__card,

View file

@ -67,7 +67,7 @@ class InitMatrix extends EventEmitter {
}, },
PREPARED: (prevState) => { PREPARED: (prevState) => {
console.log('PREPARED state'); console.log('PREPARED state');
console.log('previous state: ', prevState); console.log('Previous state: ', prevState);
// TODO: remove global.initMatrix at end // TODO: remove global.initMatrix at end
global.initMatrix = this; global.initMatrix = this;
if (prevState === null) { if (prevState === null) {
@ -76,6 +76,9 @@ class InitMatrix extends EventEmitter {
this.roomsInput = new RoomsInput(this.matrixClient, this.roomList); this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
this.notifications = new Notifications(this.roomList); this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished'); this.emit('init_loading_finished');
this.notifications._initNoti();
} else {
this.notifications._initNoti();
} }
}, },
RECONNECTING: () => { RECONNECTING: () => {

View file

@ -5,6 +5,11 @@ import { selectRoom } from '../action/navigation';
import cons from './cons'; import cons from './cons';
import navigation from './navigation'; import navigation from './navigation';
import settings from './settings'; import settings from './settings';
import { setFavicon } from '../../util/common';
import LogoSVG from '../../../public/res/svg/cinny.svg';
import LogoUnreadSVG from '../../../public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '../../../public/res/svg/cinny-highlight.svg';
function isNotifEvent(mEvent) { function isNotifEvent(mEvent) {
const eType = mEvent.getType(); const eType = mEvent.getType();
@ -37,17 +42,16 @@ class Notifications extends EventEmitter {
this.roomIdToNoti = new Map(); this.roomIdToNoti = new Map();
this._initNoti(); // this._initNoti();
this._listenEvents(); this._listenEvents();
// Ask for permission by default after loading // Ask for permission by default after loading
window.Notification?.requestPermission(); window.Notification?.requestPermission();
// TODO:
window.notifications = this;
} }
_initNoti() { async _initNoti() {
this.roomIdToNoti = new Map();
const addNoti = (roomId) => { const addNoti = (roomId) => {
const room = this.matrixClient.getRoom(roomId); const room = this.matrixClient.getRoom(roomId);
if (this.getNotiType(room.roomId) === cons.notifs.MUTE) return; if (this.getNotiType(room.roomId) === cons.notifs.MUTE) return;
@ -59,6 +63,7 @@ class Notifications extends EventEmitter {
}; };
[...this.roomList.rooms].forEach(addNoti); [...this.roomList.rooms].forEach(addNoti);
[...this.roomList.directs].forEach(addNoti); [...this.roomList.directs].forEach(addNoti);
this._updateFavicon();
} }
doesRoomHaveUnread(room) { doesRoomHaveUnread(room) {
@ -104,7 +109,8 @@ class Notifications extends EventEmitter {
} }
getTotalNoti(roomId) { getTotalNoti(roomId) {
const { total } = this.getNoti(roomId); const { total, highlight } = this.getNoti(roomId);
if (highlight > total) return highlight;
return total; return total;
} }
@ -129,6 +135,24 @@ class Notifications extends EventEmitter {
} }
} }
async _updateFavicon() {
let unread = false;
let highlight = false;
[...this.roomIdToNoti.values()].find((noti) => {
if (!unread) {
unread = noti.total > 0 || noti.highlight > 0;
}
highlight = noti.highlight > 0;
if (unread && highlight) return true;
return false;
});
if (!unread) {
setFavicon(LogoSVG);
return;
}
setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
}
_setNoti(roomId, total, highlight) { _setNoti(roomId, total, highlight) {
const addNoti = (id, t, h, fromId) => { const addNoti = (id, t, h, fromId) => {
const prevTotal = this.roomIdToNoti.get(id)?.total ?? null; const prevTotal = this.roomIdToNoti.get(id)?.total ?? null;
@ -155,6 +179,7 @@ class Notifications extends EventEmitter {
allParentSpaces.forEach((spaceId) => { allParentSpaces.forEach((spaceId) => {
addNoti(spaceId, addT, addH, roomId); addNoti(spaceId, addT, addH, roomId);
}); });
this._updateFavicon();
} }
_deleteNoti(roomId, total, highlight) { _deleteNoti(roomId, total, highlight) {
@ -187,6 +212,7 @@ class Notifications extends EventEmitter {
allParentSpaces.forEach((spaceId) => { allParentSpaces.forEach((spaceId) => {
removeNoti(spaceId, total, highlight, roomId); removeNoti(spaceId, total, highlight, roomId);
}); });
this._updateFavicon();
} }
async _displayPopupNoti(mEvent, room) { async _displayPopupNoti(mEvent, room) {

View file

@ -115,6 +115,19 @@ export function avatarInitials(text) {
return [...text][0]; return [...text][0];
} }
export function cssVar(name) {
return getComputedStyle(document.body).getPropertyValue(name);
}
export function setFavicon(url) {
const oldFav = document.querySelector('[rel=icon]');
oldFav.parentElement.removeChild(oldFav);
const fav = document.createElement('link');
fav.rel = 'icon';
fav.href = url;
document.head.appendChild(fav);
}
export function copyToClipboard(text) { export function copyToClipboard(text) {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);