diff --git a/css/_chat.scss b/css/_chat.scss index 7f52d53e2b..5d4b5c1ba0 100644 --- a/css/_chat.scss +++ b/css/_chat.scss @@ -80,6 +80,27 @@ } } +#chat-recipient { + align-items: center; + background-color: $defaultWarningColor; + display: flex; + flex-direction: row; + padding: 10px; + + span { + color: white; + display: flex; + flex: 1; + } + + div { + svg { + cursor: pointer; + fill: white + } + } +} + .chat-header { background-color: $chatHeaderBackgroundColor; height: 70px; @@ -196,6 +217,11 @@ padding: 0; } } + + .privatemessagenotice { + color: $defaultWarningColor; + font-style: italic; + } } .smiley { @@ -228,6 +254,7 @@ .smileys-panel { bottom: 100%; box-sizing: border-box; + background-color: rgba(0, 0, 0, .6) !important; height: auto; max-height: 0; overflow: hidden; @@ -312,6 +339,16 @@ .chatmessage-wrapper { max-width: 100%; + + .replywrapper { + display: flex; + flex-direction: row; + align-items: center; + + .toolbox-icon { + cursor: pointer; + } + } } .chatmessage { diff --git a/css/_popup_menu.scss b/css/_popup_menu.scss index 220aa64b40..6bb98f2508 100644 --- a/css/_popup_menu.scss +++ b/css/_popup_menu.scss @@ -6,7 +6,7 @@ min-width: 75px; text-align: left; padding: 0px; - width: 150px; + width: 180px; white-space: nowrap; &__item { @@ -87,6 +87,7 @@ display: inline-block; min-width: 20px; height: 100%; + padding-right: 10px; > * { @include absoluteAligning(); diff --git a/css/_variables.scss b/css/_variables.scss index 5ae429a06b..602d94e665 100644 --- a/css/_variables.scss +++ b/css/_variables.scss @@ -28,6 +28,7 @@ $defaultColor: #F1F1F1; $defaultSideBarFontColor: #44A5FF; $defaultSemiDarkColor: #ACACAC; $defaultDarkColor: #2b3d5c; +$defaultWarningColor: rgb(215, 121, 118); /** * Toolbar diff --git a/lang/main.json b/lang/main.json index d614ae87ab..82754d2ea9 100644 --- a/lang/main.json +++ b/lang/main.json @@ -48,11 +48,14 @@ "chat": { "error": "Error: your message \"{{originalText}}\" was not sent. Reason: {{error}}", "messagebox": "Type a message", + "messageTo": "Private message to {{recipient}}", "nickname": { "popover": "Choose a nickname", "title": "Enter a nickname to use chat" }, - "title": "Chat" + "privateNotice": "Private message to {{recipient}}", + "title": "Chat", + "you": "you" }, "connectingOverlay": { "joiningRoom": "Connecting you to your meeting..." @@ -235,6 +238,10 @@ "screenSharingFirefoxPermissionDeniedError": "Something went wrong while we were trying to share your screen. Please make sure that you have given us permission to do so. ", "screenSharingFirefoxPermissionDeniedTitle": "Oops! We weren’t able to start screen sharing!", "screenSharingPermissionDeniedError": "Oops! Something went wrong with your screen sharing extension permissions. Please reload and try again.", + "sendPrivateMessage": "You recently received a private message. Did you intend to reply to that privately, or you want to send your message to the group?", + "sendPrivateMessageCancel": "Send to the group", + "sendPrivateMessageOk": "Send privately", + "sendPrivateMessageTitle": "Send privately?", "serviceUnavailable": "Service unavailable", "sessTerminated": "Call terminated", "Share": "Share", @@ -571,6 +578,7 @@ "moreActionsMenu": "More actions menu", "mute": "Toggle mute audio", "pip": "Toggle Picture-in-Picture mode", + "privateMessage": "Send private message", "profile": "Edit your profile", "raiseHand": "Toggle raise hand", "recording": "Toggle recording", @@ -611,6 +619,7 @@ "mute": "Mute / Unmute", "openChat": "Open chat", "pip": "Enter Picture-in-Picture mode", + "privateMessage": "Send private message", "profile": "Edit your profile", "raiseHand": "Raise / Lower your hand", "raiseYourHand": "Raise your hand", diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 984d469b7c..0a9483e6b5 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -36,6 +36,7 @@ export { default as IconMenu } from './menu.svg'; export { default as IconMenuDown } from './menu-down.svg'; export { default as IconMenuThumb } from './thumb-menu.svg'; export { default as IconMenuUp } from './menu-up.svg'; +export { default as IconMessage } from './message.svg'; export { default as IconMicDisabled } from './mic-disabled.svg'; export { default as IconMicrophone } from './microphone.svg'; export { default as IconModerator } from './star.svg'; @@ -48,6 +49,7 @@ export { default as IconRaisedHand } from './raised-hand.svg'; export { default as IconRec } from './rec.svg'; export { default as IconRemoteControlStart } from './play.svg'; export { default as IconRemoteControlStop } from './stop.svg'; +export { default as IconReply } from './reply.svg'; export { default as IconRestore } from './restore.svg'; export { default as IconRoomLock } from './security.svg'; export { default as IconRoomUnlock } from './security-locked.svg'; diff --git a/react/features/base/icons/svg/message.svg b/react/features/base/icons/svg/message.svg new file mode 100644 index 0000000000..2b47ee860a --- /dev/null +++ b/react/features/base/icons/svg/message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/features/base/icons/svg/reply.svg b/react/features/base/icons/svg/reply.svg new file mode 100644 index 0000000000..8bf1653c06 --- /dev/null +++ b/react/features/base/icons/svg/reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/features/base/styles/components/styles/ColorPalette.js b/react/features/base/styles/components/styles/ColorPalette.js index 28847448b2..ccb3d2afae 100644 --- a/react/features/base/styles/components/styles/ColorPalette.js +++ b/react/features/base/styles/components/styles/ColorPalette.js @@ -28,6 +28,7 @@ export const ColorPalette = { overflowMenuItemUnderlay: '#EEEEEE', red: '#D00000', transparent: 'rgba(0, 0, 0, 0)', + warning: 'rgb(215, 121, 118)', white: '#FFFFFF', /** diff --git a/react/features/chat/actionTypes.js b/react/features/chat/actionTypes.js index 7b1122ec53..62a690dc81 100644 --- a/react/features/chat/actionTypes.js +++ b/react/features/chat/actionTypes.js @@ -30,11 +30,23 @@ export const CLEAR_MESSAGES = 'CLEAR_MESSAGES'; * * { * type: SEND_MESSAGE, + * ignorePrivacy: boolean, * message: string * } */ export const SEND_MESSAGE = 'SEND_MESSAGE'; +/** + * The type of action which signals the initiation of sending of as private message to the + * supplied recipient. + * + * { + * participant: Participant, + * type: SET_PRIVATE_MESSAGE_RECIPIENT + * } + */ +export const SET_PRIVATE_MESSAGE_RECIPIENT = 'SET_PRIVATE_MESSAGE_RECIPIENT'; + /** * The type of the action which signals to toggle the display of the chat panel. * diff --git a/react/features/chat/actions.js b/react/features/chat/actions.js index bff2c953f6..d519d9ac4d 100644 --- a/react/features/chat/actions.js +++ b/react/features/chat/actions.js @@ -4,6 +4,7 @@ import { ADD_MESSAGE, CLEAR_MESSAGES, SEND_MESSAGE, + SET_PRIVATE_MESSAGE_RECIPIENT, TOGGLE_CHAT } from './actionTypes'; @@ -53,18 +54,37 @@ export function clearMessages() { * Sends a chat message to everyone in the conference. * * @param {string} message - The chat message to send out. + * @param {boolean} ignorePrivacy - True if the privacy notification should be ignored. * @returns {{ * type: SEND_MESSAGE, + * ignorePrivacy: boolean, * message: string * }} */ -export function sendMessage(message: string) { +export function sendMessage(message: string, ignorePrivacy: boolean = false) { return { type: SEND_MESSAGE, + ignorePrivacy, message }; } +/** + * Initiates the sending of a private message to the supplied participant. + * + * @param {Participant} participant - The participant to set the recipient to. + * @returns {{ + * participant: Participant, + * type: SET_PRIVATE_MESSAGE_RECIPIENT + * }} + */ +export function setPrivateMessageRecipient(participant: Object) { + return { + participant, + type: SET_PRIVATE_MESSAGE_RECIPIENT + }; +} + /** * Toggles display of the chat side panel. * diff --git a/react/features/chat/components/AbstractChatMessage.js b/react/features/chat/components/AbstractChatMessage.js index a671b2e060..9f37e45c64 100644 --- a/react/features/chat/components/AbstractChatMessage.js +++ b/react/features/chat/components/AbstractChatMessage.js @@ -56,4 +56,17 @@ export default class AbstractChatMessage extends PureComponent

{ return getLocalizedDateFormatter(new Date(this.props.message.timestamp)) .format(TIMESTAMP_FORMAT); } + + /** + * Returns the message that is displayed as a notice for private messages. + * + * @returns {string} + */ + _getPrivateNoticeMessage() { + const { message, t } = this.props; + + return t('chat.privateNotice', { + recipient: message.messageType === 'local' ? message.recipient : t('chat.you') + }); + } } diff --git a/react/features/chat/components/AbstractChatPrivacyDialog.js b/react/features/chat/components/AbstractChatPrivacyDialog.js new file mode 100644 index 0000000000..2283e9e947 --- /dev/null +++ b/react/features/chat/components/AbstractChatPrivacyDialog.js @@ -0,0 +1,116 @@ +// @flow + +import { PureComponent } from 'react'; + +import { sendMessage, setPrivateMessageRecipient } from '../actions'; +import { getParticipantById } from '../../base/participants'; + +type Props = { + + /** + * The message that is about to be sent. + */ + message: Object, + + /** + * The ID of the participant that we think the message may be intended to. + */ + participantID: string, + + /** + * Function to be used to translate i18n keys. + */ + t: Function, + + /** + * Prop to be invoked on sending the message. + */ + _onSendMessage: Function, + + /** + * Prop to be invoked when the user wants to set a private recipient. + */ + _onSetMessageRecipient: Function, + + /** + * The participant retreived from Redux by the participanrID prop. + */ + _participant: Object +}; + +/** + * Abstract class for the dialog displayed to avoid mis-sending private messages. + */ +export class AbstractChatPrivacyDialog extends PureComponent { + /** + * Instantiates a new instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onSendGroupMessage = this._onSendGroupMessage.bind(this); + this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this); + } + + _onSendGroupMessage: () => boolean; + + /** + * Callback to be invoked for cancel action (user wants to send a group message). + * + * @returns {boolean} + */ + _onSendGroupMessage() { + this.props._onSendMessage(this.props.message); + + return true; + } + + _onSendPrivateMessage: () => boolean; + + /** + * Callback to be invoked for submit action (user wants to send a private message). + * + * @returns {void} + */ + _onSendPrivateMessage() { + const { message, _onSendMessage, _onSetMessageRecipient, _participant } = this.props; + + _onSetMessageRecipient(_participant); + _onSendMessage(message); + + return true; + } +} + +/** + * Maps part of the props of this component to Redux actions. + * + * @param {Function} dispatch - The Redux dispatch function. + * @returns {Props} + */ +export function _mapDispatchToProps(dispatch: Function): $Shape { + return { + _onSendMessage: (message: Object) => { + dispatch(sendMessage(message, true)); + }, + + _onSetMessageRecipient: participant => { + dispatch(setPrivateMessageRecipient(participant)); + } + }; +} + +/** + * Maps part of the Redux store to the props of this component. + * + * @param {Object} state - The Redux state. + * @param {Props} ownProps - The own props of the component. + * @returns {Props} + */ +export function _mapStateToProps(state: Object, ownProps: Props): $Shape { + return { + _participant: getParticipantById(state, ownProps.participantID) + }; +} diff --git a/react/features/chat/components/AbstractMessageRecipient.js b/react/features/chat/components/AbstractMessageRecipient.js new file mode 100644 index 0000000000..baf37c534e --- /dev/null +++ b/react/features/chat/components/AbstractMessageRecipient.js @@ -0,0 +1,61 @@ +// @flow + +import { PureComponent } from 'react'; + +import { getParticipantDisplayName } from '../../base/participants'; + +import { setPrivateMessageRecipient } from '../actions'; + +type Props = { + + /** + * Function used to translate i18n labels. + */ + t: Function, + + /** + * Function to remove the recipent setting of the chat window. + */ + _onRemovePrivateMessageRecipient: Function, + + /** + * The name of the message recipient, if any. + */ + _privateMessageRecipient: ?string +}; + +/** + * Abstract class for the {@code MessageRecipient} component. + */ +export default class AbstractMessageRecipient extends PureComponent { + +} + +/** + * Maps part of the props of this component to Redux actions. + * + * @param {Function} dispatch - The Redux dispatch function. + * @returns {Props} + */ +export function _mapDispatchToProps(dispatch: Function): $Shape { + return { + _onRemovePrivateMessageRecipient: () => { + dispatch(setPrivateMessageRecipient()); + } + }; +} + +/** + * Maps part of the Redux store to the props of this component. + * + * @param {Object} state - The Redux state. + * @returns {Props} + */ +export function _mapStateToProps(state: Object): $Shape { + const { privateMessageRecipient } = state['features/chat']; + + return { + _privateMessageRecipient: + privateMessageRecipient ? getParticipantDisplayName(state, privateMessageRecipient.id) : undefined + }; +} diff --git a/react/features/chat/components/PrivateMessageButton.js b/react/features/chat/components/PrivateMessageButton.js new file mode 100644 index 0000000000..1cb37993a3 --- /dev/null +++ b/react/features/chat/components/PrivateMessageButton.js @@ -0,0 +1,101 @@ +// @flow + +import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox'; + +import { translate } from '../../base/i18n'; +import { IconMessage, IconReply } from '../../base/icons'; +import { getParticipantById } from '../../base/participants'; +import { connect } from '../../base/redux'; + +import { setPrivateMessageRecipient } from '../actions'; + +export type Props = AbstractButtonProps & { + + /** + * The ID of the participant that the message is to be sent. + */ + participantID: string, + + /** + * True if the button is rendered as a reply button. + */ + reply: boolean, + + /** + * Function to be used to translate i18n labels. + */ + t: Function, + + /** + * The participant object retreived from Redux. + */ + _participant: Object, + + /** + * Function to dispatch the result of the participant selection to send a private message. + */ + _setPrivateMessageRecipient: Function +}; + +/** + * Class to render a button that initiates the sending of a private message through chet. + */ +class PrivateMessageButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.privateMessage'; + icon = IconMessage; + label = 'toolbar.privateMessage'; + toggledIcon = IconReply; + + /** + * Handles clicking / pressing the button, and kicks the participant. + * + * @private + * @returns {void} + */ + _handleClick() { + const { _participant, _setPrivateMessageRecipient } = this.props; + + _setPrivateMessageRecipient(_participant); + } + + /** + * Helper function to be implemented by subclasses, which must return a + * {@code boolean} value indicating if this button is toggled or not. + * + * @protected + * @returns {boolean} + */ + _isToggled() { + return this.props.reply; + } + +} + +/** + * Maps part of the props of this component to Redux actions. + * + * @param {Function} dispatch - The Redux dispatch function. + * @returns {Props} + */ +export function _mapDispatchToProps(dispatch: Function): $Shape { + return { + _setPrivateMessageRecipient: participant => { + dispatch(setPrivateMessageRecipient(participant)); + } + }; +} + +/** + * Maps part of the Redux store to the props of this component. + * + * @param {Object} state - The Redux state. + * @param {Props} ownProps - The own props of the component. + * @returns {Props} + */ +export function _mapStateToProps(state: Object, ownProps: Props): $Shape { + return { + _participant: getParticipantById(state, ownProps.participantID) + }; +} + +export default translate(connect(_mapStateToProps, _mapDispatchToProps)(PrivateMessageButton)); diff --git a/react/features/chat/components/index.native.js b/react/features/chat/components/index.native.js index a32ec60612..570300891e 100644 --- a/react/features/chat/components/index.native.js +++ b/react/features/chat/components/index.native.js @@ -1,3 +1,4 @@ // @flow export * from './native'; +export { default as PrivateMessageButton } from './PrivateMessageButton'; diff --git a/react/features/chat/components/index.web.js b/react/features/chat/components/index.web.js index 40d5f46528..39c5711db4 100644 --- a/react/features/chat/components/index.web.js +++ b/react/features/chat/components/index.web.js @@ -1,3 +1,5 @@ // @flow export * from './web'; + +export { default as PrivateMessageButton } from './PrivateMessageButton'; diff --git a/react/features/chat/components/native/Chat.js b/react/features/chat/components/native/Chat.js index 79b7c68468..73909cf09b 100644 --- a/react/features/chat/components/native/Chat.js +++ b/react/features/chat/components/native/Chat.js @@ -16,6 +16,7 @@ import AbstractChat, { import ChatInputBar from './ChatInputBar'; import MessageContainer from './MessageContainer'; +import MessageRecipient from './MessageRecipient'; import styles from './styles'; /** @@ -53,6 +54,7 @@ class Chat extends AbstractChat { onPressBack = { this._onClose } /> + diff --git a/react/features/chat/components/native/ChatMessage.js b/react/features/chat/components/native/ChatMessage.js index 1fa5d999e5..a396d3a041 100644 --- a/react/features/chat/components/native/ChatMessage.js +++ b/react/features/chat/components/native/ChatMessage.js @@ -10,6 +10,7 @@ import { Linkify } from '../../../base/react'; import { replaceNonUnicodeEmojis } from '../../functions'; import AbstractChatMessage, { type Props } from '../AbstractChatMessage'; +import PrivateMessageButton from '../PrivateMessageButton'; import styles from './styles'; @@ -57,14 +58,26 @@ class ChatMessage extends AbstractChatMessage { { this._renderAvatar() } - - { - this.props.showDisplayName - && this._renderDisplayName() - } - - { replaceNonUnicodeEmojis(messageText) } - + + + { + this.props.showDisplayName + && this._renderDisplayName() + } + + { replaceNonUnicodeEmojis(messageText) } + + { + message.privateMessage + && this._renderPrivateNotice() + } + + { message.privateMessage && !localMessage + && } { this.props.showTimestamp && this._renderTimestamp() } @@ -74,6 +87,8 @@ class ChatMessage extends AbstractChatMessage { _getFormattedTimestamp: () => string; + _getPrivateNoticeMessage: () => string; + /** * Renders the avatar of the sender. * @@ -106,6 +121,19 @@ class ChatMessage extends AbstractChatMessage { ); } + /** + * Renders the message privacy notice. + * + * @returns {React$Element<*>} + */ + _renderPrivateNotice() { + return ( + + { this._getPrivateNoticeMessage() } + + ); + } + /** * Renders the time at which the message was sent. * diff --git a/react/features/chat/components/native/ChatPrivacyDialog.js b/react/features/chat/components/native/ChatPrivacyDialog.js new file mode 100644 index 0000000000..a5e61533d8 --- /dev/null +++ b/react/features/chat/components/native/ChatPrivacyDialog.js @@ -0,0 +1,37 @@ +// @flow + +import React from 'react'; + +import { ConfirmDialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; + +import { AbstractChatPrivacyDialog, _mapDispatchToProps, _mapStateToProps } from '../AbstractChatPrivacyDialog'; + +/** + * Implements a component for the dialog displayed to avoid mis-sending private messages. + */ +class ChatPrivacyDialog extends AbstractChatPrivacyDialog { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + + ); + } + + _onSendGroupMessage: () => boolean; + + _onSendPrivateMessage: () => boolean; +} + +export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog)); diff --git a/react/features/chat/components/native/MessageRecipient.js b/react/features/chat/components/native/MessageRecipient.js new file mode 100644 index 0000000000..5ee48404f7 --- /dev/null +++ b/react/features/chat/components/native/MessageRecipient.js @@ -0,0 +1,52 @@ +// @flow + +import React from 'react'; +import { Text, TouchableHighlight, View } from 'react-native'; + +import { translate } from '../../../base/i18n'; +import { Icon, IconCancelSelection } from '../../../base/icons'; +import { connect } from '../../../base/redux'; + +import AbstractMessageRecipient, { + _mapDispatchToProps, + _mapStateToProps +} from '../AbstractMessageRecipient'; + +import styles from './styles'; + +/** + * Class to implement the displaying of the recipient of the next message. + */ +class MessageRecipient extends AbstractMessageRecipient { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _privateMessageRecipient } = this.props; + + if (!_privateMessageRecipient) { + return null; + } + + const { t } = this.props; + + return ( + + + { t('chat.messageTo', { + recipient: _privateMessageRecipient + }) } + + + + + + ); + } +} + +export default translate(connect(_mapStateToProps, _mapDispatchToProps)(MessageRecipient)); diff --git a/react/features/chat/components/native/index.js b/react/features/chat/components/native/index.js index 2d8300bd0c..0e3e81918d 100644 --- a/react/features/chat/components/native/index.js +++ b/react/features/chat/components/native/index.js @@ -2,3 +2,4 @@ export { default as Chat } from './Chat'; export { default as ChatButton } from './ChatButton'; +export { default as ChatPrivacyDialog } from './ChatPrivacyDialog'; diff --git a/react/features/chat/components/native/styles.js b/react/features/chat/components/native/styles.js index 4808b58bef..4f15de8cc2 100644 --- a/react/features/chat/components/native/styles.js +++ b/react/features/chat/components/native/styles.js @@ -80,6 +80,23 @@ export default { flex: 1 }, + messageRecipientCancelIcon: { + color: ColorPalette.white, + fontSize: 18 + }, + + messageRecipientContainer: { + alignItems: 'center', + backgroundColor: ColorPalette.warning, + flexDirection: 'row', + padding: BoxModel.padding + }, + + messageRecipientText: { + color: ColorPalette.white, + flex: 1 + }, + /** * The message text itself. */ @@ -115,6 +132,25 @@ export default { borderTopRightRadius: 0 }, + replyWrapper: { + alignItems: 'center', + flexDirection: 'row' + }, + + replyStyles: { + iconStyle: { + color: 'rgb(118, 136, 152)', + fontSize: 22, + margin: BoxModel.margin / 2 + } + }, + + privateNotice: { + color: ColorPalette.warning, + fontSize: 13, + fontStyle: 'italic' + }, + sendButtonIcon: { color: ColorPalette.darkGrey, fontSize: 22 diff --git a/react/features/chat/components/web/Chat.js b/react/features/chat/components/web/Chat.js index 7f9734b3de..1d2157d294 100644 --- a/react/features/chat/components/web/Chat.js +++ b/react/features/chat/components/web/Chat.js @@ -14,6 +14,7 @@ import AbstractChat, { import ChatInput from './ChatInput'; import DisplayNameForm from './DisplayNameForm'; import MessageContainer from './MessageContainer'; +import MessageRecipient from './MessageRecipient'; /** * React Component for holding the chat feature in a side panel that slides in @@ -116,7 +117,10 @@ class Chat extends AbstractChat { - + + ); } diff --git a/react/features/chat/components/web/ChatInput.js b/react/features/chat/components/web/ChatInput.js index 9b8b7e2e9d..6bd60d96d3 100644 --- a/react/features/chat/components/web/ChatInput.js +++ b/react/features/chat/components/web/ChatInput.js @@ -8,8 +8,6 @@ import type { Dispatch } from 'redux'; import { translate } from '../../../base/i18n'; import { connect } from '../../../base/redux'; -import { sendMessage } from '../../actions'; - import SmileysPanel from './SmileysPanel'; /** @@ -28,6 +26,11 @@ type Props = { */ onResize: ?Function, + /** + * Callback to invoke on message send. + */ + onSend: Function, + /** * Invoked to obtain translated strings. */ @@ -163,7 +166,7 @@ class ChatInput extends Component { const trimmed = this.state.message.trim(); if (trimmed) { - this.props.dispatch(sendMessage(trimmed)); + this.props.onSend(trimmed); this.setState({ message: '' }); } diff --git a/react/features/chat/components/web/ChatMessage.js b/react/features/chat/components/web/ChatMessage.js index 99936d356b..899099f2e8 100644 --- a/react/features/chat/components/web/ChatMessage.js +++ b/react/features/chat/components/web/ChatMessage.js @@ -10,6 +10,7 @@ import { Linkify } from '../../../base/react'; import AbstractChatMessage, { type Props } from '../AbstractChatMessage'; +import PrivateMessageButton from '../PrivateMessageButton'; /** * Renders a single chat message. @@ -45,11 +46,19 @@ class ChatMessage extends AbstractChatMessage { return (

-
- { this.props.showDisplayName && this._renderDisplayName() } -
- { processedMessage } +
+
+ { this.props.showDisplayName && this._renderDisplayName() } +
+ { processedMessage } +
+ { message.privateMessage && this._renderPrivateNotice() }
+ { message.privateMessage && message.messageType !== 'local' + && }
{ this.props.showTimestamp && this._renderTimestamp() }
@@ -58,6 +67,8 @@ class ChatMessage extends AbstractChatMessage { _getFormattedTimestamp: () => string; + _getPrivateNoticeMessage: () => string; + /** * Renders the display name of the sender. * @@ -71,6 +82,19 @@ class ChatMessage extends AbstractChatMessage { ); } + /** + * Renders the message privacy notice. + * + * @returns {React$Element<*>} + */ + _renderPrivateNotice() { + return ( +
+ { this._getPrivateNoticeMessage() } +
+ ); + } + /** * Renders the time at which the message was sent. * diff --git a/react/features/chat/components/web/ChatPrivacyDialog.js b/react/features/chat/components/web/ChatPrivacyDialog.js new file mode 100644 index 0000000000..2da381190e --- /dev/null +++ b/react/features/chat/components/web/ChatPrivacyDialog.js @@ -0,0 +1,42 @@ +/* @flow */ + +import React from 'react'; + +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; + +import { AbstractChatPrivacyDialog, _mapDispatchToProps, _mapStateToProps } from '../AbstractChatPrivacyDialog'; + +/** + * Implements a component for the dialog displayed to avoid mis-sending private messages. + */ +class ChatPrivacyDialog extends AbstractChatPrivacyDialog { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + +
+ { this.props.t('dialog.sendPrivateMessage') } +
+
+ ); + } + + _onSendGroupMessage: () => boolean; + + _onSendPrivateMessage: () => boolean; +} + +export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog)); diff --git a/react/features/chat/components/web/MessageRecipient.js b/react/features/chat/components/web/MessageRecipient.js new file mode 100644 index 0000000000..7e5a74d4d6 --- /dev/null +++ b/react/features/chat/components/web/MessageRecipient.js @@ -0,0 +1,48 @@ +// @flow + +import React from 'react'; + +import { translate } from '../../../base/i18n'; +import { Icon, IconCancelSelection } from '../../../base/icons'; +import { connect } from '../../../base/redux'; + +import AbstractMessageRecipient, { + _mapDispatchToProps, + _mapStateToProps +} from '../AbstractMessageRecipient'; + +/** + * Class to implement the displaying of the recipient of the next message. + */ +class MessageRecipient extends AbstractMessageRecipient { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _privateMessageRecipient } = this.props; + + if (!_privateMessageRecipient) { + return null; + } + + const { t } = this.props; + + return ( +
+ + { t('chat.messageTo', { + recipient: _privateMessageRecipient + }) } + +
+ +
+
+ ); + } +} + +export default translate(connect(_mapStateToProps, _mapDispatchToProps)(MessageRecipient)); diff --git a/react/features/chat/components/web/index.js b/react/features/chat/components/web/index.js index f48310329d..18dd324100 100644 --- a/react/features/chat/components/web/index.js +++ b/react/features/chat/components/web/index.js @@ -2,3 +2,4 @@ export { default as Chat } from './Chat'; export { default as ChatCounter } from './ChatCounter'; +export { default as ChatPrivacyDialog } from './ChatPrivacyDialog'; diff --git a/react/features/chat/middleware.js b/react/features/chat/middleware.js index a19633fbfa..1cca69f8dd 100644 --- a/react/features/chat/middleware.js +++ b/react/features/chat/middleware.js @@ -5,8 +5,10 @@ import { CONFERENCE_JOINED, getCurrentConference } from '../base/conference'; +import { openDialog } from '../base/dialog'; import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; import { + getLocalParticipant, getParticipantById, getParticipantDisplayName } from '../base/participants'; @@ -14,14 +16,23 @@ import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { isButtonEnabled, showToolbox } from '../toolbox'; -import { SEND_MESSAGE } from './actionTypes'; +import { SEND_MESSAGE, SET_PRIVATE_MESSAGE_RECIPIENT } from './actionTypes'; import { addMessage, clearMessages, toggleChat } from './actions'; +import { ChatPrivacyDialog } from './components'; import { INCOMING_MSG_SOUND_ID } from './constants'; import { INCOMING_MSG_SOUND_FILE } from './sounds'; declare var APP: Object; declare var interfaceConfig : Object; +/** + * Timeout for when to show the privacy notice after a private message was received. + * + * E.g. if this value is 20 secs (20000ms), then we show the privacy notice when sending a non private + * message after we have received a private message in the last 20 seconds. + */ +const PRIVACY_NOTICE_TIMEOUT = 20 * 1000; + /** * Implements the middleware of the chat feature. * @@ -29,14 +40,16 @@ declare var interfaceConfig : Object; * @returns {Function} */ MiddlewareRegistry.register(store => next => action => { + const { dispatch } = store; + switch (action.type) { case APP_WILL_MOUNT: - store.dispatch( + dispatch( registerSound(INCOMING_MSG_SOUND_ID, INCOMING_MSG_SOUND_FILE)); break; case APP_WILL_UNMOUNT: - store.dispatch(unregisterSound(INCOMING_MSG_SOUND_ID)); + dispatch(unregisterSound(INCOMING_MSG_SOUND_ID)); break; case CONFERENCE_JOINED: @@ -44,16 +57,43 @@ MiddlewareRegistry.register(store => next => action => { break; case SEND_MESSAGE: { - const { conference } = store.getState()['features/base/conference']; + const state = store.getState(); + const { conference } = state['features/base/conference']; if (conference) { - if (typeof APP !== 'undefined') { - APP.API.notifySendingChatMessage(action.message); + // There may be cases when we intend to send a private message but we forget to set the + // recipient. This logic tries to mitigate this risk. + const shouldSendPrivateMessageTo = _shouldSendPrivateMessageTo(state, action); + + if (shouldSendPrivateMessageTo) { + dispatch(openDialog(ChatPrivacyDialog, { + message: action.message, + participantID: shouldSendPrivateMessageTo + })); + } else { + // Sending the message if privacy notice doesn't need to be shown. + + if (typeof APP !== 'undefined') { + APP.API.notifySendingChatMessage(action.message); + } + + const { privateMessageRecipient } = state['features/chat']; + + if (privateMessageRecipient) { + conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message); + _persistSentPrivateMessage(store, privateMessageRecipient.id, action.message); + } else { + conference.sendTextMessage(action.message); + } } - conference.sendTextMessage(action.message); } break; } + + case SET_PRIVATE_MESSAGE_RECIPIENT: { + _maybeFocusField(); + break; + } } return next(action); @@ -112,44 +152,183 @@ function _addChatMsgListener(conference, { dispatch, getState }) { conference.on( JitsiConferenceEvents.MESSAGE_RECEIVED, (id, message, timestamp, nick) => { - // Logic for all platforms: - const state = getState(); - const { isOpen: isChatOpen } = state['features/chat']; - - if (!isChatOpen) { - dispatch(playSound(INCOMING_MSG_SOUND_ID)); - } - - // Provide a default for for the case when a message is being - // backfilled for a participant that has left the conference. - const participant = getParticipantById(state, id) || {}; - const displayName = participant.name || nick || getParticipantDisplayName(state, id); - const hasRead = participant.local || isChatOpen; - const timestampToDate = timestamp - ? new Date(timestamp) : new Date(); - const millisecondsTimestamp = timestampToDate.getTime(); - - dispatch(addMessage({ - displayName, - hasRead, + _handleReceivedMessage({ + dispatch, + getState + }, { id, - messageType: participant.local ? 'local' : 'remote', message, - timestamp: millisecondsTimestamp - })); + nick, + privateMessage: false, + timestamp + }); + } + ); - if (typeof APP !== 'undefined') { - // Logic for web only: - - APP.API.notifyReceivedChatMessage({ - body: message, - id, - nick: displayName, - ts: timestamp - }); - - dispatch(showToolbox(4000)); - } + conference.on( + JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED, + (id, message, timestamp) => { + _handleReceivedMessage({ + dispatch, + getState + }, { + id, + message, + privateMessage: true, + timestamp, + nick: undefined + }); } ); } + +/** + * Function to handle an incoming chat message. + * + * @param {Store} store - The Redux store. + * @param {Object} message - The message object. + * @returns {void} + */ +function _handleReceivedMessage({ dispatch, getState }, { id, message, nick, privateMessage, timestamp }) { + // Logic for all platforms: + const state = getState(); + const { isOpen: isChatOpen } = state['features/chat']; + + if (!isChatOpen) { + dispatch(playSound(INCOMING_MSG_SOUND_ID)); + } + + // Provide a default for for the case when a message is being + // backfilled for a participant that has left the conference. + const participant = getParticipantById(state, id) || {}; + const localParticipant = getLocalParticipant(getState); + const displayName = participant.name || nick || getParticipantDisplayName(state, id); + const hasRead = participant.local || isChatOpen; + const timestampToDate = timestamp + ? new Date(timestamp) : new Date(); + const millisecondsTimestamp = timestampToDate.getTime(); + + dispatch(addMessage({ + displayName, + hasRead, + id, + messageType: participant.local ? 'local' : 'remote', + message, + privateMessage, + recipient: getParticipantDisplayName(state, localParticipant.id), + timestamp: millisecondsTimestamp + })); + + if (typeof APP !== 'undefined') { + // Logic for web only: + + APP.API.notifyReceivedChatMessage({ + body: message, + id, + nick: displayName, + ts: timestamp + }); + + dispatch(showToolbox(4000)); + } +} + +/** + * Focuses the chat text field on web after the message recipient was updated, if needed. + * + * @returns {void} + */ +function _maybeFocusField() { + if (navigator.product !== 'ReactNative') { + const textField = document.getElementById('usermsg'); + + textField && textField.focus(); + } +} + +/** + * Persists the sent private messages as if they were received over the muc. + * + * This is required as we rely on the fact that we receive all messages from the muc that we send + * (as they are sent to everybody), but we don't receive the private messages we send to another participant. + * But those messages should be in the store as well, otherwise they don't appear in the chat window. + * + * @param {Store} store - The Redux store. + * @param {string} recipientID - The ID of the recipient the private message was sent to. + * @param {string} message - The sent message. + * @returns {void} + */ +function _persistSentPrivateMessage({ dispatch, getState }, recipientID, message) { + const localParticipant = getLocalParticipant(getState); + const displayName = getParticipantDisplayName(getState, localParticipant.id); + + dispatch(addMessage({ + displayName, + hasRead: true, + id: localParticipant.id, + messageType: 'local', + message, + privateMessage: true, + recipient: getParticipantDisplayName(getState, recipientID), + timestamp: Date.now() + })); +} + +/** + * Returns the ID of the participant who we may have wanted to send the message + * that we're about to send. + * + * @param {Object} state - The Redux state. + * @param {Object} action - The action being dispatched now. + * @returns {string?} + */ +function _shouldSendPrivateMessageTo(state, action): ?string { + if (action.ignorePrivacy) { + // Shortcut: this is only true, if we already displayed the notice, so no need to show it again. + return undefined; + } + + const { messages, privateMessageRecipient } = state['features/chat']; + + if (privateMessageRecipient) { + // We're already sending a private message, no need to warn about privacy. + return undefined; + } + + if (!messages.length) { + // No messages yet, no need to warn for privacy. + return undefined; + } + + // Platforms sort messages differently + const lastMessage = navigator.product === 'ReactNative' + ? messages[0] : messages[messages.length - 1]; + + if (lastMessage.messageType === 'local') { + // The sender is probably aware of any private messages as already sent + // a message since then. Doesn't make sense to display the notice now. + return undefined; + } + + if (lastMessage.privateMessage) { + // We show the notice if the last received message was private. + return lastMessage.id; + } + + // But messages may come rapidly, we want to protect our users from mis-sending a message + // even when there was a reasonable recently received private message. + const now = Date.now(); + const recentPrivateMessages = messages.filter( + message => + message.messageType !== 'local' + && message.privateMessage + && message.timestamp + PRIVACY_NOTICE_TIMEOUT > now); + const recentPrivateMessage = navigator.product === 'ReactNative' + ? recentPrivateMessages[0] : recentPrivateMessages[recentPrivateMessages.length - 1]; + + if (recentPrivateMessage) { + return recentPrivateMessage.id; + } + + return undefined; +} diff --git a/react/features/chat/reducer.js b/react/features/chat/reducer.js index 0e9709791d..b272928c8e 100644 --- a/react/features/chat/reducer.js +++ b/react/features/chat/reducer.js @@ -2,12 +2,18 @@ import { ReducerRegistry } from '../base/redux'; -import { ADD_MESSAGE, CLEAR_MESSAGES, TOGGLE_CHAT } from './actionTypes'; +import { + ADD_MESSAGE, + CLEAR_MESSAGES, + SET_PRIVATE_MESSAGE_RECIPIENT, + TOGGLE_CHAT +} from './actionTypes'; const DEFAULT_STATE = { isOpen: false, lastReadMessage: undefined, - messages: [] + messages: [], + privateMessageRecipient: undefined }; ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => { @@ -19,6 +25,8 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => { id: action.id, messageType: action.messageType, message: action.message, + privateMessage: action.privateMessage, + recipient: action.recipient, timestamp: action.timestamp }; @@ -48,12 +56,20 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => { messages: [] }; + case SET_PRIVATE_MESSAGE_RECIPIENT: + return { + ...state, + isOpen: Boolean(action.participant) || state.isOpen, + privateMessageRecipient: action.participant + }; + case TOGGLE_CHAT: return { ...state, isOpen: !state.isOpen, lastReadMessage: state.messages[ - navigator.product === 'ReactNative' ? 0 : state.messages.length - 1] + navigator.product === 'ReactNative' ? 0 : state.messages.length - 1], + privateMessageRecipient: state.isOpen ? undefined : state.privateMessageRecipient }; } diff --git a/react/features/remote-video-menu/components/native/RemoteVideoMenu.js b/react/features/remote-video-menu/components/native/RemoteVideoMenu.js index 3df3c103c4..3a5c051ec7 100644 --- a/react/features/remote-video-menu/components/native/RemoteVideoMenu.js +++ b/react/features/remote-video-menu/components/native/RemoteVideoMenu.js @@ -9,6 +9,7 @@ import { BottomSheet, isDialogOpen } from '../../../base/dialog'; import { getParticipantDisplayName } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; +import { PrivateMessageButton } from '../../../chat'; import { hideRemoteVideoMenu } from '../../actions'; @@ -95,6 +96,7 @@ class RemoteVideoMenu extends Component { + ); } diff --git a/react/features/remote-video-menu/components/web/PrivateMessageMenuButton.js b/react/features/remote-video-menu/components/web/PrivateMessageMenuButton.js new file mode 100644 index 0000000000..4819a2a627 --- /dev/null +++ b/react/features/remote-video-menu/components/web/PrivateMessageMenuButton.js @@ -0,0 +1,62 @@ +// @flow + +import React, { Component } from 'react'; + +import { translate } from '../../../base/i18n'; +import { IconMessage } from '../../../base/icons'; +import { connect } from '../../../base/redux'; +import { _mapDispatchToProps, _mapStateToProps, type Props } from '../../../chat/components/PrivateMessageButton'; + +import RemoteVideoMenuButton from './RemoteVideoMenuButton'; + +/** + * A custom implementation of the PrivateMessageButton specialized for + * the web version of the remote video menu. When the web platform starts to use + * the {@code AbstractButton} component for the remote video menu, we can get rid + * of this component and use the generic button in the chat feature. + */ +class PrivateMessageMenuButton extends Component { + /** + * Instantiates a new Component instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onClick = this._onClick.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { participantID, t } = this.props; + + return ( + + ); + } + + _onClick: () => void; + + /** + * Callback to be invoked on pressing the button. + * + * @returns {void} + */ + _onClick() { + const { _participant, _setPrivateMessageRecipient } = this.props; + + _setPrivateMessageRecipient(_participant); + } +} + +export default translate(connect(_mapStateToProps, _mapDispatchToProps)(PrivateMessageMenuButton)); diff --git a/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js b/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js index 665d0f60a6..4330b14446 100644 --- a/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js +++ b/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js @@ -8,6 +8,7 @@ import { Popover } from '../../../base/popover'; import { MuteButton, KickButton, + PrivateMessageMenuButton, RemoteControlButton, RemoteVideoMenu, VolumeSlider @@ -188,6 +189,12 @@ class RemoteVideoMenuTriggerButton extends Component { ); } + buttons.push( + + ); + if (onVolumeChange) { buttons.push(