Skip to content

Commit

Permalink
Initial support for producing notifications from encrypted messages
Browse files Browse the repository at this point in the history
A more serious support would need the notifyMe function from
mattermost-webapp to be exposed to plugins.

See #1
  • Loading branch information
aguinetqb authored and aguinet committed Nov 12, 2021
1 parent c149d86 commit 9f3bb06
Show file tree
Hide file tree
Showing 6 changed files with 494 additions and 11 deletions.
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,17 @@ 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](), which could be [exposed to plugins]().

Progress on this issue is tracked in [#1](https://github.com/quarkslab/mattermost-plugin-e2ee/issues/1).

Expand Down
47 changes: 44 additions & 3 deletions webapp/src/hooks.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
93 changes: 93 additions & 0 deletions webapp/src/notification_actions.js
Original file line number Diff line number Diff line change
@@ -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();
},
});
}
};
}
145 changes: 145 additions & 0 deletions webapp/src/notifications.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
Loading

0 comments on commit 9f3bb06

Please sign in to comment.