feat: private messages

This commit is contained in:
Bettenbuk Zoltan 2019-10-07 14:35:04 +02:00 committed by Zoltan Bettenbuk
parent f270b50972
commit 42271b1b89
34 changed files with 988 additions and 64 deletions

View File

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

View File

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

View File

@ -28,6 +28,7 @@ $defaultColor: #F1F1F1;
$defaultSideBarFontColor: #44A5FF;
$defaultSemiDarkColor: #ACACAC;
$defaultDarkColor: #2b3d5c;
$defaultWarningColor: rgb(215, 121, 118);
/**
* Toolbar

View File

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

View File

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@ -28,6 +28,7 @@ export const ColorPalette = {
overflowMenuItemUnderlay: '#EEEEEE',
red: '#D00000',
transparent: 'rgba(0, 0, 0, 0)',
warning: 'rgb(215, 121, 118)',
white: '#FFFFFF',
/**

View File

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

View File

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

View File

@ -56,4 +56,17 @@ export default class AbstractChatMessage<P: Props> extends PureComponent<P> {
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')
});
}
}

View File

@ -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<Props> {
/**
* 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<Props> {
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<Props> {
return {
_participant: getParticipantById(state, ownProps.participantID)
};
}

View File

@ -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<Props> {
}
/**
* 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<Props> {
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<Props> {
const { privateMessageRecipient } = state['features/chat'];
return {
_privateMessageRecipient:
privateMessageRecipient ? getParticipantDisplayName(state, privateMessageRecipient.id) : undefined
};
}

View File

@ -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<Props, any> {
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<Props> {
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<Props> {
return {
_participant: getParticipantById(state, ownProps.participantID)
};
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(PrivateMessageButton));

View File

@ -1,3 +1,4 @@
// @flow
export * from './native';
export { default as PrivateMessageButton } from './PrivateMessageButton';

View File

@ -1,3 +1,5 @@
// @flow
export * from './web';
export { default as PrivateMessageButton } from './PrivateMessageButton';

View File

@ -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<Props> {
onPressBack = { this._onClose } />
<SafeAreaView style = { styles.backdrop }>
<MessageContainer messages = { this.props._messages } />
<MessageRecipient />
<ChatInputBar onSend = { this.props._onSendMessage } />
</SafeAreaView>
</KeyboardAvoidingView>

View File

@ -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<Props> {
<View style = { styles.messageWrapper } >
{ this._renderAvatar() }
<View style = { detailsWrapperStyle }>
<View style = { textWrapperStyle } >
{
this.props.showDisplayName
&& this._renderDisplayName()
}
<Linkify linkStyle = { styles.chatLink }>
{ replaceNonUnicodeEmojis(messageText) }
</Linkify>
<View style = { styles.replyWrapper }>
<View style = { textWrapperStyle } >
{
this.props.showDisplayName
&& this._renderDisplayName()
}
<Linkify linkStyle = { styles.chatLink }>
{ replaceNonUnicodeEmojis(messageText) }
</Linkify>
{
message.privateMessage
&& this._renderPrivateNotice()
}
</View>
{ message.privateMessage && !localMessage
&& <PrivateMessageButton
participantID = { message.id }
reply = { true }
showLabel = { false }
toggledStyles = { styles.replyStyles } /> }
</View>
{ this.props.showTimestamp && this._renderTimestamp() }
</View>
@ -74,6 +87,8 @@ class ChatMessage extends AbstractChatMessage<Props> {
_getFormattedTimestamp: () => string;
_getPrivateNoticeMessage: () => string;
/**
* Renders the avatar of the sender.
*
@ -106,6 +121,19 @@ class ChatMessage extends AbstractChatMessage<Props> {
);
}
/**
* Renders the message privacy notice.
*
* @returns {React$Element<*>}
*/
_renderPrivateNotice() {
return (
<Text style = { styles.privateNotice }>
{ this._getPrivateNoticeMessage() }
</Text>
);
}
/**
* Renders the time at which the message was sent.
*

View File

@ -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 (
<ConfirmDialog
cancelKey = 'dialog.sendPrivateMessageCancel'
contentKey = 'dialog.sendPrivateMessage'
okKey = 'dialog.sendPrivateMessageOk'
onCancel = { this._onSendGroupMessage }
onSubmit = { this._onSendPrivateMessage } />
);
}
_onSendGroupMessage: () => boolean;
_onSendPrivateMessage: () => boolean;
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog));

View File

@ -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 (
<View style = { styles.messageRecipientContainer }>
<Text style = { styles.messageRecipientText }>
{ t('chat.messageTo', {
recipient: _privateMessageRecipient
}) }
</Text>
<TouchableHighlight onPress = { this.props._onRemovePrivateMessageRecipient }>
<Icon
src = { IconCancelSelection }
style = { styles.messageRecipientCancelIcon } />
</TouchableHighlight>
</View>
);
}
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(MessageRecipient));

View File

@ -2,3 +2,4 @@
export { default as Chat } from './Chat';
export { default as ChatButton } from './ChatButton';
export { default as ChatPrivacyDialog } from './ChatPrivacyDialog';

View File

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

View File

@ -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<Props> {
<MessageContainer
messages = { this.props._messages }
ref = { this._messageContainerRef } />
<ChatInput onResize = { this._onChatInputResize } />
<MessageRecipient />
<ChatInput
onResize = { this._onChatInputResize }
onSend = { this.props._onSendMessage } />
</>
);
}

View File

@ -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<Props, State> {
const trimmed = this.state.message.trim();
if (trimmed) {
this.props.dispatch(sendMessage(trimmed));
this.props.onSend(trimmed);
this.setState({ message: '' });
}

View File

@ -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<Props> {
return (
<div className = 'chatmessage-wrapper'>
<div className = 'chatmessage'>
{ this.props.showDisplayName && this._renderDisplayName() }
<div className = 'usermessage'>
{ processedMessage }
<div className = 'replywrapper'>
<div className = 'chatmessage'>
{ this.props.showDisplayName && this._renderDisplayName() }
<div className = 'usermessage'>
{ processedMessage }
</div>
{ message.privateMessage && this._renderPrivateNotice() }
</div>
{ message.privateMessage && message.messageType !== 'local'
&& <PrivateMessageButton
participantID = { message.id }
reply = { true }
showLabel = { false } /> }
</div>
{ this.props.showTimestamp && this._renderTimestamp() }
</div>
@ -58,6 +67,8 @@ class ChatMessage extends AbstractChatMessage<Props> {
_getFormattedTimestamp: () => string;
_getPrivateNoticeMessage: () => string;
/**
* Renders the display name of the sender.
*
@ -71,6 +82,19 @@ class ChatMessage extends AbstractChatMessage<Props> {
);
}
/**
* Renders the message privacy notice.
*
* @returns {React$Element<*>}
*/
_renderPrivateNotice() {
return (
<div className = 'privatemessagenotice'>
{ this._getPrivateNoticeMessage() }
</div>
);
}
/**
* Renders the time at which the message was sent.
*

View File

@ -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 (
<Dialog
cancelKey = 'dialog.sendPrivateMessageCancel'
okKey = 'dialog.sendPrivateMessageOk'
onCancel = { this._onSendGroupMessage }
onSubmit = { this._onSendPrivateMessage }
titleKey = 'dialog.sendPrivateMessageTitle'
width = 'small'>
<div>
{ this.props.t('dialog.sendPrivateMessage') }
</div>
</Dialog>
);
}
_onSendGroupMessage: () => boolean;
_onSendPrivateMessage: () => boolean;
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ChatPrivacyDialog));

View File

@ -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 (
<div id = 'chat-recipient'>
<span>
{ t('chat.messageTo', {
recipient: _privateMessageRecipient
}) }
</span>
<div onClick = { this.props._onRemovePrivateMessageRecipient }>
<Icon
src = { IconCancelSelection } />
</div>
</div>
);
}
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(MessageRecipient));

View File

@ -2,3 +2,4 @@
export { default as Chat } from './Chat';
export { default as ChatCounter } from './ChatCounter';
export { default as ChatPrivacyDialog } from './ChatPrivacyDialog';

View File

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

View File

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

View File

@ -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<Props> {
<MuteButton { ...buttonProps } />
<KickButton { ...buttonProps } />
<PinButton { ...buttonProps } />
<PrivateMessageButton { ...buttonProps } />
</BottomSheet>
);
}

View File

@ -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<Props> {
/**
* 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 (
<RemoteVideoMenuButton
buttonText = { t('toolbar.privateMessage') }
icon = { IconMessage }
id = { `privmsglink_${participantID}` }
onClick = { this._onClick } />
);
}
_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));

View File

@ -8,6 +8,7 @@ import { Popover } from '../../../base/popover';
import {
MuteButton,
KickButton,
PrivateMessageMenuButton,
RemoteControlButton,
RemoteVideoMenu,
VolumeSlider
@ -188,6 +189,12 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
);
}
buttons.push(
<PrivateMessageMenuButton
key = 'privateMessage'
participantID = { participantID } />
);
if (onVolumeChange) {
buttons.push(
<VolumeSlider

View File

@ -8,6 +8,7 @@ export { default as MuteButton } from './MuteButton';
export {
default as MuteRemoteParticipantDialog
} from './MuteRemoteParticipantDialog';
export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton';
export {
REMOTE_CONTROL_MENU_STATES,
default as RemoteControlButton