diff --git a/public/res/svg/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg new file mode 100644 index 0000000..d59b42a --- /dev/null +++ b/public/res/svg/cinny-highlight.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg new file mode 100644 index 0000000..be30137 --- /dev/null +++ b/public/res/svg/cinny-unread.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/app/atoms/avatar/render.js b/src/app/atoms/avatar/render.js index 07303dd..e8cf1a6 100644 --- a/src/app/atoms/avatar/render.js +++ b/src/app/atoms/avatar/render.js @@ -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 index 0000000..01c973e --- /dev/null +++ b/src/app/hooks/useAccountData.js @@ -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 index 0000000..a28687e --- /dev/null +++ b/src/app/molecules/global-notification/GlobalNotification.jsx @@ -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) => ( + { + 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 + onSelect('off')}>Off + onSelect('on')}>On + onSelect('noisy')}>Noisy +
+ ); +} + +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" >
-
{ e.preventDefault(); openFirstResult()}}> + { e.preventDefault(); openFirstResult(); }}> - Notification & Sound - Show desktop notification when new messages arrive.} - /> - { toggleNotificationSounds(); updateState({}); }} - /> - )} - content={Play sound when new messages arrive.} - /> -
+ <> +
+ 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);