diff --git a/README.md b/README.md index 06ecdde..00329b4 100644 --- a/README.md +++ b/README.md @@ -179,17 +179,20 @@ Fixing this is work-in-progress, and any help or suggestions would be appreciated! Please refer to ticket [#6](https://github.com/quarkslab/mattermost-plugin-e2ee/issues/6). -### Notifications from mentions are not working +### Initial support for notifications from mentions -Mentions (like `@all`) in encrypted messages aren't working for now. The -mechanism that triggers notification seems to require the server to be able to -process sent messages, which isn't obviously the case when this plugin is used. +Mentions (like `@all`) in encrypted messages would display a notification, but +with these limitations: -There is also the issue that messages are only decrypted once we try to display -them. +* the notification sound might not be played, depending on the OS & platform +* "activating" the notification would display the Mattermost application/tab, + but won't switch to the team/channel were the notification occured -There's no short-term plan to fix this, but any help or suggestion would be -appreciated! +Fixing these issues could be done by being able to use the [notifyMe function +from +mattermost-webapp](https://github.com/mattermost/mattermost-webapp/blob/53abea64747ffaf4937a65e26b697d4703bfc22b/actions/notification_actions.jsx#L192), +which could be [exposed to +plugins](https://github.com/mattermost/mattermost-webapp/blob/ce2962001c11d7a55cbd6bf146f94ab0b98496e4/plugins/export.js). Progress on this issue is tracked in [#1](https://github.com/quarkslab/mattermost-plugin-e2ee/issues/1). diff --git a/webapp/src/hooks.tsx b/webapp/src/hooks.tsx index 805152a..2a77da5 100644 --- a/webapp/src/hooks.tsx +++ b/webapp/src/hooks.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {Store} from 'redux'; -import {getCurrentUserId, makeGetProfilesInChannel, getUser} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUser, getCurrentUserId, makeGetProfilesInChannel, getUser} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; import {Post} from 'mattermost-redux/types/posts'; import {Channel} from 'mattermost-redux/types/channels'; @@ -12,18 +12,20 @@ import Icon from './components/icon'; import {getPubKeys, getChannelEncryptionMethod, sendEphemeralPost, openImportModal} from './actions'; import {EncrStatutTypes, EventTypes, PubKeyTypes} from './action_types'; import {APIClient, GPGBackupDisabledError} from './client'; -import {E2EE_CHAN_ENCR_METHOD_NONE, E2EE_CHAN_ENCR_METHOD_P2P} from './constants'; +import {E2EE_CHAN_ENCR_METHOD_NONE, E2EE_CHAN_ENCR_METHOD_P2P, E2EE_POST_TYPE} from './constants'; // eslint-disable-next-line import/no-unresolved import {PluginRegistry, ContextArgs} from './types/mattermost-webapp'; import {selectPubkeys, selectPrivkey, selectKS} from './selectors'; import {msgCache} from './msg_cache'; import {AppPrivKey} from './privkey'; -import {encryptPost} from './e2ee_post'; +import {encryptPost, decryptPost} from './e2ee_post'; import {PublicKeyMaterial} from './e2ee'; import {observeStore, isValidUsername} from './utils'; import {MyActionResult, PubKeysState} from './types'; import {pubkeyStore, getNewChannelPubkeys, storeChannelPubkeys} from './pubkeys_storage'; import {getE2EEPostUpdateSupported} from './compat'; +import {shouldNotify} from './notifications'; +import {sendDesktopNotification} from './notification_actions'; export default class E2EEHooks { store: Store @@ -54,6 +56,7 @@ export default class E2EEHooks { registry.registerWebSocketEventHandler('custom_com.quarkslab.e2ee_channelStateChanged', this.channelStateChanged.bind(this)); registry.registerWebSocketEventHandler('custom_com.quarkslab.e2ee_newPubkey', this.onNewPubKey.bind(this)); + registry.registerWebSocketEventHandler('posted', this.onPosted.bind(this)); registry.registerReconnectHandler(this.onReconnect.bind(this)); registry.registerChannelHeaderButtonAction( @@ -65,6 +68,44 @@ export default class E2EEHooks { ); } + private async onPosted(message: any) { + // Decrypt message and parse notifications, if asking for it. + const curUser = getCurrentUser(this.store.getState()); + if (curUser.notify_props.desktop === 'none') { + return; + } + try { + const post = JSON.parse(message.data.post); + if (post.type !== E2EE_POST_TYPE) { + return; + } + const state = this.store.getState(); + const privkey = selectPrivkey(state); + if (privkey === null) { + return; + } + let decrMsg = msgCache.get(post); + if (decrMsg === null) { + const sender_uid = post.user_id; + const {data, error} = await this.dispatch(getPubKeys([sender_uid])); + if (error) { + throw error; + } + const senderkey = data.get(sender_uid) || null; + if (senderkey === null) { + return; + } + decrMsg = await decryptPost(post.props.e2ee, senderkey, privkey); + msgCache.addDecrypted(post, decrMsg); + } + if (shouldNotify(decrMsg, curUser)) { + this.dispatch(sendDesktopNotification(post)); + } + } catch (e) { + // Ignore notification errors + } + } + private async checkPubkeys(store: Store, pubkeys: PubKeysState) { for (const [userID, pubkey] of pubkeys) { if (pubkey.data === null) { diff --git a/webapp/src/notification_actions.js b/webapp/src/notification_actions.js new file mode 100644 index 0000000..e2def2e --- /dev/null +++ b/webapp/src/notification_actions.js @@ -0,0 +1,93 @@ +// Based on mattermost-webapp/actions/notification_actions.jsx. Original +// copyright is below. +// +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getProfilesByIds} from 'mattermost-redux/actions/users'; +import {getChannel, getCurrentChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentUserId, getCurrentUser, getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users'; +import {isChannelMuted} from 'mattermost-redux/utils/channel_utils'; +import {isSystemMessage} from 'mattermost-redux/utils/post_utils'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import {showNotification} from 'notifications'; + +const NOTIFY_TEXT_MAX_LENGTH = 50; + +export function sendDesktopNotification(post) { + return async (dispatch, getState) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + + if (currentUserId === post.user_id) { + return; + } + + if (isSystemMessage(post)) { + return; + } + + let userFromPost = getUser(state, post.user_id); + if (!userFromPost) { + const missingProfileResponse = await dispatch(getProfilesByIds([post.user_id])); + if (missingProfileResponse.data && missingProfileResponse.data.length) { + userFromPost = missingProfileResponse.data[0]; + } + } + + const channel = getChannel(state, post.channel_id); + const user = getCurrentUser(state); + const userStatus = getStatusForUserId(state, user.id); + const member = getMyChannelMember(state, post.channel_id); + + if (!member || isChannelMuted(member) || userStatus === 'dnd' || userStatus === 'ooo') { + return; + } + + const config = getConfig(state); + let username = ''; + if (post.props.override_username && config.EnablePostUsernameOverride === 'true') { + username = post.props.override_username; + } else if (userFromPost) { + username = displayUsername(userFromPost, getTeammateNameDisplaySetting(state), false); + } else { + username = 'Someone'; + } + + let title = 'Posted'; + if (channel) { + title = channel.display_name; + } + + let notifyText = post.message; + if (notifyText.length > NOTIFY_TEXT_MAX_LENGTH) { + notifyText = notifyText.substring(0, NOTIFY_TEXT_MAX_LENGTH - 1) + '...'; + } + let body = `@${username}`; + body += `: ${notifyText}`; + + //Play a sound if explicitly set in settings + const sound = !user.notify_props || user.notify_props.desktop_sound === 'true'; + + // Notify if you're not looking in the right channel or when + // the window itself is not active + const activeChannel = getCurrentChannel(state); + const channelId = channel ? channel.id : null; + const notify = (activeChannel && activeChannel.id !== channelId) || !state.views.browser.focused; + + if (notify) { + showNotification({ + title, + body, + requireInteraction: false, + silent: !sound, + onClick: () => { + window.focus(); + }, + }); + } + }; +} diff --git a/webapp/src/notifications.ts b/webapp/src/notifications.ts new file mode 100644 index 0000000..1f620c6 --- /dev/null +++ b/webapp/src/notifications.ts @@ -0,0 +1,145 @@ +import {UserProfile} from 'mattermost-redux/types/users'; +import {Post} from 'mattermost-redux/types/posts'; + +import {isMacApp} from 'user_agent'; + +// regular expression from mattermost-server/app/command.go. Replace :alnum: by +// [A-Za-z0-9]. /g is necessary to be able to match all mentions. +const atMentionRegexp = /\B@([A-Za-z0-9][A-Za-z0-9\\.\-_:]*)(\s|$)/g; + +export function shouldNotify(msg: string, user: UserProfile) { + const notify_props = user.notify_props; + + const mentionChannel = notify_props.channel === 'true'; + const username = user.username; + const mentions = msg.matchAll(atMentionRegexp); + for (const m of mentions) { + const name = m[1]; + if (name === 'all' || name === 'channel') { + return mentionChannel; + } + if (name === 'here') { + return mentionChannel && notify_props.push_status === 'online'; + } + if (m[1] === username) { + return true; + } + } + + // See + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#comparing_strings + // as to why toUpperCase is used (and not toLowerCase). + const mention_keys = new Set(); + for (const m of notify_props.mention_keys.split(',')) { + const s = m.trim(); + if (s.length > 0) { + mention_keys.add(s.toUpperCase()); + } + } + + // First name check is case **sensitive** + const check_fn = notify_props.first_name === 'true'; + if (mention_keys.size === 0 && !check_fn) { + return false; + } + + const words = msg.split(/\s+/); + for (const w of words) { + if (mention_keys.has(w.toUpperCase())) { + return true; + } + if (check_fn && w === user.first_name) { + return true; + } + } + return false; +} + +// Adapted from mattermost-webapp/utils/notifications.tsx +let requestedNotificationPermission = false; + +// showNotification displays a platform notification with the configured parameters. +// +// If successful in showing a notification, it resolves with a callback to manually close the +// notification. If no error occurred but the user did not grant permission to show notifications, it +// resolves with a no-op callback. Notifications that do not require interaction will be closed automatically after +// the Constants.DEFAULT_NOTIFICATION_DURATION. Not all platforms support all features, and may +// choose different semantics for the notifications. + +export interface ShowNotificationParams { + title: string; + body: string; + requireInteraction: boolean; + silent: boolean; + onClick?: (this: Notification, e: Event) => any | null; +} + +export async function showNotification( + { + title, + body, + requireInteraction, + silent, + onClick, + }: ShowNotificationParams = { + title: '', + body: '', + requireInteraction: false, + silent: false, + }, +) { + if (!('Notification' in window)) { + throw new Error('Notification not supported'); + } + + if (typeof Notification.requestPermission !== 'function') { + throw new Error('Notification.requestPermission not supported'); + } + + if (Notification.permission !== 'granted' && requestedNotificationPermission) { + // User didn't allow notifications + // eslint-disable no-empty-function + return () => { /* do nothing */ }; + } + + requestedNotificationPermission = true; + + let permission = await Notification.requestPermission(); + if (typeof permission === 'undefined') { + // Handle browsers that don't support the promise-based syntax. + permission = await new Promise((resolve) => { + Notification.requestPermission(resolve); + }); + } + + if (permission !== 'granted') { + // User has denied notification for the site + return () => { /* do nothing */ }; + } + + const notification = new Notification(title, { + body, + tag: body, + requireInteraction, + silent, + }); + + if (onClick) { + notification.onclick = onClick; + } + + notification.onerror = () => { + throw new Error('Notification failed to show.'); + }; + + // Mac desktop app notification dismissal is handled by the OS + if (!requireInteraction && !isMacApp()) { + setTimeout(() => { + notification.close(); + }, 5000 /* Constants.DEFAULT_NOTIFICATION_DURATION */); + } + + return () => { + notification.close(); + }; +} diff --git a/webapp/src/user_agent.ts b/webapp/src/user_agent.ts new file mode 100644 index 0000000..a615c2c --- /dev/null +++ b/webapp/src/user_agent.ts @@ -0,0 +1,155 @@ +// Copied from mattermost-webapp. Original copyright below +// +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* +Example User Agents +-------------------- + +Chrome: + Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 + +Firefox: + Mozilla/5.0 (Windows NT 10.0; WOW64; rv:47.0) Gecko/20100101 Firefox/47.0 + +IE11: + Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko + +Edge: + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586 + +Desktop App: + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/1.2.1 Chrome/49.0.2623.75 Electron/0.37.8 Safari/537.36 + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586 + Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_0) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/53.0.2785.113 Electron/1.4.2 Safari/537.36 + Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Mattermost/3.4.1 Chrome/51.0.2704.106 Electron/1.2.8 Safari/537.36 + +Android Chrome: + Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19 + +Android Firefox: + Mozilla/5.0 (Android; U; Android; pl; rv:1.9.2.8) Gecko/20100202 Firefox/3.5.8 + Mozilla/5.0 (Android 7.0; Mobile; rv:54.0) Gecko/54.0 Firefox/54.0 + Mozilla/5.0 (Android 7.0; Mobile; rv:57.0) Gecko/57.0 Firefox/57.0 + +Android App: + Mozilla/5.0 (Linux; U; Android 4.1.1; en-gb; Build/KLP) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Safari/534.30 + Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36 + Mozilla/5.0 (Linux; Android 5.1.1; Nexus 5 Build/LMY48B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.65 Mobile Safari/537.36 + +iOS Safari: + Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ (KHTML, like Gecko) Version/3.0 Mobile/1A543 Safari/419.3 + +iOS Android: + Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3 + +iOS App: + Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13F69 +*/ + +const userAgent = () => window.navigator.userAgent; + +export function isChrome(): boolean { + return userAgent().indexOf('Chrome') > -1 && userAgent().indexOf('Edge') === -1; +} + +export function isSafari(): boolean { + return userAgent().indexOf('Safari') !== -1 && userAgent().indexOf('Chrome') === -1; +} + +export function isIosSafari(): boolean { + return (userAgent().indexOf('iPhone') !== -1 || userAgent().indexOf('iPad') !== -1) && userAgent().indexOf('Safari') !== -1 && userAgent().indexOf('CriOS') === -1; +} + +export function isIosChrome(): boolean { + return userAgent().indexOf('CriOS') !== -1; +} + +export function isIosWeb(): boolean { + return isIosSafari() || isIosChrome(); +} + +export function isIos(): boolean { + return userAgent().indexOf('iPhone') !== -1 || userAgent().indexOf('iPad') !== -1; +} + +export function isAndroid(): boolean { + return userAgent().indexOf('Android') !== -1; +} + +export function isAndroidChrome(): boolean { + return userAgent().indexOf('Android') !== -1 && userAgent().indexOf('Chrome') !== -1 && userAgent().indexOf('Version') === -1; +} + +export function isAndroidFirefox(): boolean { + return userAgent().indexOf('Android') !== -1 && userAgent().indexOf('Firefox') !== -1; +} + +export function isAndroidWeb(): boolean { + return isAndroidChrome() || isAndroidFirefox(); +} + +export function isIosClassic(): boolean { + return isMobileApp() && isIos(); +} + +// Returns true if and only if the user is using a Mattermost mobile app. This will return false if the user is using the +// web browser on a mobile device. +export function isMobileApp(): boolean { + return isMobile() && !isIosWeb() && !isAndroidWeb(); +} + +// Returns true if and only if the user is using Mattermost from either the mobile app or the web browser on a mobile device. +export function isMobile(): boolean { + return isIos() || isAndroid(); +} + +export function isFirefox(): boolean { + return userAgent().indexOf('Firefox') !== -1; +} + +export function isInternetExplorer(): boolean { + return userAgent().indexOf('Trident') !== -1; +} + +export function isEdge(): boolean { + return userAgent().indexOf('Edge') !== -1; +} + +export function isDesktopApp(): boolean { + return userAgent().indexOf('Mattermost') !== -1 && userAgent().indexOf('Electron') !== -1; +} + +export function isWindowsApp(): boolean { + return isDesktopApp() && isWindows(); +} + +export function isMacApp(): boolean { + return isDesktopApp() && isMac(); +} + +export function isWindows(): boolean { + return userAgent().indexOf('Windows') !== -1; +} + +export function isMac(): boolean { + return userAgent().indexOf('Macintosh') !== -1; +} + +export function isWindows7(): boolean { + const appVersion = navigator.appVersion; + + if (!appVersion) { + return false; + } + + return (/\bWindows NT 6\.1\b/).test(appVersion); +} + +export function getDesktopVersion(): string { + // use if the value window.desktop.version is not set yet + const regex = /Mattermost\/(\d+\.\d+\.\d+)/gm; + const match = regex.exec(window.navigator.appVersion)?.[1] || ''; + return match; +} diff --git a/webapp/tests/notifications.test.ts b/webapp/tests/notifications.test.ts new file mode 100644 index 0000000..a155c86 --- /dev/null +++ b/webapp/tests/notifications.test.ts @@ -0,0 +1,49 @@ +import 'mattermost-webapp/tests/setup'; +import {UserProfile} from 'mattermost-redux/types/users'; + +import {shouldNotify} from '../src/notifications'; + +test('mentions', () => { + const profile: UserProfile = {}; + profile.notify_props = {}; + + profile.notify_props.channel = 'true'; + profile.notify_props.desktop = 'all'; + profile.notify_props.push_status = 'online'; + profile.notify_props.first_name = 'false'; + profile.notify_props.mention_keys = ''; + + profile.username = 'roger'; + expect(shouldNotify('test @roger test', profile)).toStrictEqual(true); + expect(shouldNotify('test @roger @henri-the-best test', profile)).toStrictEqual(true); + expect(shouldNotify('test roger', profile)).toStrictEqual(false); + expect(shouldNotify('test @roger@henri', profile)).toStrictEqual(false); + expect(shouldNotify('test @roger @henri', profile)).toStrictEqual(true); + expect(shouldNotify('@roger', profile)).toStrictEqual(true); + expect(shouldNotify('@all', profile)).toStrictEqual(true); + expect(shouldNotify('@channel', profile)).toStrictEqual(true); + + profile.username = 'henri'; + expect(shouldNotify('test @roger @henri-the-best test', profile)).toStrictEqual(false); + expect(shouldNotify('test @roger@henri', profile)).toStrictEqual(false); + expect(shouldNotify('test @roger @henri', profile)).toStrictEqual(true); + + profile.username = 'Roger'; + expect(shouldNotify('test @roger test', profile)).toStrictEqual(false); + expect(shouldNotify('test @roger @henri-the-best test', profile)).toStrictEqual(false); + + profile.username = 'henri-the-best'; + expect(shouldNotify('test @roger @henri-the-best test', profile)).toStrictEqual(true); + + profile.username = 'test.with.point'; + expect(shouldNotify('@test.with.point', profile)).toStrictEqual(true); + + profile.notify_props.first_name = 'true'; + profile.first_name = 'Henri'; + expect(shouldNotify('hello henri', profile)).toStrictEqual(false); + expect(shouldNotify('hello Henri', profile)).toStrictEqual(true); + expect(shouldNotify('Henri', profile)).toStrictEqual(true); + + profile.notify_props.mention_keys = 'chocolate'; + expect(shouldNotify('hello henri do you want some Chocolate', profile)).toStrictEqual(true); +});