{
+ if (rulesToType[rule] !== value) setRule(rule, value);
+ requestClose();
+ }}
+ />
+ ),
+ );
+ };
+
+ return (
+
+ Global Notifications
+ onSelect(evt, DM)} iconSrc={ChevronBottomIC}>
+ { typeToLabel[rulesToType[DM]] }
+
+ )}
+ content={Default notification settings for all direct message.}
+ />
+ onSelect(evt, ENC_DM)} iconSrc={ChevronBottomIC}>
+ {typeToLabel[rulesToType[ENC_DM]]}
+
+ )}
+ content={Default notification settings for all encrypted direct message.}
+ />
+ onSelect(evt, ROOM)} iconSrc={ChevronBottomIC}>
+ {typeToLabel[rulesToType[ROOM]]}
+
+ )}
+ content={Default notification settings for all room message.}
+ />
+ onSelect(evt, ENC_ROOM)} iconSrc={ChevronBottomIC}>
+ {typeToLabel[rulesToType[ENC_ROOM]]}
+
+ )}
+ content={Default notification settings for all encrypted room message.}
+ />
+
+ );
+}
+
+export default GlobalNotification;
diff --git a/src/app/molecules/global-notification/KeywordNotification.jsx b/src/app/molecules/global-notification/KeywordNotification.jsx
new file mode 100644
index 0000000..c44ffc4
--- /dev/null
+++ b/src/app/molecules/global-notification/KeywordNotification.jsx
@@ -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) => (
+ {
+ 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 (
+
+
Mentions & keywords
+
onSelect(evt, DISPLAY_NAME)} iconSrc={ChevronBottomIC}>
+ { typeToLabel[rulesToType[DISPLAY_NAME]] }
+
+ )}
+ content={Default notification settings for all message containing your display name.}
+ />
+ onSelect(evt, USERNAME)} iconSrc={ChevronBottomIC}>
+ { typeToLabel[rulesToType[USERNAME]] }
+
+ )}
+ content={Default notification settings for all message containing your username.}
+ />
+ onSelect(evt, ROOM_PING)} iconSrc={ChevronBottomIC}>
+ {typeToLabel[rulesToType[ROOM_PING]]}
+
+ )}
+ content={Default notification settings for all messages containing @room.}
+ />
+ { rulesToType[KEYWORD] && (
+ onSelect(evt, KEYWORD)} iconSrc={ChevronBottomIC}>
+ {typeToLabel[rulesToType[KEYWORD]]}
+
+ )}
+ content={Default notification settings for all message containing keywords.}
+ />
+ )}
+
+ Get notification when a message contains keyword.
+
+ {keywordRules.length > 0 && (
+
+ {keywordRules.map((rule) => (
+ removeKeyword(rule)}
+ />
+ ))}
+
+ )}
+
+ )}
+ />
+
+ );
+}
+
+export default GlobalNotification;
diff --git a/src/app/molecules/global-notification/KeywordNotification.scss b/src/app/molecules/global-notification/KeywordNotification.scss
new file mode 100644
index 0000000..a587002
--- /dev/null
+++ b/src/app/molecules/global-notification/KeywordNotification.scss
@@ -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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/molecules/global-notification/NotificationSelector.jsx b/src/app/molecules/global-notification/NotificationSelector.jsx
new file mode 100644
index 0000000..b2a8f4e
--- /dev/null
+++ b/src/app/molecules/global-notification/NotificationSelector.jsx
@@ -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 (
+
+ Notification
+
+
+
+
+ );
+}
+
+NotificationSelector.propTypes = {
+ value: PropTypes.oneOf(['off', 'on', 'noisy']).isRequired,
+ onSelect: PropTypes.func.isRequired,
+};
+
+export default NotificationSelector;
diff --git a/src/app/organisms/navigation/Drawer.jsx b/src/app/organisms/navigation/Drawer.jsx
index a8b7f1f..fb75ee5 100644
--- a/src/app/organisms/navigation/Drawer.jsx
+++ b/src/app/organisms/navigation/Drawer.jsx
@@ -56,7 +56,9 @@ function Drawer() {
useEffect(() => {
requestAnimationFrame(() => {
- scrollRef.current.scrollTop = 0;
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = 0;
+ }
});
}, [selectedTab]);
diff --git a/src/app/organisms/search/Search.jsx b/src/app/organisms/search/Search.jsx
index d40d861..64c898b 100644
--- a/src/app/organisms/search/Search.jsx
+++ b/src/app/organisms/search/Search.jsx
@@ -168,7 +168,7 @@ function Search() {
}
};
- const notifs = initMatrix.notifications;
+ const noti = initMatrix.notifications;
const renderRoomSelector = (item) => {
let imageSrc = null;
let iconSrc = null;
@@ -178,9 +178,6 @@ function Search() {
iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
}
- const isUnread = notifs.hasNoti(item.roomId);
- const noti = notifs.getNoti(item.roomId);
-
return (
0}
+ isUnread={noti.hasNoti(item.roomId)}
+ notificationCount={noti.getTotalNoti(item.roomId)}
+ isAlert={noti.getHighlightNoti(item.roomId) > 0}
onClick={() => openItem(item.roomId, item.type)}
/>
);
@@ -207,7 +204,7 @@ function Search() {
size="small"
>
-
+ <>
+
+ Notification & Sound
+ Show desktop notification when new messages arrive.}
+ />
+ { toggleNotificationSounds(); updateState({}); }}
+ />
+ )}
+ content={Play sound when new messages arrive.}
+ />
+
+
+
+ >
);
}
diff --git a/src/app/organisms/settings/Settings.scss b/src/app/organisms/settings/Settings.scss
index d77e634..aa45570 100644
--- a/src/app/organisms/settings/Settings.scss
+++ b/src/app/organisms/settings/Settings.scss
@@ -38,6 +38,8 @@
}
.settings-appearance__card,
.settings-notifications,
+.global-notification,
+.keyword-notification,
.settings-security__card,
.settings-security .device-manage,
.settings-about__card,
diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js
index e219a77..fccfe51 100644
--- a/src/client/initMatrix.js
+++ b/src/client/initMatrix.js
@@ -67,7 +67,7 @@ class InitMatrix extends EventEmitter {
},
PREPARED: (prevState) => {
console.log('PREPARED state');
- console.log('previous state: ', prevState);
+ console.log('Previous state: ', prevState);
// TODO: remove global.initMatrix at end
global.initMatrix = this;
if (prevState === null) {
@@ -76,6 +76,9 @@ class InitMatrix extends EventEmitter {
this.roomsInput = new RoomsInput(this.matrixClient, this.roomList);
this.notifications = new Notifications(this.roomList);
this.emit('init_loading_finished');
+ this.notifications._initNoti();
+ } else {
+ this.notifications._initNoti();
}
},
RECONNECTING: () => {
diff --git a/src/client/state/Notifications.js b/src/client/state/Notifications.js
index f0f7a8c..c3ec90c 100644
--- a/src/client/state/Notifications.js
+++ b/src/client/state/Notifications.js
@@ -5,6 +5,11 @@ import { selectRoom } from '../action/navigation';
import cons from './cons';
import navigation from './navigation';
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) {
const eType = mEvent.getType();
@@ -37,17 +42,16 @@ class Notifications extends EventEmitter {
this.roomIdToNoti = new Map();
- this._initNoti();
+ // this._initNoti();
this._listenEvents();
// Ask for permission by default after loading
window.Notification?.requestPermission();
-
- // TODO:
- window.notifications = this;
}
- _initNoti() {
+ async _initNoti() {
+ this.roomIdToNoti = new Map();
+
const addNoti = (roomId) => {
const room = this.matrixClient.getRoom(roomId);
if (this.getNotiType(room.roomId) === cons.notifs.MUTE) return;
@@ -59,6 +63,7 @@ class Notifications extends EventEmitter {
};
[...this.roomList.rooms].forEach(addNoti);
[...this.roomList.directs].forEach(addNoti);
+ this._updateFavicon();
}
doesRoomHaveUnread(room) {
@@ -104,7 +109,8 @@ class Notifications extends EventEmitter {
}
getTotalNoti(roomId) {
- const { total } = this.getNoti(roomId);
+ const { total, highlight } = this.getNoti(roomId);
+ if (highlight > total) return highlight;
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) {
const addNoti = (id, t, h, fromId) => {
const prevTotal = this.roomIdToNoti.get(id)?.total ?? null;
@@ -155,6 +179,7 @@ class Notifications extends EventEmitter {
allParentSpaces.forEach((spaceId) => {
addNoti(spaceId, addT, addH, roomId);
});
+ this._updateFavicon();
}
_deleteNoti(roomId, total, highlight) {
@@ -187,6 +212,7 @@ class Notifications extends EventEmitter {
allParentSpaces.forEach((spaceId) => {
removeNoti(spaceId, total, highlight, roomId);
});
+ this._updateFavicon();
}
async _displayPopupNoti(mEvent, room) {
diff --git a/src/util/common.js b/src/util/common.js
index 83fd20f..c2a17cb 100644
--- a/src/util/common.js
+++ b/src/util/common.js
@@ -115,6 +115,19 @@ export function avatarInitials(text) {
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) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);