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 (
+
+ );
+ }
+
+ _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(