Merge remote-tracking branch 'upstream/master' into chat-old-messages-re-render

This commit is contained in:
João Francisco Siebel 2018-12-18 15:48:11 -02:00
commit 8ecab69821
72 changed files with 933 additions and 1797 deletions

View File

@ -2,15 +2,18 @@ BigBlueButton
=============
BigBlueButton is an open source web conferencing system.
BigBlueButton supports real-time sharing of audio, video, slides (with whiteboard controls), chat, and the screen. Instructors can engage remote students with polling, emojis, and breakout rooms. BigBlueButton can record and playback all content shared in a session.
BigBlueButton supports real-time sharing of audio, video, slides (with whiteboard controls), chat, and the screen. Instructors can engage remote students with polling, emojis, multi-user whiteboard, and breakout rooms.
Presenters can record and playback content for later sharing with others.
We designed BigBlueButton for online learning (though it can be used for many [other applications](http://www.c4isrnet.com/story/military-tech/disa/2015/02/11/disa-to-save-12m-defense-collaboration-services/23238997/)). The educational use cases for BigBlueButton are
* One-to-one on-line tutoring
* Small group collaboration
* On-line classes
* Online tutoring (one-to-one)
* Flipped classrooms (recording content ahead of your session)
* Group collaboration (many-to-many)
* Online classes (one-to-many)
BigBlueButton runs on a Ubuntu 16.04 64-bit server. If you follow the [installation instructions](http://docs.bigbluebutton.org/install/install.html), we guarantee you will have BigBlueButton installed and running within 30 minutes (or your money back :-).
You can install on a Ubuntu 16.04 64-bit server. We provide [bbb-install.sh](https://github.com/bigbluebutton/bbb-install) to let you have a server up and running within 30 minutes (or your money back :-).
For full technical documentation BigBlueButton -- including architecture, features, API, and GreenLight (the default front-end) -- see [http://docs.bigbluebutton.org/](http://docs.bigbluebutton.org/).

View File

@ -10,7 +10,7 @@
}
body {
position: absolute;
position: fixed;
height: 100%;
font-family: 'Source Sans Pro', Arial, sans-serif;
font-size: 1rem; /* 16px */

View File

@ -8,10 +8,15 @@ function breakouts(credentials, moderator) {
requesterUserId,
} = credentials;
Logger.info(`Publishing Breakouts for ${meetingId} ${requesterUserId}`);
if (moderator) {
const presenterSelector = {
parentMeetingId: meetingId,
$or: [
{ parentMeetingId: meetingId },
{ breakoutId: meetingId },
],
};
return Breakouts.find(presenterSelector);
}

View File

@ -47,9 +47,9 @@ class Base extends Component {
componentWillUpdate() {
const { approved } = this.props;
const isLoading = this.state.loading;
const { loading } = this.state;
if (approved && isLoading) this.updateLoadingState(false);
if (approved && loading) this.updateLoadingState(false);
}
updateLoadingState(loading = false) {
@ -131,7 +131,8 @@ const BaseContainer = withTracker(() => {
},
};
const subscriptionsHandlers = SUBSCRIPTIONS_NAME.map(name => Meteor.subscribe(name, credentials, subscriptionErrorHandler));
const subscriptionsHandlers = SUBSCRIPTIONS_NAME
.map(name => Meteor.subscribe(name, credentials, subscriptionErrorHandler));
const chats = GroupChat.find({
$or: [
@ -147,7 +148,8 @@ const BaseContainer = withTracker(() => {
const chatIds = chats.map(chat => chat.chatId);
const groupChatMessageHandler = Meteor.subscribe('group-chat-msg', credentials, chatIds, subscriptionErrorHandler);
const User = Users.findOne({ intId: credentials.externUserID });
const User = Users.findOne({ intId: credentials.requesterUserId });
if (User) {
const mappedUser = mapUser(User);
breakoutRoomSubscriptionHandler = Meteor.subscribe('breakouts', credentials, mappedUser.isModerator, subscriptionErrorHandler);

View File

@ -20,6 +20,17 @@ const propTypes = {
isUserPresenter: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
mountModal: PropTypes.func.isRequired,
isUserModerator: PropTypes.bool.isRequired,
allowStartStopRecording: PropTypes.bool.isRequired,
isRecording: PropTypes.bool.isRequired,
record: PropTypes.func.isRequired,
toggleRecording: PropTypes.func.isRequired,
meetingIsBreakout: PropTypes.bool.isRequired,
hasBreakoutRoom: PropTypes.bool.isRequired,
createBreakoutRoom: PropTypes.func.isRequired,
meetingName: PropTypes.string.isRequired,
shortcuts: PropTypes.string.isRequired,
users: PropTypes.arrayOf(PropTypes.object).isRequired,
};
const intlMessages = defineMessages({
@ -116,20 +127,37 @@ class ActionsDropdown extends Component {
isRecording,
record,
toggleRecording,
togglePollMenu,
meetingIsBreakout,
hasBreakoutRoom,
} = this.props;
const {
pollBtnLabel,
pollBtnDesc,
presentationLabel,
presentationDesc,
startRecording,
stopRecording,
createBreakoutRoom,
createBreakoutRoomDesc,
} = intlMessages;
const {
formatMessage,
} = intl;
return _.compact([
(isUserPresenter
? (
<DropdownListItem
icon="user"
label={intl.formatMessage(intlMessages.pollBtnLabel)}
description={intl.formatMessage(intlMessages.pollBtnDesc)}
label={formatMessage(pollBtnLabel)}
description={formatMessage(pollBtnDesc)}
key={this.pollId}
onClick={() => togglePollMenu()}
onClick={() => {
Session.set('openPanel', 'poll');
Session.set('forcePollOpen', true);
}}
/>
)
: null),
@ -138,8 +166,8 @@ class ActionsDropdown extends Component {
<DropdownListItem
data-test="uploadPresentation"
icon="presentation"
label={intl.formatMessage(intlMessages.presentationLabel)}
description={intl.formatMessage(intlMessages.presentationDesc)}
label={formatMessage(presentationLabel)}
description={formatMessage(presentationDesc)}
key={this.presentationItemId}
onClick={this.handlePresentationClick}
/>
@ -149,10 +177,10 @@ class ActionsDropdown extends Component {
? (
<DropdownListItem
icon="record"
label={intl.formatMessage(isRecording
? intlMessages.stopRecording : intlMessages.startRecording)}
description={intl.formatMessage(isRecording
? intlMessages.stopRecording : intlMessages.startRecording)}
label={formatMessage(isRecording
? stopRecording : startRecording)}
description={formatMessage(isRecording
? stopRecording : startRecording)}
key={this.recordId}
onClick={toggleRecording}
/>
@ -162,8 +190,8 @@ class ActionsDropdown extends Component {
? (
<DropdownListItem
icon="rooms"
label={intl.formatMessage(intlMessages.createBreakoutRoom)}
description={intl.formatMessage(intlMessages.createBreakoutRoomDesc)}
label={formatMessage(createBreakoutRoom)}
description={formatMessage(createBreakoutRoomDesc)}
key={this.createBreakoutRoomId}
onClick={this.handleCreateBreakoutRoomClick}
/>
@ -173,7 +201,8 @@ class ActionsDropdown extends Component {
}
handlePresentationClick() {
this.props.mountModal(<PresentationUploaderContainer />);
const { mountModal } = this.props;
mountModal(<PresentationUploaderContainer />);
}
handleCreateBreakoutRoomClick() {
@ -189,7 +218,8 @@ class ActionsDropdown extends Component {
createBreakoutRoom={createBreakoutRoom}
meetingName={meetingName}
users={users}
/>);
/>,
);
}
render() {

View File

@ -18,7 +18,6 @@ class ActionsBar extends React.PureComponent {
isUserModerator,
recordSettingsList,
toggleRecording,
togglePollMenu,
screenSharingCheck,
enableVideo,
createBreakoutRoom,
@ -48,7 +47,6 @@ class ActionsBar extends React.PureComponent {
isRecording,
record,
toggleRecording,
togglePollMenu,
createBreakoutRoom,
meetingIsBreakout,
hasBreakoutRoom,
@ -57,21 +55,27 @@ class ActionsBar extends React.PureComponent {
}}
/>
</div>
<div className={isUserPresenter ? cx(styles.centerWithActions, actionBarClasses) : styles.center}>
<div
className={
isUserPresenter ? cx(styles.centerWithActions, actionBarClasses) : styles.center
}
>
<AudioControlsContainer />
{enableVideo ?
<JoinVideoOptionsContainer
handleJoinVideo={handleJoinVideo}
handleCloseVideo={handleExitVideo}
/>
{enableVideo
? (
<JoinVideoOptionsContainer
handleJoinVideo={handleJoinVideo}
handleCloseVideo={handleExitVideo}
/>
)
: null}
<DesktopShare {...{
handleShareScreen,
handleUnshareScreen,
isVideoBroadcasting,
isUserPresenter,
screenSharingCheck,
}}
handleShareScreen,
handleUnshareScreen,
isVideoBroadcasting,
isUserPresenter,
screenSharingCheck,
}}
/>
</div>
</div>

View File

@ -1,6 +1,5 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { Session } from 'meteor/session';
import getFromUserSettings from '/imports/ui/services/users-settings';
import Meetings from '/imports/api/meetings';
import Auth from '/imports/ui/services/auth';
@ -12,23 +11,6 @@ import { shareScreen, unshareScreen, isVideoBroadcasting } from '../screenshare/
const ActionsBarContainer = props => <ActionsBar {...props} />;
export default withTracker(() => {
const togglePollMenu = () => {
const showPoll = Session.equals('isPollOpen', false) || !Session.get('isPollOpen');
const show = () => {
Session.set('isUserListOpen', true);
Session.set('isPollOpen', true);
Session.set('forcePollOpen', true);
};
const hide = () => Session.set('isPollOpen', false);
Session.set('isChatOpen', false);
Session.set('breakoutRoomIsOpen', false);
return showPoll ? show() : hide();
};
Meetings.find({ meetingId: Auth.meetingID }).observeChanges({
changed: (id, fields) => {
if (fields.recordProp && fields.recordProp.recording) {
@ -57,7 +39,6 @@ export default withTracker(() => {
meetingIsBreakout: Service.meetingIsBreakout(),
hasBreakoutRoom: Service.hasBreakoutRoom(),
meetingName: Service.meetingName(),
togglePollMenu,
users: Service.users(),
};
})(ActionsBarContainer);

View File

@ -3,12 +3,9 @@ import PropTypes from 'prop-types';
import { throttle } from 'lodash';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import Modal from 'react-modal';
import cx from 'classnames';
import Resizable from 're-resizable';
import browser from 'browser-detect';
import BreakoutRoomContainer from '/imports/ui/components/breakout-room/container';
import PanelManager from '/imports/ui/components/panel-manager/component';
import PollingContainer from '/imports/ui/components/polling/container';
import PollContainer from '/imports/ui/components/poll/container';
import logger from '/imports/startup/client/logger';
import ToastContainer from '../toast/container';
import ModalContainer from '../modal/container';
@ -16,12 +13,8 @@ import NotificationsBarContainer from '../notifications-bar/container';
import AudioContainer from '../audio/container';
import ChatAlertContainer from '../chat/alert/container';
import { styles } from './styles';
import UserListContainer from '../user-list/container';
import ChatContainer from '../chat/container';
const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
const USERLIST_COMPACT_WIDTH = 50;
const intlMessages = defineMessages({
userListLabel: {
@ -51,7 +44,6 @@ const propTypes = {
closedCaption: PropTypes.element,
userListIsOpen: PropTypes.bool.isRequired,
chatIsOpen: PropTypes.bool.isRequired,
pollIsOpen: PropTypes.bool.isRequired,
locale: PropTypes.string,
intl: intlShape.isRequired,
};
@ -71,7 +63,6 @@ class App extends Component {
super();
this.state = {
compactUserList: false,
enableResize: !window.matchMedia(MOBILE_MEDIA).matches,
};
@ -79,11 +70,11 @@ class App extends Component {
}
componentDidMount() {
const { locale } = this.props;
const { locale, fontSize } = this.props;
Modal.setAppElement('#app');
document.getElementsByTagName('html')[0].lang = locale;
document.getElementsByTagName('html')[0].style.fontSize = this.props.fontSize;
document.getElementsByTagName('html')[0].style.fontSize = fontSize;
const BROWSER_RESULTS = browser();
const body = document.getElementsByTagName('body')[0];
@ -112,15 +103,17 @@ class App extends Component {
this.setState({ enableResize: shouldEnableResize });
}
renderPoll() {
const { pollIsOpen } = this.props;
if (!pollIsOpen) return null;
renderPanel() {
const { enableResize } = this.state;
const { openPanel } = this.props;
return (
<div className={styles.poll}>
<PollContainer />
</div>
<PanelManager
{...{
openPanel,
enableResize,
}}
/>
);
}
@ -160,136 +153,6 @@ class App extends Component {
);
}
renderUserList() {
const {
intl, chatIsOpen, userListIsOpen,
} = this.props;
const { compactUserList } = this.state;
if (!userListIsOpen) return null;
const userListStyle = {};
userListStyle[styles.compact] = compactUserList;
// userList = React.cloneElement(userList, {
// compact: compactUserList, // TODO 4767
// });
return (
<div
className={cx(styles.userList, userListStyle)}
aria-label={intl.formatMessage(intlMessages.userListLabel)}
aria-hidden={chatIsOpen}
>
<UserListContainer />
</div>
);
}
renderBreakoutRoom() {
const { hasBreakoutRooms, breakoutRoomIsOpen } = this.props;
if (!breakoutRoomIsOpen) return null;
if (!hasBreakoutRooms) return null;
return (
<div className={styles.breakoutRoom}>
<BreakoutRoomContainer />
</div>
);
}
renderUserListResizable() {
const { userListIsOpen } = this.props;
// Variables for resizing user-list.
const USERLIST_MIN_WIDTH_PX = 150;
const USERLIST_MAX_WIDTH_PX = 240;
const USERLIST_DEFAULT_WIDTH_RELATIVE = 18;
// decide whether using pixel or percentage unit as a default width for userList
const USERLIST_DEFAULT_WIDTH = (window.innerWidth * (USERLIST_DEFAULT_WIDTH_RELATIVE / 100.0)) < USERLIST_MAX_WIDTH_PX ? `${USERLIST_DEFAULT_WIDTH_RELATIVE}%` : USERLIST_MAX_WIDTH_PX;
if (!userListIsOpen) return null;
const resizableEnableOptions = {
top: false,
right: true,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
defaultSize={{ width: USERLIST_DEFAULT_WIDTH }}
minWidth={USERLIST_MIN_WIDTH_PX}
maxWidth={USERLIST_MAX_WIDTH_PX}
ref={(node) => { this.resizableUserList = node; }}
enable={resizableEnableOptions}
onResize={(e, direction, ref) => {
const { compactUserList } = this.state;
const shouldBeCompact = ref.clientWidth <= USERLIST_COMPACT_WIDTH;
if (compactUserList === shouldBeCompact) return;
this.setState({ compactUserList: shouldBeCompact });
}}
>
{this.renderUserList()}
</Resizable>
);
}
renderChat() {
const { intl, chatIsOpen } = this.props;
if (!chatIsOpen) return null;
return (
<section
className={styles.chat}
aria-label={intl.formatMessage(intlMessages.chatLabel)}
>
<ChatContainer />
</section>
);
}
renderChatResizable() {
const { chatIsOpen } = this.props;
// Variables for resizing chat.
const CHAT_MIN_WIDTH = '10%';
const CHAT_MAX_WIDTH = '25%';
const CHAT_DEFAULT_WIDTH = '15%';
if (!chatIsOpen) return null;
const resizableEnableOptions = {
top: false,
right: true,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
defaultSize={{ width: CHAT_DEFAULT_WIDTH }}
minWidth={CHAT_MIN_WIDTH}
maxWidth={CHAT_MAX_WIDTH}
ref={(node) => { this.resizableChat = node; }}
enable={resizableEnableOptions}
>
{this.renderChat()}
</Resizable>
);
}
renderMedia() {
const {
media, intl, chatIsOpen, userListIsOpen,
@ -329,9 +192,8 @@ class App extends Component {
render() {
const {
userListIsOpen, customStyle, customStyleUrl, micsLocked,
customStyle, customStyleUrl, micsLocked,
} = this.props;
const { enableResize } = this.state;
return (
<main className={styles.main}>
@ -342,11 +204,7 @@ class App extends Component {
{this.renderMedia()}
{this.renderActionsBar()}
</div>
{enableResize ? this.renderUserListResizable() : this.renderUserList()}
{userListIsOpen && enableResize ? <div className={styles.userlistPad} /> : null}
{enableResize ? this.renderChatResizable() : this.renderChat()}
{this.renderPoll()}
{this.renderBreakoutRoom()}
{this.renderPanel()}
{this.renderSidebar()}
</section>
<PollingContainer />

View File

@ -67,7 +67,6 @@ const AppContainer = (props) => {
);
};
export default injectIntl(withModalMounter(withTracker(({ intl, baseControls }) => {
const currentUser = Users.findOne({ userId: Auth.userID });
const currentUserIsLocked = mapUser(currentUser).isLocked;
@ -111,12 +110,12 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
closedCaption: getCaptionsStatus() ? <ClosedCaptionsContainer /> : null,
fontSize: getFontSize(),
hasBreakoutRooms: getBreakoutRooms().length > 0,
userListIsOpen: Session.get('isUserListOpen'),
breakoutRoomIsOpen: Session.get('breakoutRoomIsOpen') && Session.get('isUserListOpen'),
chatIsOpen: Session.get('isChatOpen') && Session.get('isUserListOpen'),
pollIsOpen: Session.get('isPollOpen') && Session.get('isUserListOpen'),
customStyle: getFromUserSettings('customStyle', false),
customStyleUrl: getFromUserSettings('customStyleUrl', false),
breakoutRoomIsOpen: Session.equals('openPanel', 'breakoutroom'),
chatIsOpen: Session.equals('openPanel', 'chat'),
openPanel: Session.get('openPanel'),
userListIsOpen: !Session.equals('openPanel', ''),
micsLocked: (currentUserIsLocked && meeting.lockSettingsProp.disableMic),
};
})(AppContainer)));

View File

@ -12,9 +12,8 @@
}
.main {
position: fixed;
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}

View File

@ -25,9 +25,11 @@ const breakoutRoomUser = (breakoutId) => {
return breakoutUser;
};
const closeBreakoutPanel = () => Session.set('openPanel', 'userlist');
const endAllBreakouts = () => {
makeCall('endAllBreakouts');
closeBreakoutPanel();
};
const requestJoinURL = (breakoutId) => {
@ -63,9 +65,6 @@ const isModerator = () => {
return mappedUser.isModerator;
};
const closeBreakoutPanel = () => Session.set('breakoutRoomIsOpen', false);
export default {
findBreakouts,
endAllBreakouts,

View File

@ -23,10 +23,9 @@ class ChatPushAlert extends PureComponent {
aria-label={message}
tabIndex={0}
onClick={() => {
Session.set('isUserListOpen', true);
Session.set('isChatOpen', true);
Session.set('idChatOpen', chatId);
}}
Session.set('openPanel', 'chat');
Session.set('idChatOpen', chatId);
}}
>
{ message }
</div>

View File

@ -56,7 +56,7 @@ const Chat = (props) => {
>
<Button
onClick={() => {
Session.set('isChatOpen', false);
Session.set('openPanel', 'userlist');
}}
aria-label={intl.formatMessage(intlMessages.hideChatLabel, { 0: title })}
accessKey={HIDE_CHAT_AK}
@ -66,23 +66,23 @@ const Chat = (props) => {
/>
</div>
{
chatID !== 'public' ?
<Button
className={styles.closeBtn}
icon="close"
size="md"
hideLabel
onClick={() => {
actions.handleClosePrivateChat(chatID);
Session.set('isChatOpen', false);
Session.set('idChatOpen', '');
}}
aria-label={intl.formatMessage(intlMessages.closeChatLabel, { 0: title })}
label={intl.formatMessage(intlMessages.closeChatLabel, { 0: title })}
accessKey={CLOSE_CHAT_AK}
/>
:
<ChatDropdown />
chatID !== 'public'
? (
<Button
className={styles.closeBtn}
icon="close"
size="md"
hideLabel
onClick={() => {
actions.handleClosePrivateChat(chatID);
Session.set('openPanel', 'userlist');
}}
aria-label={intl.formatMessage(intlMessages.closeChatLabel, { 0: title })}
label={intl.formatMessage(intlMessages.closeChatLabel, { 0: title })}
accessKey={CLOSE_CHAT_AK}
/>
)
: <ChatDropdown />
}
</header>
<MessageList
@ -122,6 +122,7 @@ const propTypes = {
PropTypes.object,
])).isRequired).isRequired,
scrollPosition: PropTypes.number,
shortcuts: PropTypes.string.isRequired,
hasUnreadMessages: PropTypes.bool.isRequired,
lastReadMessageTime: PropTypes.number.isRequired,
partnerIsLoggedOut: PropTypes.bool.isRequired,

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { Session } from 'meteor/session';
import PropTypes from 'prop-types';
import Auth from '/imports/ui/services/auth';
import { setCustomLogoUrl } from '/imports/ui/components/user-list/service';
import { makeCall } from '/imports/ui/services/api';
@ -7,6 +8,9 @@ import deviceInfo from '/imports/utils/deviceInfo';
import logger from '/imports/startup/client/logger';
import LoadingScreen from '/imports/ui/components/loading-screen/component';
const propTypes = {
children: PropTypes.element.isRequired,
};
class JoinHandler extends Component {
static setError(codeError) {
@ -99,7 +103,6 @@ class JoinHandler extends Component {
};
// use enter api to get params for the client
const url = `/bigbluebutton/api/enter?sessionToken=${sessionToken}`;
const fetchContent = await fetch(url, { credentials: 'same-origin' });
const parseToJson = await fetchContent.json();
const { response } = parseToJson;
@ -109,7 +112,11 @@ class JoinHandler extends Component {
await setCustomData(response);
setLogoURL(response);
logUserInfo();
Session.set('isUserListOpen', deviceInfo.type().isPhone);
Session.set('openPanel', 'chat');
Session.set('idChatOpen', '');
if (deviceInfo.type().isPhone) Session.set('openPanel', '');
logger.info(`User successfully went through main.joinRouteHandler with [${JSON.stringify(response)}].`);
} else {
const e = new Error('Session not found');
@ -128,3 +135,5 @@ class JoinHandler extends Component {
}
export default JoinHandler;
JoinHandler.propTypes = propTypes;

View File

@ -24,7 +24,7 @@ export default class Media extends Component {
render() {
const {
swapLayout, floatingOverlay, hideOverlay, disableVideo,
swapLayout, floatingOverlay, hideOverlay, disableVideo, children,
} = this.props;
const contentClassName = cx({
@ -40,7 +40,7 @@ export default class Media extends Component {
return (
<div className={styles.container}>
<div className={!swapLayout ? contentClassName : overlayClassName}>
{this.props.children}
{children}
</div>
<div className={!swapLayout ? overlayClassName : contentClassName}>
{ !disableVideo ? <VideoProviderContainer /> : null }

View File

@ -1,5 +1,6 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Session } from 'meteor/session';
import _ from 'lodash';
import cx from 'classnames';
import Auth from '/imports/ui/services/auth';
@ -48,7 +49,7 @@ const intlMessages = defineMessages({
const propTypes = {
presentationTitle: PropTypes.string,
hasUnreadMessages: PropTypes.bool,
beingRecorded: PropTypes.object,
beingRecorded: PropTypes.bool,
shortcuts: PropTypes.string,
};
@ -59,10 +60,12 @@ const defaultProps = {
shortcuts: '',
};
const openBreakoutJoinConfirmation = (breakout, breakoutName, mountModal) => mountModal(<BreakoutJoinConfirmation
breakout={breakout}
breakoutName={breakoutName}
/>);
const openBreakoutJoinConfirmation = (breakout, breakoutName, mountModal) => mountModal(
<BreakoutJoinConfirmation
breakout={breakout}
breakoutName={breakoutName}
/>,
);
const closeBreakoutJoinConfirmation = mountModal => mountModal(null);
@ -85,6 +88,10 @@ class NavBar extends PureComponent {
mountModal,
} = this.props;
const {
didSendBreakoutInvite,
} = this.state;
const hadBreakouts = oldProps.breakouts.length;
const hasBreakouts = breakouts.length;
@ -101,18 +108,23 @@ class NavBar extends PureComponent {
if (!userOnMeeting) return;
if (!this.state.didSendBreakoutInvite && !isBreakoutRoom) {
if (!didSendBreakoutInvite && !isBreakoutRoom) {
this.inviteUserToBreakout(breakout);
}
});
if (!breakouts.length && this.state.didSendBreakoutInvite) {
if (!breakouts.length && didSendBreakoutInvite) {
this.setState({ didSendBreakoutInvite: false });
}
}
handleToggleUserList() {
this.props.toggleUserList();
Session.set(
'openPanel',
Session.get('openPanel') !== ''
? ''
: 'userlist',
);
}
inviteUserToBreakout(breakout) {
@ -132,6 +144,10 @@ class NavBar extends PureComponent {
presentationTitle,
} = this.props;
const {
isActionsOpen,
} = this.state;
if (isBreakoutRoom || !breakouts.length) {
return (
<h1 className={styles.presentationTitle}>{presentationTitle}</h1>
@ -140,7 +156,7 @@ class NavBar extends PureComponent {
const breakoutItems = breakouts.map(breakout => this.renderBreakoutItem(breakout));
return (
<Dropdown isOpen={this.state.isActionsOpen}>
<Dropdown isOpen={isActionsOpen}>
<DropdownTrigger>
<h1 className={cx(styles.presentationTitle, styles.dropdownBreakout)}>
{presentationTitle}
@ -170,7 +186,9 @@ class NavBar extends PureComponent {
<DropdownListItem
key={_.uniqueId('action-header')}
label={breakoutName}
onClick={openBreakoutJoinConfirmation.bind(this, breakout, breakoutName, mountModal)}
onClick={
openBreakoutJoinConfirmation.bind(this, breakout, breakoutName, mountModal)
}
/>
);
}

View File

@ -0,0 +1,198 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import BreakoutRoomContainer from '/imports/ui/components/breakout-room/container';
import UserListContainer from '/imports/ui/components/user-list/container';
import ChatContainer from '/imports/ui/components/chat/container';
import PollContainer from '/imports/ui/components/poll/container';
import { defineMessages, injectIntl } from 'react-intl';
import Resizable from 're-resizable';
import { styles } from '/imports/ui/components/app/styles';
import _ from 'lodash';
const intlMessages = defineMessages({
chatLabel: {
id: 'app.chat.label',
description: 'Aria-label for Chat Section',
},
userListLabel: {
id: 'app.userList.label',
description: 'Aria-label for Userlist Nav',
},
});
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
enableResize: PropTypes.bool.isRequired,
openPanel: PropTypes.string.isRequired,
};
class PanelManager extends Component {
constructor() {
super();
this.padKey = _.uniqueId('resize-pad-');
this.userlistKey = _.uniqueId('userlist-');
this.breakoutroomKey = _.uniqueId('breakoutroom-');
this.chatKey = _.uniqueId('chat-');
this.pollKey = _.uniqueId('poll-');
}
renderUserList() {
const {
intl,
enableResize,
} = this.props;
return (
<div
className={styles.userList}
aria-label={intl.formatMessage(intlMessages.userListLabel)}
key={enableResize ? null : this.userlistKey}
>
<UserListContainer />
</div>
);
}
renderUserListResizable() {
// Variables for resizing user-list.
const USERLIST_MIN_WIDTH_PX = 150;
const USERLIST_MAX_WIDTH_PX = 240;
const USERLIST_DEFAULT_WIDTH_RELATIVE = 18;
// decide whether using pixel or percentage unit as a default width for userList
const USERLIST_DEFAULT_WIDTH = (window.innerWidth * (USERLIST_DEFAULT_WIDTH_RELATIVE / 100.0)) < USERLIST_MAX_WIDTH_PX ? `${USERLIST_DEFAULT_WIDTH_RELATIVE}%` : USERLIST_MAX_WIDTH_PX;
const resizableEnableOptions = {
top: false,
right: true,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
defaultSize={{ width: USERLIST_DEFAULT_WIDTH }}
minWidth={USERLIST_MIN_WIDTH_PX}
maxWidth={USERLIST_MAX_WIDTH_PX}
ref={(node) => { this.resizableUserList = node; }}
className={styles.resizableUserList}
enable={resizableEnableOptions}
key={this.userlistKey}
>
{this.renderUserList()}
</Resizable>
);
}
renderChat() {
const { intl, enableResize } = this.props;
return (
<section
className={styles.chat}
aria-label={intl.formatMessage(intlMessages.chatLabel)}
key={enableResize ? null : this.chatKey}
>
<ChatContainer />
</section>
);
}
renderChatResizable() {
// Variables for resizing chat.
const CHAT_MIN_WIDTH = '10%';
const CHAT_MAX_WIDTH = '25%';
const CHAT_DEFAULT_WIDTH = '15%';
const resizableEnableOptions = {
top: false,
right: true,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
defaultSize={{ width: CHAT_DEFAULT_WIDTH }}
minWidth={CHAT_MIN_WIDTH}
maxWidth={CHAT_MAX_WIDTH}
ref={(node) => { this.resizableChat = node; }}
className={styles.resizableChat}
enable={resizableEnableOptions}
key={this.chatKey}
>
{this.renderChat()}
</Resizable>
);
}
renderPoll() {
return (
<div className={styles.poll} key={this.pollKey}>
<PollContainer />
</div>
);
}
renderBreakoutRoom() {
return (
<div className={styles.breakoutRoom} key={this.breakoutroomKey}>
<BreakoutRoomContainer />
</div>
);
}
render() {
const { enableResize, openPanel } = this.props;
if (openPanel === '') return null;
const panels = [this.renderUserList()];
const resizablePanels = [
this.renderUserListResizable(),
<div className={styles.userlistPad} key={this.padKey} />,
];
if (openPanel === 'chat') {
if (enableResize) {
resizablePanels.push(this.renderChatResizable());
} else {
panels.push(this.renderChat());
}
}
if (openPanel === 'poll') {
if (enableResize) {
resizablePanels.push(this.renderPoll());
} else {
panels.push(this.renderPoll());
}
}
if (openPanel === 'breakoutroom') {
if (enableResize) {
resizablePanels.push(this.renderBreakoutRoom());
} else {
panels.push(this.renderBreakoutRoom());
}
}
return enableResize
? resizablePanels
: panels;
}
}
export default injectIntl(PanelManager);
PanelManager.propTypes = propTypes;

View File

@ -92,6 +92,16 @@ class Poll extends Component {
this.handleBackClick = this.handleBackClick.bind(this);
}
componentDidUpdate() {
const { currentUser } = this.props;
if (!currentUser.presenter) {
Session.set('openPanel', 'userlist');
Session.set('forcePollOpen', false);
}
}
handleInputChange(index, event) {
// This regex will replace any instance of 2 or more consecutive white spaces
// with a single white space character.
@ -100,21 +110,15 @@ class Poll extends Component {
this.setState({ customPollValues: this.inputEditor });
}
renderInputFields() {
const { intl } = this.props;
const items = [];
handleBackClick() {
const { stopPoll } = this.props;
items = _.range(1, MAX_CUSTOM_FIELDS + 1).map((ele, index) => (
<input
key={`custom-poll-${index}`}
placeholder={intl.formatMessage(intlMessages.customPlaceholder)}
className={styles.input}
onChange={event => this.handleInputChange(index, event)}
defaultValue={this.state.customPollValues[index]}
/>
));
return items;
stopPoll();
this.inputEditor = [];
this.setState({
isPolling: false,
customPollValues: this.inputEditor,
}, document.activeElement.blur());
}
toggleCustomFields() {
@ -133,7 +137,8 @@ class Poll extends Component {
const label = intl.formatMessage(
// regex removes the - to match the message id
intlMessages[type.replace(/-/g, '').toLowerCase()]);
intlMessages[type.replace(/-/g, '').toLowerCase()],
);
return (
<Button
@ -142,8 +147,8 @@ class Poll extends Component {
className={styles.pollBtn}
key={_.uniqueId('quick-poll-')}
onClick={() => {
this.setState({ isPolling: true }, () => startPoll(type));
}}
this.setState({ isPolling: true }, () => startPoll(type));
}}
/>);
});
@ -172,15 +177,25 @@ class Poll extends Component {
);
}
handleBackClick() {
const { stopPoll } = this.props;
renderInputFields() {
const { intl } = this.props;
const { customPollValues } = this.state;
let items = [];
stopPoll();
this.inputEditor = [];
this.setState({
isPolling: false,
customPollValues: this.inputEditor,
}, document.activeElement.blur());
items = _.range(1, MAX_CUSTOM_FIELDS + 1).map((ele, index) => {
const id = index;
return (
<input
key={`custom-poll-${id}`}
placeholder={intl.formatMessage(intlMessages.customPlaceholder)}
className={styles.input}
onChange={event => this.handleInputChange(id, event)}
defaultValue={customPollValues[id]}
/>
);
});
return items;
}
renderActivePollOptions() {
@ -235,9 +250,13 @@ class Poll extends Component {
render() {
const {
intl, stopPoll, currentPoll,
intl, stopPoll, currentPoll, currentUser,
} = this.props;
const { isPolling } = this.state;
if (!currentUser.presenter) return null;
return (
<div>
<header className={styles.header}>
@ -248,22 +267,19 @@ class Poll extends Component {
aria-label={intl.formatMessage(intlMessages.hidePollDesc)}
className={styles.hideBtn}
onClick={() => {
Session.set('isPollOpen', false);
Session.set('forcePollOpen', true);
Session.set('isUserListOpen', true);
Session.set('openPanel', 'userlist');
}}
/>
<Button
label={intl.formatMessage(intlMessages.closeLabel)}
onClick={() => {
if (currentPoll) {
stopPoll();
}
Session.set('isPollOpen', false);
Session.set('forcePollOpen', false);
Session.set('isUserListOpen', true);
}}
if (currentPoll) {
stopPoll();
}
Session.set('openPanel', 'userlist');
Session.set('forcePollOpen', false);
}}
className={styles.closeBtn}
icon="close"
size="sm"
@ -272,8 +288,8 @@ class Poll extends Component {
</header>
{
this.state.isPolling || !this.state.isPolling && currentPoll
? this.renderActivePollOptions() : this.renderPollOptions()
(isPolling || (!isPolling && currentPoll))
? this.renderActivePollOptions() : this.renderPollOptions()
}
</div>
);

View File

@ -1,23 +1,13 @@
import React from 'react';
import { makeCall } from '/imports/ui/services/api';
import { withTracker } from 'meteor/react-meteor-data';
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import Presentations from '/imports/api/presentations';
import PresentationAreaService from '/imports/ui/components/presentation/service';
import Poll from './component';
import Service from './service';
const PollContainer = ({ ...props }) => {
const currentUser = Users.findOne({ userId: Auth.userID });
if (currentUser.presenter) {
return (<Poll {...props} />);
}
Session.set('isPollOpen', false);
Session.set('forcePollOpen', false);
Session.set('isUserListOpen', true);
return null;
};
const PollContainer = ({ ...props }) => <Poll {...props} />;
export default withTracker(({ }) => {
Meteor.subscribe('current-poll', Auth.meetingID);

View File

@ -87,17 +87,19 @@ class LiveResult extends Component {
answers.map((obj) => {
const pct = Math.round(obj.numVotes / numRespondents * 100);
return pollStats.push(<div className={styles.main} key={_.uniqueId('stats-')}>
<div className={styles.left}>
{obj.key}
</div>
<div className={styles.center}>
{obj.numVotes}
</div>
<div className={styles.right}>
{`${isNaN(pct) ? 0 : pct}%`}
</div>
</div>);
return pollStats.push(
<div className={styles.main} key={_.uniqueId('stats-')}>
<div className={styles.left}>
{obj.key}
</div>
<div className={styles.center}>
{obj.numVotes}
</div>
<div className={styles.right}>
{`${Number.isNaN(pct) ? 0 : pct}%`}
</div>
</div>,
);
});
return pollStats;
@ -117,8 +119,7 @@ class LiveResult extends Component {
onClick={() => {
publishPoll();
stopPoll();
Session.set('isUserListOpen', true);
Session.set('isPollOpen', false);
Session.set('openPanel', 'userlist');
Session.set('forcePollOpen', false);
}}
label={intl.formatMessage(intlMessages.publishLabel)}
@ -134,8 +135,12 @@ class LiveResult extends Component {
className={styles.btn}
/>
<div className={styles.container}>
<div className={styles.usersHeading}>{intl.formatMessage(intlMessages.usersTitle)}</div>
<div className={styles.responseHeading}>{intl.formatMessage(intlMessages.responsesTitle)}</div>
<div className={styles.usersHeading}>
{intl.formatMessage(intlMessages.usersTitle)}
</div>
<div className={styles.responseHeading}>
{intl.formatMessage(intlMessages.responsesTitle)}
</div>
{this.renderAnswers()}
</div>
</div>
@ -149,4 +154,9 @@ LiveResult.propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
getUser: PropTypes.func.isRequired,
currentPoll: PropTypes.arrayOf(Object).isRequired,
publishPoll: PropTypes.func.isRequired,
stopPoll: PropTypes.func.isRequired,
handleBackClick: PropTypes.func.isRequired,
};

View File

@ -45,9 +45,13 @@ const defaultProps = {
shortcuts: '',
};
const toggleChatOpen = () => {
Session.set('isChatOpen', !Session.get('isChatOpen'));
Session.set('breakoutRoomIsOpen', false);
const handleClickToggleChat = (id) => {
Session.set(
'openPanel',
Session.get('openPanel') === 'chat' && Session.get('idChatOpen') === id
? 'userlist' : 'chat',
);
Session.set('idChatOpen', id);
};
const ChatListItem = (props) => {
@ -73,15 +77,7 @@ const ChatListItem = (props) => {
aria-expanded={isCurrentChat}
tabIndex={tabIndex}
accessKey={isPublicChat(chat) ? TOGGLE_CHAT_PUB_AK : null}
onClick={() => {
toggleChatOpen();
Session.set('idChatOpen', chat.id);
if (Session.equals('isPollOpen', true)) {
Session.set('isPollOpen', false);
Session.set('forcePollOpen', true);
}
}}
onClick={() => handleClickToggleChat(chat.id)}
id="chat-toggle-button"
aria-label={isPublicChat(chat) ? intl.formatMessage(intlMessages.titlePublic) : chat.name}
>

View File

@ -41,11 +41,11 @@ const getCustomLogoUrl = () => Storage.getItem(CUSTOM_LOGO_URL_KEY);
const sortUsersByName = (a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
} else if (a.name.toLowerCase() > b.name.toLowerCase()) {
} if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
} else if (a.id.toLowerCase() > b.id.toLowerCase()) {
} if (a.id.toLowerCase() > b.id.toLowerCase()) {
return -1;
} else if (a.id.toLowerCase() < b.id.toLowerCase()) {
} if (a.id.toLowerCase() < b.id.toLowerCase()) {
return 1;
}
@ -62,12 +62,12 @@ const sortUsersByEmoji = (a, b) => {
if (emojiA && emojiB && (emojiA !== 'none' && emojiB !== 'none')) {
if (a.emoji.changedAt < b.emoji.changedAt) {
return -1;
} else if (a.emoji.changedAt > b.emoji.changedAt) {
} if (a.emoji.changedAt > b.emoji.changedAt) {
return 1;
}
} else if (emojiA && emojiA !== 'none') {
} if (emojiA && emojiA !== 'none') {
return -1;
} else if (emojiB && emojiB !== 'none') {
} if (emojiB && emojiB !== 'none') {
return 1;
}
return 0;
@ -76,9 +76,9 @@ const sortUsersByEmoji = (a, b) => {
const sortUsersByModerator = (a, b) => {
if (a.isModerator && b.isModerator) {
return sortUsersByEmoji(a, b);
} else if (a.isModerator) {
} if (a.isModerator) {
return -1;
} else if (b.isModerator) {
} if (b.isModerator) {
return 1;
}
@ -88,9 +88,9 @@ const sortUsersByModerator = (a, b) => {
const sortUsersByPhoneUser = (a, b) => {
if (!a.isPhoneUser && !b.isPhoneUser) {
return 0;
} else if (!a.isPhoneUser) {
} if (!a.isPhoneUser) {
return -1;
} else if (!b.isPhoneUser) {
} if (!b.isPhoneUser) {
return 1;
}
@ -101,7 +101,7 @@ const sortUsersByPhoneUser = (a, b) => {
const sortUsersByCurrent = (a, b) => {
if (a.isCurrent) {
return -1;
} else if (b.isCurrent) {
} if (b.isCurrent) {
return 1;
}
@ -133,11 +133,11 @@ const sortUsers = (a, b) => {
const sortChatsByName = (a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
} else if (a.name.toLowerCase() > b.name.toLowerCase()) {
} if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
} else if (a.id.toLowerCase() > b.id.toLowerCase()) {
} if (a.id.toLowerCase() > b.id.toLowerCase()) {
return -1;
} else if (a.id.toLowerCase() < b.id.toLowerCase()) {
} if (a.id.toLowerCase() < b.id.toLowerCase()) {
return 1;
}
@ -147,9 +147,9 @@ const sortChatsByName = (a, b) => {
const sortChatsByIcon = (a, b) => {
if (a.icon && b.icon) {
return sortChatsByName(a, b);
} else if (a.icon) {
} if (a.icon) {
return -1;
} else if (b.icon) {
} if (b.icon) {
return 1;
}
@ -436,6 +436,11 @@ const getGroupChatPrivate = (sender, receiver) => {
}
};
const isUserModerator = (userId) => {
const u = Users.findOne({ userId });
return u ? u.moderator : false;
};
export default {
setEmojiStatus,
assignPresenter,
@ -457,7 +462,7 @@ export default {
getCustomLogoUrl,
getGroupChatPrivate,
hasBreakoutRoom,
isUserModerator,
getEmojiList: () => EMOJI_STATUSES,
getEmoji: () => Users.findOne({ userId: Auth.userID }).emoji,
};

View File

@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { Session } from 'meteor/session';
import Icon from '/imports/ui/components/icon/component';
@ -11,10 +12,12 @@ const intlMessages = defineMessages({
},
});
const toggleBreakoutPanel = () => {
const breakoutPanelState = Session.get('breakoutRoomIsOpen');
Session.set('breakoutRoomIsOpen', !breakoutPanelState);
Session.set('isChatOpen', false);
Session.set('isPollOpen', false);
Session.set(
'openPanel',
Session.get('openPanel') === 'breakoutroom'
? 'userlist'
: 'breakoutroom',
);
};
const BreakoutRoomItem = ({
@ -24,13 +27,17 @@ const BreakoutRoomItem = ({
if (hasBreakoutRoom) {
return (
<div role="button" onClick={toggleBreakoutPanel}>
<h2 className={styles.smallTitle}> {intl.formatMessage(intlMessages.breakoutTitle).toUpperCase()}</h2>
<h2 className={styles.smallTitle}>
{intl.formatMessage(intlMessages.breakoutTitle).toUpperCase()}
</h2>
<div className={styles.BreakoutRoomsItem}>
<div className={styles.BreakoutRoomsContents}>
<div className={styles.BreakoutRoomsIcon} >
<div className={styles.BreakoutRoomsIcon}>
<Icon iconName="rooms" />
</div>
<span className={styles.BreakoutRoomsText}>{intl.formatMessage(intlMessages.breakoutTitle)}</span>
<span className={styles.BreakoutRoomsText}>
{intl.formatMessage(intlMessages.breakoutTitle)}
</span>
</div>
</div>
</div>
@ -40,3 +47,10 @@ const BreakoutRoomItem = ({
};
export default injectIntl(BreakoutRoomItem);
BreakoutRoomItem.propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
hasBreakoutRoom: PropTypes.bool.isRequired,
};

View File

@ -72,12 +72,13 @@ class UserParticipants extends Component {
}
componentDidMount() {
if (!this.props.compact) {
const { compact, roving, users } = this.props;
if (!compact) {
this.refScrollContainer.addEventListener(
'keydown',
event => this.props.roving(
event => roving(
event,
this.props.users.length,
users.length,
this.changeState,
),
);
@ -91,12 +92,13 @@ class UserParticipants extends Component {
}
componentDidUpdate(prevProps, prevState) {
if (this.state.index === -1) {
const { index } = this.state;
if (index === -1) {
return;
}
if (this.state.index !== prevState.index) {
this.focusUserItem(this.state.index);
if (index !== prevState.index) {
this.focusUserItem(index);
}
}
@ -129,44 +131,43 @@ class UserParticipants extends Component {
const { meetingId } = meeting;
return users.map(u =>
(
<CSSTransition
classNames={listTransition}
appear
enter
exit
timeout={0}
component="div"
className={cx(styles.participantsList)}
key={u}
>
<div ref={(node) => { this.userRefs[index += 1] = node; }}>
<UserListItemContainer
{...{
currentUser,
compact,
isBreakoutRoom,
meetingId,
getAvailableActions,
normalizeEmojiName,
isMeetingLocked,
handleEmojiChange,
getEmojiList,
getEmoji,
setEmojiStatus,
assignPresenter,
removeUser,
toggleVoice,
changeRole,
getGroupChatPrivate,
}}
userId={u}
getScrollContainerRef={this.getScrollContainerRef}
/>
</div>
</CSSTransition>
));
return users.map(u => (
<CSSTransition
classNames={listTransition}
appear
enter
exit
timeout={0}
component="div"
className={cx(styles.participantsList)}
key={u}
>
<div ref={(node) => { this.userRefs[index += 1] = node; }}>
<UserListItemContainer
{...{
currentUser,
compact,
isBreakoutRoom,
meetingId,
getAvailableActions,
normalizeEmojiName,
isMeetingLocked,
handleEmojiChange,
getEmojiList,
getEmoji,
setEmojiStatus,
assignPresenter,
removeUser,
toggleVoice,
changeRole,
getGroupChatPrivate,
}}
userId={u}
getScrollContainerRef={this.getScrollContainerRef}
/>
</div>
</CSSTransition>
));
}
focusUserItem(index) {
@ -181,28 +182,40 @@ class UserParticipants extends Component {
render() {
const {
intl, users, compact, setEmojiStatus, muteAllUsers, meeting, muteAllExceptPresenter,
intl,
users,
compact,
setEmojiStatus,
muteAllUsers,
meeting,
muteAllExceptPresenter,
currentUser,
} = this.props;
return (
<div className={styles.userListColumn}>
{
!compact ?
<div className={styles.container}>
<h2 className={styles.smallTitle}>
{intl.formatMessage(intlMessages.usersTitle)}
&nbsp;({users.length})
!compact
? (
<div className={styles.container}>
<h2 className={styles.smallTitle}>
{intl.formatMessage(intlMessages.usersTitle)}
&nbsp;(
{users.length}
)
</h2>
<UserOptionsContainer {...{
users,
muteAllUsers,
muteAllExceptPresenter,
setEmojiStatus,
meeting,
}}
/>
</div>
</h2>
<UserOptionsContainer {...{
users,
muteAllUsers,
muteAllExceptPresenter,
setEmojiStatus,
meeting,
currentUser,
}}
/>
</div>
)
: <hr className={styles.separator} />
}
<div

View File

@ -131,38 +131,52 @@ class UserDropdown extends PureComponent {
this.seperator = _.uniqueId('action-separator-');
}
componentDidUpdate(prevProps, prevState) {
if (!this.state.isActionsOpen && this.state.showNestedOptions) {
componentDidUpdate() {
const { isActionsOpen, showNestedOptions } = this.state;
if (!isActionsOpen && showNestedOptions) {
return this.resetMenuState();
}
this.checkDropdownDirection();
return this.checkDropdownDirection();
}
makeDropdownItem(key, label, onClick, icon = null, iconRight = null) {
return (
<DropdownListItem
{...{
key,
label,
onClick,
icon,
iconRight,
}}
className={key === this.props.getEmoji ? styles.emojiSelected : null}
data-test={key}
/>
);
onActionsShow() {
const { getScrollContainerRef } = this.props;
const dropdown = this.getDropdownMenuParent();
const scrollContainer = getScrollContainerRef();
if (dropdown && scrollContainer) {
const dropdownTrigger = dropdown.children[0];
const list = findDOMNode(this.list);
const children = [].slice.call(list.children);
children.find(child => child.getAttribute('role') === 'menuitem').focus();
this.setState({
isActionsOpen: true,
dropdownVisible: false,
dropdownOffset: dropdownTrigger.offsetTop - scrollContainer.scrollTop,
dropdownDirection: 'top',
});
scrollContainer.addEventListener('scroll', this.handleScroll, false);
}
}
resetMenuState() {
return this.setState({
onActionsHide(callback) {
const { getScrollContainerRef } = this.props;
this.setState({
isActionsOpen: false,
dropdownOffset: 0,
dropdownDirection: 'top',
dropdownVisible: false,
showNestedOptions: false,
});
const scrollContainer = getScrollContainerRef();
scrollContainer.removeEventListener('scroll', this.handleScroll, false);
if (callback) {
return callback;
}
}
getUsersActions() {
@ -182,6 +196,8 @@ class UserDropdown extends PureComponent {
changeRole,
} = this.props;
const { showNestedOptions } = this.state;
const actionPermissions = getAvailableActions(currentUser, user, isBreakoutRoom);
const actions = [];
@ -197,7 +213,7 @@ class UserDropdown extends PureComponent {
allowedToChangeStatus,
} = actionPermissions;
if (this.state.showNestedOptions) {
if (showNestedOptions) {
if (allowedToChangeStatus) {
actions.push(this.makeDropdownItem(
'back',
@ -236,12 +252,8 @@ class UserDropdown extends PureComponent {
intl.formatMessage(messages.ChatLabel),
() => {
getGroupChatPrivate(currentUser, user);
if (Session.equals('isPollOpen', true)) {
Session.set('isPollOpen', false);
Session.set('forcePollOpen', true);
}
Session.set('openPanel', 'chat');
Session.set('idChatOpen', user.id);
Session.set('isChatOpen', true);
},
'chat',
));
@ -313,45 +325,38 @@ class UserDropdown extends PureComponent {
return actions;
}
onActionsShow() {
const dropdown = this.getDropdownMenuParent();
const scrollContainer = this.props.getScrollContainerRef();
if (dropdown && scrollContainer) {
const dropdownTrigger = dropdown.children[0];
const list = findDOMNode(this.list);
const children = [].slice.call(list.children);
children.find(child => child.getAttribute('role') === 'menuitem').focus();
this.setState({
isActionsOpen: true,
dropdownVisible: false,
dropdownOffset: dropdownTrigger.offsetTop - scrollContainer.scrollTop,
dropdownDirection: 'top',
});
scrollContainer.addEventListener('scroll', this.handleScroll, false);
}
}
onActionsHide(callback) {
this.setState({
isActionsOpen: false,
dropdownVisible: false,
});
const scrollContainer = this.props.getScrollContainerRef();
scrollContainer.removeEventListener('scroll', this.handleScroll, false);
if (callback) {
return callback;
}
}
getDropdownMenuParent() {
return findDOMNode(this.dropdown);
}
makeDropdownItem(key, label, onClick, icon = null, iconRight = null) {
const { getEmoji } = this.props;
return (
<DropdownListItem
{...{
key,
label,
onClick,
icon,
iconRight,
}}
className={key === getEmoji ? styles.emojiSelected : null}
data-test={key}
/>
);
}
resetMenuState() {
return this.setState({
isActionsOpen: false,
dropdownOffset: 0,
dropdownDirection: 'top',
dropdownVisible: false,
showNestedOptions: false,
});
}
handleScroll() {
this.setState({ isActionsOpen: false });
}
@ -360,12 +365,13 @@ class UserDropdown extends PureComponent {
* Check if the dropdown is visible, if so, check if should be draw on top or bottom direction.
*/
checkDropdownDirection() {
const { getScrollContainerRef } = this.props;
if (this.isDropdownActivedByUser()) {
const dropdown = this.getDropdownMenuParent();
const dropdownTrigger = dropdown.children[0];
const dropdownContent = dropdown.children[1];
const scrollContainer = this.props.getScrollContainerRef();
const scrollContainer = getScrollContainerRef();
const nextState = {
dropdownVisible: true,
@ -377,7 +383,7 @@ class UserDropdown extends PureComponent {
);
if (!isDropdownVisible) {
const offsetPageTop = ((dropdownTrigger.offsetTop + dropdownTrigger.offsetHeight) - scrollContainer.scrollTop);
const offsetPageTop = (dropdownTrigger.offsetTop + dropdownTrigger.offsetHeight) - scrollContainer.scrollTop;
nextState.dropdownOffset = window.innerHeight - offsetPageTop;
nextState.dropdownDirection = 'bottom';
@ -449,8 +455,8 @@ class UserDropdown extends PureComponent {
const userItemContentsStyle = {};
userItemContentsStyle[styles.dropdown] = true;
userItemContentsStyle[styles.userListItem] = !this.state.isActionsOpen;
userItemContentsStyle[styles.usertListItemWithMenu] = this.state.isActionsOpen;
userItemContentsStyle[styles.userListItem] = !isActionsOpen;
userItemContentsStyle[styles.usertListItemWithMenu] = isActionsOpen;
const you = (user.isCurrent) ? intl.formatMessage(messages.you) : '';
@ -503,7 +509,7 @@ class UserDropdown extends PureComponent {
return (
<Dropdown
ref={(ref) => { this.dropdown = ref; }}
isOpen={this.state.isActionsOpen}
isOpen={isActionsOpen}
onShow={this.onActionsShow}
onHide={this.onActionsHide}
className={userItemContentsStyle}

View File

@ -20,6 +20,7 @@ const propTypes = {
toggleMuteAllUsers: PropTypes.func.isRequired,
toggleMuteAllUsersExceptPresenter: PropTypes.func.isRequired,
toggleStatus: PropTypes.func.isRequired,
mountModal: PropTypes.func.isRequired,
};
const intlMessages = defineMessages({
@ -83,7 +84,14 @@ class UserOptions extends PureComponent {
}
componentWillMount() {
const { intl, isMeetingMuted, mountModal } = this.props;
const {
intl,
isMeetingMuted,
mountModal,
toggleStatus,
toggleMuteAllUsers,
toggleMuteAllUsersExceptPresenter,
} = this.props;
this.menuItems = _.compact([
(<DropdownListItem
@ -91,21 +99,21 @@ class UserOptions extends PureComponent {
icon="clear_status"
label={intl.formatMessage(intlMessages.clearAllLabel)}
description={intl.formatMessage(intlMessages.clearAllDesc)}
onClick={this.props.toggleStatus}
onClick={toggleStatus}
/>),
(<DropdownListItem
key={_.uniqueId('list-item-')}
icon="mute"
label={intl.formatMessage(intlMessages.muteAllLabel)}
description={intl.formatMessage(intlMessages.muteAllDesc)}
onClick={this.props.toggleMuteAllUsers}
onClick={toggleMuteAllUsers}
/>),
(<DropdownListItem
key={_.uniqueId('list-item-')}
icon="mute"
label={intl.formatMessage(intlMessages.muteAllExceptPresenterLabel)}
description={intl.formatMessage(intlMessages.muteAllExceptPresenterDesc)}
onClick={this.props.toggleMuteAllUsersExceptPresenter}
onClick={toggleMuteAllUsersExceptPresenter}
/>),
(<DropdownListItem
key={_.uniqueId('list-item-')}
@ -122,7 +130,8 @@ class UserOptions extends PureComponent {
}
componentDidUpdate(prevProps) {
if (prevProps.isMeetingMuted !== this.props.isMeetingMuted) {
const { isMeetingMuted } = this.props;
if (prevProps.isMeetingMuted !== isMeetingMuted) {
this.alterMenu();
}
}
@ -140,16 +149,23 @@ class UserOptions extends PureComponent {
}
alterMenu() {
const { intl, isMeetingMuted } = this.props;
const {
intl,
isMeetingMuted,
toggleMuteAllUsers,
toggleMuteAllUsersExceptPresenter,
} = this.props;
if (isMeetingMuted) {
const menuButton = (<DropdownListItem
key={_.uniqueId('list-item-')}
icon="unmute"
label={intl.formatMessage(intlMessages.unmuteAllLabel)}
description={intl.formatMessage(intlMessages.unmuteAllDesc)}
onClick={this.props.toggleMuteAllUsers}
/>);
const menuButton = (
<DropdownListItem
key={_.uniqueId('list-item-')}
icon="unmute"
label={intl.formatMessage(intlMessages.unmuteAllLabel)}
description={intl.formatMessage(intlMessages.unmuteAllDesc)}
onClick={toggleMuteAllUsers}
/>
);
this.menuItems.splice(1, 2, menuButton);
} else {
const muteMeetingButtons = [(<DropdownListItem
@ -157,13 +173,13 @@ class UserOptions extends PureComponent {
icon="mute"
label={intl.formatMessage(intlMessages.muteAllLabel)}
description={intl.formatMessage(intlMessages.muteAllDesc)}
onClick={this.props.toggleMuteAllUsers}
onClick={toggleMuteAllUsers}
/>), (<DropdownListItem
key={_.uniqueId('list-item-')}
icon="mute"
label={intl.formatMessage(intlMessages.muteAllExceptPresenterLabel)}
description={intl.formatMessage(intlMessages.muteAllExceptPresenterDesc)}
onClick={this.props.toggleMuteAllUsersExceptPresenter}
onClick={toggleMuteAllUsersExceptPresenter}
/>)];
this.menuItems.splice(1, 1, muteMeetingButtons[0], muteMeetingButtons[1]);
@ -172,12 +188,13 @@ class UserOptions extends PureComponent {
render() {
const { intl } = this.props;
const { isUserOptionsOpen } = this.state;
return (
<Dropdown
ref={(ref) => { this.dropdown = ref; }}
autoFocus={false}
isOpen={this.state.isUserOptionsOpen}
isOpen={isUserOptionsOpen}
onShow={this.onActionsShow}
onHide={this.onActionsHide}
className={styles.dropdown}

View File

@ -1,8 +1,6 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import Auth from '/imports/ui/services/auth';
import mapUser from '/imports/ui/services/user/mapUser';
import Users from '/imports/api/users/';
import UserOptions from './component';
@ -31,43 +29,37 @@ export default class UserOptionsContainer extends PureComponent {
muteMeeting() {
const { muteAllUsers } = this.props;
const currentUser = Users.findOne({ userId: Auth.userID });
muteAllUsers(currentUser.userId);
muteAllUsers(Auth.userID);
}
muteAllUsersExceptPresenter() {
const { muteAllExceptPresenter } = this.props;
const currentUser = Users.findOne({ userId: Auth.userID });
muteAllExceptPresenter(currentUser.userId);
muteAllExceptPresenter(Auth.userID);
}
handleClearStatus() {
const { users, setEmojiStatus } = this.props;
users.forEach((user) => {
if (user.emoji.status !== 'none') {
setEmojiStatus(user.id, 'none');
}
users.forEach((id) => {
setEmojiStatus(id, 'none');
});
}
render() {
const currentUser = Users.findOne({ userId: Auth.userID });
const currentUserIsModerator = mapUser(currentUser).isModerator;
const { meeting } = this.props;
const { currentUser } = this.props;
const currentUserIsModerator = currentUser.isModerator;
this.state.meetingMuted = meeting.voiceProp.muteOnStart;
const { meetingMuted } = this.state;
return (
currentUserIsModerator ?
<UserOptions
toggleMuteAllUsers={this.muteMeeting}
toggleMuteAllUsersExceptPresenter={this.muteAllUsersExceptPresenter}
toggleStatus={this.handleClearStatus}
isMeetingMuted={this.state.meetingMuted}
/> : null
currentUserIsModerator
? (
<UserOptions
toggleMuteAllUsers={this.muteMeeting}
toggleMuteAllUsersExceptPresenter={this.muteAllUsersExceptPresenter}
toggleStatus={this.handleClearStatus}
isMeetingMuted={meetingMuted}
/>) : null
);
}
}

View File

@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import Icon from '/imports/ui/components/icon/component';
import { Session } from 'meteor/session';
@ -14,6 +14,15 @@ const intlMessages = defineMessages({
class UserPolls extends PureComponent {
render() {
const handleClickTogglePoll = () => {
Session.set(
'openPanel',
Session.get('openPanel') === 'poll'
? 'userlist'
: 'poll',
);
};
const {
intl,
isPresenter,
@ -36,17 +45,10 @@ class UserPolls extends PureComponent {
role="button"
tabIndex={0}
className={styles.pollLink}
onClick={() => {
Session.set('isChatOpen', false);
Session.set('breakoutRoomIsOpen', false);
return Session.equals('isPollOpen', true)
? Session.set('isPollOpen', false)
: Session.set('isPollOpen', true);
}}
onClick={handleClickTogglePoll}
>
<Icon iconName="polling" className={styles.icon} />
<span className={styles.label} >{intl.formatMessage(intlMessages.pollLabel)}</span>
<span className={styles.label}>{intl.formatMessage(intlMessages.pollLabel)}</span>
</div>
</div>
</div>
@ -55,3 +57,12 @@ class UserPolls extends PureComponent {
}
export default injectIntl(UserPolls);
UserPolls.propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
isPresenter: PropTypes.bool.isRequired,
pollIsOpen: PropTypes.bool.isRequired,
forcePollOpen: PropTypes.bool.isRequired,
};

View File

@ -49,9 +49,25 @@ const intlMessages = defineMessages({
id: 'app.videoPreview.webcamNotFoundLabel',
description: 'Webcam not found label',
},
sharingError: {
id: 'app.video.sharingError',
description: 'Error on sharing webcam',
permissionError: {
id: 'app.video.permissionError',
description: 'Error message for webcam permission',
},
NotFoundError: {
id: 'app.video.notFoundError',
description: 'error message when can not get webcam video',
},
NotAllowedError: {
id: 'app.video.notAllowed',
description: 'error message when webcam had permission denied',
},
NotSupportedError: {
id: 'app.video.notSupportedError',
description: 'error message when origin do not have ssl valid',
},
NotReadableError: {
id: 'app.video.notReadableError',
description: 'error message When the webcam is being used by other software',
},
});
@ -91,6 +107,16 @@ class VideoPreview extends Component {
}
}
handlegUMError(error) {
const {
intl,
} = this.props;
const errorMessage = intlMessages[error.name]
|| intlMessages.permissionError;
notify(intl.formatMessage(errorMessage), 'error', 'video');
logger.error(error);
}
handleSelectWebcam(event) {
const {
intl,
@ -108,8 +134,7 @@ class VideoPreview extends Component {
this.video.srcObject = stream;
this.deviceStream = stream;
}).catch((error) => {
notify(intl.formatMessage(intlMessages.sharingError), 'error', 'video');
logger.error(error);
this.handlegUMError(error);
});
}
@ -156,8 +181,9 @@ class VideoPreview extends Component {
this.setState({ availableWebcams: webcams });
}
});
}).catch(() => {
}).catch((error) => {
this.setState({ isStartSharingDisabled: true });
this.handlegUMError(error);
});
}

View File

@ -17,10 +17,6 @@ const intlClientErrors = defineMessages({
id: 'app.video.iceCandidateError',
description: 'Error message for ice candidate fail',
},
sharingError: {
id: 'app.video.sharingError',
description: 'Error on sharing webcam',
},
chromeExtensionError: {
id: 'app.video.chromeExtensionError',
description: 'Error message for Chrome Extension not installed',
@ -53,6 +49,10 @@ const intlClientErrors = defineMessages({
id: 'app.video.iceConnectionStateError',
description: 'Error message for ice connection state being failed',
},
mediaFlowTimeout: {
id: 'app.video.mediaFlowTimeout1020',
description: 'Error message when media could not go through the server within the specified period',
},
});
const intlSFUErrors = defineMessages({
@ -62,7 +62,7 @@ const intlSFUErrors = defineMessages({
},
2001: {
id: 'app.sfu.mediaServerOffline2001',
description: 'error message when kurento is offline',
description: 'error message when SFU is offline',
},
2002: {
id: 'app.sfu.mediaServerNoResources2002',
@ -80,6 +80,10 @@ const intlSFUErrors = defineMessages({
id: 'app.sfu.serverIceStateFailed2022',
description: 'Error message fired when the server endpoint transitioned to a FAILED ICE state',
},
2200: {
id: 'app.sfu.mediaGenericError2200',
description: 'Error message fired when the SFU component generated a generic error',
},
2202: {
id: 'app.sfu.invalidSdp2202',
description: 'Error message fired when the clients provides an invalid SDP',
@ -116,6 +120,9 @@ class VideoProvider extends Component {
this.videoTags = {};
this.sharedWebcam = false;
this.createVideoTag = this.createVideoTag.bind(this);
this.getStats = this.getStats.bind(this);
this.stopGettingStats = this.stopGettingStats.bind(this);
this.onWsOpen = this.onWsOpen.bind(this);
this.onWsClose = this.onWsClose.bind(this);
this.onWsMessage = this.onWsMessage.bind(this);
@ -129,46 +136,10 @@ class VideoProvider extends Component {
this.customGetStats = this.customGetStats.bind(this);
}
logger(type, message, options = {}) {
const { userId, userName } = this.props;
const topic = options.topic || 'video';
logger[type]({ obj: Object.assign(options, { userId, userName, topic }) }, `[${topic}] ${message}`);
}
_sendPauseStream(id, role, state) {
this.sendMessage({
cameraId: id,
id: 'pause',
type: 'video',
role,
state,
});
}
pauseViewers() {
this.logger('debug', 'Calling pause in viewer streams');
Object.keys(this.webRtcPeers).forEach((id) => {
if (this.props.userId !== id && this.webRtcPeers[id].started) {
this._sendPauseStream(id, 'viewer', true);
}
});
}
unpauseViewers() {
this.logger('debug', 'Calling un-pause in viewer streams');
Object.keys(this.webRtcPeers).forEach((id) => {
if (id !== this.props.userId && this.webRtcPeers[id].started) {
this._sendPauseStream(id, 'viewer', false);
}
});
}
componentWillMount() {
this.ws.addEventListener('open', this.onWsOpen);
this.ws.addEventListener('close', this.onWsClose);
this.ws.onopen = this.onWsOpen;
this.ws.onclose = this.onWsClose;
window.addEventListener('online', this.openWs);
window.addEventListener('offline', this.onWsClose);
@ -177,7 +148,7 @@ class VideoProvider extends Component {
componentDidMount() {
document.addEventListener('joinVideo', this.shareWebcam); // TODO find a better way to do this
document.addEventListener('exitVideo', this.unshareWebcam);
this.ws.addEventListener('message', this.onWsMessage);
this.ws.onmessage = this.onWsMessage;
window.addEventListener('beforeunload', this.unshareWebcam);
this.visibility.onVisible(this.unpauseViewers);
@ -199,9 +170,9 @@ class VideoProvider extends Component {
document.removeEventListener('joinVideo', this.shareWebcam);
document.removeEventListener('exitVideo', this.unshareWebcam);
this.ws.removeEventListener('message', this.onWsMessage);
this.ws.removeEventListener('open', this.onWsOpen);
this.ws.removeEventListener('close', this.onWsClose);
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.onclose = null;
window.removeEventListener('online', this.openWs);
window.removeEventListener('offline', this.onWsClose);
@ -222,35 +193,6 @@ class VideoProvider extends Component {
this.ws.close();
}
onWsOpen() {
this.logger('debug', '------ Websocket connection opened.', { topic: 'ws' });
// -- Resend queued messages that happened when socket was not connected
while (this.wsQueue.length > 0) {
this.sendMessage(this.wsQueue.pop());
}
this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL);
this.setState({ socketOpen: true });
}
onWsClose(error) {
this.logger('debug', '------ Websocket connection closed.', { topic: 'ws' });
this.stopWebRTCPeer(this.props.userId);
clearInterval(this.pingInterval);
this.setState({ socketOpen: false });
}
ping() {
const message = {
id: 'ping',
};
this.sendMessage(message);
}
onWsMessage(msg) {
const parsedMessage = JSON.parse(msg.data);
@ -274,7 +216,6 @@ class VideoProvider extends Component {
break;
case 'pong':
this.logger('debug', 'Received pong from server', { topic: 'ws' });
break;
case 'error':
@ -284,6 +225,100 @@ class VideoProvider extends Component {
}
}
onWsClose() {
const {
intl,
} = this.props;
this.logger('debug', '------ Websocket connection closed.', { topic: 'ws' });
clearInterval(this.pingInterval);
if (this.sharedWebcam) {
this.unshareWebcam();
}
// Notify user that the SFU component has gone offline
this.notifyError(intl.formatMessage(intlSFUErrors[2001]));
this.setState({ socketOpen: false });
}
onWsOpen() {
this.logger('debug', '------ Websocket connection opened.', { topic: 'ws' });
// -- Resend queued messages that happened when socket was not connected
while (this.wsQueue.length > 0) {
this.sendMessage(this.wsQueue.pop());
}
this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL);
this.setState({ socketOpen: true });
}
getStats(id, video, callback) {
const peer = this.webRtcPeers[id];
const hasLocalStream = peer && peer.started === true
&& peer.peerConnection.getLocalStreams().length > 0;
const hasRemoteStream = peer && peer.started === true
&& peer.peerConnection.getRemoteStreams().length > 0;
if (hasLocalStream) {
this.monitorTrackStart(peer.peerConnection,
peer.peerConnection.getLocalStreams()[0].getVideoTracks()[0], true, callback);
} else if (hasRemoteStream) {
this.monitorTrackStart(peer.peerConnection,
peer.peerConnection.getRemoteStreams()[0].getVideoTracks()[0], false, callback);
}
}
logger(type, message, options = {}) {
const { userId, userName } = this.props;
const topic = options.topic || 'video';
logger[type]({ obj: Object.assign(options, { userId, userName, topic }) }, `[${topic}] ${message}`);
}
_sendPauseStream(id, role, state) {
this.sendMessage({
cameraId: id,
id: 'pause',
type: 'video',
role,
state,
});
}
pauseViewers() {
const { userId } = this.props;
this.logger('debug', 'Calling pause in viewer streams');
Object.keys(this.webRtcPeers).forEach((id) => {
if (userId !== id && this.webRtcPeers[id].started) {
this._sendPauseStream(id, 'viewer', true);
}
});
}
unpauseViewers() {
const { userId } = this.props;
this.logger('debug', 'Calling un-pause in viewer streams');
Object.keys(this.webRtcPeers).forEach((id) => {
if (id !== userId && this.webRtcPeers[id].started) {
this._sendPauseStream(id, 'viewer', false);
}
});
}
ping() {
const message = {
id: 'ping',
};
this.sendMessage(message);
}
sendMessage(message) {
const { ws } = this;
@ -347,7 +382,7 @@ class VideoProvider extends Component {
}
}
stopWebRTCPeer(id) {
stopWebRTCPeer(id, restarting = false) {
this.logger('info', 'Stopping webcam', { cameraId: id });
const { userId } = this.props;
const shareWebcam = id === userId;
@ -369,9 +404,16 @@ class VideoProvider extends Component {
cameraId: id,
});
// Clear the shared camera fail timeout when destroying
clearTimeout(this.restartTimeout[id]);
delete this.restartTimeout[id];
// Clear the shared camera media flow timeout when destroying it
if (!restarting) {
if (this.restartTimeout[id]) {
clearTimeout(this.restartTimeout[id]);
}
if (this.restartTimer[id]) {
delete this.restartTimer[id];
}
}
this.destroyWebRTCPeer(id);
}
@ -393,6 +435,11 @@ class VideoProvider extends Component {
const { meetingId, sessionToken, voiceBridge } = this.props;
let iceServers = [];
// Check if there's connectivity to the SFU component
if (!this.connectedToMediaServer()) {
return this._webRTCOnError(2001, id, shareWebcam);
}
// Check if the peer is already being processed
if (this.webRtcPeers[id]) {
return;
@ -467,23 +514,25 @@ class VideoProvider extends Component {
});
});
if (this.webRtcPeers[id].peerConnection) {
this.webRtcPeers[id].peerConnection.oniceconnectionstatechange =
this._getOnIceConnectionStateChangeCallback(id);
this.webRtcPeers[id].peerConnection.oniceconnectionstatechange = this._getOnIceConnectionStateChangeCallback(id);
}
}
}
_getWebRTCStartTimeout(id, shareWebcam, peer) {
const { intl } = this.props;
_getWebRTCStartTimeout(id, shareWebcam) {
const { intl, userId } = this.props;
return () => {
this.logger('error', `Camera share has not suceeded in ${CAMERA_SHARE_FAILED_WAIT_TIME}`, { cameraId: id });
if (this.props.userId === id) {
this.notifyError(intl.formatMessage(intlClientErrors.sharingError));
this.stopWebRTCPeer(id);
if (userId === id) {
this.notifyError(intl.formatMessage(intlClientErrors.mediaFlowTimeout));
this.stopWebRTCPeer(id, false);
} else {
this.stopWebRTCPeer(id);
// Subscribers try to reconnect according to their timers if media could
// not reach the server. That's why we pass the restarting flag as true
// to the stop procedure as to not destroy the timers
this.stopWebRTCPeer(id, true);
this.createWebRTCPeer(id, shareWebcam);
// Increment reconnect interval
@ -508,19 +557,13 @@ class VideoProvider extends Component {
}
}
_webRTCOnError(error, id, shareWebcam) {
_webRTCOnError(error, id) {
const { intl } = this.props;
this.logger('error', ' WebRTC peerObj create error', id);
this.logger('error', error, id);
const errorMessage = intlClientErrors[error.name]
|| intlClientErrors.permissionError;
|| intlSFUErrors[error] || intlClientErrors.permissionError;
this.notifyError(intl.formatMessage(errorMessage));
/* This notification error is displayed considering kurento-utils
* returned the error 'The request is not allowed by the user agent
* or the platform in the current context.', but there are other
* errors that could be returned. */
this.stopWebRTCPeer(id);
return this.logger('error', errorMessage, { cameraId: id });
@ -555,7 +598,7 @@ class VideoProvider extends Component {
const { intl } = this.props;
const peer = this.webRtcPeers[id];
return (event) => {
return () => {
const connectionState = peer.peerConnection.iceConnectionState;
if (connectionState === 'failed' || connectionState === 'closed') {
// prevent the same error from being detected multiple times
@ -569,6 +612,7 @@ class VideoProvider extends Component {
}
attachVideoStream(id) {
const { userId } = this.props;
const video = this.videoTags[id];
if (video == null) {
this.logger('warn', 'Peer', id, 'has not been started yet');
@ -580,7 +624,7 @@ class VideoProvider extends Component {
return; // Skip if the stream is already attached
}
const isCurrent = id === this.props.userId;
const isCurrent = id === userId;
const peer = this.webRtcPeers[id];
const attachVideoStreamHelper = () => {
@ -615,8 +659,9 @@ class VideoProvider extends Component {
}
}
customGetStats(peer, mediaStreamTrack, callback, interval) {
const statsState = this.state.stats;
customGetStats(peer, mediaStreamTrack, callback) {
const { stats } = this.state;
const statsState = stats;
let promise;
try {
promise = peer.getStats(mediaStreamTrack);
@ -627,9 +672,9 @@ class VideoProvider extends Component {
let videoInOrOutbound = {};
results.forEach((res) => {
if (res.type === 'ssrc' || res.type === 'inbound-rtp' || res.type === 'outbound-rtp') {
res.packetsSent = parseInt(res.packetsSent);
res.packetsLost = parseInt(res.packetsLost) || 0;
res.packetsReceived = parseInt(res.packetsReceived);
res.packetsSent = parseInt(res.packetsSent, 10);
res.packetsLost = parseInt(res.packetsLost, 10) || 0;
res.packetsReceived = parseInt(res.packetsReceived, 10);
if ((isNaN(res.packetsSent) && res.packetsReceived === 0)
|| (res.type === 'outbound-rtp' && res.isRemote)) {
@ -637,11 +682,11 @@ class VideoProvider extends Component {
}
if (res.googFrameWidthReceived) {
res.width = parseInt(res.googFrameWidthReceived);
res.height = parseInt(res.googFrameHeightReceived);
res.width = parseInt(res.googFrameWidthReceived, 10);
res.height = parseInt(res.googFrameHeightReceived, 10);
} else if (res.googFrameWidthSent) {
res.width = parseInt(res.googFrameWidthSent);
res.height = parseInt(res.googFrameHeightSent);
res.width = parseInt(res.googFrameWidthSent, 10);
res.height = parseInt(res.googFrameHeightSent, 10);
}
// Extra fields available on Chrome
@ -692,11 +737,14 @@ class VideoProvider extends Component {
const videoKbitsReceivedPerSecond = (videoIntervalBytesReceived * 8) / videoReceivedInterval;
const videoKbitsSentPerSecond = (videoIntervalBytesSent * 8) / videoSentInterval;
const videoPacketDuration = (videoIntervalPacketsSent / videoSentInterval) * 1000;
let videoLostPercentage,
videoLostRecentPercentage,
videoBitrate;
let videoLostPercentage;
let videoLostRecentPercentage;
let videoBitrate;
if (videoStats.packetsReceived > 0) { // Remote video
videoLostPercentage = ((videoStats.packetsLost / ((videoStats.packetsLost + videoStats.packetsReceived) * 100)) || 0).toFixed(1);
videoBitrate = Math.floor(videoKbitsReceivedPerSecond || 0);
@ -775,24 +823,13 @@ class VideoProvider extends Component {
}
}
getStats(id, video, callback) {
const peer = this.webRtcPeers[id];
const hasLocalStream = peer && peer.started === true && peer.peerConnection.getLocalStreams().length > 0;
const hasRemoteStream = peer && peer.started === true && peer.peerConnection.getRemoteStreams().length > 0;
if (hasLocalStream) {
this.monitorTrackStart(peer.peerConnection, peer.peerConnection.getLocalStreams()[0].getVideoTracks()[0], true, callback);
} else if (hasRemoteStream) {
this.monitorTrackStart(peer.peerConnection, peer.peerConnection.getRemoteStreams()[0].getVideoTracks()[0], false, callback);
}
}
stopGettingStats(id) {
const peer = this.webRtcPeers[id];
const hasLocalStream = peer && peer.started === true && peer.peerConnection.getLocalStreams().length > 0;
const hasRemoteStream = peer && peer.started === true && peer.peerConnection.getRemoteStreams().length > 0;
const hasLocalStream = peer && peer.started === true
&& peer.peerConnection.getLocalStreams().length > 0;
const hasRemoteStream = peer && peer.started === true
&& peer.peerConnection.getRemoteStreams().length > 0;
if (hasLocalStream) {
this.monitorTrackStop(peer.peerConnection.getLocalStreams()[0].getVideoTracks()[0].id);
@ -811,9 +848,9 @@ class VideoProvider extends Component {
handlePlayStart(message) {
const id = message.cameraId;
const peer = this.webRtcPeers[id];
const videoTag = this.videoTags[id];
if (peer) {
const { userId } = this.props;
this.logger('info', 'Handle play start for camera', { cameraId: id });
// Clear camera shared timeout when camera succesfully starts
@ -827,7 +864,7 @@ class VideoProvider extends Component {
this.attachVideoStream(id);
}
if (id === this.props.userId) {
if (id === userId) {
VideoService.sendUserShareWebcam(id);
VideoService.joinedVideo();
}
@ -844,7 +881,7 @@ class VideoProvider extends Component {
if (message.streamId === userId) {
this.unshareWebcam();
this.notifyError(intl.formatMessage(intlSFUErrors[code]
|| intlClientErrors.sharingError));
|| intlSFUErrors[2200]));
} else {
this.stopWebRTCPeer(message.cameraId);
}
@ -857,36 +894,34 @@ class VideoProvider extends Component {
}
shareWebcam() {
const { intl } = this.props;
if (this.connectedToMediaServer()) {
this.logger('info', 'Sharing webcam');
this.sharedWebcam = true;
VideoService.joiningVideo();
} else {
this.logger('debug', 'Error on sharing webcam');
this.notifyError(intl.formatMessage(intlClientErrors.sharingError));
}
}
unshareWebcam() {
const { userId } = this.props;
this.logger('info', 'Unsharing webcam');
VideoService.sendUserUnshareWebcam(this.props.userId);
VideoService.sendUserUnshareWebcam(userId);
VideoService.exitedVideo();
this.sharedWebcam = false;
}
render() {
if (!this.state.socketOpen) return null;
const { socketOpen } = this.state;
if (!socketOpen) return null;
const { users, enableVideoStats } = this.props;
return (
<VideoList
users={this.props.users}
onMount={this.createVideoTag.bind(this)}
getStats={this.getStats.bind(this)}
stopGettingStats={this.stopGettingStats.bind(this)}
enableVideoStats={this.props.enableVideoStats}
users={users}
onMount={this.createVideoTag}
getStats={this.getStats}
stopGettingStats={this.stopGettingStats}
enableVideoStats={enableVideoStats}
/>
);
}

View File

@ -4,8 +4,10 @@ import getFromUserSettings from '/imports/ui/services/users-settings';
import VideoProvider from './component';
import VideoService from './service';
const VideoProviderContainer = ({ children, ...props }) =>
(!props.users.length ? null : <VideoProvider {...props}>{children}</VideoProvider>);
const VideoProviderContainer = ({ children, ...props }) => {
const { users } = props;
return (!users.length ? null : <VideoProvider {...props}>{children}</VideoProvider>);
};
export default withTracker(() => ({
meetingId: VideoService.meetingId(),

View File

@ -5,7 +5,6 @@ import Meetings from '/imports/api/meetings/';
import Users from '/imports/api/users/';
import mapUser from '/imports/ui/services/user/mapUser';
import UserListService from '/imports/ui/components/user-list/service';
import SessionStorage from '/imports/ui/services/storage/session';
class VideoService {
constructor() {
@ -100,8 +99,8 @@ class VideoService {
}
webcamOnlyModerator() {
const m = Meetings.findOne({ meetingId: Auth.meetingID });
return m.usersProp.webcamsOnlyForModerator;
const m = Meetings.findOne({ meetingId: Auth.meetingID }) || {};
return m.usersProp ? m.usersProp.webcamsOnlyForModerator : false;
}
isLocked() {
@ -127,8 +126,8 @@ class VideoService {
}
voiceBridge() {
const voiceBridge = Meetings.findOne({ meetingId: Auth.meetingID }).voiceProp.voiceConf;
return voiceBridge;
const m = Meetings.findOne({ meetingId: Auth.meetingID }) || {};
return m.voiceProp ? m.voiceProp.voiceConf : null;
}
isConnected() {

View File

@ -387,6 +387,7 @@
"app.video.notAllowed": "Missing permission for share webcam, please make sure your browser permissions",
"app.video.notSupportedError": "Can share webcam video only with safe sources, make sure your SSL certificate is valid",
"app.video.notReadableError": "Could not get webcam video. Please make sure another program is not using the webcam ",
"app.video.mediaFlowTimeout1020": "Error 1020: media could not reach the server",
"app.video.swapCam": "Swap",
"app.video.swapCamDesc": "swap the direction of webcams",
"app.video.videoMenu": "Video menu",
@ -414,6 +415,7 @@
"app.sfu.mediaServerRequestTimeout2003": "Error 2003: Media server requests are timing out",
"app.sfu.serverIceGatheringFailed2021": "Error 2021: Media server cannot gather ICE candidates",
"app.sfu.serverIceGatheringFailed2022": "Error 2022: Media server ICE connection failed",
"app.sfu.mediaGenericError2200": "Error 2200: Media server failed to process request",
"app.sfu.invalidSdp2202":"Error 2202: Client generated an invalid SDP",
"app.sfu.noAvailableCodec2203": "Error 2203: Server could not find an appropriate codec",
"app.meeting.endNotification.ok.label": "OK",

View File

@ -1 +0,0 @@
node_modules

View File

@ -1,65 +0,0 @@
fs = require 'fs'
{print} = require 'util'
{spawn, exec} = require 'child_process'
glob = require 'glob'
REPORTER = "min"
config = {}
config.binPath = './node_modules/.bin/'
# cake test # run all tests
# cake -f test/lib/file.coffee test # run the files passed
# cake -b test # run all tests and stop at first failure
option '-f', '--file [FILE*]', 'input file(s)'
option '-b', '--bail', 'bail'
task 'test', 'Run the test suite', (options) ->
process.env.NODE_ENV = "test"
testFiles = [
]
testOpts = [
'--require', 'coffee-script/register',
'--compilers', 'coffee:coffee-script/register',
'--require', 'should',
'--colors',
'--ignore-leaks',
'--timeout', '15000',
'--reporter', 'spec'
]
if options.bail? and options.bail
testOpts = testOpts.concat('-b')
if options.file?
if _.isArray(options.file)
files = testFiles.concat(options.file)
else
files = testFiles.concat([options.file])
for opt in testOpts.reverse()
files.unshift opt
run 'mocha', files
else
glob 'test/**/*.coffee', (error, files) ->
for opt in testOpts.reverse()
files.unshift opt
run 'mocha', files
# Internal methods
# Spawns an application with `options` and calls `onExit`
# when it finishes.
run = (bin, options, onExit) ->
bin = config.binPath + bin
console.log timeNow() + ' - running: ' + bin + ' ' + (if options? then options.join(' ') else "")
cmd = spawn bin, options
cmd.stdout.on 'data', (data) -> print data.toString()
cmd.stderr.on 'data', (data) -> print data.toString()
cmd.on 'exit', (code) ->
console.log 'done.'
onExit?(code, options)
# Returns a string with the current time to print out.
timeNow = ->
today = new Date()
today.getHours() + ":" + today.getMinutes() + ":" + today.getSeconds()

View File

@ -1,41 +0,0 @@
exploringHapi
=============
This was used as a playground for attempts to validate URL parameters
and to calculate and compare checksum
Keywords: hapi, joi, OAuth, checksum, hmac_sha1
Instructions:
=============
from Terminal:
$ coffee index.coffee
Listening on http://x.x.x.x:PORT
go to the browser, open an MCONF API-MATE window
modify the "server"(id="input-custom-server-url") field to http://x.x.x.x:PORT
click on the link for creating a meeting ("create ...")
In the Terminal window you should see something like:
the checksum from url is
e8b540ab61a71c46ebc99e7250e2ca6372115d9a and mine is
e8b540ab61a71c46ebc99e7250e2ca6372115d9a
YAY! They match!
or
the checksum from url is
e8b540ab61a71c46ebc99e7250e2ca6372115d9a and mine is
dkfjhdkjfhlkafhdfklahfkfhfjhkgfeq349492a
The browser window will display
"everything is fine" if the parameter validation was successful
or Error if it was not
LOGGING
# To use for CLI
npm install -g bunyan
https://github.com/trentm/node-bunyan

View File

@ -1,17 +0,0 @@
Hapi = require("hapi")
pack = require './package'
routes = require './lib/routes'
bunyan = require 'bunyan'
log = bunyan.createLogger({name: 'myapp'});
log.info('hi')
log.warn({lang: 'fr'}, 'au revoir')
server = Hapi.createServer("0.0.0.0", parseInt(process.env.PORT, 10) or 4000)
server.start(() ->
log.info(['start'], pack.name + ' - web interface: ' + server.info.uri);
)
server.route routes.routes

View File

@ -1,30 +0,0 @@
hapi = require 'hapi'
Joi = require 'joi'
util = require './util'
sha1 = require 'js-sha1'
sharedSecret = '8cd8ef52e8e101574e400365b55e11a6'
index = (req, resp) ->
resp "Hello World!"
createHandler = (req, resp) ->
console.log("CREATE: " + req.originalUrl )
checksum = req.query.checksum
console.log("checksum = [" + checksum + "]")
query = util.removeChecksumFromQuery(req.query)
baseString = util.buildCreateBaseString(query)
ourChecksum = util.calculateChecksum("create", baseString, sharedSecret)
console.log "the checksum from url is \n" + checksum + " and mine is\n" + ourChecksum
if checksum isnt ourChecksum
resp "Fail!"
else
resp "everything is fine"
exports.index = index
exports.create = createHandler

View File

@ -1,33 +0,0 @@
hapi = require 'hapi'
handlers = require './handlers'
Joi = require 'joi'
createValidation =
attendeePW: Joi.string().max(20).required()
checksum: Joi.string().required()
meetingID: Joi.string().min(3).max(30).required()
moderatorPW: Joi.string().required()
name: Joi.string().regex(/[a-zA-Z0-9]{3,30}/)
record: Joi.boolean()
voiceBridge: Joi.string()
welcome: Joi.string()
routes = [{
method: 'GET',
path: '/',
config: {
handler: handlers.index
}
}, {
method: "GET",
path: "/bigbluebutton/api/create",
config: {
handler: handlers.create,
validate: {
query: createValidation
}
}
}];
exports.routes = routes;

View File

@ -1,32 +0,0 @@
sha1 = require 'js-sha1'
removeChecksumFromQuery = (query) ->
for own propName of query
console.log(propName + "=" + query[propName])
delete query['checksum']
query
buildCreateBaseString = (query) ->
baseString = ""
for own propName of query
propVal = query[propName]
if (propName == "welcome")
propVal = encodeURIComponent(query.welcome).replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A")
baseString += propName + "=" + propVal + "&"
console.log(propName + "=" + query[propName])
console.log("baseString=[" + baseString.slice(0, -1) + "]")
baseString.slice(0, -1)
calculateChecksum = (method, baseString, sharedSecret) ->
qStr = method + baseString + sharedSecret
console.log("[" + qStr + "]")
sha1(qStr)
exports.removeChecksumFromQuery = removeChecksumFromQuery
exports.buildCreateBaseString = buildCreateBaseString
exports.calculateChecksum = calculateChecksum

View File

@ -1,24 +0,0 @@
{
"name": "exploringHapi",
"version": "0.0.2",
"private": true,
"scripts": {
"start": "coffee index.coffee"
},
"dependencies": {
"hapi": "2.6.0",
"joi": "2.7.0",
"oauth-signature": "1.1.3",
"coffee-script": "1.7.1",
"js-sha1": "0.1.1",
"bunyan": "0.22.2",
"glob": "3.2.6"
},
"devDependencies": {
"coffee-script": "1.7.1",
"mocha": "1.18.2",
"should": "3.3.1",
"glob": "3.2.6",
"chai": "1.9.x"
}
}

View File

@ -1,3 +0,0 @@

View File

@ -1,31 +0,0 @@
assert = require("assert")
oauth = require("oauth-signature")
describe "Array", ->
describe '#indexOf()', ->
it 'should return -1 when the value is not present', ->
assert.equal(-1, [1,2,3].indexOf(5))
it "should calc checksum", ->
httpMethod = 'GET'
url = 'http://photos.example.net/photos'
parameters = {
oauth_consumer_key : 'dpf43f3p2l4k3l03',
oauth_token : 'nnch734d00sl2jdk',
oauth_nonce : 'kllo9940pd9333jh',
oauth_timestamp : '1191242096',
oauth_signature_method : 'HMAC-SHA1',
oauth_version : '1.0',
file : 'vacation.jpg',
size : 'original'
}
consumerSecret = 'kd94hf93k423kf44'
tokenSecret = 'pfkkdhi9sl3r4s00'
encodedSignature = oauth.generate(httpMethod, url, parameters, consumerSecret, tokenSecret);
console.log(encodedSignature)
assert.equal(encodedSignature, "tR3%2BTy81lMeYAr%2FFid0kMTYa%2FWM%3D")

View File

@ -1 +0,0 @@
node_modules

View File

@ -1,65 +0,0 @@
fs = require 'fs'
{print} = require 'util'
{spawn, exec} = require 'child_process'
glob = require 'glob'
REPORTER = "min"
config = {}
config.binPath = './node_modules/.bin/'
# cake test # run all tests
# cake -f test/lib/file.coffee test # run the files passed
# cake -b test # run all tests and stop at first failure
option '-f', '--file [FILE*]', 'input file(s)'
option '-b', '--bail', 'bail'
task 'test', 'Run the test suite', (options) ->
process.env.NODE_ENV = "test"
testFiles = [
]
testOpts = [
'--require', 'coffee-script/register',
'--compilers', 'coffee:coffee-script/register',
'--require', 'should',
'--colors',
'--ignore-leaks',
'--timeout', '15000',
'--reporter', 'spec'
]
if options.bail? and options.bail
testOpts = testOpts.concat('-b')
if options.file?
if _.isArray(options.file)
files = testFiles.concat(options.file)
else
files = testFiles.concat([options.file])
for opt in testOpts.reverse()
files.unshift opt
run 'mocha', files
else
glob 'test/**/*.coffee', (error, files) ->
for opt in testOpts.reverse()
files.unshift opt
run 'mocha', files
# Internal methods
# Spawns an application with `options` and calls `onExit`
# when it finishes.
run = (bin, options, onExit) ->
bin = config.binPath + bin
console.log timeNow() + ' - running: ' + bin + ' ' + (if options? then options.join(' ') else "")
cmd = spawn bin, options
cmd.stdout.on 'data', (data) -> print data.toString()
cmd.stderr.on 'data', (data) -> print data.toString()
cmd.on 'exit', (code) ->
console.log 'done.'
onExit?(code, options)
# Returns a string with the current time to print out.
timeNow = ->
today = new Date()
today.getHours() + ":" + today.getMinutes() + ":" + today.getSeconds()

View File

@ -1,41 +0,0 @@
exploringHapi
=============
This was used as a playground for attempts to validate URL parameters
and to calculate and compare checksum
Keywords: hapi, joi, OAuth, checksum, hmac_sha1
Instructions:
=============
from Terminal:
$ coffee index.coffee
Listening on http://x.x.x.x:PORT
go to the browser, open an MCONF API-MATE window
modify the "server"(id="input-custom-server-url") field to http://x.x.x.x:PORT
click on the link for creating a meeting ("create ...")
In the Terminal window you should see something like:
the checksum from url is
e8b540ab61a71c46ebc99e7250e2ca6372115d9a and mine is
e8b540ab61a71c46ebc99e7250e2ca6372115d9a
YAY! They match!
or
the checksum from url is
e8b540ab61a71c46ebc99e7250e2ca6372115d9a and mine is
dkfjhdkjfhlkafhdfklahfkfhfjhkgfeq349492a
The browser window will display
"everything is fine" if the parameter validation was successful
or Error if it was not
LOGGING
# To use for CLI
npm install -g bunyan
https://github.com/trentm/node-bunyan

View File

@ -1,17 +0,0 @@
Hapi = require("hapi")
pack = require './package'
routes = require './lib/routes'
bunyan = require 'bunyan'
log = bunyan.createLogger({name: 'myapp'});
log.info('hi')
log.warn({lang: 'fr'}, 'au revoir')
server = Hapi.createServer("0.0.0.0", parseInt(process.env.PORT, 10) or 4000)
server.start(() ->
log.info(['start'], pack.name + ' - web interface: ' + server.info.uri);
)
server.route routes.routes

View File

@ -1,30 +0,0 @@
hapi = require 'hapi'
Joi = require 'joi'
util = require './util'
sha1 = require 'js-sha1'
sharedSecret = '8cd8ef52e8e101574e400365b55e11a6'
index = (req, resp) ->
resp "Hello World!"
createHandler = (req, resp) ->
console.log("CREATE: " + req.originalUrl )
checksum = req.query.checksum
console.log("checksum = [" + checksum + "]")
query = util.removeChecksumFromQuery(req.query)
baseString = util.buildCreateBaseString(query)
ourChecksum = util.calculateChecksum("create", baseString, sharedSecret)
console.log "the checksum from url is \n" + checksum + " and mine is\n" + ourChecksum
if checksum isnt ourChecksum
resp "Fail!"
else
resp "everything is fine"
exports.index = index
exports.create = createHandler

View File

@ -1,33 +0,0 @@
hapi = require 'hapi'
handlers = require './handlers'
Joi = require 'joi'
createValidation =
attendeePW: Joi.string().max(20).required()
checksum: Joi.string().required()
meetingID: Joi.string().min(3).max(30).required()
moderatorPW: Joi.string().required()
name: Joi.string().regex(/[a-zA-Z0-9]{3,30}/)
record: Joi.boolean()
voiceBridge: Joi.string()
welcome: Joi.string()
routes = [{
method: 'GET',
path: '/',
config: {
handler: handlers.index
}
}, {
method: "GET",
path: "/bigbluebutton/api/create",
config: {
handler: handlers.create,
validate: {
query: createValidation
}
}
}];
exports.routes = routes;

View File

@ -1,32 +0,0 @@
sha1 = require 'js-sha1'
removeChecksumFromQuery = (query) ->
for own propName of query
console.log(propName + "=" + query[propName])
delete query['checksum']
query
buildCreateBaseString = (query) ->
baseString = ""
for own propName of query
propVal = query[propName]
if (propName == "welcome")
propVal = encodeURIComponent(query.welcome).replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A")
baseString += propName + "=" + propVal + "&"
console.log(propName + "=" + query[propName])
console.log("baseString=[" + baseString.slice(0, -1) + "]")
baseString.slice(0, -1)
calculateChecksum = (method, baseString, sharedSecret) ->
qStr = method + baseString + sharedSecret
console.log("[" + qStr + "]")
sha1(qStr)
exports.removeChecksumFromQuery = removeChecksumFromQuery
exports.buildCreateBaseString = buildCreateBaseString
exports.calculateChecksum = calculateChecksum

View File

@ -1,23 +0,0 @@
{
"name": "meetingApi",
"version": "0.0.2",
"private": true,
"scripts": {
"start": "coffee index.coffee"
},
"dependencies": {
"hapi": "2.6.0",
"joi": "2.7.0",
"coffee-script": "1.7.1",
"js-sha1": "0.1.1",
"bunyan": "0.22.2",
"glob": "3.2.6"
},
"devDependencies": {
"coffee-script": "1.7.1",
"mocha": "1.18.2",
"should": "3.3.1",
"glob": "3.2.6",
"chai": "1.9.x"
}
}

View File

@ -1,39 +0,0 @@
hapi = require('hapi')
assert = require('assert')
chai = require('chai')
assert = chai.assert
routes = require('../lib/routes')
# integration tests for API endpoint
# setup server with firing up - use inject instead
server = new hapi.Server()
server.route(routes.routes)
# parseurls endpoint test
describe 'add endpoint', ->
it 'add - should add two numbers together', ->
server.inject({method: 'PUT', url: '/sum/add/5/5'}, (res) ->
assert.deepEqual({'equals': 10}, JSON.parse(res.payload))
done()
)
it 'add - should error if a string is passed', (done) ->
server.inject({method: 'PUT', url: '/sum/add/100/x'}, (res) ->
assert.deepEqual({
'statusCode': 400,
'error': 'Bad Request',
'message': 'the value of b must be a number',
'validation': {
'source': 'path',
'keys': [
'b'
]
}
}, JSON.parse(res.payload))
done()
)

View File

@ -1,3 +0,0 @@

View File

@ -1,11 +0,0 @@
assert = require("assert")
describe "Array", ->
describe '#indexOf()', ->
it 'should return -1 when the value is not present', ->
assert.equal(-1, [1,2,3].indexOf(5))

View File

@ -1,2 +0,0 @@
node_modules
log/*.log

View File

@ -1,65 +0,0 @@
fs = require 'fs'
{print} = require 'util'
{spawn, exec} = require 'child_process'
glob = require 'glob'
REPORTER = "min"
config = {}
config.binPath = './node_modules/.bin/'
# cake test # run all tests
# cake -f test/lib/file.coffee test # run the files passed
# cake -b test # run all tests and stop at first failure
option '-f', '--file [FILE*]', 'input file(s)'
option '-b', '--bail', 'bail'
task 'test', 'Run the test suite', (options) ->
process.env.NODE_ENV = "test"
testFiles = [
]
testOpts = [
'--require', 'coffee-script/register',
'--compilers', 'coffee:coffee-script/register',
'--require', 'should',
'--colors',
'--ignore-leaks',
'--timeout', '15000',
'--reporter', 'spec'
]
if options.bail? and options.bail
testOpts = testOpts.concat('-b')
if options.file?
if _.isArray(options.file)
files = testFiles.concat(options.file)
else
files = testFiles.concat([options.file])
for opt in testOpts.reverse()
files.unshift opt
run 'mocha', files
else
glob 'test/**/*.coffee', (error, files) ->
for opt in testOpts.reverse()
files.unshift opt
run 'mocha', files
# Internal methods
# Spawns an application with `options` and calls `onExit`
# when it finishes.
run = (bin, options, onExit) ->
bin = config.binPath + bin
console.log timeNow() + ' - running: ' + bin + ' ' + (if options? then options.join(' ') else "")
cmd = spawn bin, options
cmd.stdout.on 'data', (data) -> print data.toString()
cmd.stderr.on 'data', (data) -> print data.toString()
cmd.on 'exit', (code) ->
console.log 'done.'
onExit?(code, options)
# Returns a string with the current time to print out.
timeNow = ->
today = new Date()
today.getHours() + ":" + today.getMinutes() + ":" + today.getSeconds()

View File

@ -1,43 +0,0 @@
recordingsWatcher
=============
This app is used to watch the file tree for recording files changes
in the directories
/var/bigbluebutton/published
and
/var/bigbluebutton/unpublished
For each recording modified, we push into Redis:
key = bbb:recordings:<meetingID>
value = a set of JSON strings
{"format": "<format>", "timestamp": "<timestamp>"}
For example:
bbb:recordings:fbdbde6fd7b6499723a101c4c962f03843b4879c
[{"format": "presentation", "timestamp": "1396623833035"}, {"format": "capture", "timestamp": "1396623833045"}]
Instructions:
=============
from Terminal:
$ coffee index.coffee
in another Terminal:
$ curl localhost:4000/recordings?meetingid=fbdbde6fd7b6499723a101c4c962f03843b48
returns an array of stringified json recordings (see above for the structure of the JSON)
if there are no recordings for the given meetingID, the message
"No recordings for meetingid=some_random_string" appears
Running Tests
=============
while the application is running // $ coffee index.coffee
open another console and enter:
$ cake test
or
$ ./node_modules/.bin/mocha --require coffee-script/register --compilers coffee:coffee-script/register --require should --colors --ignore-leaks --timeout 15000 --reporter spec test/routetests.coffee
(where test/routetests.coffee is the collecion of tests you want to execute)

View File

@ -1,13 +0,0 @@
# # Global configurations file
config = {}
# Logging
config.log = {}
config.log.path = if process.env.NODE_ENV == "production"
"/var/log/bigbluebutton/recording-api.log"
else
"./log/recording-api-dev.log"
module.exports = config

View File

@ -1,17 +0,0 @@
hapi = require 'hapi'
log = require './lib/logger'
pack = require './package'
recWatcher = require './lib/recording-dir-watcher'
routes = require './lib/routes'
server = hapi.createServer("0.0.0.0",
parseInt(process.env.PORT, 10) or 4000)
server.start(() ->
log.info(['start'], pack.name + ' - web interface: ' + server.info.uri)
)
server.route(routes.routes)
recWatcher.watch()

View File

@ -1,38 +0,0 @@
util = require './util'
recWatcher = require './recording-dir-watcher'
sharedSecret = '8cd8ef52e8e101574e400365b55e11a6'
index = (req, resp) ->
resp "Hello World!"
createHandler = (req, resp) ->
console.log("CREATE: " + req.originalUrl )
checksum = req.query.checksum
console.log("checksum = [" + checksum + "]")
query = util.removeChecksumFromQuery(req.query)
baseString = util.buildCreateBaseString(query)
ourChecksum = util.calculateChecksum("create", baseString, sharedSecret)
console.log "the checksum from url is \n" + checksum + " and mine is\n" + ourChecksum
if checksum isnt ourChecksum
resp "Fail!"
else
resp "everything is fine"
getRecordings = (req, resp) ->
requestedMeetingID = req.query.meetingid
console.log("recordings for: " + requestedMeetingID)
recWatcher.getRecordingsArray requestedMeetingID, (array) ->
if array?.length > 0
resp JSON.stringify(array)
else
resp "No recordings for meetingid=#{requestedMeetingID}\n"
exports.index = index
exports.create = createHandler
exports.recordings = getRecordings

View File

@ -1,19 +0,0 @@
bunyan = require 'bunyan'
config = require '../config'
logger = bunyan.createLogger({
name: 'bbbnode'
streams: [
{
level: 'debug'
stream: process.stdout
},
{
level: 'info'
path: config.log.path
}
]
})
module.exports = logger

View File

@ -1,57 +0,0 @@
##
## Watches the recording dirs for new recordings
##
chokidar = require 'chokidar'
redis = require 'redis'
log = require './logger'
client = redis.createClient()
baseKey = 'bbb:recordings:'
watch = ->
#clear the keys first
keys = client.keys(baseKey.concat('*'))
client.del(keys)
#start watching
chokidar.watch('/var/bigbluebutton/published', {ignored: /[\/\\]\./}).on 'all', (event, path) ->
somethingChanged(event,path)
chokidar.watch('/var/bigbluebutton/unpublished', {ignored: /[\/\\]\./}).on 'all', (event, path) ->
somethingChanged(event,path)
somethingChanged = (event, path) ->
uri = path.split('/')
if uri[5]? #excludes the parent directories being added
pathArray = path.substring(path.lastIndexOf('/')+1).split('-')
meetingID = pathArray[0]
timestamp = pathArray[1]
thisKey = baseKey.concat(meetingID)
json = {
"format": uri[4]
"timestamp": timestamp
}
log.info(event, path)
str = JSON.stringify(json)
client.sadd(thisKey, str)
getRecordingsArray = (meetingID, callback) ->
thisKey = baseKey.concat(meetingID)
client.smembers thisKey, (err, members) ->
if err
console.log "Error: #{err}"
else
callback members
exports.watch = watch
exports.getRecordingsArray = getRecordingsArray

View File

@ -1,44 +0,0 @@
Joi = require 'joi'
handlers = require './handlers'
createValidation =
attendeePW: Joi.string().max(20).required()
checksum: Joi.string().required()
meetingID: Joi.string().min(3).max(30).required()
moderatorPW: Joi.string().required()
name: Joi.string().regex(/[a-zA-Z0-9]{3,30}/)
record: Joi.boolean()
voiceBridge: Joi.string()
welcome: Joi.string()
recordingsValidation =
meetingid: Joi.string().min(3).max(45).required()
routes = [{
method: 'GET'
path: '/'
config: {
handler: handlers.index
}
}, {
method: "GET"
path: "/bigbluebutton/api/create"
config: {
handler: handlers.create
validate: {
query: createValidation
}
}
}, {
method: "GET"
path: "/recordings"
config: {
handler: handlers.recordings
validate: {
query: recordingsValidation
}
}
}]
exports.routes = routes

View File

@ -1,12 +0,0 @@
parser1 = require 'xml2json'
parser2 = require 'json2xml'
xml2json = (xmlStr) ->
parser1.toJson(xmlStr)
json2xml = (jsonObj) ->
#parser2(jsonObj)
parser1.toXml(jsonObj)
exports.xml2json = xml2json
exports.json2xml = json2xml

View File

@ -1,29 +0,0 @@
{
"name": "recordingsWatcher",
"version": "0.0.2",
"private": true,
"scripts": {
"start": "coffee index.coffee"
},
"dependencies": {
"chokidar": "0.8.2",
"redis": "0.10.1",
"hiredis": "0.1.16",
"hapi": "2.6.0",
"joi": "2.7.0",
"coffee-script": "1.7.1",
"js-sha1": "0.1.1",
"bunyan": "0.22.2",
"json2xml": "0.1.1",
"xml2json": "0.4.0",
"easyxml": "0.0.5",
"glob": "3.2.6"
},
"devDependencies": {
"coffee-script": "1.7.1",
"mocha": "1.18.2",
"should": "3.3.1",
"glob": "3.2.6",
"chai": "1.9.x"
}
}

View File

@ -1,86 +0,0 @@
assert = require('chai').assert
hapi = require('hapi')
routes = require('../lib/routes')
# integration tests for API endpoint
# setup server with firing up - use inject instead
server = new hapi.Server()
server.route(routes.routes)
# parseurls endpoint test
describe 'checking recordings', ->
it 'recordings for a given meetingid', ->
server.inject({method: 'GET', url: '192.168.0.203:4000/recordings?meetingid=fbdbde6fd7b6499723a101c4c962f03843b4879c'}, (res) ->
#console.log "json:" + res.payload
array = [
{
'format': 'presentation'
'timestamp':'1396619572523'
}, {
'format': 'capture'
'timestamp':'1396623833044'
}, {
'format': 'presentation'
'timestamp':'1396620788271'
}, {
'format': 'presentation'
'timestamp':'1396622260421'
}, {
'format': 'capture'
'timestamp':'1396623833035'
}, {
'format': 'capture'
'timestamp':'1396623830000'
}, {
'format': 'capture'
'timestamp':'1396619572523'
}, {
'format': 'capture'
'timestamp':'1396622260421'
}, {
'format': 'capture'
'timestamp':'1396620788271'
}, {
'format': 'presentation'
'timestamp':'1396623833035'
}, {
'format': 'capture'
'timestamp':'1396623831111'
}
]
parsedOnce = JSON.parse(res.payload)
index = 0
while index < parsedOnce.length
assert.deepEqual(JSON.stringify(array[index]), parsedOnce[index])
index++
#console.log index
)
###it 'add - should add two numbers together', ->
server.inject({method: 'PUT', url: '/sum/add/5/5'}, (res) ->
console.log "json:" +JSON.stringify(res.payload)
assert.deepEqual({'equals': 10}, JSON.parse(res.payload))
done()
)###
###it 'add - should error if a string is passed', (done) ->
server.inject({method: 'PUT', url: '/sum/add/100/1'}, (res) ->
console.log "json:" +JSON.stringify(res)
assert.deepEqual({
'statusCode': 400
'error': 'Bad Request'
'message': 'the value of b must be a number'
'validation': {
'source': 'path'
'keys': [
'b'
]
}
}, JSON.parse(res.payload))
done()
)###

View File

@ -1,3 +0,0 @@

View File

@ -1,11 +0,0 @@
assert = require("assert")
describe "Array", ->
describe '#indexOf()', ->
it 'should return -1 when the value is not present', ->
assert.equal(-1, [1,2,3].indexOf(5))

View File

@ -1,58 +0,0 @@
assert = require("assert")
util = require '../lib/util'
sampleXml = """
<recording>
<id>6e35e3b2778883f5db637d7a5dba0a427f692e91-1398363221956</id>
<state>available</state>
<published>true</published>
<start_time>1398363223514</start_time>
<end_time>1398363348994</end_time>
<playback>
<format>presentation</format>
<link>http://example.com/playback/presentation/playback.html?meetingID=6e35e3b2778883f5db637d7a5dba0a427f692e91-1398363221956</link>
<processing_time>5429</processing_time>
<duration>101014</duration>
<extension>
<custom>... Any XML element, to be passed through into playback format element.</custom>
</extension>
</playback>
<meta>
<meetingId>English 101</meetingId>
<meetingName>English 101</meetingName>
<description>Test recording</description>
<title>English 101</title>
</meta>
</recording>
"""
jsonResult = {
"recording": {
"id": "6e35e3b2778883f5db637d7a5dba0a427f692e91-1398363221956",
"state": "available",
"published": true,
"start_time": 1398363223514,
"end_time": 1398363348994,
"playback": {
"format": "presentation",
"link": "http://example.com/playback/presentation/playback.html?meetingID=6e35e3b2778883f5db637d7a5dba0a427f692e91-1398363221956",
"processing_time": 5429,
"duration": 101014,
"extension": {
"custom": "... Any XML element, to be passed through into playback format element."
}
},
"meta": {
"meetingId": "English 101",
"meetingName": "English 101",
"description": "Test recording",
"title": "English 101"
}
}
}
describe "util", ->
describe 'xml2json()', ->
it 'should return a json string', ->
assert.deepEqual(jsonResult, JSON.parse(util.xml2json(sampleXml)))

View File

@ -1,34 +0,0 @@
util = require './lib/util'
sampleXml = """
<recording>
<id>6e35e3b2778883f5db637d7a5dba0a427f692e91-1398363221956</id>
<state>available</state>
<published>true</published>
<start_time>1398363223514</start_time>
<end_time>1398363348994</end_time>
<playback>
<format>presentation</format>
<link>http://example.com/playback/presentation/playback.html?meetingID=6e35e3b2778883f5db637d7a5dba0a427f692e91-1398363221956</link>
<processing_time>5429</processing_time>
<duration>101014</duration>
<extension>
<custom>... Any XML element, to be passed through into playback format element.</custom>
</extension>
</playback>
<meta>
<meetingId>English 101</meetingId>
<meetingName>English 101</meetingName>
<description>Test recording</description>
<title>English 101</title>
</meta>
</recording>
"""
jsonObj = util.xml2json( sampleXml )
console.log(jsonObj)
jstr = util.json2xml(JSON.parse(jsonObj))
console.log(jstr)

View File

@ -7,11 +7,11 @@ GEM
java_properties (0.0.4)
jwt (2.1.0)
mini_portile2 (2.3.0)
nokogiri (1.8.1)
nokogiri (1.8.5)
mini_portile2 (~> 2.3.0)
open4 (1.3.4)
redis (4.0.1)
rubyzip (1.2.1)
rubyzip (1.2.2)
trollop (2.1.2)
PLATFORMS