import EventEmitter from 'events'; import renderAvatar from '../../app/atoms/avatar/render'; import { cssColorMXID } from '../../util/colorMXID'; import { selectRoom } from '../action/navigation'; import cons from './cons'; import navigation from './navigation'; import settings from './settings'; import NotificationSound from '../../../public/sound/notification.ogg'; import InviteSound from '../../../public/sound/invite.ogg'; function isNotifEvent(mEvent) { const eType = mEvent.getType(); if (!cons.supportEventTypes.includes(eType)) return false; if (eType === 'm.room.member') return false; if (mEvent.isRedacted()) return false; if (mEvent.getRelation()?.rel_type === 'm.replace') return false; return true; } function isMutedRule(rule) { return rule.actions[0] === 'dont_notify' && rule.conditions[0].kind === 'event_match'; } function findMutedRule(overrideRules, roomId) { return overrideRules.find((rule) => ( rule.rule_id === roomId && isMutedRule(rule) )); } class Notifications extends EventEmitter { constructor(roomList) { super(); this.matrixClient = roomList.matrixClient; this.roomList = roomList; this.roomIdToNoti = new Map(); this._initNoti(); this._listenEvents(); // Ask for permission by default after loading window.Notification?.requestPermission(); // TODO: window.notifications = this; } _initNoti() { const addNoti = (roomId) => { const room = this.matrixClient.getRoom(roomId); if (this.getNotiType(room.roomId) === cons.notifs.MUTE) return; if (this.doesRoomHaveUnread(room) === false) return; const total = room.getUnreadNotificationCount('total'); const highlight = room.getUnreadNotificationCount('highlight'); this._setNoti(room.roomId, total ?? 0, highlight ?? 0); }; [...this.roomList.rooms].forEach(addNoti); [...this.roomList.directs].forEach(addNoti); } doesRoomHaveUnread(room) { const userId = this.matrixClient.getUserId(); const readUpToId = room.getEventReadUpTo(userId); const liveEvents = room.getLiveTimeline().getEvents(); if (liveEvents[liveEvents.length - 1]?.getSender() === userId) { return false; } for (let i = liveEvents.length - 1; i >= 0; i -= 1) { const event = liveEvents[i]; if (event.getId() === readUpToId) return false; if (isNotifEvent(event)) return true; } return true; } getNotiType(roomId) { const mx = this.matrixClient; const pushRule = mx.getRoomPushRule('global', roomId); if (pushRule === undefined) { const overrideRules = mx.getAccountData('m.push_rules')?.getContent()?.global?.override; if (overrideRules === undefined) return cons.notifs.DEFAULT; const isMuted = findMutedRule(overrideRules, roomId); return isMuted ? cons.notifs.MUTE : cons.notifs.DEFAULT; } if (pushRule.actions[0] === 'notify') return cons.notifs.ALL_MESSAGES; return cons.notifs.MENTIONS_AND_KEYWORDS; } getNoti(roomId) { return this.roomIdToNoti.get(roomId) || { total: 0, highlight: 0, from: null }; } getTotalNoti(roomId) { const { total } = this.getNoti(roomId); return total; } getHighlightNoti(roomId) { const { highlight } = this.getNoti(roomId); return highlight; } getFromNoti(roomId) { const { from } = this.getNoti(roomId); return from; } hasNoti(roomId) { return this.roomIdToNoti.has(roomId); } deleteNoti(roomId) { if (this.hasNoti(roomId)) { const noti = this.getNoti(roomId); this._deleteNoti(roomId, noti.total, noti.highlight); } } _setNoti(roomId, total, highlight) { const addNoti = (id, t, h, fromId) => { const prevTotal = this.roomIdToNoti.get(id)?.total ?? null; const noti = this.getNoti(id); noti.total += t; noti.highlight += h; if (fromId) { if (noti.from === null) noti.from = new Set(); noti.from.add(fromId); } this.roomIdToNoti.set(id, noti); this.emit(cons.events.notifications.NOTI_CHANGED, id, noti.total, prevTotal); }; const noti = this.getNoti(roomId); const addT = total - noti.total; const addH = highlight - noti.highlight; if (addT < 0 || addH < 0) return; addNoti(roomId, addT, addH); const allParentSpaces = this.roomList.getAllParentSpaces(roomId); allParentSpaces.forEach((spaceId) => { addNoti(spaceId, addT, addH, roomId); }); } _deleteNoti(roomId, total, highlight) { const removeNoti = (id, t, h, fromId) => { if (this.roomIdToNoti.has(id) === false) return; const noti = this.getNoti(id); const prevTotal = noti.total; noti.total -= t; noti.highlight -= h; if (noti.total < 0) { noti.total = 0; noti.highlight = 0; } if (fromId && noti.from !== null) { if (!this.hasNoti(fromId)) noti.from.delete(fromId); } if (noti.from === null || noti.from.size === 0) { this.roomIdToNoti.delete(id); this.emit(cons.events.notifications.FULL_READ, id); this.emit(cons.events.notifications.NOTI_CHANGED, id, null, prevTotal); } else { this.roomIdToNoti.set(id, noti); this.emit(cons.events.notifications.NOTI_CHANGED, id, noti.total, prevTotal); } }; removeNoti(roomId, total, highlight); const allParentSpaces = this.roomList.getAllParentSpaces(roomId); allParentSpaces.forEach((spaceId) => { removeNoti(spaceId, total, highlight, roomId); }); } async _displayPopupNoti(mEvent, room) { if (!settings.showNotifications && !settings.isNotificationSounds) return; const actions = this.matrixClient.getPushActionsForEvent(mEvent); if (!actions?.notify) return; if (navigation.selectedRoomId === room.roomId && document.visibilityState === 'visible') return; if (mEvent.isEncrypted()) { await mEvent.attemptDecryption(this.matrixClient.crypto); } if (settings.showNotifications) { let title; if (!mEvent.sender || room.name === mEvent.sender.name) { title = room.name; } else if (mEvent.sender) { title = `${mEvent.sender.name} (${room.name})`; } const iconSize = 36; const icon = await renderAvatar({ text: mEvent.sender.name, bgColor: cssColorMXID(mEvent.getSender()), imageSrc: mEvent.sender?.getAvatarUrl(this.matrixClient.baseUrl, iconSize, iconSize, 'crop'), size: iconSize, borderRadius: 8, scale: 8, }); const noti = new window.Notification(title, { body: mEvent.getContent().body, icon, silent: settings.isNotificationSounds, }); if (settings.isNotificationSounds) { noti.onshow = () => this._playNotiSound(); } noti.onclick = () => selectRoom(room.roomId, mEvent.getId()); } else { this._playNotiSound(); } } _playNotiSound() { if (!this._notiAudio) { this._notiAudio = new Audio(NotificationSound); } this._notiAudio.play(); } _playInviteSound() { if (!this._inviteAudio) { this._inviteAudio = new Audio(InviteSound); } this._inviteAudio.play(); } _listenEvents() { this.matrixClient.on('Room.timeline', (mEvent, room) => { if (room.isSpaceRoom()) return; if (!isNotifEvent(mEvent)) return; const liveEvents = room.getLiveTimeline().getEvents(); const lastTimelineEvent = liveEvents[liveEvents.length - 1]; if (lastTimelineEvent.getId() !== mEvent.getId()) return; if (mEvent.getSender() === this.matrixClient.getUserId()) return; const total = room.getUnreadNotificationCount('total'); const highlight = room.getUnreadNotificationCount('highlight'); if (this.getNotiType(room.roomId) === cons.notifs.MUTE) { this.deleteNoti(room.roomId, total ?? 0, highlight ?? 0); return; } this._setNoti(room.roomId, total ?? 0, highlight ?? 0); if (this.matrixClient.getSyncState() === 'SYNCING') { this._displayPopupNoti(mEvent, room); } }); this.matrixClient.on('accountData', (mEvent, oldMEvent) => { if (mEvent.getType() === 'm.push_rules') { const override = mEvent?.getContent()?.global?.override; const oldOverride = oldMEvent?.getContent()?.global?.override; if (!override || !oldOverride) return; const isMuteToggled = (rule, otherOverride) => { const roomId = rule.rule_id; const room = this.matrixClient.getRoom(roomId); if (room === null) return false; if (room.isSpaceRoom()) return false; const isMuted = isMutedRule(rule); if (!isMuted) return false; const isOtherMuted = findMutedRule(otherOverride, roomId); if (isOtherMuted) return false; return true; }; const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride)); const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override)); mutedRules.forEach((rule) => { this.emit(cons.events.notifications.MUTE_TOGGLED, rule.rule_id, true); this.deleteNoti(rule.rule_id); }); unMutedRules.forEach((rule) => { this.emit(cons.events.notifications.MUTE_TOGGLED, rule.rule_id, false); const room = this.matrixClient.getRoom(rule.rule_id); if (!this.doesRoomHaveUnread(room)) return; const total = room.getUnreadNotificationCount('total'); const highlight = room.getUnreadNotificationCount('highlight'); this._setNoti(room.roomId, total ?? 0, highlight ?? 0); }); } }); this.matrixClient.on('Room.receipt', (mEvent, room) => { if (mEvent.getType() === 'm.receipt') { if (room.isSpaceRoom()) return; const content = mEvent.getContent(); const readedEventId = Object.keys(content)[0]; const readerUserId = Object.keys(content[readedEventId]['m.read'])[0]; if (readerUserId !== this.matrixClient.getUserId()) return; this.deleteNoti(room.roomId); } }); this.matrixClient.on('Room.myMembership', (room, membership) => { if (membership === 'leave' && this.hasNoti(room.roomId)) { this.deleteNoti(room.roomId); } if (membership === 'invite') { this._playInviteSound(); } }); } } export default Notifications;