Notification settings (#807)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 4 Sep 2022 07:40:07 +0000 (13:10 +0530)
committerGitHub <noreply@github.com>
Sun, 4 Sep 2022 07:40:07 +0000 (13:10 +0530)
* 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

15 files changed:
public/res/svg/cinny-highlight.svg [new file with mode: 0644]
public/res/svg/cinny-unread.svg [new file with mode: 0644]
src/app/atoms/avatar/render.js
src/app/hooks/useAccountData.js [new file with mode: 0644]
src/app/molecules/global-notification/GlobalNotification.jsx [new file with mode: 0644]
src/app/molecules/global-notification/KeywordNotification.jsx [new file with mode: 0644]
src/app/molecules/global-notification/KeywordNotification.scss [new file with mode: 0644]
src/app/molecules/global-notification/NotificationSelector.jsx [new file with mode: 0644]
src/app/organisms/navigation/Drawer.jsx
src/app/organisms/search/Search.jsx
src/app/organisms/settings/Settings.jsx
src/app/organisms/settings/Settings.scss
src/client/initMatrix.js
src/client/state/Notifications.js
src/util/common.js

diff --git a/public/res/svg/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg
new file mode 100644 (file)
index 0000000..d59b42a
--- /dev/null
@@ -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>
diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg
new file mode 100644 (file)
index 0000000..be30137
--- /dev/null
@@ -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>
index 07303dd1b68d8fb523224f73db9dd002770e1c27..e8cf1a66134b5dd1e42a02fdbec463e64fa4433c 100644 (file)
@@ -1,8 +1,4 @@
-import { avatarInitials } from '../../../util/common';
-
-function cssVar(name) {
-  return getComputedStyle(document.body).getPropertyValue(name);
-}
+import { avatarInitials, cssVar } from '../../../util/common';
 
 // renders the avatar and returns it as an URL
 export default async function renderAvatar({
diff --git a/src/app/hooks/useAccountData.js b/src/app/hooks/useAccountData.js
new file mode 100644 (file)
index 0000000..01c973e
--- /dev/null
@@ -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;
+}
diff --git a/src/app/molecules/global-notification/GlobalNotification.jsx b/src/app/molecules/global-notification/GlobalNotification.jsx
new file mode 100644 (file)
index 0000000..a28687e
--- /dev/null
@@ -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;
diff --git a/src/app/molecules/global-notification/KeywordNotification.jsx b/src/app/molecules/global-notification/KeywordNotification.jsx
new file mode 100644 (file)
index 0000000..c44ffc4
--- /dev/null
@@ -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;
diff --git a/src/app/molecules/global-notification/KeywordNotification.scss b/src/app/molecules/global-notification/KeywordNotification.scss
new file mode 100644 (file)
index 0000000..a587002
--- /dev/null
@@ -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 (file)
index 0000000..b2a8f4e
--- /dev/null
@@ -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;
index a8b7f1f438a0a4afbb74aaf0c2316c8acdd80427..fb75ee5b1999caa4f0560991e0bbb7c152a88c8e 100644 (file)
@@ -56,7 +56,9 @@ function Drawer() {
 
   useEffect(() => {
     requestAnimationFrame(() => {
-      scrollRef.current.scrollTop = 0;
+      if (scrollRef.current) {
+        scrollRef.current.scrollTop = 0;
+      }
     });
   }, [selectedTab]);
 
index d40d8615eee470f11d54759afc17cc453377a49a..64c898bfdae3fa9b5a1c1853f3b1982c32611df6 100644 (file)
@@ -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 (
       <RoomSelector
         key={item.roomId}
@@ -189,9 +186,9 @@ function Search() {
         roomId={item.roomId}
         imageSrc={imageSrc}
         iconSrc={iconSrc}
-        isUnread={isUnread}
-        notificationCount={noti.total}
-        isAlert={noti.highlight > 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"
     >
       <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" />
           <Input
             onChange={handleOnChange}
index b50c9926f0d8c39cda77a1f06f5193751492889e..5f69fdb7409c26eccf77b40102cbbe06c175563c 100644 (file)
@@ -25,6 +25,8 @@ import SettingTile from '../../molecules/setting-tile/SettingTile';
 import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
 import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
 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 CrossSigning from './CrossSigning';
@@ -150,24 +152,28 @@ function NotificationsSection() {
   };
 
   return (
-    <div className="settings-notifications">
-      <MenuHeader>Notification & Sound</MenuHeader>
-      <SettingTile
-        title="Desktop notification"
-        options={renderOptions()}
-        content={<Text variant="b3">Show desktop notification when new messages arrive.</Text>}
-      />
-      <SettingTile
-        title="Notification Sound"
-        options={(
-          <Toggle
-            isActive={settings.isNotificationSounds}
-            onToggle={() => { toggleNotificationSounds(); updateState({}); }}
-          />
-          )}
-        content={<Text variant="b3">Play sound when new messages arrive.</Text>}
-      />
-    </div>
+    <>
+      <div className="settings-notifications">
+        <MenuHeader>Notification & Sound</MenuHeader>
+        <SettingTile
+          title="Desktop notification"
+          options={renderOptions()}
+          content={<Text variant="b3">Show desktop notification when new messages arrive.</Text>}
+        />
+        <SettingTile
+          title="Notification Sound"
+          options={(
+            <Toggle
+              isActive={settings.isNotificationSounds}
+              onToggle={() => { toggleNotificationSounds(); updateState({}); }}
+            />
+            )}
+          content={<Text variant="b3">Play sound when new messages arrive.</Text>}
+        />
+      </div>
+      <GlobalNotification />
+      <KeywordNotification />
+    </>
   );
 }
 
index d77e634a41e8e67b78d619d2d2c5d6aaac235dfd..aa4557009076160d118f09c468fb7f2bceb12cf7 100644 (file)
@@ -38,6 +38,8 @@
 }
 .settings-appearance__card,
 .settings-notifications,
+.global-notification,
+.keyword-notification,
 .settings-security__card,
 .settings-security .device-manage,
 .settings-about__card,
index e219a777d485da4b683aae8cc2f848a4f96d0688..fccfe5148b22d9e22b08836f8690cf3dc141c682 100644 (file)
@@ -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: () => {
index f0f7a8c25ee0f2764d0a0393b9d734912a615ce0..c3ec90cf3d1e324800b55f5f1cbc781146a5df36 100644 (file)
@@ -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) {
index 83fd20fe5a593fccbc1c31846e0c9fbe47b049ee..c2a17cbf0dcb773fa69e64ef4de527513d598b12 100644 (file)
@@ -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);