Skip to content

Commit

Permalink
feat(chat/settings) - add ephemeral chat notifications with user sett…
Browse files Browse the repository at this point in the history
…ings support (jitsi#10617)
  • Loading branch information
mihhu authored and humbledroid committed Jan 6, 2022
1 parent a879606 commit ae95582
Show file tree
Hide file tree
Showing 17 changed files with 332 additions and 66 deletions.
1 change: 1 addition & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,7 @@ var config = {
// 'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected
// 'lobby.notificationTitle', // shown when lobby is toggled and when join requests are allowed / denied
// 'localRecording.localRecording', // shown when a local recording is started
// 'notify.chatMessages', // shown when receiving chat messages while the chat window is closed
// 'notify.disconnected', // shown when a participant has left
// 'notify.connectedOneMember', // show when a participant joined
// 'notify.connectedTwoMembers', // show when two participants joined simultaneously
Expand Down
2 changes: 2 additions & 0 deletions lang/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -581,10 +581,12 @@
"allowedUnmute": "You can unmute your microphone, start your camera or share your screen.",
"audioUnmuteBlockedTitle": "Mic unmute blocked!",
"audioUnmuteBlockedDescription": "Mic unmute operation has been temporarily blocked because of system limits.",
"chatMessages": "Chat messages",
"connectedOneMember": "{{name}} joined the meeting",
"connectedThreePlusMembers": "{{name}} and many others joined the meeting",
"connectedTwoMembers": "{{first}} and {{second}} joined the meeting",
"disconnected": "disconnected",
"displayNotifications": "Display notifications for",
"focus": "Conference focus",
"focusFail": "{{component}} not available - retry in {{ms}} sec",
"hostAskedUnmute": "The moderator would like you to speak",
Expand Down
86 changes: 86 additions & 0 deletions react/features/base/react/components/web/Message.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @flow

import React, { Component } from 'react';
import { toArray } from 'react-emoji-render';

import Linkify from './Linkify';

type Props = {

/**
* The body of the message.
*/
text: string
};

/**
* Renders the content of a chat message.
*/
class Message extends Component<Props> {
/**
* Initializes a new {@code Message} instance.
*
* @param {Props} props - The props of the component.
* @inheritdoc
*/
constructor(props: Props) {
super(props);

// Bind event handlers so they are only bound once for every instance
this._processMessage = this._processMessage.bind(this);
}

/**
* Parses and builds the message tokens to include emojis and urls.
*
* @returns {Array<string|ReactElement>}
*/
_processMessage() {
const { text } = this.props;
const message = [];

// Tokenize the text in order to avoid emoji substitution for URLs
const tokens = text ? text.split(' ') : [];

const content = [];

for (const token of tokens) {
if (token.includes('://')) {

// Bypass the emojification when urls are involved
content.push(token);
} else {
content.push(...toArray(token, { className: 'smiley' }));
}

content.push(' ');
}

content.forEach(token => {
if (typeof token === 'string' && token !== ' ') {
message.push(<Linkify key = { token }>{ token }</Linkify>);
} else {
message.push(token);
}
});

return message;
}

_processMessage: () => Array<string | React$Element<*>>;

/**
* Implements React's {@link Component#render()}.
*
* @returns {ReactElement}
*/
render() {
return (
<>
{ this._processMessage() }
</>
);
}
}

export default Message;
3 changes: 3 additions & 0 deletions react/features/base/settings/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const DEFAULT_STATE = {
userSelectedMicDeviceId: undefined,
userSelectedAudioOutputDeviceLabel: undefined,
userSelectedCameraDeviceLabel: undefined,
userSelectedNotifications: {
'notify.chatMessages': true
},
userSelectedMicDeviceLabel: undefined,
userSelectedSkipPrejoin: undefined
};
Expand Down
33 changes: 2 additions & 31 deletions react/features/chat/components/web/ChatMessage.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// @flow

import React from 'react';
import { toArray } from 'react-emoji-render';

import { translate } from '../../../base/i18n';
import { Linkify } from '../../../base/react';
import Message from '../../../base/react/components/web/Message';
import { MESSAGE_TYPE_LOCAL } from '../../constants';
import AbstractChatMessage, { type Props } from '../AbstractChatMessage';

Expand All @@ -22,34 +21,6 @@ class ChatMessage extends AbstractChatMessage<Props> {
*/
render() {
const { message, t } = this.props;
const processedMessage = [];

const txt = this._getMessageText();

// Tokenize the text in order to avoid emoji substitution for URLs.
const tokens = txt.split(' ');

// Content is an array of text and emoji components
const content = [];

for (const token of tokens) {
if (token.includes('://')) {
// It contains a link, bypass the emojification.
content.push(token);
} else {
content.push(...toArray(token, { className: 'smiley' }));
}

content.push(' ');
}

content.forEach(i => {
if (typeof i === 'string' && i !== ' ') {
processedMessage.push(<Linkify key = { i }>{ i }</Linkify>);
} else {
processedMessage.push(i);
}
});

return (
<div
Expand All @@ -66,7 +37,7 @@ class ChatMessage extends AbstractChatMessage<Props> {
: t('chat.messageAccessibleTitle',
{ user: this.props.message.displayName }) }
</span>
{ processedMessage }
<Message text = { this._getMessageText() } />
</div>
{ message.privateMessage && this._renderPrivateNotice() }
</div>
Expand Down
59 changes: 40 additions & 19 deletions react/features/chat/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,56 @@ import { escapeRegexp } from '../base/util';

/**
* An ASCII emoticon regexp array to find and replace old-style ASCII
* emoticons (such as :O) to new Unicode representation, so then devices
* and browsers that support them can render these natively without
* a 3rd party component.
* emoticons (such as :O) with the new Unicode representation, so that
* devices and browsers that support them can render these natively
* without a 3rd party component.
*
* NOTE: this is currently only used on mobile, but it can be used
* on web too once we drop support for browsers that don't support
* unicode emoji rendering.
*/
const EMOTICON_REGEXP_ARRAY: Array<Array<Object>> = [];
const ASCII_EMOTICON_REGEXP_ARRAY: Array<Array<Object>> = [];

/**
* An emoji regexp array to find and replace alias emoticons
* (such as :smiley:) with the new Unicode representation, so that
* devices and browsers that support them can render these natively
* without a 3rd party component.
*
* NOTE: this is currently only used on mobile, but it can be used
* on web too once we drop support for browsers that don't support
* unicode emoji rendering.
*/
const SLACK_EMOJI_REGEXP_ARRAY: Array<Array<Object>> = [];

(function() {
for (const [ key, value ] of Object.entries(aliases)) {
let escapedValues;
const asciiEmojies = emojiAsciiAliases[key];

// Adding ascii emoticons
if (asciiEmojies) {
escapedValues = asciiEmojies.map(v => escapeRegexp(v));
} else {
escapedValues = [];
}

// Adding slack-type emoji format
escapedValues.push(escapeRegexp(`:${key}:`));
// Add ASCII emoticons
const asciiEmoticons = emojiAsciiAliases[key];

const regexp = `\\B(${escapedValues.join('|')})\\B`;
if (asciiEmoticons) {
const asciiEscapedValues = asciiEmoticons.map(v => escapeRegexp(v));

EMOTICON_REGEXP_ARRAY.push([ new RegExp(regexp, 'g'), value ]);
const asciiRegexp = `(${asciiEscapedValues.join('|')})`;

// Escape urls
const formattedAsciiRegexp = key === 'confused'
? `(?=(${asciiRegexp}))(:(?!//).)`
: asciiRegexp;

ASCII_EMOTICON_REGEXP_ARRAY.push([ new RegExp(formattedAsciiRegexp, 'g'), value ]);
}

// Add slack-type emojis
const emojiRegexp = `\\B(${escapeRegexp(`:${key}:`)})\\B`;

SLACK_EMOJI_REGEXP_ARRAY.push([ new RegExp(emojiRegexp, 'g'), value ]);
}
})();

/**
* Replaces ascii and other non-unicode emoticons with unicode emojis to let the emojis be rendered
* Replaces ASCII and other non-unicode emoticons with unicode emojis to let the emojis be rendered
* by the platform native renderer.
*
* @param {string} message - The message to parse and replace.
Expand All @@ -48,7 +65,11 @@ const EMOTICON_REGEXP_ARRAY: Array<Array<Object>> = [];
export function replaceNonUnicodeEmojis(message: string) {
let replacedMessage = message;

for (const [ regexp, replaceValue ] of EMOTICON_REGEXP_ARRAY) {
for (const [ regexp, replaceValue ] of SLACK_EMOJI_REGEXP_ARRAY) {
replacedMessage = replacedMessage.replace(regexp, replaceValue);
}

for (const [ regexp, replaceValue ] of ASCII_EMOTICON_REGEXP_ARRAY) {
replacedMessage = replacedMessage.replace(regexp, replaceValue);
}

Expand Down
12 changes: 10 additions & 2 deletions react/features/chat/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { NOTIFICATION_TIMEOUT_TYPE, showMessageNotification } from '../notifications';
import { resetNbUnreadPollsMessages } from '../polls/actions';
import { ADD_REACTION_MESSAGE } from '../reactions/actionTypes';
import { pushReactions } from '../reactions/actions.any';
Expand Down Expand Up @@ -304,7 +305,7 @@ function _handleReceivedMessage({ dispatch, getState },
const state = getState();
const { isOpen: isChatOpen } = state['features/chat'];
const { iAmRecorder } = state['features/base/config'];
const { soundsIncomingMessage: soundEnabled } = state['features/base/settings'];
const { soundsIncomingMessage: soundEnabled, userSelectedNotifications } = state['features/base/settings'];

if (soundEnabled && shouldPlaySound && !isChatOpen) {
dispatch(playSound(INCOMING_MSG_SOUND_ID));
Expand All @@ -318,6 +319,7 @@ function _handleReceivedMessage({ dispatch, getState },
const hasRead = participant.local || isChatOpen;
const timestampToDate = timestamp ? new Date(timestamp) : new Date();
const millisecondsTimestamp = timestampToDate.getTime();
const shouldShowNotification = userSelectedNotifications['notify.chatMessages'] && !hasRead && !isReaction;

dispatch(addMessage({
displayName,
Expand All @@ -331,6 +333,13 @@ function _handleReceivedMessage({ dispatch, getState },
isReaction
}));

if (shouldShowNotification) {
dispatch(showMessageNotification({
title: displayName,
description: message
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
}

if (typeof APP !== 'undefined') {
// Logic for web only:

Expand All @@ -345,7 +354,6 @@ function _handleReceivedMessage({ dispatch, getState },
if (!iAmRecorder) {
dispatch(showToolbox(4000));
}

}
}

Expand Down
18 changes: 18 additions & 0 deletions react/features/notifications/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SHOW_NOTIFICATION
} from './actionTypes';
import {
NOTIFICATION_ICON,
NOTIFICATION_TIMEOUT_TYPE,
NOTIFICATION_TIMEOUT,
NOTIFICATION_TYPE,
Expand Down Expand Up @@ -156,6 +157,23 @@ export function showWarningNotification(props: Object, type: ?string) {
}, type);
}

/**
* Queues a message notification for display.
*
* @param {Object} props - The props needed to show the notification component.
* @param {string} type - Notification type.
* @returns {Object}
*/
export function showMessageNotification(props: Object, type: ?string) {
return showNotification({
...props,
concatText: true,
titleKey: 'notify.chatMessages',
appearance: NOTIFICATION_TYPE.NORMAL,
icon: NOTIFICATION_ICON.MESSAGE
}, type);
}

/**
* An array of names of participants that have joined the conference. The array
* is replaced with an empty array as notifications are displayed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export type Props = {
*/
hideErrorSupportLink: boolean,

/**
* The type of icon to be displayed. If not passed in, the appearance
* type will be used.
*/
icon?: String,

/**
* Whether or not the dismiss button should be displayed.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Text, TouchableOpacity, View } from 'react-native';

import { translate } from '../../../base/i18n';
import { Icon, IconClose } from '../../../base/icons';
import { replaceNonUnicodeEmojis } from '../../../chat/functions';
import AbstractNotification, {
type Props
} from '../AbstractNotification';
Expand Down Expand Up @@ -81,7 +82,7 @@ class Notification extends AbstractNotification<Props> {
key = { index }
numberOfLines = { maxLines }
style = { styles.contentText }>
{ line }
{ replaceNonUnicodeEmojis(line) }
</Text>
));
}
Expand Down
Loading

0 comments on commit ae95582

Please sign in to comment.