feat: Lobby chat (#10847)

* feat(lobby): lobby chat

lobby chat support
knocking participants list updates
knocking participants conditonal checks to show message button
handle lobby chat message events
lobby messages from or to moderators only

Co-authored-by: Fecri Kaan Ulubey <f.kaan93@gmail.com>

* squash: Drop typos.

Co-authored-by: Kusi Musah Hussein <kusimusah@gmail.com>
Co-authored-by: Fecri Kaan Ulubey <f.kaan93@gmail.com>
Co-authored-by: Дамян Минков <damencho@jitsi.org>
pull/11074/head jitsi-meet_7009
Doganbros 3 years ago committed by GitHub
parent 7a4a234f8e
commit 7522de033a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      conference.js
  2. 3
      config.js
  3. 14
      css/_chat.scss
  4. 2
      css/_variables.scss
  5. 164
      css/premeeting/_lobby.scss
  6. 4
      lang/main-tr.json
  7. 4
      lang/main.json
  8. 2
      react/features/base/color-scheme/defaultScheme.js
  9. 3
      react/features/base/conference/middleware.any.js
  10. 1
      react/features/base/config/configWhitelist.js
  11. 29
      react/features/chat/actionTypes.js
  12. 131
      react/features/chat/actions.any.js
  13. 5
      react/features/chat/components/AbstractChatMessage.js
  14. 36
      react/features/chat/components/AbstractMessageRecipient.js
  15. 24
      react/features/chat/components/native/ChatMessage.js
  16. 54
      react/features/chat/components/native/MessageRecipient.js
  17. 17
      react/features/chat/components/native/PrivateMessageButton.js
  18. 17
      react/features/chat/components/native/styles.js
  19. 34
      react/features/chat/components/web/ChatMessage.js
  20. 24
      react/features/chat/components/web/MessageRecipient.js
  21. 15
      react/features/chat/components/web/PrivateMessageButton.js
  22. 6
      react/features/chat/constants.js
  23. 87
      react/features/chat/middleware.js
  24. 43
      react/features/chat/reducer.js
  25. 10
      react/features/lobby/actionTypes.js
  26. 157
      react/features/lobby/actions.any.js
  27. 58
      react/features/lobby/components/AbstractLobbyScreen.js
  28. 50
      react/features/lobby/components/native/KnockingParticipantList.js
  29. 71
      react/features/lobby/components/native/LobbyScreen.js
  30. 26
      react/features/lobby/components/native/styles.js
  31. 126
      react/features/lobby/components/web/LobbyScreen.js
  32. 14
      react/features/lobby/constants.js
  33. 42
      react/features/lobby/functions.js
  34. 67
      react/features/lobby/middleware.js
  35. 30
      react/features/lobby/reducer.js
  36. 94
      react/features/participants-pane/components/web/LobbyParticipantItem.js
  37. 7
      react/features/participants-pane/hooks.js
  38. 20
      resources/prosody-plugins/mod_muc_lobby_rooms.lua

@ -126,6 +126,7 @@ import {
maybeOpenFeedbackDialog, maybeOpenFeedbackDialog,
submitFeedback submitFeedback
} from './react/features/feedback'; } from './react/features/feedback';
import { maybeSetLobbyChatMessageListener } from './react/features/lobby/actions.any';
import { import {
isModerationNotificationDisplayed, isModerationNotificationDisplayed,
showNotification, showNotification,
@ -2102,6 +2103,10 @@ export default {
if (this.isLocalId(id)) { if (this.isLocalId(id)) {
logger.info(`My role changed, new role: ${role}`); logger.info(`My role changed, new role: ${role}`);
if (role === 'moderator') {
APP.store.dispatch(maybeSetLobbyChatMessageListener());
}
APP.store.dispatch(localParticipantRoleChanged(role)); APP.store.dispatch(localParticipantRoleChanged(role));
APP.API.notifyUserRoleChanged(id, role); APP.API.notifyUserRoleChanged(id, role);
} else { } else {

@ -473,6 +473,9 @@ var config = {
// If Lobby is enabled starts knocking automatically. // If Lobby is enabled starts knocking automatically.
// autoKnockLobby: false, // autoKnockLobby: false,
// Enable lobby chat.
// enableLobbyChat: true,
// DEPRECATED! Use `breakoutRooms.hideAddRoomButton` instead. // DEPRECATED! Use `breakoutRooms.hideAddRoomButton` instead.
// Hides add breakout room button // Hides add breakout room button
// hideAddRoomButton: false, // hideAddRoomButton: false,

@ -85,6 +85,10 @@
fill: white; fill: white;
} }
} }
&.lobby-chat-recipient {
background-color: $chatLobbyMessageBackgroundColor;
}
} }
@ -455,6 +459,9 @@
&.privatemessage { &.privatemessage {
background-color: $chatPrivateMessageBackgroundColor; background-color: $chatPrivateMessageBackgroundColor;
} }
&.lobbymessage {
background-color: $chatLobbyMessageBackgroundColor;
}
} }
.display-name { .display-name {
@ -494,6 +501,10 @@
justify-content: center; justify-content: center;
padding: 5px; padding: 5px;
&.lobbychatmessageactions {
border-left-color: $chatLobbyActionsSeparatorColor;
}
.toolbox-icon { .toolbox-icon {
cursor: pointer; cursor: pointer;
} }
@ -511,6 +522,9 @@
&.privatemessage { &.privatemessage {
background-color: $chatPrivateMessageBackgroundColor; background-color: $chatPrivateMessageBackgroundColor;
} }
&.lobbymessage {
background-color: $chatLobbyMessageBackgroundColor;
}
} }
} }

@ -83,6 +83,8 @@ $modalTextColor: #333;
$chatActionsSeparatorColor: rgb(173, 105, 112); $chatActionsSeparatorColor: rgb(173, 105, 112);
$chatBackgroundColor: #131519; $chatBackgroundColor: #131519;
$chatInputSeparatorColor: #A4B8D1; $chatInputSeparatorColor: #A4B8D1;
$chatLobbyMessageBackgroundColor: #6A50D3;
$chatLobbyActionsSeparatorColor: #6A50D3;
$chatLocalMessageBackgroundColor: #484A4F; $chatLocalMessageBackgroundColor: #484A4F;
$chatPrivateMessageBackgroundColor: rgb(153, 69, 77); $chatPrivateMessageBackgroundColor: rgb(153, 69, 77);
$chatRemoteMessageBackgroundColor: #242528; $chatRemoteMessageBackgroundColor: #242528;

@ -12,11 +12,29 @@
margin: 8px; margin: 8px;
} }
.lobby-chat-container {
background-color: $chatBackgroundColor;
width: 100%;
height: 314px;
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 16px;
border-radius: 5px;
.lobby-chat-header {
display: none;
}
}
.joining-message { .joining-message {
color: white; color: white;
margin: 24px auto; margin: 24px auto;
text-align: center; text-align: center;
} }
.open-chat-button {
display: none;
}
} }
} }
@ -40,3 +58,149 @@
} }
} }
} }
#notification-participant-list {
background-color: $newToolbarBackgroundColor;
border: 1px solid rgba(255, 255, 255, .4);
border-radius: 8px;
left: 0;
margin: 20px;
max-height: 600px;
overflow: hidden;
overflow-y: auto;
position: fixed;
top: 30px;
z-index: $toolbarZ + 1;
&:empty {
border: none;
}
&.toolbox-visible {
// Same as toolbox subject position
top: 120px;
}
&.avoid-chat {
left: 315px;
}
.title {
background-color: rgba(0, 0, 0, .2);
font-size: 1.2em;
padding: 15px
}
button {
align-self: stretch;
margin-bottom: 8px 0;
padding: 12px;
transition: .2s transform ease;
&:disabled {
opacity: .5;
}
&:hover {
transform: scale(1.05);
&:disabled {
transform: none;
}
}
&.borderLess {
background-color: transparent;
border-width: 0;
}
&.primary {
background-color: rgb(3, 118, 218);
border-width: 0;
}
}
}
.knocking-participants-container {
list-style-type: none;
padding: 0 15px 15px 15px;
}
.knocking-participant {
align-items: center;
display: flex;
flex-direction: row;
margin: 8px 0;
.details {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-evenly;
margin: 0 30px 0 10px;
}
button {
align-self: unset;
margin: 0 5px;
}
}
@media (max-width: 300px) {
#knocking-participant-list {
margin: 0;
text-align: center;
width: 100%;
.avatar {
display: none;
}
}
.knocking-participant {
flex-direction: column;
.details {
margin: 0;
}
}
}
@media (max-width: 1000px) {
.lobby-screen-content {
.lobby-chat-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 255;
&.hidden {
display: none;
}
.lobby-chat-header {
display: flex;
flex-direction: row;
padding-top: 20px;
padding-left: 16px;
padding-right: 16px;
.title {
flex: 1;
color: #fff;
font-size: 20px;
font-weight: 600;
line-height: 28px;
letter-spacing: -1.2%;
}
}
}
.open-chat-button {
display: block;
}
}
}

@ -68,6 +68,7 @@
"enter": "Odaya gir", "enter": "Odaya gir",
"error": "Hata: Mesajınız gönderilmedi. Neden: {{error}}", "error": "Hata: Mesajınız gönderilmedi. Neden: {{error}}",
"fieldPlaceHolder": "Mesajınızı buraya yazın", "fieldPlaceHolder": "Mesajınızı buraya yazın",
"lobbyChatMessageTo": "{{recipient}} adlı kişiye lobi mesajı",
"message": "Mesaj", "message": "Mesaj",
"messageAccessibleTitle": "{{user}} diyor:", "messageAccessibleTitle": "{{user}} diyor:",
"messageAccessibleTitleMe": "ben diyorum:", "messageAccessibleTitleMe": "ben diyorum:",
@ -526,6 +527,7 @@
"admitAll": "Hepsini kabul et", "admitAll": "Hepsini kabul et",
"allow": "İzin ver", "allow": "İzin ver",
"backToKnockModeButton": "Parola yok, bunun yerine katılmayı isteyin", "backToKnockModeButton": "Parola yok, bunun yerine katılmayı isteyin",
"chat": "Sohbet et",
"dialogTitle": "Lobi modu", "dialogTitle": "Lobi modu",
"disableDialogContent": "Lobi modu şu anda etkin. Bu özellik, istenmeyen katılımcıların toplantınıza katılamamasını sağlar. Devre dışı bırakmak istiyor musunuz?", "disableDialogContent": "Lobi modu şu anda etkin. Bu özellik, istenmeyen katılımcıların toplantınıza katılamamasını sağlar. Devre dışı bırakmak istiyor musunuz?",
"disableDialogSubmit": "Devre Dışı", "disableDialogSubmit": "Devre Dışı",
@ -546,6 +548,8 @@
"knockButton": "Katılmak için sor", "knockButton": "Katılmak için sor",
"knockTitle": "Birisi toplantıya katılmak istiyor", "knockTitle": "Birisi toplantıya katılmak istiyor",
"knockingParticipantList": "Kapıyı çalan katılımcı listesi", "knockingParticipantList": "Kapıyı çalan katılımcı listesi",
"lobbyChatStartedNotification": "{{moderator}} {{attendee}} adlı kişiyle lobi mesajlaşması başlattı",
"lobbyChatStartedTitle": "{{moderator}} sizinle lobi mesajlaşması başlattı",
"nameField": "Adınızı giriniz", "nameField": "Adınızı giriniz",
"notificationLobbyAccessDenied": "{{targetParticipantName}} adlı katılımcı {{originParticipantName}} tarafından reddedildi", "notificationLobbyAccessDenied": "{{targetParticipantName}} adlı katılımcı {{originParticipantName}} tarafından reddedildi",
"notificationLobbyAccessGranted": "{{targetParticipantName}} adlı katılımcı {{originParticipantName}} tarafından kabul edildi", "notificationLobbyAccessGranted": "{{targetParticipantName}} adlı katılımcı {{originParticipantName}} tarafından kabul edildi",

@ -83,6 +83,7 @@
"enter": "Enter room", "enter": "Enter room",
"error": "Error: your message was not sent. Reason: {{error}}", "error": "Error: your message was not sent. Reason: {{error}}",
"fieldPlaceHolder": "Type your message here", "fieldPlaceHolder": "Type your message here",
"lobbyChatMessageTo": "Lobby chat message to {{recipient}}",
"message": "Message", "message": "Message",
"messageAccessibleTitle": "{{user}} says:", "messageAccessibleTitle": "{{user}} says:",
"messageAccessibleTitleMe": "me says:", "messageAccessibleTitleMe": "me says:",
@ -529,6 +530,7 @@
"admitAll": "Admit all", "admitAll": "Admit all",
"allow": "Allow", "allow": "Allow",
"backToKnockModeButton": "Ask to join", "backToKnockModeButton": "Ask to join",
"chat": "Chat",
"dialogTitle": "Lobby mode", "dialogTitle": "Lobby mode",
"disableDialogContent": "Lobby mode is currently enabled. This feature ensures that unwanted participants can't join your meeting. Do you want to disable it?", "disableDialogContent": "Lobby mode is currently enabled. This feature ensures that unwanted participants can't join your meeting. Do you want to disable it?",
"disableDialogSubmit": "Disable", "disableDialogSubmit": "Disable",
@ -549,6 +551,8 @@
"knockButton": "Ask to Join", "knockButton": "Ask to Join",
"knockTitle": "Someone wants to join the meeting", "knockTitle": "Someone wants to join the meeting",
"knockingParticipantList": "Knocking participant list", "knockingParticipantList": "Knocking participant list",
"lobbyChatStartedNotification": "{{moderator}} started a lobby chat with {{attendee}}",
"lobbyChatStartedTitle": "{{moderator}} has started a lobby chat with you.",
"nameField": "Enter your name", "nameField": "Enter your name",
"notificationLobbyAccessDenied": "{{targetParticipantName}} has been rejected to join by {{originParticipantName}}", "notificationLobbyAccessDenied": "{{targetParticipantName}} has been rejected to join by {{originParticipantName}}",
"notificationLobbyAccessGranted": "{{targetParticipantName}} has been allowed to join by {{originParticipantName}}", "notificationLobbyAccessGranted": "{{targetParticipantName}} has been allowed to join by {{originParticipantName}}",

@ -17,6 +17,8 @@ export default {
'Chat': { 'Chat': {
displayName: 'rgb(94, 109, 121)', displayName: 'rgb(94, 109, 121)',
localMsgBackground: 'rgb(215, 230, 249)', localMsgBackground: 'rgb(215, 230, 249)',
lobbyMsgBackground: 'rgb(106, 80, 211)',
lobbyMsgNotice: 'rgb(16, 10, 41)',
privateMsgBackground: 'rgb(250, 219, 219)', privateMsgBackground: 'rgb(250, 219, 219)',
privateMsgNotice: 'rgb(186, 39, 58)', privateMsgNotice: 'rgb(186, 39, 58)',
remoteMsgBackground: 'rgb(241, 242, 246)', remoteMsgBackground: 'rgb(241, 242, 246)',

@ -9,6 +9,7 @@ import {
sendAnalytics sendAnalytics
} from '../../analytics'; } from '../../analytics';
import { reloadNow } from '../../app/actions'; import { reloadNow } from '../../app/actions';
import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name'; import { openDisplayNamePrompt } from '../../display-name';
import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../notifications'; import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../notifications';
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, connectionDisconnected } from '../connection'; import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, connectionDisconnected } from '../connection';
@ -212,6 +213,8 @@ function _conferenceJoined({ dispatch, getState }, next, action) {
const { pendingSubjectChange } = getState()['features/base/conference']; const { pendingSubjectChange } = getState()['features/base/conference'];
const { requireDisplayName, disableBeforeUnloadHandlers = false } = getState()['features/base/config']; const { requireDisplayName, disableBeforeUnloadHandlers = false } = getState()['features/base/config'];
dispatch(removeLobbyChatParticipant(true));
pendingSubjectChange && dispatch(setSubject(pendingSubjectChange)); pendingSubjectChange && dispatch(setSubject(pendingSubjectChange));
// FIXME: Very dirty solution. This will work on web only. // FIXME: Very dirty solution. This will work on web only.

@ -142,6 +142,7 @@ export default [
'enableInsecureRoomNameWarning', 'enableInsecureRoomNameWarning',
'enableLayerSuspension', 'enableLayerSuspension',
'enableLipSync', 'enableLipSync',
'enableLobbyChat',
'enableOpusRed', 'enableOpusRed',
'enableRemb', 'enableRemb',
'enableSaveLogs', 'enableSaveLogs',

@ -84,3 +84,32 @@ export const SET_PRIVATE_MESSAGE_RECIPIENT = 'SET_PRIVATE_MESSAGE_RECIPIENT';
* } * }
*/ */
export const SET_IS_POLL_TAB_FOCUSED = 'SET_IS_POLL_TAB_FOCUSED'; export const SET_IS_POLL_TAB_FOCUSED = 'SET_IS_POLL_TAB_FOCUSED';
/**
* The type of action which sets the current recipient for lobby messages.
*
* {
* participant: Object,
* type: SET_LOBBY_CHAT_RECIPIENT
* }
*/
export const SET_LOBBY_CHAT_RECIPIENT = 'SET_LOBBY_CHAT_RECIPIENT';
/**
* The type of action sets the state of lobby messaging status.
*
* {
* type: SET_LOBBY_CHAT_ACTIVE_STATE
* payload: boolean
* }
*/
export const SET_LOBBY_CHAT_ACTIVE_STATE = 'SET_LOBBY_CHAT_ACTIVE_STATE';
/**
* The type of action removes the lobby messaging from participant.
*
* {
* type: REMOVE_LOBBY_CHAT_PARTICIPANT
* }
*/
export const REMOVE_LOBBY_CHAT_PARTICIPANT = 'REMOVE_LOBBY_CHAT_PARTICIPANT';

@ -1,4 +1,9 @@
// @flow // @flow
import { type Dispatch } from 'redux';
import { getCurrentConference } from '../base/conference';
import { getLocalParticipant } from '../base/participants';
import { LOBBY_CHAT_INITIALIZED } from '../lobby/constants';
import { import {
ADD_MESSAGE, ADD_MESSAGE,
@ -7,7 +12,10 @@ import {
EDIT_MESSAGE, EDIT_MESSAGE,
SEND_MESSAGE, SEND_MESSAGE,
SET_PRIVATE_MESSAGE_RECIPIENT, SET_PRIVATE_MESSAGE_RECIPIENT,
SET_IS_POLL_TAB_FOCUSED SET_IS_POLL_TAB_FOCUSED,
SET_LOBBY_CHAT_RECIPIENT,
REMOVE_LOBBY_CHAT_PARTICIPANT,
SET_LOBBY_CHAT_ACTIVE_STATE
} from './actionTypes'; } from './actionTypes';
/** /**
@ -132,3 +140,124 @@ export function setIsPollsTabFocused(isPollsTabFocused: boolean) {
type: SET_IS_POLL_TAB_FOCUSED type: SET_IS_POLL_TAB_FOCUSED
}; };
} }
/**
* Initiates the sending of messages between a moderator and a lobby attendee.
*
* @param {Object} lobbyChatInitializedInfo - The information about the attendee and the moderator
* that is going to chat.
*
* @returns {Function}
*/
export function onLobbyChatInitialized(lobbyChatInitializedInfo: Object) {
return async (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const conference = getCurrentConference(state);
const lobbyLocalId = conference.myLobbyUserId();
if (!lobbyLocalId) {
return;
}
if (lobbyChatInitializedInfo.moderator.id === lobbyLocalId) {
dispatch({
type: SET_LOBBY_CHAT_RECIPIENT,
participant: lobbyChatInitializedInfo.attendee,
open: true
});
}
if (lobbyChatInitializedInfo.attendee.id === lobbyLocalId) {
return dispatch({
type: SET_LOBBY_CHAT_RECIPIENT,
participant: lobbyChatInitializedInfo.moderator,
open: false
});
}
};
}
/**
* Sets the lobby room's chat active state.
*
* @param {boolean} value - The active state.
*
* @returns {Object}
*/
export function setLobbyChatActiveState(value: boolean) {
return {
type: SET_LOBBY_CHAT_ACTIVE_STATE,
payload: value
};
}
/**
* Removes lobby type messages.
*
* @param {boolean} removeLobbyChatMessages - Should remove messages from chat (works only for accepted users).
* If not specified, it will delete all lobby messages.
*
* @returns {Object}
*/
export function removeLobbyChatParticipant(removeLobbyChatMessages: ?boolean) {
return {
type: REMOVE_LOBBY_CHAT_PARTICIPANT,
removeLobbyChatMessages
};
}
/**
* Handles initial setup of lobby message between
* Moderator and participant.
*
* @param {string} participantId - The participant id.
*
* @returns {Object}
*/
export function handleLobbyChatInitialized(participantId: string) {
return async (dispatch: Dispatch<any>, getState: Function) => {
if (!participantId) {
return;
}
const state = getState();
const conference = state['features/base/conference'].conference;
const { knockingParticipants } = state['features/lobby'];
const { lobbyMessageRecipient } = state['features/chat'];
const me = getLocalParticipant(state);
const lobbyLocalId = conference.myLobbyUserId();
if (lobbyMessageRecipient && lobbyMessageRecipient.id === participantId) {
return dispatch(setLobbyChatActiveState(true));
}
const attendee = knockingParticipants.find(p => p.id === participantId);
if (attendee && attendee.chattingWithModerator === lobbyLocalId) {
return dispatch({
type: SET_LOBBY_CHAT_RECIPIENT,
participant: attendee,
open: true
});
}
if (!attendee) {
return;
}
const payload = { type: LOBBY_CHAT_INITIALIZED,
moderator: {
...me,
name: 'Moderator',
id: lobbyLocalId
},
attendee };
// notify attendee privately.
conference.sendLobbyMessage(payload, attendee.id);
// notify other moderators.
return conference.sendLobbyMessage(payload);
};
}

@ -38,6 +38,11 @@ export type Props = {
*/ */
showTimestamp: boolean, showTimestamp: boolean,
/**
* Whether current participant is currently knocking in the lobby room.
*/
knocking: boolean,
/** /**
* Invoked to receive translated strings. * Invoked to receive translated strings.
*/ */

@ -2,8 +2,9 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { getParticipantDisplayName } from '../../base/participants'; import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants';
import { setPrivateMessageRecipient } from '../actions'; import { setPrivateMessageRecipient } from '../actions';
import { setLobbyChatActiveState } from '../actions.any';
export type Props = { export type Props = {
@ -17,10 +18,30 @@ export type Props = {
*/ */
_onRemovePrivateMessageRecipient: Function, _onRemovePrivateMessageRecipient: Function,
/**
* Function to make the lobby message receipient inactive.
*/
_onHideLobbyChatRecipient: Function,
/** /**
* The name of the message recipient, if any. * The name of the message recipient, if any.
*/ */
_privateMessageRecipient: ?string _privateMessageRecipient: ?string,
/**
* Is lobby messaging active.
*/
_isLobbyChatActive: boolean,
/**
* The name of the lobby message recipient, if any.
*/
_lobbyMessageRecipient: ?string,
/**
* Shows widget if it is necessary.
*/
_visible: boolean;
}; };
/** /**
@ -40,6 +61,9 @@ export function _mapDispatchToProps(dispatch: Function): $Shape<Props> {
return { return {
_onRemovePrivateMessageRecipient: () => { _onRemovePrivateMessageRecipient: () => {
dispatch(setPrivateMessageRecipient()); dispatch(setPrivateMessageRecipient());
},
_onHideLobbyChatRecipient: () => {
dispatch(setLobbyChatActiveState(false));
} }
}; };
} }
@ -51,10 +75,14 @@ export function _mapDispatchToProps(dispatch: Function): $Shape<Props> {
* @returns {Props} * @returns {Props}
*/ */
export function _mapStateToProps(state: Object): $Shape<Props> { export function _mapStateToProps(state: Object): $Shape<Props> {
const { privateMessageRecipient } = state['features/chat']; const { privateMessageRecipient, lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
return { return {
_privateMessageRecipient: _privateMessageRecipient:
privateMessageRecipient ? getParticipantDisplayName(state, privateMessageRecipient.id) : undefined privateMessageRecipient ? getParticipantDisplayName(state, privateMessageRecipient.id) : undefined,
_isLobbyChatActive: isLobbyChatActive,
_lobbyMessageRecipient:
isLobbyChatActive && lobbyMessageRecipient ? lobbyMessageRecipient.name : undefined,
_visible: isLobbyChatActive ? isLocalParticipantModerator(state) : true
}; };
} }

@ -34,9 +34,9 @@ class ChatMessage extends AbstractChatMessage<Props> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { _styles, message } = this.props; const { _styles, message, knocking } = this.props;
const localMessage = message.messageType === MESSAGE_TYPE_LOCAL; const localMessage = message.messageType === MESSAGE_TYPE_LOCAL;
const { privateMessage } = message; const { privateMessage, lobbyChat } = message;
// Style arrays that need to be updated in various scenarios, such as // Style arrays that need to be updated in various scenarios, such as
// error messages or others. // error messages or others.
@ -71,6 +71,10 @@ class ChatMessage extends AbstractChatMessage<Props> {
messageBubbleStyle.push(_styles.privateMessageBubble); messageBubbleStyle.push(_styles.privateMessageBubble);
} }
if (lobbyChat && !knocking) {
messageBubbleStyle.push(_styles.lobbyMessageBubble);
}
return ( return (
<View style = { styles.messageWrapper } > <View style = { styles.messageWrapper } >
{ this._renderAvatar() } { this._renderAvatar() }
@ -141,14 +145,14 @@ class ChatMessage extends AbstractChatMessage<Props> {
* @returns {React$Element<*> | null} * @returns {React$Element<*> | null}
*/ */
_renderPrivateNotice() { _renderPrivateNotice() {
const { _styles, message } = this.props; const { _styles, message, knocking } = this.props;
if (!message.privateMessage) { if (!(message.privateMessage || (message.lobbyChat && !knocking))) {
return null; return null;
} }
return ( return (
<Text style = { _styles.privateNotice }> <Text style = { message.lobbyChat ? _styles.lobbyMsgNotice : _styles.privateNotice }>
{ this._getPrivateNoticeMessage() } { this._getPrivateNoticeMessage() }
</Text> </Text>
); );
@ -160,16 +164,17 @@ class ChatMessage extends AbstractChatMessage<Props> {
* @returns {React$Element<*> | null} * @returns {React$Element<*> | null}
*/ */
_renderPrivateReplyButton() { _renderPrivateReplyButton() {
const { _styles, message } = this.props; const { _styles, message, knocking } = this.props;
const { messageType, privateMessage } = message; const { messageType, privateMessage, lobbyChat } = message;
if (!privateMessage || messageType === MESSAGE_TYPE_LOCAL) { if (!(privateMessage || lobbyChat) || messageType === MESSAGE_TYPE_LOCAL || knocking) {
return null; return null;
} }
return ( return (
<View style = { _styles.replyContainer }> <View style = { _styles.replyContainer }>
<PrivateMessageButton <PrivateMessageButton
isLobbyMessage = { lobbyChat }
participantID = { message.id } participantID = { message.id }
reply = { true } reply = { true }
showLabel = { false } showLabel = { false }
@ -204,7 +209,8 @@ class ChatMessage extends AbstractChatMessage<Props> {
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
return { return {
_styles: ColorSchemeRegistry.get(state, 'Chat') _styles: ColorSchemeRegistry.get(state, 'Chat'),
knocking: state['features/lobby'].knocking
}; };
} }

@ -11,7 +11,7 @@ import { type StyleType } from '../../../base/styles';
import { import {
setParams setParams
} from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef'; } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { setPrivateMessageRecipient } from '../../actions.any'; import { setPrivateMessageRecipient, setLobbyChatActiveState } from '../../actions.any';
import AbstractMessageRecipient, { import AbstractMessageRecipient, {
type Props as AbstractProps type Props as AbstractProps
} from '../AbstractMessageRecipient'; } from '../AbstractMessageRecipient';
@ -28,6 +28,16 @@ type Props = AbstractProps & {
*/ */
dispatch: Function, dispatch: Function,
/**
* Is lobby messaging active.
*/
isLobbyChatActive: boolean,
/**
* The participant string for lobby chat messaging.
*/
lobbyMessageRecipient: Object,
/** /**
* The participant object set for private messaging. * The participant object set for private messaging.
*/ */
@ -48,6 +58,20 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
super(props); super(props);
this._onResetPrivateMessageRecipient = this._onResetPrivateMessageRecipient.bind(this); this._onResetPrivateMessageRecipient = this._onResetPrivateMessageRecipient.bind(this);
this._onResetLobbyMessageRecipient = this._onResetLobbyMessageRecipient.bind(this);
}
_onResetLobbyMessageRecipient: () => void;
/**
* Resets lobby message recipient from state.
*
* @returns {void}
*/
_onResetLobbyMessageRecipient() {
const { dispatch } = this.props;
dispatch(setLobbyChatActiveState(false));
} }
_onResetPrivateMessageRecipient: () => void; _onResetPrivateMessageRecipient: () => void;
@ -74,7 +98,27 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { _styles, privateMessageRecipient, t } = this.props; const { _styles, privateMessageRecipient, t,
isLobbyChatActive, lobbyMessageRecipient } = this.props;
if (isLobbyChatActive) {
return (
<View style = { _styles.lobbyMessageRecipientContainer }>
<Text style = { _styles.messageRecipientText }>
{ t('chat.lobbyChatMessageTo', {
recipient: lobbyMessageRecipient.name
}) }
</Text>
<TouchableHighlight
onPress = { this._onResetLobbyMessageRecipient }>
<Icon
src = { IconCancelSelection }
style = { _styles.messageRecipientCancelIcon } />
</TouchableHighlight>
</View>
);
}
if (!privateMessageRecipient) { if (!privateMessageRecipient) {
return null; return null;
@ -105,8 +149,12 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
* @returns {Props} * @returns {Props}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
const { lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
return { return {
_styles: ColorSchemeRegistry.get(state, 'Chat') _styles: ColorSchemeRegistry.get(state, 'Chat'),
isLobbyChatActive,
lobbyMessageRecipient
}; };
} }

@ -6,11 +6,17 @@ import { IconMessage, IconReply } from '../../../base/icons';
import { getParticipantById } from '../../../base/participants'; import { getParticipantById } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
import { handleLobbyChatInitialized } from '../../../chat/actions.any';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef'; import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes'; import { screen } from '../../../mobile/navigation/routes';
export type Props = AbstractButtonProps & { export type Props = AbstractButtonProps & {
/**
* The Redux Dispatch function.
*/
dispatch: Function,
/** /**
* The ID of the participant that the message is to be sent. * The ID of the participant that the message is to be sent.
*/ */
@ -31,6 +37,11 @@ export type Props = AbstractButtonProps & {
*/ */
_isPollsDisabled: boolean, _isPollsDisabled: boolean,
/**
* True if message is a lobby chat message.
*/
_isLobbyMessage: boolean,
/** /**
* The participant object retrieved from Redux. * The participant object retrieved from Redux.
*/ */
@ -53,6 +64,9 @@ class PrivateMessageButton extends AbstractButton<Props, any> {
* @returns {void} * @returns {void}
*/ */
_handleClick() { _handleClick() {
if (this.props._isLobbyMessage) {
this.props.dispatch(handleLobbyChatInitialized(this.props.participantID));
}
this.props._isPollsDisabled this.props._isPollsDisabled
? navigate(screen.conference.chat, { ? navigate(screen.conference.chat, {
privateMessageRecipient: this.props._participant privateMessageRecipient: this.props._participant
@ -88,11 +102,12 @@ class PrivateMessageButton extends AbstractButton<Props, any> {
export function _mapStateToProps(state: Object, ownProps: Props): $Shape<Props> { export function _mapStateToProps(state: Object, ownProps: Props): $Shape<Props> {
const enabled = getFeatureFlag(state, CHAT_ENABLED, true); const enabled = getFeatureFlag(state, CHAT_ENABLED, true);
const { disablePolls } = state['features/base/config']; const { disablePolls } = state['features/base/config'];
const { visible = enabled } = ownProps; const { visible = enabled, isLobbyMessage } = ownProps;
return { return {
_isPollsDisabled: disablePolls, _isPollsDisabled: disablePolls,
_participant: getParticipantById(state, ownProps.participantID), _participant: getParticipantById(state, ownProps.participantID),
_isLobbyMessage: isLobbyMessage,
visible visible
}; };
} }

@ -170,6 +170,23 @@ ColorSchemeRegistry.register('Chat', {
textAlign: 'center' textAlign: 'center'
}, },
lobbyMessageBubble: {
backgroundColor: schemeColor('lobbyMsgBackground')
},
lobbyMsgNotice: {
color: schemeColor('lobbyMsgNotice'),
fontSize: 11,
marginTop: 6
},
lobbyMessageRecipientContainer: {
alignItems: 'center',
backgroundColor: schemeColor('lobbyMsgBackground'),
flexDirection: 'row',
padding: BoxModel.padding
},
localMessageBubble: { localMessageBubble: {
backgroundColor: schemeColor('localMsgBackground'), backgroundColor: schemeColor('localMsgBackground'),
borderTopRightRadius: 0 borderTopRightRadius: 0

@ -4,6 +4,7 @@ import React from 'react';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import Message from '../../../base/react/components/web/Message'; import Message from '../../../base/react/components/web/Message';
import { connect } from '../../../base/redux';
import { MESSAGE_TYPE_LOCAL } from '../../constants'; import { MESSAGE_TYPE_LOCAL } from '../../constants';
import AbstractChatMessage, { type Props } from '../AbstractChatMessage'; import AbstractChatMessage, { type Props } from '../AbstractChatMessage';
@ -20,13 +21,15 @@ class ChatMessage extends AbstractChatMessage<Props> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { message, t } = this.props; const { message, t, knocking } = this.props;
return ( return (
<div <div
className = 'chatmessage-wrapper' className = 'chatmessage-wrapper'
tabIndex = { -1 }> tabIndex = { -1 }>
<div className = { `chatmessage ${message.privateMessage ? 'privatemessage' : ''}` }> <div
className = { `chatmessage ${message.privateMessage ? 'privatemessage' : ''} ${
message.lobbyChat && !knocking ? 'lobbymessage' : ''}` }>
<div className = 'replywrapper'> <div className = 'replywrapper'>
<div className = 'messagecontent'> <div className = 'messagecontent'>
{ this.props.showDisplayName && this._renderDisplayName() } { this.props.showDisplayName && this._renderDisplayName() }
@ -39,12 +42,17 @@ class ChatMessage extends AbstractChatMessage<Props> {
</span> </span>
<Message text = { this._getMessageText() } /> <Message text = { this._getMessageText() } />
</div> </div>
{ message.privateMessage && this._renderPrivateNotice() } { (message.privateMessage || (message.lobbyChat && !knocking))
&& this._renderPrivateNotice() }
</div> </div>
{ message.privateMessage && message.messageType !== MESSAGE_TYPE_LOCAL { (message.privateMessage || (message.lobbyChat && !knocking))
&& message.messageType !== MESSAGE_TYPE_LOCAL
&& ( && (
<div className = 'messageactions'> <div
className = { `messageactions ${
message.lobbyChat ? 'lobbychatmessageactions' : ''}` }>
<PrivateMessageButton <PrivateMessageButton
isLobbyMessage = { message.lobbyChat }
participantID = { message.id } participantID = { message.id }
reply = { true } reply = { true }
showLabel = { false } /> showLabel = { false } />
@ -105,4 +113,18 @@ class ChatMessage extends AbstractChatMessage<Props> {
} }
} }
export default translate(ChatMessage); /**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {Props}
*/
function _mapStateToProps(state: Object): $Shape<Props> {
const { knocking } = state['features/lobby'];
return {
knocking
};
}
export default translate(connect(_mapStateToProps)(ChatMessage));

@ -38,9 +38,16 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
* @returns {void} * @returns {void}
*/ */
_onKeyPress(e) { _onKeyPress(e) {
if (this.props._onRemovePrivateMessageRecipient && (e.key === ' ' || e.key === 'Enter')) { if (
(this.props._onRemovePrivateMessageRecipient || this.props._onHideLobbyChatRecipient)
&& (e.key === ' ' || e.key === 'Enter')
) {
e.preventDefault(); e.preventDefault();
this.props._onRemovePrivateMessageRecipient(); if (this.props._isLobbyChatActive && this.props._onHideLobbyChatRecipient) {
this.props._onHideLobbyChatRecipient();
} else if (this.props._onRemovePrivateMessageRecipient) {
this.props._onRemovePrivateMessageRecipient();
}
} }
} }
@ -50,9 +57,10 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { _privateMessageRecipient } = this.props; const { _privateMessageRecipient, _isLobbyChatActive,
_lobbyMessageRecipient, _visible } = this.props;
if (!_privateMessageRecipient) { if ((!_privateMessageRecipient && !_isLobbyChatActive) || !_visible) {
return null; return null;
} }
@ -60,16 +68,18 @@ class MessageRecipient extends AbstractMessageRecipient<Props> {
return ( return (
<div <div
className = { _isLobbyChatActive ? 'lobby-chat-recipient' : '' }
id = 'chat-recipient' id = 'chat-recipient'
role = 'alert'> role = 'alert'>
<span> <span>
{ t('chat.messageTo', { { t(_isLobbyChatActive ? 'chat.lobbyChatMessageTo' : 'chat.messageTo', {
recipient: _privateMessageRecipient recipient: _isLobbyChatActive ? _lobbyMessageRecipient : _privateMessageRecipient
}) } }) }
</span> </span>
<div <div
aria-label = { t('dialog.close') } aria-label = { t('dialog.close') }
onClick = { this.props._onRemovePrivateMessageRecipient } onClick = { _isLobbyChatActive
? this.props._onHideLobbyChatRecipient : this.props._onRemovePrivateMessageRecipient }
onKeyPress = { this._onKeyPress } onKeyPress = { this._onKeyPress }
role = 'button' role = 'button'
tabIndex = { 0 }> tabIndex = { 0 }>

@ -6,10 +6,15 @@ import { IconMessage, IconReply } from '../../../base/icons';
import { getParticipantById } from '../../../base/participants'; import { getParticipantById } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
import { openChat } from '../../actions'; import { openChat, handleLobbyChatInitialized } from '../../actions';
export type Props = AbstractButtonProps & { export type Props = AbstractButtonProps & {
/**
* True if the message is a lobby chat message.
*/
isLobbyMessage: boolean,
/** /**
* The ID of the participant that the message is to be sent. * The ID of the participant that the message is to be sent.
*/ */
@ -52,9 +57,13 @@ class PrivateMessageButton extends AbstractButton<Props, any> {
* @returns {void} * @returns {void}
*/ */
_handleClick() { _handleClick() {
const { _participant, dispatch } = this.props; const { _participant, participantID, dispatch, isLobbyMessage } = this.props;
dispatch(openChat(_participant)); if (isLobbyMessage) {
dispatch(handleLobbyChatInitialized(participantID));
} else {
dispatch(openChat(_participant));
}
} }
/** /**

@ -30,6 +30,12 @@ export const MESSAGE_TYPE_REMOTE = 'remote';
export const SMALL_WIDTH_THRESHOLD = 580; export const SMALL_WIDTH_THRESHOLD = 580;
/**
* Lobby message type.
*/
export const LOBBY_CHAT_MESSAGE = 'LOBBY_CHAT_MESSAGE';
/** /**
* The modes of the buttons of the chat and polls tabs. * The modes of the buttons of the chat and polls tabs.
*/ */

@ -1,4 +1,5 @@
// @flow // @flow
import { type Dispatch } from 'redux';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app';
import { import {
@ -35,6 +36,7 @@ import { closeChat } from './actions.any';
import { ChatPrivacyDialog } from './components'; import { ChatPrivacyDialog } from './components';
import { import {
INCOMING_MSG_SOUND_ID, INCOMING_MSG_SOUND_ID,
LOBBY_CHAT_MESSAGE,
MESSAGE_TYPE_ERROR, MESSAGE_TYPE_ERROR,
MESSAGE_TYPE_LOCAL, MESSAGE_TYPE_LOCAL,
MESSAGE_TYPE_REMOTE MESSAGE_TYPE_REMOTE
@ -122,7 +124,7 @@ MiddlewareRegistry.register(store => next => action => {
case SEND_MESSAGE: { case SEND_MESSAGE: {
const state = store.getState(); const state = store.getState();
const { conference } = state['features/base/conference']; const conference = getCurrentConference(state);
if (conference) { if (conference) {
// There may be cases when we intend to send a private message but we forget to set the // There may be cases when we intend to send a private message but we forget to set the
@ -137,13 +139,20 @@ MiddlewareRegistry.register(store => next => action => {
} else { } else {
// Sending the message if privacy notice doesn't need to be shown. // Sending the message if privacy notice doesn't need to be shown.
const { privateMessageRecipient } = state['features/chat']; const { privateMessageRecipient, isLobbyChatActive, lobbyMessageRecipient }
= state['features/chat'];
if (typeof APP !== 'undefined') { if (typeof APP !== 'undefined') {
APP.API.notifySendingChatMessage(action.message, Boolean(privateMessageRecipient)); APP.API.notifySendingChatMessage(action.message, Boolean(privateMessageRecipient));
} }
if (privateMessageRecipient) { if (isLobbyChatActive && lobbyMessageRecipient) {
conference.sendLobbyMessage({
type: LOBBY_CHAT_MESSAGE,
message: action.message
}, lobbyMessageRecipient.id);
_persistSentPrivateMessage(store, lobbyMessageRecipient.id, action.message, true);
} else if (privateMessageRecipient) {
conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message); conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message);
_persistSentPrivateMessage(store, privateMessageRecipient.id, action.message); _persistSentPrivateMessage(store, privateMessageRecipient.id, action.message);
} else { } else {
@ -159,7 +168,8 @@ MiddlewareRegistry.register(store => next => action => {
id: localParticipant.id, id: localParticipant.id,
message: action.message, message: action.message,
privateMessage: false, privateMessage: false,
timestamp: Date.now() timestamp: Date.now(),
lobbyChat: false
}, false, true); }, false, true);
} }
} }
@ -220,6 +230,7 @@ function _addChatMsgListener(conference, store) {
id, id,
message, message,
privateMessage: false, privateMessage: false,
lobbyChat: false,
timestamp timestamp
}); });
} }
@ -232,6 +243,7 @@ function _addChatMsgListener(conference, store) {
id, id,
message, message,
privateMessage: true, privateMessage: true,
lobbyChat: false,
timestamp timestamp
}); });
} }
@ -258,6 +270,7 @@ function _addChatMsgListener(conference, store) {
id: _id, id: _id,
message: getReactionMessageFromBuffer(eventData.reactions), message: getReactionMessageFromBuffer(eventData.reactions),
privateMessage: false, privateMessage: false,
lobbyChat: false,
timestamp: eventData.timestamp timestamp: eventData.timestamp
}, false, true); }, false, true);
} }
@ -287,6 +300,49 @@ function _handleChatError({ dispatch }, error) {
})); }));
} }
/**
* Function to handle an incoming chat message from lobby room.
*
* @param {string} message - The message received.
* @param {string} participantId - The participant id.
* @returns {Function}
*/
export function handleLobbyMessageReceived(message: string, participantId: string) {
return async (dispatch: Dispatch<any>, getState: Function) => {
_handleReceivedMessage({ dispatch,
getState }, { id: participantId,
message,
privateMessage: false,
lobbyChat: true,
timestamp: Date.now() });
};
}
/**
* Function to get lobby chat user display name.
*
* @param {Store} state - The Redux store.
* @param {string} id - The knocking participant id.
* @returns {string}
*/
function getLobbyChatDisplayName(state, id) {
const { knockingParticipants } = state['features/lobby'];
const { lobbyMessageRecipient } = state['features/chat'];
if (id === lobbyMessageRecipient.id) {
return lobbyMessageRecipient.name;
}
const knockingParticipant = knockingParticipants.find(p => p.id === id);
if (knockingParticipant) {
return knockingParticipant.name;
}
}
/** /**
* Function to handle an incoming chat message. * Function to handle an incoming chat message.
* *
@ -297,7 +353,7 @@ function _handleChatError({ dispatch }, error) {
* @returns {void} * @returns {void}
*/ */
function _handleReceivedMessage({ dispatch, getState }, function _handleReceivedMessage({ dispatch, getState },
{ id, message, privateMessage, timestamp }, { id, message, privateMessage, timestamp, lobbyChat },
shouldPlaySound = true, shouldPlaySound = true,
isReaction = false isReaction = false
) { ) {
@ -316,7 +372,9 @@ function _handleReceivedMessage({ dispatch, getState },
const participant = getParticipantById(state, id) || {}; const participant = getParticipantById(state, id) || {};
const localParticipant = getLocalParticipant(getState); const localParticipant = getLocalParticipant(getState);
const displayName = getParticipantDisplayName(state, id); const displayName = lobbyChat
? getLobbyChatDisplayName(state, id)
: getParticipantDisplayName(state, id);
const hasRead = participant.local || isChatOpen; const hasRead = participant.local || isChatOpen;
const timestampToDate = timestamp ? new Date(timestamp) : new Date(); const timestampToDate = timestamp ? new Date(timestamp) : new Date();
const millisecondsTimestamp = timestampToDate.getTime(); const millisecondsTimestamp = timestampToDate.getTime();
@ -332,6 +390,7 @@ function _handleReceivedMessage({ dispatch, getState },
messageType: participant.local ? MESSAGE_TYPE_LOCAL : MESSAGE_TYPE_REMOTE, messageType: participant.local ? MESSAGE_TYPE_LOCAL : MESSAGE_TYPE_REMOTE,
message, message,
privateMessage, privateMessage,
lobbyChat,
recipient: getParticipantDisplayName(state, localParticipant.id), recipient: getParticipantDisplayName(state, localParticipant.id),
timestamp: millisecondsTimestamp, timestamp: millisecondsTimestamp,
isReaction isReaction
@ -371,11 +430,14 @@ function _handleReceivedMessage({ dispatch, getState },
* @param {Store} store - The Redux store. * @param {Store} store - The Redux store.
* @param {string} recipientID - The ID of the recipient the private message was sent to. * @param {string} recipientID - The ID of the recipient the private message was sent to.
* @param {string} message - The sent message. * @param {string} message - The sent message.
* @param {boolean} isLobbyPrivateMessage - Is a lobby message.
* @returns {void} * @returns {void}
*/ */
function _persistSentPrivateMessage({ dispatch, getState }, recipientID, message) { function _persistSentPrivateMessage({ dispatch, getState }, recipientID, message, isLobbyPrivateMessage = false) {
const localParticipant = getLocalParticipant(getState); const state = getState();
const displayName = getParticipantDisplayName(getState, localParticipant.id); const localParticipant = getLocalParticipant(state);
const displayName = getParticipantDisplayName(state, localParticipant.id);
const { lobbyMessageRecipient } = state['features/chat'];
dispatch(addMessage({ dispatch(addMessage({
displayName, displayName,
@ -383,8 +445,11 @@ function _persistSentPrivateMessage({ dispatch, getState }, recipientID, message
id: localParticipant.id, id: localParticipant.id,
messageType: MESSAGE_TYPE_LOCAL, messageType: MESSAGE_TYPE_LOCAL,
message, message,
privateMessage: true, privateMessage: !isLobbyPrivateMessage,
recipient: getParticipantDisplayName(getState, recipientID), lobbyChat: isLobbyPrivateMessage,
recipient: isLobbyPrivateMessage
? lobbyMessageRecipient && lobbyMessageRecipient.name
: getParticipantDisplayName(getState, recipientID),
timestamp: Date.now() timestamp: Date.now()
})); }));
} }

@ -11,7 +11,10 @@ import {
EDIT_MESSAGE, EDIT_MESSAGE,
OPEN_CHAT, OPEN_CHAT,
SET_PRIVATE_MESSAGE_RECIPIENT, SET_PRIVATE_MESSAGE_RECIPIENT,
SET_IS_POLL_TAB_FOCUSED SET_IS_POLL_TAB_FOCUSED,
SET_LOBBY_CHAT_RECIPIENT,
SET_LOBBY_CHAT_ACTIVE_STATE,
REMOVE_LOBBY_CHAT_PARTICIPANT
} from './actionTypes'; } from './actionTypes';
const DEFAULT_STATE = { const DEFAULT_STATE = {
@ -21,7 +24,9 @@ const DEFAULT_STATE = {
lastReadPoll: undefined, lastReadPoll: undefined,
messages: [], messages: [],
nbUnreadMessages: 0, nbUnreadMessages: 0,
privateMessageRecipient: undefined privateMessageRecipient: undefined,
lobbyMessageRecipient: undefined,
isLobbyChatActive: false
}; };
ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => { ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => {
@ -36,6 +41,7 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => {
messageType: action.messageType, messageType: action.messageType,
message: action.message, message: action.message,
privateMessage: action.privateMessage, privateMessage: action.privateMessage,
lobbyChat: action.lobbyChat,
recipient: action.recipient, recipient: action.recipient,
timestamp: action.timestamp timestamp: action.timestamp
}; };
@ -110,7 +116,8 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => {
isOpen: false, isOpen: false,
lastReadMessage: state.messages[ lastReadMessage: state.messages[
navigator.product === 'ReactNative' ? 0 : state.messages.length - 1], navigator.product === 'ReactNative' ? 0 : state.messages.length - 1],
privateMessageRecipient: action.participant privateMessageRecipient: action.participant,
isLobbyChatActive: false
}; };
case SET_IS_POLL_TAB_FOCUSED: { case SET_IS_POLL_TAB_FOCUSED: {
@ -119,6 +126,36 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => {
isPollsTabFocused: action.isPollsTabFocused, isPollsTabFocused: action.isPollsTabFocused,
nbUnreadMessages: 0 nbUnreadMessages: 0
}; } }; }
case SET_LOBBY_CHAT_RECIPIENT:
return {
...state,
isLobbyChatActive: true,
lobbyMessageRecipient: action.participant,
privateMessageRecipient: undefined,
isOpen: action.open
};
case SET_LOBBY_CHAT_ACTIVE_STATE:
return {
...state,
isLobbyChatActive: action.payload,
isOpen: action.payload || state.isOpen,
privateMessageRecipient: undefined
};
case REMOVE_LOBBY_CHAT_PARTICIPANT:
return {
...state,
messages: state.messages.filter(m => {
if (action.removeLobbyChatMessages) {
return !m.lobbyChat;
}
return true;
}),
isOpen: state.isOpen && state.isLobbyChatActive ? false : state.isOpen,
isLobbyChatActive: false,
lobbyMessageRecipient: undefined
};
} }
return state; return state;

@ -29,3 +29,13 @@ export const SET_LOBBY_VISIBILITY = 'TOGGLE_LOBBY_VISIBILITY';
* Action type to set the password join failed status. * Action type to set the password join failed status.
*/ */
export const SET_PASSWORD_JOIN_FAILED = 'SET_PASSWORD_JOIN_FAILED'; export const SET_PASSWORD_JOIN_FAILED = 'SET_PASSWORD_JOIN_FAILED';
/**
* Action type to set a lobby chat participant's state to chatting
*/
export const SET_LOBBY_PARTICIPANT_CHAT_STATE = 'SET_LOBBY_PARTICIPANT_CHAT_STATE';
/**
* Action type to remove chattingWithModerator field
*/
export const REMOVE_LOBBY_CHAT_WITH_MODERATOR = 'REMOVE_LOBBY_CHAT_WITH_MODERATOR';

@ -9,7 +9,11 @@ import {
setPassword setPassword
} from '../base/conference'; } from '../base/conference';
import { getLocalParticipant } from '../base/participants'; import { getLocalParticipant } from '../base/participants';
import { onLobbyChatInitialized, removeLobbyChatParticipant, sendMessage } from '../chat/actions.any';
import { LOBBY_CHAT_MESSAGE } from '../chat/constants';
import { handleLobbyMessageReceived } from '../chat/middleware';
import { hideNotification, LOBBY_NOTIFICATION_ID } from '../notifications'; import { hideNotification, LOBBY_NOTIFICATION_ID } from '../notifications';
import { showNotification } from '../notifications/actions';
import { import {
KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED, KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED,
@ -17,8 +21,12 @@ import {
SET_KNOCKING_STATE, SET_KNOCKING_STATE,
SET_LOBBY_MODE_ENABLED, SET_LOBBY_MODE_ENABLED,
SET_PASSWORD_JOIN_FAILED, SET_PASSWORD_JOIN_FAILED,
SET_LOBBY_VISIBILITY SET_LOBBY_VISIBILITY,
SET_LOBBY_PARTICIPANT_CHAT_STATE,
REMOVE_LOBBY_CHAT_WITH_MODERATOR
} from './actionTypes'; } from './actionTypes';
import { LOBBY_CHAT_INITIALIZED, MODERATOR_IN_CHAT_WITH_LEFT } from './constants';
import { getKnockingParticipants, getLobbyEnabled } from './functions';
/** /**
* Tries to join with a preset password. * Tries to join with a preset password.
@ -211,6 +219,7 @@ export function startKnocking() {
sendLocalParticipant(state, membersOnly); sendLocalParticipant(state, membersOnly);
membersOnly.joinLobby(localParticipant.name, localParticipant.email); membersOnly.joinLobby(localParticipant.name, localParticipant.email);
dispatch(setLobbyMessageListener());
dispatch(setKnockingState(true)); dispatch(setKnockingState(true));
}; };
} }
@ -256,3 +265,149 @@ export function hideLobbyScreen() {
visible: false visible: false
}; };
} }
/**
* Action to handle chat initialized in the lobby room.
*
* @param {Object} payload - The payload received,
* contains the information about the two participants
* that will chat with each other in the lobby room.
*
* @returns {Promise<void>}
*/
export function handleLobbyChatInitialized(payload: Object) {
return async (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const conference = getCurrentConference(state);
const id = conference.myLobbyUserId();
dispatch({
type: SET_LOBBY_PARTICIPANT_CHAT_STATE,
participant: payload.attendee,
moderator: payload.moderator
});
dispatch(onLobbyChatInitialized(payload));
const attendeeIsKnocking = getKnockingParticipants(state).some(p => p.id === payload.attendee.id);
if (attendeeIsKnocking && conference.getRole() === 'moderator' && payload.moderator.id !== id) {
dispatch(showNotification({
titleKey: 'lobby.lobbyChatStartedNotification',
titleArguments: {
moderator: payload.moderator.name,
attendee: payload.attendee.name
}
}));
}
};
}
/**
* Action to send message to the moderator.
*
* @param {string} message - The message to be sent.
*
* @returns {Promise<void>}
*/
export function onSendMessage(message: string) {
return async (dispatch: Dispatch<any>) => {
dispatch(sendMessage(message));
};
}
/**
* Action to send lobby message to every participant. Only allowed for moderators.
*
* @param {Object} message - The message to be sent.
*
* @returns {Promise<void>}
*/
export function sendLobbyChatMessage(message: Object) {
return async (dispatch: Dispatch<any>, getState: Function) => {
const conference = getCurrentConference(getState);
conference.sendLobbyMessage(message);
};
}
/**
* Sets lobby listeners if lobby has been enabled.
*
* @returns {Function}
*/
export function maybeSetLobbyChatMessageListener() {
return async (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const lobbyEnabled = getLobbyEnabled(state);
if (lobbyEnabled) {
dispatch(setLobbyMessageListener());
}
};
}
/**
* Action to handle the event when a moderator leaves during lobby chat.
*
* @param {string} participantId - The participant id of the moderator who left.
* @returns {Function}
*/
export function updateLobbyParticipantOnLeave(participantId: string) {
return async (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const { knocking, knockingParticipants } = state['features/lobby'];
const { lobbyMessageRecipient } = state['features/chat'];
const { conference } = state['features/base/conference'];
if (knocking && lobbyMessageRecipient && lobbyMessageRecipient.id === participantId) {
return dispatch(removeLobbyChatParticipant(true));
}
if (!knocking) {
// inform knocking participant when their moderator leaves
const participantToNotify = knockingParticipants.find(p => p.chattingWithModerator === participantId);
if (participantToNotify) {
conference.sendLobbyMessage({
type: MODERATOR_IN_CHAT_WITH_LEFT,
moderatorId: participantToNotify.chattingWithModerator
}, participantToNotify.id);
}
dispatch({
type: REMOVE_LOBBY_CHAT_WITH_MODERATOR,
moderatorId: participantId
});
}
};
}
/**
* Handles all messages received in the lobby room.
*
* @returns {Function}
*/
export function setLobbyMessageListener() {
return async (dispatch: Dispatch<any>, getState: Function) => {
const state = getState();
const conference = getCurrentConference(state);
const { enableLobbyChat = true } = state['features/base/config'];
if (!enableLobbyChat) {
return;
}
conference.addLobbyMessageListener((message: Object, participantId: string) => {
if (message.type === LOBBY_CHAT_MESSAGE) {
return dispatch(handleLobbyMessageReceived(message.message, participantId));
}
if (message.type === LOBBY_CHAT_INITIALIZED) {
return dispatch(handleLobbyChatInitialized(message));
}
if (message.type === MODERATOR_IN_CHAT_WITH_LEFT) {
return dispatch(updateLobbyParticipantOnLeave(message.moderatorId));
}
});
};
}

@ -8,7 +8,7 @@ import { getLocalParticipant } from '../../base/participants';
import { getFieldValue } from '../../base/react'; import { getFieldValue } from '../../base/react';
import { updateSettings } from '../../base/settings'; import { updateSettings } from '../../base/settings';
import { isDeviceStatusVisible } from '../../prejoin/functions'; import { isDeviceStatusVisible } from '../../prejoin/functions';
import { cancelKnocking, joinWithPassword, setPasswordJoinFailed, startKnocking } from '../actions'; import { cancelKnocking, joinWithPassword, setPasswordJoinFailed, startKnocking, onSendMessage } from '../actions';
export const SCREEN_STATES = { export const SCREEN_STATES = {
EDIT: 1, EDIT: 1,
@ -28,6 +28,21 @@ export type Props = {
*/ */
_knocking: boolean, _knocking: boolean,
/**
* Lobby messages between moderator and the participant.
*/
_lobbyChatMessages: Object,
/**
* Name of the lobby chat recipient.
*/
_lobbyMessageRecipient: string,
/**
* True if moderator initiated a chat session with the participant.
*/
_isLobbyChatActive: boolean,
/** /**
* The name of the meeting we're about to join. * The name of the meeting we're about to join.
*/ */
@ -91,6 +106,11 @@ type State = {
*/ */
email: string, email: string,
/**
* True if lobby chat widget is open.
*/
isChatOpen: boolean,
/** /**
* The password value entered into the field. * The password value entered into the field.
*/ */
@ -122,6 +142,7 @@ export default class AbstractLobbyScreen<P: Props = Props> extends PureComponent
this.state = { this.state = {
displayName: props._participantName || '', displayName: props._participantName || '',
email: props._participantEmail || '', email: props._participantEmail || '',
isChatOpen: true,
password: '', password: '',
passwordJoinFailed: false, passwordJoinFailed: false,
screenState: props._participantName ? SCREEN_STATES.VIEW : SCREEN_STATES.EDIT screenState: props._participantName ? SCREEN_STATES.VIEW : SCREEN_STATES.EDIT
@ -134,8 +155,10 @@ export default class AbstractLobbyScreen<P: Props = Props> extends PureComponent
this._onChangePassword = this._onChangePassword.bind(this); this._onChangePassword = this._onChangePassword.bind(this);
this._onEnableEdit = this._onEnableEdit.bind(this); this._onEnableEdit = this._onEnableEdit.bind(this);
this._onJoinWithPassword = this._onJoinWithPassword.bind(this); this._onJoinWithPassword = this._onJoinWithPassword.bind(this);
this._onSendMessage = this._onSendMessage.bind(this);
this._onSwitchToKnockMode = this._onSwitchToKnockMode.bind(this); this._onSwitchToKnockMode = this._onSwitchToKnockMode.bind(this);
this._onSwitchToPasswordMode = this._onSwitchToPasswordMode.bind(this); this._onSwitchToPasswordMode = this._onSwitchToPasswordMode.bind(this);
this._onToggleChat = this._onToggleChat.bind(this);
} }
/** /**
@ -164,7 +187,7 @@ export default class AbstractLobbyScreen<P: Props = Props> extends PureComponent
const passwordPrompt = screenState === SCREEN_STATES.PASSWORD; const passwordPrompt = screenState === SCREEN_STATES.PASSWORD;
return !passwordPrompt && this.props._knocking return !passwordPrompt && this.props._knocking
? 'lobby.joiningTitle' ? this.props._isLobbyChatActive ? 'lobby.lobbyChatStartedTitle' : 'lobby.joiningTitle'
: passwordPrompt ? 'lobby.enterPasswordTitle' : 'lobby.joinTitle'; : passwordPrompt ? 'lobby.enterPasswordTitle' : 'lobby.joinTitle';
} }
@ -280,6 +303,18 @@ export default class AbstractLobbyScreen<P: Props = Props> extends PureComponent
this.props.dispatch(joinWithPassword(this.state.password)); this.props.dispatch(joinWithPassword(this.state.password));
} }
_onSendMessage: () => void;
/**
* Callback to be invoked for sending lobby chat messages.
*
* @param {string} message - Message to be sent.
* @returns {void}
*/
_onSendMessage(message) {
this.props.dispatch(onSendMessage(message));
}
_onSwitchToKnockMode: () => void; _onSwitchToKnockMode: () => void;
/** /**
@ -311,6 +346,21 @@ export default class AbstractLobbyScreen<P: Props = Props> extends PureComponent
}); });
} }
_onToggleChat: () => void;
/**
* Callback to be invoked for toggling lobby chat visibility.
*
* @returns {void}
*/
_onToggleChat() {
this.setState(_prevState => {
return {
isChatOpen: !_prevState.isChatOpen
};
});
}
/** /**
* Renders the content of the dialog. * Renders the content of the dialog.
* *
@ -396,10 +446,14 @@ export function _mapStateToProps(state: Object): $Shape<Props> {
const showCopyUrlButton = inviteEnabledFlag || !disableInviteFunctions; const showCopyUrlButton = inviteEnabledFlag || !disableInviteFunctions;
const deviceStatusVisible = isDeviceStatusVisible(state); const deviceStatusVisible = isDeviceStatusVisible(state);
const { membersOnly } = state['features/base/conference']; const { membersOnly } = state['features/base/conference'];
const { isLobbyChatActive, lobbyMessageRecipient, messages } = state['features/chat'];
return { return {
_deviceStatusVisible: deviceStatusVisible, _deviceStatusVisible: deviceStatusVisible,
_knocking: knocking, _knocking: knocking,
_lobbyChatMessages: messages,
_lobbyMessageRecipient: lobbyMessageRecipient?.name,
_isLobbyChatActive: isLobbyChatActive,
_meetingName: getConferenceName(state), _meetingName: getConferenceName(state),
_membersOnlyConference: membersOnly, _membersOnlyConference: membersOnly,
_participantEmail: localParticipant?.email, _participantEmail: localParticipant?.email,

@ -7,9 +7,12 @@ import { Avatar } from '../../../base/avatar';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { isLocalParticipantModerator } from '../../../base/participants'; import { isLocalParticipantModerator } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { handleLobbyChatInitialized } from '../../../chat/actions.any';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { setKnockingParticipantApproval } from '../../actions'; import { setKnockingParticipantApproval } from '../../actions';
import { HIDDEN_EMAILS } from '../../constants'; import { HIDDEN_EMAILS } from '../../constants';
import { getKnockingParticipants, getLobbyEnabled } from '../../functions'; import { showLobbyChatButton, getKnockingParticipants, getLobbyEnabled } from '../../functions';
import styles from './styles'; import styles from './styles';
@ -28,6 +31,16 @@ export type Props = {
*/ */
_visible: boolean, _visible: boolean,
/**
* True if the polls feature is disabled.
*/
_isPollsDisabled: boolean,
/**
* Returns true if the lobby chat button should be shown.
*/
_showChatButton: Function,
/** /**
* The Redux Dispatch function. * The Redux Dispatch function.
*/ */
@ -61,7 +74,7 @@ class KnockingParticipantList extends PureComponent<Props> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { _participants, _visible, t } = this.props; const { _participants, _visible, _showChatButton, t } = this.props;
if (!_visible) { if (!_visible) {
return null; return null;
@ -108,6 +121,18 @@ class KnockingParticipantList extends PureComponent<Props> {
{ t('lobby.reject') } { t('lobby.reject') }
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{_showChatButton(p) ? (
<TouchableOpacity
onPress = { this._onInitializeLobbyChat(p.id) }
style = { [
styles.knockingParticipantListButton,
styles.knockingParticipantListSecondaryButton
] }>
<Text style = { styles.knockingParticipantListText }>
{ t('lobby.chat') }
</Text>
</TouchableOpacity>
) : null}
</View> </View>
)) } )) }
</ScrollView> </ScrollView>
@ -128,6 +153,24 @@ class KnockingParticipantList extends PureComponent<Props> {
this.props.dispatch(setKnockingParticipantApproval(id, approve)); this.props.dispatch(setKnockingParticipantApproval(id, approve));
}; };
} }
_onInitializeLobbyChat: (string) => Function;
/**
* Function that constructs a callback for the lobby chat button.
*
* @param {string} id - The id of the knocking participant.
* @returns {Function}
*/
_onInitializeLobbyChat(id) {
return () => {
this.props.dispatch(handleLobbyChatInitialized(id));
if (this.props._isPollsDisabled) {
return navigate(screen.conference.chat);
}
navigate(screen.conference.chatandpolls.main);
};
}
} }
/** /**
@ -139,9 +182,12 @@ class KnockingParticipantList extends PureComponent<Props> {
function _mapStateToProps(state): Object { function _mapStateToProps(state): Object {
const lobbyEnabled = getLobbyEnabled(state); const lobbyEnabled = getLobbyEnabled(state);
const knockingParticipants = getKnockingParticipants(state); const knockingParticipants = getKnockingParticipants(state);
const { disablePolls } = state['features/base/config'];
return { return {
_visible: lobbyEnabled && isLocalParticipantModerator(state), _visible: lobbyEnabled && isLocalParticipantModerator(state),
_showChatButton: participant => showLobbyChatButton(participant)(state),
_isPollsDisabled: disablePolls,
// On mobile we only show a portion of the list for screen real estate reasons // On mobile we only show a portion of the list for screen real estate reasons
_participants: knockingParticipants.slice(0, 2) _participants: knockingParticipants.slice(0, 2)

@ -6,10 +6,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Avatar } from '../../../base/avatar'; import { Avatar } from '../../../base/avatar';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { Icon, IconEdit } from '../../../base/icons'; import { Icon, IconClose, IconEdit } from '../../../base/icons';
import JitsiScreen from '../../../base/modal/components/JitsiScreen'; import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { LoadingIndicator } from '../../../base/react'; import { LoadingIndicator } from '../../../base/react';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import ChatInputBar from '../../../chat/components/native/ChatInputBar';
import MessageContainer from '../../../chat/components/native/MessageContainer';
import AbstractLobbyScreen, { _mapStateToProps } from '../AbstractLobbyScreen'; import AbstractLobbyScreen, { _mapStateToProps } from '../AbstractLobbyScreen';
import styles from './styles'; import styles from './styles';
@ -28,16 +30,21 @@ class LobbyScreen extends AbstractLobbyScreen {
return ( return (
<JitsiScreen <JitsiScreen
style = { styles.contentWrapper }> style = { this.props._isLobbyChatActive && this.state.isChatOpen
<SafeAreaView> ? styles.lobbyChatWrapper
<Text style = { styles.dialogTitle }> : styles.contentWrapper }>
{ t(this._getScreenTitleKey()) } {this.props._isLobbyChatActive && this.state.isChatOpen
</Text> ? this._renderLobbyChat()
<Text style = { styles.secondaryText }> : <SafeAreaView>
{ _meetingName } <Text style = { styles.dialogTitle }>
</Text> { t(this._getScreenTitleKey(), { moderator: this.props._lobbyMessageRecipient }) }
{ this._renderContent() } </Text>
</SafeAreaView>
<Text style = { styles.secondaryText }>
{ _meetingName }
</Text>
{ this._renderContent()}
</SafeAreaView> }
</JitsiScreen> </JitsiScreen>
); );
} }
@ -62,8 +69,38 @@ class LobbyScreen extends AbstractLobbyScreen {
_onSwitchToPasswordMode: () => void; _onSwitchToPasswordMode: () => void;
_onSendMessage: () => void;
_onToggleChat: () => void;
_renderContent: () => React$Element<*>; _renderContent: () => React$Element<*>;
/**
* Renders the lobby chat.
*
* @inheritdoc
*/
_renderLobbyChat() {
const { t } = this.props;
return (
<>
<View style = { styles.lobbyChatHeader }>
<Text style = { styles.lobbyChatTitle }>
{ t(this._getScreenTitleKey(), { moderator: this.props._lobbyMessageRecipient }) }
</Text>
<TouchableOpacity onPress = { this._onToggleChat }>
<Icon
src = { IconClose }
style = { styles.lobbyChatCloseButton } />
</TouchableOpacity>
</View>
<MessageContainer messages = { this.props._lobbyChatMessages } />
<ChatInputBar onSend = { this._onSendMessage } />
</>
);
}
/** /**
* Renders the joining (waiting) fragment of the screen. * Renders the joining (waiting) fragment of the screen.
* *
@ -210,7 +247,7 @@ class LobbyScreen extends AbstractLobbyScreen {
* @inheritdoc * @inheritdoc
*/ */
_renderStandardButtons() { _renderStandardButtons() {
const { _knocking, _renderPassword, t } = this.props; const { _knocking, _renderPassword, _isLobbyChatActive, t } = this.props;
return ( return (
<> <>
@ -225,6 +262,16 @@ class LobbyScreen extends AbstractLobbyScreen {
{ t('lobby.knockButton') } { t('lobby.knockButton') }
</Text> </Text>
</TouchableOpacity> } </TouchableOpacity> }
{ _knocking && _isLobbyChatActive && <TouchableOpacity
onPress = { this._onToggleChat }
style = { [
styles.button,
styles.secondaryButton
] }>
<Text>
{ t('toolbar.openChat') }
</Text>
</TouchableOpacity>}
{ _renderPassword && <TouchableOpacity { _renderPassword && <TouchableOpacity
onPress = { this._onSwitchToPasswordMode } onPress = { this._onSwitchToPasswordMode }
style = { [ style = { [

@ -12,6 +12,32 @@ export default {
paddingVertical: BaseTheme.spacing[2] paddingVertical: BaseTheme.spacing[2]
}, },
lobbyChatWrapper: {
backgroundColor: BaseTheme.palette.ui01,
alignItems: 'stretch',
flexDirection: 'column',
justifyItems: 'center',
height: '100%'
},
lobbyChatHeader: {
flexDirection: 'row',
padding: 20
},
lobbyChatTitle: {
color: '#fff',
fontSize: 20,
fontWeight: 'bold',
flexShrink: 1
},
lobbyChatCloseButton: {
fontSize: 20,
marginLeft: 20,
color: '#fff'
},
contentWrapper: { contentWrapper: {
alignItems: 'center', alignItems: 'center',
display: 'flex', display: 'flex',

@ -3,17 +3,61 @@
import React from 'react'; import React from 'react';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { Icon, IconClose } from '../../../base/icons';
import { ActionButton, InputField, PreMeetingScreen } from '../../../base/premeeting'; import { ActionButton, InputField, PreMeetingScreen } from '../../../base/premeeting';
import { LoadingIndicator } from '../../../base/react'; import { LoadingIndicator } from '../../../base/react';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import ChatInput from '../../../chat/components/web/ChatInput';
import MessageContainer from '../../../chat/components/web/MessageContainer';
import AbstractLobbyScreen, { import AbstractLobbyScreen, {
_mapStateToProps _mapStateToProps,
type Props
} from '../AbstractLobbyScreen'; } from '../AbstractLobbyScreen';
/** /**
* Implements a waiting screen that represents the participant being in the lobby. * Implements a waiting screen that represents the participant being in the lobby.
*/ */
class LobbyScreen extends AbstractLobbyScreen { class LobbyScreen extends AbstractLobbyScreen<Props> {
/**
* Reference to the React Component for displaying chat messages. Used for
* scrolling to the end of the chat messages.
*/
_messageContainerRef: Object;
/**
* Initializes a new {@code LobbyScreen} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Props) {
super(props);
this._messageContainerRef = React.createRef();
}
/**
* Implements {@code Component#componentDidMount}.
*
* @inheritdoc
*/
componentDidMount() {
this._scrollMessageContainerToBottom(true);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
componentDidUpdate(prevProps) {
if (this.props._lobbyChatMessages !== prevProps._lobbyChatMessages) {
this._scrollMessageContainerToBottom(true);
} else if (this.props._isLobbyChatActive && !prevProps._isLobbyChatActive) {
this._scrollMessageContainerToBottom(false);
}
}
/** /**
* Implements {@code PureComponent#render}. * Implements {@code PureComponent#render}.
* *
@ -27,7 +71,7 @@ class LobbyScreen extends AbstractLobbyScreen {
className = 'lobby-screen' className = 'lobby-screen'
showCopyUrlButton = { showCopyUrlButton } showCopyUrlButton = { showCopyUrlButton }
showDeviceStatus = { _deviceStatusVisible } showDeviceStatus = { _deviceStatusVisible }
title = { t(this._getScreenTitleKey()) }> title = { t(this._getScreenTitleKey(), { moderator: this.props._lobbyMessageRecipient }) }>
{ this._renderContent() } { this._renderContent() }
</PreMeetingScreen> </PreMeetingScreen>
); );
@ -49,12 +93,16 @@ class LobbyScreen extends AbstractLobbyScreen {
_onJoinWithPassword: () => void; _onJoinWithPassword: () => void;
_onSendMessage: () => void;
_onSubmit: () => boolean; _onSubmit: () => boolean;
_onSwitchToKnockMode: () => void; _onSwitchToKnockMode: () => void;
_onSwitchToPasswordMode: () => void; _onSwitchToPasswordMode: () => void;
_onToggleChat: () => void;
_renderContent: () => React$Element<*>; _renderContent: () => React$Element<*>;
/** /**
@ -63,19 +111,56 @@ class LobbyScreen extends AbstractLobbyScreen {
* @inheritdoc * @inheritdoc
*/ */
_renderJoining() { _renderJoining() {
const { _isLobbyChatActive } = this.props;
return ( return (
<div className = 'lobby-screen-content'> <div className = 'lobby-screen-content'>
<div className = 'spinner'> {_isLobbyChatActive
<LoadingIndicator size = 'large' /> ? this._renderLobbyChat()
</div> : (
<span className = 'joining-message'> <>
{ this.props.t('lobby.joiningMessage') } <div className = 'spinner'>
</span> <LoadingIndicator size = 'large' />
</div>
<span className = 'joining-message'>
{ this.props.t('lobby.joiningMessage') }
</span>
</>
)}
{ this._renderStandardButtons() } { this._renderStandardButtons() }
</div> </div>
); );
} }
/**
* Renders the widget to chat with the moderator before allowed in.
*
* @inheritdoc
*/
_renderLobbyChat() {
const { _lobbyChatMessages, t } = this.props;
const { isChatOpen } = this.state;
return (
<div className = { `lobby-chat-container ${isChatOpen ? 'hidden' : ''}` }>
<div className = 'lobby-chat-header'>
<h1 className = 'title'>
{ t(this._getScreenTitleKey(), { moderator: this.props._lobbyMessageRecipient }) }
</h1>
<Icon
ariaLabel = { t('toolbar.closeChat') }
onClick = { this._onToggleChat }
role = 'button'
src = { IconClose } />
</div>
<MessageContainer
messages = { _lobbyChatMessages }
ref = { this._messageContainerRef } />
<ChatInput onSend = { this._onSendMessage } />
</div>
);
}
/** /**
* Renders the participant form to let the knocking participant enter its details. * Renders the participant form to let the knocking participant enter its details.
* *
@ -163,7 +248,7 @@ class LobbyScreen extends AbstractLobbyScreen {
* @inheritdoc * @inheritdoc
*/ */
_renderStandardButtons() { _renderStandardButtons() {
const { _knocking, _renderPassword, t } = this.props; const { _knocking, _isLobbyChatActive, _renderPassword, t } = this.props;
return ( return (
<> <>
@ -174,6 +259,13 @@ class LobbyScreen extends AbstractLobbyScreen {
type = 'primary'> type = 'primary'>
{ t('lobby.knockButton') } { t('lobby.knockButton') }
</ActionButton> } </ActionButton> }
{ (_knocking && _isLobbyChatActive) && <ActionButton
className = 'open-chat-button'
onClick = { this._onToggleChat }
testId = 'toolbar.openChat'
type = 'primary' >
{ t('toolbar.openChat') }
</ActionButton> }
{_renderPassword && <ActionButton {_renderPassword && <ActionButton
onClick = { this._onSwitchToPasswordMode } onClick = { this._onSwitchToPasswordMode }
testId = 'lobby.enterPasswordButton' testId = 'lobby.enterPasswordButton'
@ -183,6 +275,20 @@ class LobbyScreen extends AbstractLobbyScreen {
</> </>
); );
} }
/**
* Scrolls the chat messages so the latest message is visible.
*
* @param {boolean} withAnimation - Whether or not to show a scrolling
* animation.
* @private
* @returns {void}
*/
_scrollMessageContainerToBottom(withAnimation) {
if (this._messageContainerRef.current) {
this._messageContainerRef.current.scrollToBottom(withAnimation);
}
}
} }
export default translate(connect(_mapStateToProps)(LobbyScreen)); export default translate(connect(_mapStateToProps)(LobbyScreen));

@ -9,3 +9,17 @@ export const HIDDEN_EMAILS = [ 'inbound-sip-jibri@jitsi.net', 'outbound-sip-jibr
* @type {string} * @type {string}
*/ */
export const KNOCKING_PARTICIPANT_SOUND_ID = 'KNOCKING_PARTICIPANT_SOUND'; export const KNOCKING_PARTICIPANT_SOUND_ID = 'KNOCKING_PARTICIPANT_SOUND';
/**
* Lobby chat initialized message type.
*
* @type {string}
*/
export const LOBBY_CHAT_INITIALIZED = 'LOBBY_CHAT_INITIALIZED';
/**
* Event message sent to knocking participant when moderator in chat with leaves.
*
* @type {string}
*/
export const MODERATOR_IN_CHAT_WITH_LEFT = 'MODERATOR_IN_CHAT_WITH_LEFT';

@ -1,5 +1,7 @@
// @flow // @flow
import { getCurrentConference } from '../base/conference';
/** /**
* Selector to return lobby enable state. * Selector to return lobby enable state.
* *
@ -39,3 +41,43 @@ export function getIsLobbyVisible(state: any) {
export function getKnockingParticipantsById(state: any) { export function getKnockingParticipantsById(state: any) {
return getKnockingParticipants(state).map(participant => participant.id); return getKnockingParticipants(state).map(participant => participant.id);
} }
/**
* Function that handles the visibility of the lobby chat message.
*
* @param {Object} participant - Lobby Participant.
* @returns {Function}
*/
export function showLobbyChatButton(
participant: Object
) {
return function(state: Object) {
const { enableLobbyChat = true } = state['features/base/config'];
const { lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
const conference = getCurrentConference(state);
const lobbyLocalId = conference.myLobbyUserId();
if (!enableLobbyChat) {
return false;
}
if (!isLobbyChatActive
&& (!participant.chattingWithModerator
|| participant.chattingWithModerator === lobbyLocalId)
) {
return true;
}
if (isLobbyChatActive && lobbyMessageRecipient
&& participant.id !== lobbyMessageRecipient.id
&& (!participant.chattingWithModerator
|| participant.chattingWithModerator === lobbyLocalId)) {
return true;
}
return false;
};
}

@ -14,6 +14,7 @@ import { getFirstLoadableAvatarUrl, getParticipantDisplayName } from '../base/pa
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { isTestModeEnabled } from '../base/testing'; import { isTestModeEnabled } from '../base/testing';
import { handleLobbyChatInitialized, removeLobbyChatParticipant } from '../chat/actions.any';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../lobby/actions'; import { approveKnockingParticipant, rejectKnockingParticipant } from '../lobby/actions';
import { import {
LOBBY_NOTIFICATION_ID, LOBBY_NOTIFICATION_ID,
@ -35,10 +36,12 @@ import {
participantIsKnockingOrUpdated, participantIsKnockingOrUpdated,
setLobbyModeEnabled, setLobbyModeEnabled,
startKnocking, startKnocking,
setPasswordJoinFailed setPasswordJoinFailed,
setLobbyMessageListener
} from './actions'; } from './actions';
import { updateLobbyParticipantOnLeave } from './actions.any';
import { KNOCKING_PARTICIPANT_SOUND_ID } from './constants'; import { KNOCKING_PARTICIPANT_SOUND_ID } from './constants';
import { getKnockingParticipants } from './functions'; import { getKnockingParticipants, showLobbyChatButton } from './functions';
import { KNOCKING_PARTICIPANT_FILE } from './sounds'; import { KNOCKING_PARTICIPANT_FILE } from './sounds';
declare var APP: Object; declare var APP: Object;
@ -87,6 +90,9 @@ StateListenerRegistry.register(
if (conference && !previousConference) { if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.MEMBERS_ONLY_CHANGED, enabled => { conference.on(JitsiConferenceEvents.MEMBERS_ONLY_CHANGED, enabled => {
dispatch(setLobbyModeEnabled(enabled)); dispatch(setLobbyModeEnabled(enabled));
if (enabled) {
dispatch(setLobbyMessageListener());
}
}); });
conference.on(JitsiConferenceEvents.LOBBY_USER_JOINED, (id, name) => { conference.on(JitsiConferenceEvents.LOBBY_USER_JOINED, (id, name) => {
@ -110,6 +116,57 @@ StateListenerRegistry.register(
getState getState
}); });
let notificationTitle;
let customActionNameKey;
let customActionHandler;
let descriptionKey;
let icon;
const knockingParticipants = getKnockingParticipants(getState());
const firstParticipant = knockingParticipants[0];
const showChat = showLobbyChatButton(firstParticipant)(getState());
if (knockingParticipants.length > 1) {
descriptionKey = 'notify.participantsWantToJoin';
notificationTitle = i18n.t('notify.waitingParticipants', {
waitingParticipants: knockingParticipants.length
});
icon = NOTIFICATION_ICON.PARTICIPANTS;
customActionNameKey = [ 'notify.viewLobby' ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(openParticipantsPane());
}) ];
} else {
descriptionKey = 'notify.participantWantsToJoin';
notificationTitle = firstParticipant.name;
icon = NOTIFICATION_ICON.PARTICIPANT;
customActionNameKey = [ 'lobby.admit', 'lobby.reject' ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(approveKnockingParticipant(firstParticipant.id));
}),
() => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(rejectKnockingParticipant(firstParticipant.id));
}) ];
if (showChat) {
customActionNameKey.splice(1, 0, 'lobby.chat');
customActionHandler.splice(1, 0, () => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(handleLobbyChatInitialized(firstParticipant.id));
}));
}
}
dispatch(showNotification({
title: notificationTitle,
descriptionKey,
uid: LOBBY_NOTIFICATION_ID,
customActionNameKey,
customActionHandler,
icon
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
if (typeof APP !== 'undefined') { if (typeof APP !== 'undefined') {
APP.API.notifyKnockingParticipant({ APP.API.notifyKnockingParticipant({
id, id,
@ -129,7 +186,11 @@ StateListenerRegistry.register(
}); });
conference.on(JitsiConferenceEvents.LOBBY_USER_LEFT, id => { conference.on(JitsiConferenceEvents.LOBBY_USER_LEFT, id => {
dispatch(knockingParticipantLeft(id)); batch(() => {
dispatch(knockingParticipantLeft(id));
dispatch(removeLobbyChatParticipant());
dispatch(updateLobbyParticipantOnLeave(id));
});
}); });
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, (origin, sender) => conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, (origin, sender) =>

@ -6,8 +6,10 @@ import { ReducerRegistry } from '../base/redux';
import { import {
KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED, KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED,
KNOCKING_PARTICIPANT_LEFT, KNOCKING_PARTICIPANT_LEFT,
REMOVE_LOBBY_CHAT_WITH_MODERATOR,
SET_KNOCKING_STATE, SET_KNOCKING_STATE,
SET_LOBBY_MODE_ENABLED, SET_LOBBY_MODE_ENABLED,
SET_LOBBY_PARTICIPANT_CHAT_STATE,
SET_LOBBY_VISIBILITY, SET_LOBBY_VISIBILITY,
SET_PASSWORD_JOIN_FAILED SET_PASSWORD_JOIN_FAILED
} from './actionTypes'; } from './actionTypes';
@ -70,6 +72,34 @@ ReducerRegistry.register('features/lobby', (state = DEFAULT_STATE, action) => {
...state, ...state,
passwordJoinFailed: action.failed passwordJoinFailed: action.failed
}; };
case SET_LOBBY_PARTICIPANT_CHAT_STATE:
return {
...state,
knockingParticipants: state.knockingParticipants.map(participant => {
if (participant.id === action.participant.id) {
return {
...participant,
chattingWithModerator: action.moderator.id
};
}
return participant;
})
};
case REMOVE_LOBBY_CHAT_WITH_MODERATOR:
return {
...state,
knockingParticipants: state.knockingParticipants.map(participant => {
if (participant.chattingWithModerator === action.moderatorId) {
return {
...participant,
chattingWithModerator: undefined
};
}
return participant;
})
};
} }
return state; return state;

@ -1,10 +1,14 @@
// @flow // @flow
import { makeStyles } from '@material-ui/styles'; import { makeStyles } from '@material-ui/styles';
import React from 'react'; import React, { useCallback, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
import { Icon, IconChat, IconCloseCircle, IconHorizontalPoints } from '../../../base/icons';
import { hasRaisedHand } from '../../../base/participants'; import { hasRaisedHand } from '../../../base/participants';
import { showLobbyChatButton } from '../../../lobby/functions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants'; import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import { useLobbyActions } from '../../hooks'; import { useLobbyActions } from '../../hooks';
@ -33,6 +37,16 @@ const useStyles = makeStyles(theme => {
return { return {
button: { button: {
marginRight: `${theme.spacing(2)}px` marginRight: `${theme.spacing(2)}px`
},
moreButton: {
paddingRight: '6px',
paddingLeft: '6px',
marginRight: `${theme.spacing(2)}px`
},
contextMenu: {
position: 'fixed',
top: 'auto',
marginRight: '8px'
} }
}; };
}); });
@ -43,10 +57,27 @@ export const LobbyParticipantItem = ({
openDrawerForParticipant openDrawerForParticipant
}: Props) => { }: Props) => {
const { id } = p; const { id } = p;
const [ admit, reject ] = useLobbyActions({ participantID: id }); const [ admit, reject, chat ] = useLobbyActions({ participantID: id });
const { t } = useTranslation(); const { t } = useTranslation();
const [ isOpen, setIsOpen ] = useState(false);
const styles = useStyles(); const styles = useStyles();
const showChat = useSelector(showLobbyChatButton(p));
const moreButtonRef = useRef();
const openContextMenu = useCallback(() => setIsOpen(true));
const closeContextMenu = useCallback(() => setIsOpen(false));
const renderAdmitButton = () => (
<LobbyParticipantQuickAction
accessibilityLabel = { `${t('lobby.admit')} ${p.name}` }
className = { styles.button }
onClick = { admit }
testId = { `admit-${id}` }>
{t('lobby.admit')}
</LobbyParticipantQuickAction>);
return ( return (
<ParticipantItem <ParticipantItem
actionsTrigger = { ACTION_TRIGGER.PERMANENT } actionsTrigger = { ACTION_TRIGGER.PERMANENT }
@ -59,20 +90,51 @@ export const LobbyParticipantItem = ({
raisedHand = { hasRaisedHand(p) } raisedHand = { hasRaisedHand(p) }
videoMediaState = { MEDIA_STATE.NONE } videoMediaState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }> youText = { t('chat.you') }>
<LobbyParticipantQuickAction
accessibilityLabel = { `${t('lobby.reject')} ${p.name}` } {showChat ? <>
className = { styles.button } {renderAdmitButton()}
onClick = { reject } <LobbyParticipantQuickAction
secondary = { true } accessibilityLabel = { `${t('participantsPane.actions.moreModerationActions')} ${p.name}` }
testId = { `reject-${id}` }> className = { styles.moreButton }
{t('lobby.reject') } onClick = { openContextMenu }
</LobbyParticipantQuickAction> ref = { moreButtonRef }
<LobbyParticipantQuickAction secondary = { true }>
accessibilityLabel = { `${t('lobby.admit')} ${p.name}` } <Icon src = { IconHorizontalPoints } />
onClick = { admit } </LobbyParticipantQuickAction>
testId = { `admit-${id}` }> <ContextMenu
{t('lobby.admit')} className = { styles.contextMenu }
</LobbyParticipantQuickAction> hidden = { !isOpen }
offsetTarget = { moreButtonRef.current }
onMouseLeave = { closeContextMenu }>
<ContextMenuItemGroup
actions = { [ {
accessibilityLabel: `${t('lobby.chat')} ${p.name}`,
onClick: chat,
testId: `lobby-chat-${id}`,
icon: IconChat,
text: t('lobby.chat')
} ] } />
<ContextMenuItemGroup
actions = { [ {
accessibilityLabel: `${t('lobby.reject')} ${p.name}`,
onClick: reject,
testId: `reject-${id}`,
icon: IconCloseCircle,
text: t('lobby.reject')
} ] } />
</ContextMenu>
</> : <>
<LobbyParticipantQuickAction
accessibilityLabel = { `${t('lobby.reject')} ${p.name}` }
className = { styles.button }
onClick = { reject }
secondary = { true }
testId = { `reject-${id}` }>
{t('lobby.reject') }
</LobbyParticipantQuickAction>
{renderAdmitButton()}
</>
}
</ParticipantItem> </ParticipantItem>
); );
}; };

@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { handleLobbyChatInitialized } from '../chat/actions.any';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../lobby/actions'; import { approveKnockingParticipant, rejectKnockingParticipant } from '../lobby/actions';
/** /**
@ -23,7 +24,11 @@ export function useLobbyActions(participant, closeDrawer) {
useCallback(() => { useCallback(() => {
dispatch(rejectKnockingParticipant(participant && participant.participantID)); dispatch(rejectKnockingParticipant(participant && participant.participantID));
closeDrawer && closeDrawer(); closeDrawer && closeDrawer();
}, [ dispatch, closeDrawer ]) }, [ dispatch, closeDrawer ]),
useCallback(() => {
dispatch(handleLobbyChatInitialized(participant && participant.participantID));
}, [ dispatch ])
]; ];
} }

@ -158,6 +158,26 @@ function filter_stanza(stanza)
elseif stanza.name == 'iq' and stanza:get_child('query', DISCO_INFO_NS) then elseif stanza.name == 'iq' and stanza:get_child('query', DISCO_INFO_NS) then
-- allow disco info from the lobby component -- allow disco info from the lobby component
return stanza; return stanza;
elseif stanza.name == 'message' then
-- allow messages to or from moderator
local lobby_room_jid = jid_bare(stanza.attr.from);
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
local from_real_jid;
if from_occupant then
for real_jid in from_occupant:each_session() do
from_real_jid = real_jid;
end
end
local is_from_moderator = lobby_room:get_affiliation(from_real_jid) == 'owner';
if is_to_moderator or is_from_moderator then
return stanza;
end
return nil;
end end
return nil; return nil;

Loading…
Cancel
Save