Merge pull request #11175 from MaximKhlobystov/select-random-user

Presenter selecting a random viewer
This commit is contained in:
Anton Georgiev 2021-01-22 15:31:00 -05:00 committed by GitHub
commit 45c1ff6e40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 423 additions and 4 deletions

View File

@ -0,0 +1,42 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core.models.{ UserState, Users2x }
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core2.MeetingStatus2x
import scala.util.Random
trait SelectRandomViewerReqMsgHdlr extends RightsManagementTrait {
this: UsersApp =>
val outGW: OutMsgRouter
def handleSelectRandomViewerReqMsg(msg: SelectRandomViewerReqMsg): Unit = {
log.debug("Received SelectRandomViewerReqMsg {}", SelectRandomViewerReqMsg)
def broadcastEvent(msg: SelectRandomViewerReqMsg, selectedUser: UserState): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, liveMeeting.props.meetingProp.intId, msg.header.userId)
val envelope = BbbCoreEnvelope(SelectRandomViewerRespMsg.NAME, routing)
val header = BbbClientMsgHeader(SelectRandomViewerRespMsg.NAME, liveMeeting.props.meetingProp.intId, msg.header.userId)
val body = SelectRandomViewerRespMsgBody(msg.header.userId, selectedUser.intId)
val event = SelectRandomViewerRespMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)
}
if (permissionFailed(PermissionCheck.GUEST_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to select random user."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
} else {
val users = Users2x.findViewers(liveMeeting.users2x)
val randNum = new scala.util.Random
if (users.size > 0) {
broadcastEvent(msg, users(randNum.nextInt(users.size)))
}
}
}
}

View File

@ -145,6 +145,7 @@ class UsersApp(
with SendRecordingTimerInternalMsgHdlr
with UpdateWebcamsOnlyForModeratorCmdMsgHdlr
with GetRecordingStatusReqMsgHdlr
with SelectRandomViewerReqMsgHdlr
with GetWebcamsOnlyForModeratorReqMsgHdlr
with AssignPresenterReqMsgHdlr
with EjectDuplicateUserReqMsgHdlr

View File

@ -59,6 +59,10 @@ object Users2x {
users.toVector.filter(u => !u.presenter)
}
def findViewers(users: Users2x): Vector[UserState] = {
users.toVector.filter(u => u.role == Roles.VIEWER_ROLE)
}
def updateLastUserActivity(users: Users2x, u: UserState): UserState = {
val newUserState = modify(u)(_.lastActivityTime).setTo(TimeUtil.timeNowInMs())
users.save(newUserState)
@ -241,6 +245,7 @@ class Users2x {
}
}
}
}
case class OldPresenter(userId: String, changedPresenterOn: Long)

View File

@ -103,6 +103,8 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[GetPresenterGroupReqMsg](envelope, jsonNode)
case UserActivitySignCmdMsg.NAME =>
routeGenericMsg[UserActivitySignCmdMsg](envelope, jsonNode)
case SelectRandomViewerReqMsg.NAME =>
routeGenericMsg[SelectRandomViewerReqMsg](envelope, jsonNode)
// Poll
case StartCustomPollReqMsg.NAME =>

View File

@ -308,6 +308,7 @@ class MeetingActor(
case m: UpdateWebcamsOnlyForModeratorCmdMsg => usersApp.handleUpdateWebcamsOnlyForModeratorCmdMsg(m)
case m: GetRecordingStatusReqMsg => usersApp.handleGetRecordingStatusReqMsg(m)
case m: ChangeUserEmojiCmdMsg => handleChangeUserEmojiCmdMsg(m)
case m: SelectRandomViewerReqMsg => usersApp.handleSelectRandomViewerReqMsg(m)
// Client requested to eject user
case m: EjectUserFromMeetingCmdMsg =>

View File

@ -394,3 +394,17 @@ case class UserInactivityInspectMsgBody(meetingId: String, responseDelay: Long)
object UserActivitySignCmdMsg { val NAME = "UserActivitySignCmdMsg" }
case class UserActivitySignCmdMsg(header: BbbClientMsgHeader, body: UserActivitySignCmdMsgBody) extends StandardMsg
case class UserActivitySignCmdMsgBody(userId: String)
/**
* Sent from client to randomly select a viewer
*/
object SelectRandomViewerReqMsg { val NAME = "SelectRandomViewerReqMsg" }
case class SelectRandomViewerReqMsg(header: BbbClientMsgHeader, body: SelectRandomViewerReqMsgBody) extends StandardMsg
case class SelectRandomViewerReqMsgBody(requestedBy: String)
/**
* Response to request for a random viewer
*/
object SelectRandomViewerRespMsg { val NAME = "SelectRandomViewerRespMsg" }
case class SelectRandomViewerRespMsg(header: BbbClientMsgHeader, body: SelectRandomViewerRespMsgBody) extends StandardMsg
case class SelectRandomViewerRespMsgBody(requestedBy: String, selectedUserId: String)

View File

@ -9,6 +9,7 @@ import handleRecordingStatusChange from './handlers/recordingStatusChange';
import handleRecordingTimerChange from './handlers/recordingTimerChange';
import handleTimeRemainingUpdate from './handlers/timeRemainingUpdate';
import handleChangeWebcamOnlyModerator from './handlers/webcamOnlyModerator';
import handleSelectRandomViewer from './handlers/selectRandomViewer';
RedisPubSub.on('MeetingCreatedEvtMsg', handleMeetingCreation);
RedisPubSub.on('SyncGetMeetingInfoRespMsg', handleGetAllMeetings);
@ -21,3 +22,4 @@ RedisPubSub.on('UpdateRecordingTimerEvtMsg', handleRecordingTimerChange);
RedisPubSub.on('WebcamsOnlyForModeratorChangedEvtMsg', handleChangeWebcamOnlyModerator);
RedisPubSub.on('GetLockSettingsRespMsg', handleMeetingLocksChange);
RedisPubSub.on('MeetingTimeRemainingUpdateEvtMsg', handleTimeRemainingUpdate);
RedisPubSub.on('SelectRandomViewerRespMsg', handleSelectRandomViewer);

View File

@ -0,0 +1,13 @@
import { check } from 'meteor/check';
import updateRandomViewer from '../modifiers/updateRandomViewer';
export default function randomlySelectedUser({ header, body }) {
const { selectedUserId, requestedBy } = body;
const { meetingId } = header;
check(meetingId, String);
check(requestedBy, String);
check(selectedUserId, String);
updateRandomViewer(meetingId, selectedUserId, requestedBy);
}

View File

@ -4,6 +4,7 @@ import toggleRecording from './methods/toggleRecording';
import transferUser from './methods/transferUser';
import toggleLockSettings from './methods/toggleLockSettings';
import toggleWebcamsOnlyForModerator from './methods/toggleWebcamsOnlyForModerator';
import clearRandomlySelectedUser from './methods/clearRandomlySelectedUser';
Meteor.methods({
endMeeting,
@ -11,4 +12,5 @@ Meteor.methods({
toggleLockSettings,
transferUser,
toggleWebcamsOnlyForModerator,
clearRandomlySelectedUser,
});

View File

@ -0,0 +1,26 @@
import Logger from '/imports/startup/server/logger';
import Meetings from '/imports/api/meetings';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function clearRandomlySelectedUser() {
const { meetingId, requesterUserId } = extractCredentials(this.userId);
const selector = {
meetingId,
};
const modifier = {
$set: {
randomlySelectedUser: '',
},
};
try {
const { insertedId } = Meetings.update(selector, modifier);
if (insertedId) {
Logger.info(`Cleared randomly selected user from meeting=${meetingId} by id=${requesterUserId}`);
}
} catch (err) {
Logger.error(`Clearing randomly selected user : ${err}`);
}
}

View File

@ -146,6 +146,7 @@ export default function addMeeting(meeting) {
meetingId,
meetingEnded,
publishedPoll: false,
randomlySelectedUser: '',
}, flat(newMeeting, {
safe: true,
})),

View File

@ -0,0 +1,28 @@
import Meetings from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
export default function updateRandomUser(meetingId, userId, requesterId) {
check(meetingId, String);
check(userId, String);
check(requesterId, String);
const selector = {
meetingId,
};
const modifier = {
$set: {
randomlySelectedUser: userId,
},
};
try {
const { insertedId } = Meetings.upsert(selector, modifier);
if (insertedId) {
Logger.info(`Set randomly selected userId=${userId} by requesterId=${requesterId} in meeitingId=${meetingId}`);
}
} catch (err) {
Logger.error(`Setting randomly selected userId=${userId} by requesterId=${requesterId} in meetingId=${meetingId}`);
}
}

View File

@ -8,6 +8,7 @@ import toggleUserLock from './methods/toggleUserLock';
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
import userActivitySign from './methods/userActivitySign';
import userLeftMeeting from './methods/userLeftMeeting';
import setRandomUser from './methods/setRandomUser';
Meteor.methods({
setEmojiStatus,
@ -19,4 +20,5 @@ Meteor.methods({
setUserEffectiveConnectionType,
userActivitySign,
userLeftMeeting,
setRandomUser,
});

View File

@ -0,0 +1,17 @@
import { Meteor } from 'meteor/meteor';
import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function setRandomUser() {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'SelectRandomViewerReqMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
const payload = {
requestedBy: requesterUserId,
};
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -12,6 +12,7 @@ import { withModalMounter } from '/imports/ui/components/modal/service';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component';
import ExternalVideoModal from '/imports/ui/components/external-video-player/modal/container';
import RandomUserSelectContainer from '/imports/ui/components/modal/random-user/container';
import cx from 'classnames';
import { styles } from '../styles';
@ -75,6 +76,14 @@ const intlMessages = defineMessages({
id: 'app.actionsBar.actionsDropdown.stopShareExternalVideo',
description: 'Stop sharing external video button',
},
selectRandUserLabel: {
id: 'app.actionsBar.actionsDropdown.selectRandUserLabel',
description: 'Label for selecting a random user',
},
selectRandUserDesc: {
id: 'app.actionsBar.actionsDropdown.selectRandUserDesc',
description: 'Description for select random user option',
},
});
const handlePresentationClick = () => Session.set('showUploadPresentationView', true);
@ -86,6 +95,7 @@ class ActionsDropdown extends PureComponent {
this.presentationItemId = _.uniqueId('action-item-');
this.pollId = _.uniqueId('action-item-');
this.takePresenterId = _.uniqueId('action-item-');
this.selectUserRandId = _.uniqueId('action-item-');
this.handleExternalVideoClick = this.handleExternalVideoClick.bind(this);
this.makePresentationItems = this.makePresentationItems.bind(this);
@ -108,6 +118,7 @@ class ActionsDropdown extends PureComponent {
isSharingVideo,
isPollingEnabled,
stopExternalVideoShare,
mountModal,
} = this.props;
const {
@ -177,6 +188,17 @@ class ActionsDropdown extends PureComponent {
/>
)
: null),
(amIPresenter
? (
<DropdownListItem
icon="user"
label={intl.formatMessage(intlMessages.selectRandUserLabel)}
description={intl.formatMessage(intlMessages.selectRandUserDesc)}
key={this.selectUserRandId}
onClick={() => mountModal(<RandomUserSelectContainer isSelectedUser={false} />)}
/>
)
: null),
]);
}

View File

@ -22,6 +22,7 @@ import StatusNotifier from '/imports/ui/components/status-notifier/container';
import MediaService from '/imports/ui/components/media/service';
import ManyWebcamsNotifier from '/imports/ui/components/video-provider/many-users-notify/container';
import UploaderContainer from '/imports/ui/components/presentation/presentation-uploader/container';
import RandomUserSelectContainer from '/imports/ui/components/modal/random-user/container';
import { withDraggableContext } from '../media/webcam-draggable-overlay/context';
import { styles } from './styles';
import { NAVBAR_HEIGHT } from '/imports/ui/components/layout/layout-manager';
@ -154,9 +155,18 @@ class App extends Component {
componentDidUpdate(prevProps) {
const {
meetingMuted, notify, currentUserEmoji, intl, hasPublishedPoll,
meetingMuted,
notify,
currentUserEmoji,
intl,
hasPublishedPoll,
randomlySelectedUser,
currentUserId,
mountModal,
} = this.props;
if (randomlySelectedUser === currentUserId) mountModal(<RandomUserSelectContainer />);
if (prevProps.currentUserEmoji.status !== currentUserEmoji.status) {
const formattedEmojiStatus = intl.formatMessage({ id: `app.actionsBar.emojiMenu.${currentUserEmoji.status}Label` })
|| currentUserEmoji.status;

View File

@ -91,10 +91,10 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
},
});
const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { approved: 1, emoji: 1 } });
const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { approved: 1, emoji: 1, userId: 1 } });
const currentMeeting = Meetings.findOne({ meetingId: Auth.meetingID },
{ fields: { publishedPoll: 1, voiceProp: 1 } });
const { publishedPoll, voiceProp } = currentMeeting;
{ fields: { publishedPoll: 1, voiceProp: 1, randomlySelectedUser: 1 } });
const { publishedPoll, voiceProp, randomlySelectedUser } = currentMeeting;
if (!currentUser.approved) {
baseControls.updateLoadingState(intl.formatMessage(intlMessages.waitingApprovalMessage));
@ -122,6 +122,8 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
hasPublishedPoll: publishedPoll,
startBandwidthMonitoring,
handleNetworkConnection: () => updateNavigatorConnection(navigator.connection),
randomlySelectedUser,
currentUserId: currentUser.userId,
};
})(AppContainer)));

View File

@ -0,0 +1,122 @@
import React, { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import Modal from '/imports/ui/components/modal/simple/component';
import Button from '/imports/ui/components/button/component';
import { styles } from './styles';
const messages = defineMessages({
noViewers: {
id: 'app.modal.randomUser.noViewers.description',
description: 'Label displayed when no viewers are avaiable',
},
selected: {
id: 'app.modal.randomUser.selected.description',
description: 'Label shown to the selected user',
},
randUserTitle: {
id: 'app.modal.randomUser.title',
description: 'Modal title label',
},
reselect: {
id: 'app.modal.randomUser.reselect.label',
description: 'select new random user button label',
},
ariaModalTitle: {
id: 'app.modal.randomUser.ariaLabel.title',
description: 'modal title displayed to screen reader',
},
});
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
mountModal: PropTypes.func.isRequired,
numAvailableViewers: PropTypes.number.isRequired,
randomUserReq: PropTypes.func.isRequired,
};
class RandomUserSelect extends Component {
constructor(props) {
super(props);
if (props.currentUser.presenter) {
props.randomUserReq();
}
}
componentDidUpdate() {
const { selectedUser, currentUser, mountModal } = this.props;
if (selectedUser && selectedUser.userId !== currentUser.userId && !currentUser.presenter) {
mountModal(null);
}
}
render() {
const {
intl,
mountModal,
numAvailableViewers,
randomUserReq,
selectedUser,
currentUser,
clearRandomlySelectedUser,
} = this.props;
if (!selectedUser) return null;
const isSelectedUser = currentUser.userId === selectedUser.userId;
const viewElement = numAvailableViewers < 1 ? (
<div className={styles.modalViewContainer}>
<div className={styles.modalViewTitle}>
{intl.formatMessage(messages.randUserTitle)}
</div>
<div>{intl.formatMessage(messages.noViewers)}</div>
</div>
) : (
<div className={styles.modalViewContainer}>
<div className={styles.modalViewTitle}>
{isSelectedUser
? `${intl.formatMessage(messages.selected)}`
: `${intl.formatMessage(messages.randUserTitle)}`
}
</div>
<div aria-hidden className={styles.modalAvatar} style={{ backgroundColor: `${selectedUser.color}` }}>
{selectedUser.name.slice(0, 2)}
</div>
<div className={styles.selectedUserName}>
{selectedUser.name}
</div>
{!isSelectedUser
&& (
<Button
label={intl.formatMessage(messages.reselect)}
color="primary"
size="md"
className={styles.selectBtn}
onClick={() => randomUserReq()}
/>
)
}
</div>
);
return (
<Modal
hideBorder
onRequestClose={() => {
if (currentUser.presenter) clearRandomlySelectedUser();
mountModal(null);
}}
contentLabel={intl.formatMessage(messages.ariaModalTitle)}
>
{viewElement}
</Modal>
);
}
}
RandomUserSelect.propTypes = propTypes;
export default injectIntl(RandomUserSelect);

View File

@ -0,0 +1,58 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import { withModalMounter } from '/imports/ui/components/modal/service';
import { makeCall } from '/imports/ui/services/api';
import RandomUserSelect from './component';
const RandomUserSelectContainer = props => <RandomUserSelect {...props} />;
export default withModalMounter(withTracker(({ mountModal }) => {
const viewerPool = Users.find({
meetingId: Auth.meetingID,
presenter: { $ne: true },
role: { $eq: 'VIEWER' },
}, {
fields: {
userId: 1,
},
}).fetch();
const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, {
fields: {
randomlySelectedUser: 1,
},
});
const selectedUser = Users.findOne({
meetingId: Auth.meetingID,
userId: meeting.randomlySelectedUser,
}, {
fields: {
userId: 1,
avatar: 1,
color: 1,
name: 1,
},
});
const currentUser = Users.findOne(
{ userId: Auth.userID },
{ fields: { userId: 1, presenter: 1 } },
);
const randomUserReq = () => makeCall('setRandomUser');
const clearRandomlySelectedUser = () => makeCall('clearRandomlySelectedUser');
return ({
closeModal: () => mountModal(null),
numAvailableViewers: viewerPool.length,
randomUserReq,
selectedUser,
currentUser,
clearRandomlySelectedUser,
});
})(RandomUserSelectContainer));

View File

@ -0,0 +1,41 @@
.modalViewContainer {
display: flex;
flex-flow: column;
align-items: center;
}
.modalViewTitle {
font-weight: 600;
font-size: var(--font-size-large);
margin-bottom: var(--md-padding-x);
}
.modalAvatar {
height: 6rem;
width: 6rem;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: var(--font-size-xxl);
font-weight: 400;
margin-bottom: var(--sm-padding-x);
text-transform: capitalize;
}
.selectedUserName {
margin-bottom: var(--md-padding-x);;
font-weight: var(--headings-font-weight);
font-size: 2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
width: 100%;
text-align: center;
}
.selectBtn {
margin-bottom: var(--md-padding-x);
}

View File

@ -6,6 +6,7 @@
--font-family-base: var(--font-family-sans-serif);
--font-size-base: 1rem;
--font-size-xxl: 2.75rem;
--font-size-xl: 1.75rem;
--font-size-larger: 1.5rem;
--font-size-large: 1.25rem;

View File

@ -370,6 +370,8 @@
"app.actionsBar.actionsDropdown.captionsDesc": "Toggles captions pane",
"app.actionsBar.actionsDropdown.takePresenter": "Take presenter",
"app.actionsBar.actionsDropdown.takePresenterDesc": "Assign yourself as the new presenter",
"app.actionsBar.actionsDropdown.selectRandUserLabel": "Select Random User",
"app.actionsBar.actionsDropdown.selectRandUserDesc": "Chooses a user from available viewers at random",
"app.actionsBar.emojiMenu.statusTriggerLabel": "Set status",
"app.actionsBar.emojiMenu.awayLabel": "Away",
"app.actionsBar.emojiMenu.awayDesc": "Change your status to away",
@ -487,6 +489,11 @@
"app.modal.confirm": "Done",
"app.modal.newTab": "(opens new tab)",
"app.modal.confirm.description": "Saves changes and closes the modal",
"app.modal.randomUser.noViewers.description": "No viewers available to randomly select from",
"app.modal.randomUser.selected.description": "You have been randomly selected",
"app.modal.randomUser.title": "Randomly selected user",
"app.modal.randomUser.reselect.label": "Select again",
"app.modal.randomUser.ariaLabel.title": "Randomly selected User Modal",
"app.dropdown.close": "Close",
"app.dropdown.list.item.activeLabel": "Active",
"app.error.400": "Bad Request",