feat(chat/settings) - add ephemeral chat notifications with user settings support (#10617)

This commit is contained in:
Mihaela Dumitru 2021-12-17 14:39:15 +02:00 committed by GitHub
parent ce0a044742
commit 8e9034601d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 331 additions and 65 deletions

View File

@ -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

View File

@ -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",

View File

@ -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;

View File

@ -41,6 +41,9 @@ const DEFAULT_STATE = {
userSelectedMicDeviceId: undefined,
userSelectedAudioOutputDeviceLabel: undefined,
userSelectedCameraDeviceLabel: undefined,
userSelectedNotifications: {
'notify.chatMessages': true
},
userSelectedMicDeviceLabel: undefined,
userSelectedSkipPrejoin: undefined
};

View File

@ -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';
@ -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
@ -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>

View File

@ -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 = [];
// Add ASCII emoticons
const asciiEmoticons = emojiAsciiAliases[key];
if (asciiEmoticons) {
const asciiEscapedValues = asciiEmoticons.map(v => escapeRegexp(v));
const asciiRegexp = `(${asciiEscapedValues.join('|')})`;
// Escape urls
const formattedAsciiRegexp = key === 'confused'
? `(?=(${asciiRegexp}))(:(?!//).)`
: asciiRegexp;
ASCII_EMOTICON_REGEXP_ARRAY.push([ new RegExp(formattedAsciiRegexp, 'g'), value ]);
}
// Adding slack-type emoji format
escapedValues.push(escapeRegexp(`:${key}:`));
// Add slack-type emojis
const emojiRegexp = `\\B(${escapeRegexp(`:${key}:`)})\\B`;
const regexp = `\\B(${escapedValues.join('|')})\\B`;
EMOTICON_REGEXP_ARRAY.push([ new RegExp(regexp, 'g'), value ]);
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.
@ -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);
}

View File

@ -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';
@ -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));
@ -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,
@ -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:
@ -345,7 +354,6 @@ function _handleReceivedMessage({ dispatch, getState },
if (!iAmRecorder) {
dispatch(showToolbox(4000));
}
}
}

View File

@ -14,6 +14,7 @@ import {
SHOW_NOTIFICATION
} from './actionTypes';
import {
NOTIFICATION_ICON,
NOTIFICATION_TIMEOUT_TYPE,
NOTIFICATION_TIMEOUT,
NOTIFICATION_TYPE,
@ -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.

View File

@ -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.
*/

View File

@ -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';
@ -81,7 +82,7 @@ class Notification extends AbstractNotification<Props> {
key = { index }
numberOfLines = { maxLines }
style = { styles.contentText }>
{ line }
{ replaceNonUnicodeEmojis(line) }
</Text>
));
}

View File

@ -1,12 +1,17 @@
// @flow
import Flag from '@atlaskit/flag';
import EditorErrorIcon from '@atlaskit/icon/glyph/editor/error';
import EditorInfoIcon from '@atlaskit/icon/glyph/editor/info';
import EditorSuccessIcon from '@atlaskit/icon/glyph/editor/success';
import EditorWarningIcon from '@atlaskit/icon/glyph/editor/warning';
import QuestionsIcon from '@atlaskit/icon/glyph/questions';
import React from 'react';
import { translate } from '../../../base/i18n';
import Message from '../../../base/react/components/web/Message';
import { colors } from '../../../base/ui/Tokens';
import { NOTIFICATION_TYPE } from '../../constants';
import { NOTIFICATION_ICON, NOTIFICATION_TYPE } from '../../constants';
import AbstractNotification, {
type Props
} from '../AbstractNotification';
@ -71,12 +76,12 @@ class Notification extends AbstractNotification<Props> {
* @returns {ReactElement}
*/
_renderDescription() {
const description = this._getDescription();
const description = this._getDescription().join(' ');
// the id is used for testing the UI
return (
<p data-testid = { this._getDescriptionKey() } >
{ description }
<Message text = { description } />
</p>
);
}
@ -145,6 +150,35 @@ class Notification extends AbstractNotification<Props> {
}
}
/**
* Returns the Icon type component to be used, based on icon or appearance.
*
* @returns {ReactElement}
*/
_getIcon() {
let Icon;
switch (this.props.icon || this.props.appearance) {
case NOTIFICATION_ICON.ERROR:
Icon = EditorErrorIcon;
break;
case NOTIFICATION_ICON.WARNING:
Icon = EditorWarningIcon;
break;
case NOTIFICATION_ICON.SUCCESS:
Icon = EditorSuccessIcon;
break;
case NOTIFICATION_ICON.MESSAGE:
Icon = QuestionsIcon;
break;
default:
Icon = EditorInfoIcon;
break;
}
return Icon;
}
/**
* Creates an icon component depending on the configured notification
* appearance.
@ -153,17 +187,18 @@ class Notification extends AbstractNotification<Props> {
* @returns {ReactElement}
*/
_mapAppearanceToIcon() {
const appearance = this.props.appearance;
const secIconColor = ICON_COLOR[this.props.appearance];
const { appearance, icon } = this.props;
const secIconColor = ICON_COLOR[appearance];
const iconSize = 'medium';
const Icon = this._getIcon();
return (<>
return (<div className = { icon }>
<div className = { `ribbon ${appearance}` } />
<EditorInfoIcon
<Icon
label = { appearance }
secondaryColor = { secIconColor }
size = { iconSize } />
</>);
</div>);
}
}

View File

@ -86,6 +86,10 @@ const useStyles = theme => {
color: theme.palette.field01
},
'& div.message > span': {
color: theme.palette.link01Active
},
'& .ribbon': {
width: '4px',
height: 'calc(100% - 16px)',

View File

@ -46,6 +46,16 @@ export const NOTIFICATION_TYPE_PRIORITIES = {
[NOTIFICATION_TYPE.WARNING]: 4
};
/**
* The set of possible notification icons.
*
* @enum {string}
*/
export const NOTIFICATION_ICON = {
...NOTIFICATION_TYPE,
MESSAGE: 'message'
};
/**
* The identifier of the raise hand notification.
*

View File

@ -101,6 +101,17 @@ export function submitMoreTab(newState: Object): Function {
});
}
const enabledNotifications = newState.enabledNotifications;
if (enabledNotifications !== currentState.enabledNotifications) {
dispatch(updateSettings({
userSelectedNotifications: {
...getState()['features/base/settings'].userSelectedNotifications,
...enabledNotifications
}
}));
}
if (newState.currentLanguage !== currentState.currentLanguage) {
i18next.changeLanguage(newState.currentLanguage);
}

View File

@ -50,6 +50,11 @@ export type Props = {
*/
languages: Array<string>,
/**
* The types of enabled notifications that can be configured and their specific visibility.
*/
enabledNotifications: Object,
/**
* Whether or not to display the language select dropdown.
*/
@ -60,6 +65,11 @@ export type Props = {
*/
showModeratorSettings: boolean,
/**
* Whether or not to display notifications settings.
*/
showNotificationsSettings: boolean,
/**
* Whether or not to display the prejoin settings section.
*/
@ -122,6 +132,7 @@ class MoreTab extends AbstractDialogTab<Props, State> {
this._onFramerateItemSelect = this._onFramerateItemSelect.bind(this);
this._onLanguageDropdownOpenChange = this._onLanguageDropdownOpenChange.bind(this);
this._onLanguageItemSelect = this._onLanguageItemSelect.bind(this);
this._onEnabledNotificationsChanged = this._onEnabledNotificationsChanged.bind(this);
this._onShowPrejoinPageChanged = this._onShowPrejoinPageChanged.bind(this);
this._onKeyboardShortcutEnableChanged = this._onKeyboardShortcutEnableChanged.bind(this);
this._onHideSelfViewChanged = this._onHideSelfViewChanged.bind(this);
@ -220,6 +231,26 @@ class MoreTab extends AbstractDialogTab<Props, State> {
_onKeyboardShortcutEnableChanged: (Object) => void;
/**
* Callback invoked to select if the given type of
* notifications should be shown.
*
* @param {Object} e - The key event to handle.
* @param {string} type - The type of the notification.
*
* @returns {void}
*/
_onEnabledNotificationsChanged({ target: { checked } }, type) {
super._onChange({
enabledNotifications: {
...this.props.enabledNotifications,
[type]: checked
}
});
}
_onEnabledNotificationsChanged: (Object, string) => void;
/**
* Callback invoked to select if global keyboard shortcuts
* should be enabled.
@ -428,6 +459,37 @@ class MoreTab extends AbstractDialogTab<Props, State> {
);
}
/**
* Returns the React Element for modifying the enabled notifications settings.
*
* @private
* @returns {ReactElement}
*/
_renderNotificationsSettings() {
const { t, enabledNotifications } = this.props;
return (
<div
className = 'settings-sub-pane-element'
key = 'notifications'>
<h2 className = 'mock-atlaskit-label'>
{ t('notify.displayNotifications') }
</h2>
{
Object.keys(enabledNotifications).map(key => (
<Checkbox
isChecked = { enabledNotifications[key] }
key = { key }
label = { t(key) }
name = { `show-${key}` }
/* eslint-disable-next-line react/jsx-no-bind */
onChange = { e => this._onEnabledNotificationsChanged(e, key) } />
))
}
</div>
);
}
/**
* Returns the React element that needs to be displayed on the right half of the more tabs.
*
@ -453,13 +515,14 @@ class MoreTab extends AbstractDialogTab<Props, State> {
* @returns {ReactElement}
*/
_renderSettingsLeft() {
const { disableHideSelfView, showPrejoinSettings } = this.props;
const { disableHideSelfView, showNotificationsSettings, showPrejoinSettings } = this.props;
return (
<div
className = 'settings-sub-pane left'
key = 'settings-sub-pane-left'>
{ showPrejoinSettings && this._renderPrejoinScreenSettings() }
{ showNotificationsSettings && this._renderNotificationsSettings() }
{ this._renderKeyboardShortcutCheckbox() }
{ !disableHideSelfView && this._renderSelfViewCheckbox() }
</div>

View File

@ -31,7 +31,6 @@ import MoreTab from './MoreTab';
import ProfileTab from './ProfileTab';
import SoundsTab from './SoundsTab';
declare var APP: Object;
declare var interfaceConfig: Object;
/**
@ -144,7 +143,8 @@ function _mapStateToProps(state) {
const moreTabProps = getMoreTabProps(state);
const moderatorTabProps = getModeratorTabProps(state);
const { showModeratorSettings } = moderatorTabProps;
const { showLanguageSettings, showPrejoinSettings } = moreTabProps;
const { showLanguageSettings, showNotificationsSettings, showPrejoinSettings } = moreTabProps;
const showMoreTab = showLanguageSettings || showNotificationsSettings || showPrejoinSettings;
const showProfileSettings
= configuredTabs.includes('profile') && !state['features/base/config'].disableProfile;
const showCalendarSettings
@ -231,7 +231,7 @@ function _mapStateToProps(state) {
});
}
if (showLanguageSettings || showPrejoinSettings) {
if (showMoreTab) {
tabs.push({
name: SETTINGS_TABS.MORE,
component: MoreTab,
@ -245,7 +245,8 @@ function _mapStateToProps(state) {
currentFramerate: tabState.currentFramerate,
currentLanguage: tabState.currentLanguage,
hideSelfView: tabState.hideSelfView,
showPrejoinPage: tabState.showPrejoinPage
showPrejoinPage: tabState.showPrejoinPage,
enabledNotifications: tabState.enabledNotifications
};
},
styles: 'settings-pane more-pane',

View File

@ -79,6 +79,28 @@ export function normalizeUserInputURL(url: string) {
/* eslint-enable no-param-reassign */
}
/**
* Returns the notification types and their user selected configuration.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {Object} - The section of notifications to be configured.
*/
export function getNotificationsMap(stateful: Object | Function) {
const state = toState(stateful);
const { notifications } = state['features/base/config'];
const { userSelectedNotifications } = state['features/base/settings'];
return Object.keys(userSelectedNotifications)
.filter(key => !notifications || notifications.includes(key))
.reduce((notificationsMap, key) => {
return {
...notificationsMap,
[key]: userSelectedNotifications[key]
};
}, {});
}
/**
* Returns the properties for the "More" tab from settings dialog from Redux
* state.
@ -92,6 +114,7 @@ export function getMoreTabProps(stateful: Object | Function) {
const framerate = state['features/screen-share'].captureFrameRate ?? SS_DEFAULT_FRAME_RATE;
const language = i18next.language || DEFAULT_LANGUAGE;
const configuredTabs = interfaceConfig.SETTINGS_SECTIONS || [];
const enabledNotifications = getNotificationsMap(stateful);
// when self view is controlled by the config we hide the settings
const { disableSelfView, disableSelfViewSettings } = state['features/base/config'];
@ -104,6 +127,8 @@ export function getMoreTabProps(stateful: Object | Function) {
hideSelfView: getHideSelfView(state),
languages: LANGUAGES,
showLanguageSettings: configuredTabs.includes('language'),
enabledNotifications,
showNotificationsSettings: Object.keys(enabledNotifications).length > 0,
showPrejoinPage: !state['features/base/settings'].userSelectedSkipPrejoin,
showPrejoinSettings: state['features/base/config'].prejoinConfig?.enabled
};