Merge branch 'master' into record-indicator

This commit is contained in:
Anton Georgiev 2019-02-07 18:01:37 -05:00 committed by GitHub
commit 1a369f0fc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 530 additions and 260 deletions

View File

@ -7,6 +7,7 @@ import handleMeetingLocksChange from './handlers/meetingLockChange';
import handleUserLockChange from './handlers/userLockChange';
import handleRecordingStatusChange from './handlers/recordingStatusChange';
import handleRecordingTimerChange from './handlers/recordingTimerChange';
import handleTimeRemainingUpdate from './handlers/timeRemainingUpdate';
import handleChangeWebcamOnlyModerator from './handlers/webcamOnlyModerator';
RedisPubSub.on('MeetingCreatedEvtMsg', handleMeetingCreation);
@ -18,3 +19,4 @@ RedisPubSub.on('UserLockedInMeetingEvtMsg', handleUserLockChange);
RedisPubSub.on('RecordingStatusChangedEvtMsg', handleRecordingStatusChange);
RedisPubSub.on('UpdateRecordingTimerEvtMsg', handleRecordingTimerChange);
RedisPubSub.on('WebcamsOnlyForModeratorChangedEvtMsg', handleChangeWebcamOnlyModerator);
RedisPubSub.on('MeetingTimeRemainingUpdateEvtMsg', handleTimeRemainingUpdate);

View File

@ -3,6 +3,8 @@ import addMeeting from '../modifiers/addMeeting';
export default function handleMeetingCreation({ body }) {
const meeting = body.props;
const durationInSecods = (meeting.durationProps.duration * 60);
meeting.durationProps.timeRemaining = durationInSecods;
check(meeting, Object);
return addMeeting(meeting);

View File

@ -0,0 +1,30 @@
import { check } from 'meteor/check';
import Meetings from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
export default function handleTimeRemainingUpdate({ body }, meetingId) {
check(meetingId, String);
check(body, {
timeLeftInSec: Number,
});
const { timeLeftInSec } = body;
const selector = {
meetingId,
};
const modifier = {
$set: {
'durationProps.timeRemaining': timeLeftInSec,
},
};
const cb = (err) => {
if (err) {
Logger.error(`Changing recording time: ${err}`);
}
};
return Meetings.upsert(selector, modifier, cb);
}

View File

@ -36,6 +36,7 @@ export default function addMeeting(meeting) {
userInactivityInspectTimerInMinutes: Number,
userInactivityThresholdInMinutes: Number,
userActivitySignResponseDelayInMinutes: Number,
timeRemaining: Number,
},
welcomeProp: {
welcomeMsg: String,

View File

@ -58,10 +58,10 @@ export default function addUser(meetingId, user) {
let userRole = user.role;
if (
dummyUser &&
dummyUser.clientType === 'HTML5' &&
userRole === ROLE_MODERATOR &&
!ALLOW_HTML5_MODERATOR
dummyUser
&& dummyUser.clientType === 'HTML5'
&& userRole === ROLE_MODERATOR
&& !ALLOW_HTML5_MODERATOR
) {
userRole = ROLE_VIEWER;
}
@ -110,6 +110,8 @@ export default function addUser(meetingId, user) {
if (userRole === ROLE_MODERATOR) {
changeRole(ROLE_MODERATOR, true, userId, meetingId);
} else {
changeRole(ROLE_MODERATOR, false, userId, meetingId);
}
if (Meeting.usersProp.guestPolicy === GUEST_ALWAYS_ACCEPT) {

View File

@ -1,6 +1,7 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { defineMessages, injectIntl } from 'react-intl';
import injectNotify from '/imports/ui/components/toast/inject-notify/component';
import humanizeSeconds from '/imports/utils/humanizeSeconds';
import _ from 'lodash';
import BreakoutRemainingTimeComponent from './component';
@ -77,7 +78,14 @@ const startCounter = (sec, set, get, interval) => {
};
export default injectIntl(withTracker(({ breakoutRoom, intl, messageDuration }) => {
export default injectNotify(injectIntl(withTracker(({
breakoutRoom,
intl,
notify,
messageDuration,
timeEndedMessage,
alertMessageUnderOneMinute,
}) => {
const data = {};
if (breakoutRoom) {
const roomRemainingTime = breakoutRoom.timeRemaining;
@ -94,16 +102,17 @@ export default injectIntl(withTracker(({ breakoutRoom, intl, messageDuration })
clearInterval(timeRemainingInterval);
}
if (timeRemaining) {
if (timeRemaining >= 0) {
if (timeRemaining > 0) {
const time = getTimeRemaining();
if (time === (1 * 60) && alertMessageUnderOneMinute) notify(alertMessageUnderOneMinute, 'info', 'rooms');
data.message = intl.formatMessage(messageDuration, { 0: humanizeSeconds(time) });
} else {
clearInterval(timeRemainingInterval);
data.message = intl.formatMessage(intlMessages.breakoutWillClose);
data.message = intl.formatMessage(timeEndedMessage || intlMessages.breakoutWillClose);
}
} else if (breakoutRoom) {
data.message = intl.formatMessage(intlMessages.calculatingBreakoutTimeRemaining);
}
return data;
})(breakoutRemainingTimeContainer));
})(breakoutRemainingTimeContainer)));

View File

@ -12,6 +12,10 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.title',
description: 'breakout title',
},
breakoutAriaTitle: {
id: 'app.createBreakoutRoom.ariaTitle',
description: 'breakout aria title',
},
breakoutDuration: {
id: 'app.createBreakoutRoom.duration',
description: 'breakout duration time',
@ -119,7 +123,7 @@ class BreakoutRoom extends Component {
this.setState({ joinedAudioOnly: false, breakoutId });
}
renderUserActions(breakoutId) {
renderUserActions(breakoutId, number) {
const {
isMicrophoneUser,
isModerator,
@ -145,6 +149,9 @@ class BreakoutRoom extends Component {
label={generated && requestedBreakoutId === breakoutId
? intl.formatMessage(intlMessages.generatedURL)
: intl.formatMessage(intlMessages.breakoutJoin)}
aria-label={generated && requestedBreakoutId === breakoutId
? intl.formatMessage(intlMessages.generatedURL)
: `${intl.formatMessage(intlMessages.breakoutJoin)} ${number}`}
onClick={() => this.getBreakoutURL(breakoutId)}
disabled={disable}
className={styles.joinButton}
@ -186,13 +193,13 @@ class BreakoutRoom extends Component {
const roomItems = breakoutRooms.map(item => (
<div className={styles.content} key={`breakoutRoomList-${item.breakoutId}`}>
<span>{intl.formatMessage(intlMessages.breakoutRoom, item.sequence.toString())}</span>
<span aria-hidden>{intl.formatMessage(intlMessages.breakoutRoom, item.sequence.toString())}</span>
{waiting && requestedBreakoutId === item.breakoutId ? (
<span>
{intl.formatMessage(intlMessages.generatingURL)}
<span className={styles.connectingAnimation} />
</span>
) : this.renderUserActions(item.breakoutId)}
) : this.renderUserActions(item.breakoutId, item.sequence.toString())}
</div>
));
@ -217,12 +224,13 @@ class BreakoutRoom extends Component {
} = this.props;
return (
<div className={styles.panel}>
<div className={styles.header} role="button" onClick={closeBreakoutPanel} >
<span>
<Icon iconName="left_arrow" />
{intl.formatMessage(intlMessages.breakoutTitle)}
</span>
</div>
<Button
icon="left_arrow"
label={intl.formatMessage(intlMessages.breakoutTitle)}
aria-label={intl.formatMessage(intlMessages.breakoutAriaTitle)}
className={styles.header}
onClick={closeBreakoutPanel}
/>
{this.renderBreakoutRooms()}
{this.renderDuration()}
{

View File

@ -23,8 +23,14 @@
}
.header {
position: relative;
margin-bottom: 2rem;
cursor: pointer;
margin-right: auto;
padding-left: 0;
> i {
color: var(--color-gray-dark);
}
}
.content {

View File

@ -15,11 +15,11 @@ const isUrlValid = (url) => {
const getUrlFromVideoId = id => (id ? `${YOUTUBE_PREFIX}${id}` : '');
// https://stackoverflow.com/questions/3452546/how-do-i-get-the-youtube-video-id-from-a-url
const videoIdFromUrl = (url) => {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
return params.get('v');
const regExp = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/;
const match = url.match(regExp);
return (match && match[1].length == 11) ? match[1] : false;
};
const startWatching = (url) => {

View File

@ -294,7 +294,7 @@ class NavBar extends PureComponent {
<div className={styles.center}>
{this.renderPresentationTitle()}
{recordProps.record
? <span className={styles.presentationTitleSeparator}>|</span>
? <span className={styles.presentationTitleSeparator} aria-hidden>|</span>
: null}
<RecordingIndicator
{...recordProps}

View File

@ -88,7 +88,6 @@ class RecordingIndicator extends React.PureComponent {
{recording
? <span aria-hidden>{humanizeSeconds(time)}</span> : <span>{buttonTitle}</span>}
</div>
</div>
) : null }

View File

@ -9,7 +9,6 @@ const COLORS = [
];
const propTypes = {
children: PropTypes.string.isRequired,
color: PropTypes.oneOf(COLORS),
};
@ -18,14 +17,14 @@ const defaultProps = {
};
const NotificationsBar = (props) => {
const { color } = props;
const { color, children } = props;
return (
<div
role="alert"
className={cx(styles.notificationsBar, styles[color])}
>
{props.children}
{children}
</div>
);
};

View File

@ -4,8 +4,9 @@ import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import Auth from '/imports/ui/services/auth';
import humanizeSeconds from '/imports/utils/humanizeSeconds';
import Meetings from '/imports/api/meetings';
import NavBarService from '../nav-bar/service';
import BreakoutRemainingTime from '/imports/ui/components/breakout-room/breakout-remaining-time/container';
import NotificationsBar from './component';
@ -43,6 +44,22 @@ const intlMessages = defineMessages({
id: 'app.calculatingBreakoutTimeRemaining',
description: 'Message that tells that the remaining time is being calculated',
},
meetingTimeRemaining: {
id: 'app.meeting.meetingTimeRemaining',
description: 'Message that tells how much time is remaining for the meeting',
},
meetingWillClose: {
id: 'app.meeting.meetingTimeHasEnded',
description: 'Message that tells time has ended and meeting will close',
},
alertMeetingEndsUnderOneMinute: {
id: 'app.meeting.alertMeetingEndsUnderOneMinute',
description: 'Alert that tells that the meeting end under a minute',
},
alertBreakoutEndsUnderOneMinute: {
id: 'app.meeting.alertBreakoutEndsUnderOneMinute',
description: 'Alert that tells that the breakout end under a minute',
},
});
const NotificationsBarContainer = (props) => {
@ -60,22 +77,14 @@ const NotificationsBarContainer = (props) => {
};
let retrySeconds = 0;
let timeRemaining = 0;
const retrySecondsDep = new Tracker.Dependency();
const timeRemainingDep = new Tracker.Dependency();
let retryInterval = null;
let timeRemainingInterval = null;
const getRetrySeconds = () => {
retrySecondsDep.depend();
return retrySeconds;
};
const getTimeRemaining = () => {
timeRemainingDep.depend();
return timeRemaining;
};
const setRetrySeconds = (sec = 0) => {
if (sec !== retrySeconds) {
retrySeconds = sec;
@ -83,13 +92,6 @@ const setRetrySeconds = (sec = 0) => {
}
};
const setTimeRemaining = (sec = 0) => {
if (sec !== timeRemaining) {
timeRemaining = sec;
timeRemainingDep.changed();
}
};
const startCounter = (sec, set, get, interval) => {
clearInterval(interval);
set(sec);
@ -137,37 +139,38 @@ export default injectIntl(withTracker(({ intl }) => {
const currentBreakout = breakouts.find(b => b.breakoutId === meetingId);
if (currentBreakout) {
const roomRemainingTime = currentBreakout.timeRemaining;
if (!timeRemainingInterval && roomRemainingTime) {
timeRemainingInterval = startCounter(
roomRemainingTime,
setTimeRemaining,
getTimeRemaining,
timeRemainingInterval,
);
}
} else if (timeRemainingInterval) {
clearInterval(timeRemainingInterval);
}
timeRemaining = getTimeRemaining();
if (timeRemaining) {
if (timeRemaining > 0) {
data.message = intl.formatMessage(
intlMessages.breakoutTimeRemaining,
{ 0: humanizeSeconds(timeRemaining) },
);
} else {
clearInterval(timeRemainingInterval);
data.message = intl.formatMessage(intlMessages.breakoutWillClose);
}
} else if (currentBreakout) {
data.message = intl.formatMessage(intlMessages.calculatingBreakoutTimeRemaining);
data.message = (
<BreakoutRemainingTime
breakoutRoom={currentBreakout}
messageDuration={intlMessages.breakoutTimeRemaining}
timeEndedMessage={intlMessages.breakoutWillClose}
alertMessageUnderOneMinute={intl.formatMessage(intlMessages.alertBreakoutEndsUnderOneMinute)}
/>
);
}
}
const Meeting = Meetings.findOne({ meetingId: Auth.meetingID });
if (Meeting) {
const { timeRemaining } = Meeting.durationProps;
const { isBreakout } = Meeting.meetingProp;
const underThirtyMin = timeRemaining && timeRemaining <= (30 * 60);
if (underThirtyMin && !isBreakout) {
data.message = (
<BreakoutRemainingTime
breakoutRoom={Meeting.durationProps}
messageDuration={intlMessages.meetingTimeRemaining}
timeEndedMessage={intlMessages.meetingWillClose}
alertMessageUnderOneMinute={intl.formatMessage(intlMessages.alertMeetingEndsUnderOneMinute)}
/>
);
}
}
data.color = 'primary';
return data;
})(NotificationsBarContainer));

View File

@ -242,6 +242,7 @@ class Poll extends Component {
color="default"
onClick={this.toggleCustomFields}
label={intl.formatMessage(intlMessages.customPollLabel)}
aria-expanded={customPollReq}
/>
{!customPollReq ? null : this.renderCustomView()}
</div>

View File

@ -131,12 +131,12 @@ class LiveResult extends Component {
)
}
<div className={styles.container}>
<div className={styles.usersHeading}>
<h3 className={styles.usersHeading}>
{intl.formatMessage(intlMessages.usersTitle)}
</div>
<div className={styles.responseHeading}>
</h3>
<h3 className={styles.responseHeading}>
{intl.formatMessage(intlMessages.responsesTitle)}
</div>
</h3>
{userAnswers}
</div>
</div>

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import WhiteboardOverlayContainer from '/imports/ui/components/whiteboard/whiteboard-overlay/container';
import WhiteboardToolbarContainer from '/imports/ui/components/whiteboard/whiteboard-toolbar/container';
@ -10,11 +11,25 @@ import PresentationToolbarContainer from './presentation-toolbar/container';
import PresentationOverlayContainer from './presentation-overlay/container';
import Slide from './slide/component';
import { styles } from './styles.scss';
import MediaService from '../media/service';
import MediaService, { shouldEnableSwapLayout } from '../media/service';
import PresentationCloseButton from './presentation-close-button/component';
import FullscreenButton from '../video-provider/fullscreen-button/component';
export default class PresentationArea extends Component {
const intlMessages = defineMessages({
presentationLabel: {
id: 'app.presentationUploder.title',
description: 'presentation area element label',
},
});
const isFullscreen = () => document.fullscreenElement !== null;
const renderPresentationClose = () => {
if (!shouldEnableSwapLayout() || isFullscreen()) return null;
return <PresentationCloseButton toggleSwapLayout={MediaService.toggleSwapLayout} />;
};
class PresentationArea extends Component {
constructor() {
super();
@ -64,6 +79,7 @@ export default class PresentationArea extends Component {
const presentationSizes = {};
if (refPresentationArea && refWhiteboardArea) {
const { userIsPresenter, multiUser } = this.props;
// By default presentation sizes are equal to the sizes of the refPresentationArea
// direct parent of the svg wrapper
let { clientWidth, clientHeight } = refPresentationArea;
@ -71,7 +87,7 @@ export default class PresentationArea extends Component {
// if a user is a presenter - this means there is a whiteboard toolbar on the right
// and we have to get the width/height of the refWhiteboardArea
// (inner hidden div with absolute position)
if (this.props.userIsPresenter || this.props.multiUser) {
if (userIsPresenter || multiUser) {
({ clientWidth, clientHeight } = refWhiteboardArea);
}
@ -106,9 +122,10 @@ export default class PresentationArea extends Component {
}
calculateSize() {
const originalWidth = this.props.currentSlide.calculatedData.width;
const originalHeight = this.props.currentSlide.calculatedData.height;
const { presentationHeight, presentationWidth } = this.state;
const { currentSlide } = this.props;
const originalWidth = currentSlide.calculatedData.width;
const originalHeight = currentSlide.calculatedData.height;
let adjustedWidth;
let adjustedHeight;
@ -139,13 +156,14 @@ export default class PresentationArea extends Component {
};
}
zoomChanger(zoom) {
let newZoom = zoom;
const isDifferent = newZoom !== this.state.zoom;
zoomChanger(incomingZoom) {
const { zoom } = this.state;
let newZoom = incomingZoom;
const isDifferent = newZoom !== zoom;
if (newZoom <= HUNDRED_PERCENT) {
newZoom = HUNDRED_PERCENT;
} else if (zoom >= MAX_PERCENT) {
} else if (incomingZoom >= MAX_PERCENT) {
newZoom = MAX_PERCENT;
}
if (isDifferent) this.setState({ zoom: newZoom });
@ -167,23 +185,29 @@ export default class PresentationArea extends Component {
}
fitToWidthHandler() {
this.setState({
fitToWidth: !this.state.fitToWidth,
});
const { fitToWidth } = this.state;
this.setState({ fitToWidth: !fitToWidth });
}
isPresentationAccessible() {
const { currentSlide } = this.props;
// sometimes tomcat publishes the slide url, but the actual file is not accessible (why?)
return currentSlide && currentSlide.calculatedData;
}
// renders the whole presentation area
renderPresentationArea() {
if (!this.isPresentationAccessible()) {
return null;
}
const { fitToWidth } = this.state;
const { podId, currentSlide } = this.props;
if (!this.isPresentationAccessible()) return null;
// to control the size of the svg wrapper manually
// and adjust cursor's thickness, so that svg didn't scale it automatically
const adjustedSizes = this.calculateSize();
// a reference to the slide object
const slideObj = this.props.currentSlide;
const slideObj = currentSlide;
const presentationCloseButton = this.renderPresentationClose();
const presentationCloseButton = renderPresentationClose();
const presentationFullscreenButton = this.renderPresentationFullscreen();
// retrieving the pre-calculated data from the slide object
@ -196,7 +220,7 @@ export default class PresentationArea extends Component {
viewBoxHeight,
imageUri,
} = slideObj.calculatedData;
const svgDimensions = this.state.fitToWidth ? {
const svgDimensions = fitToWidth ? {
position: 'absolute',
width: 'inherit',
} : {
@ -226,8 +250,10 @@ export default class PresentationArea extends Component {
>
<svg
data-test="whiteboard"
width={width}
height={height}
{...{
width,
height,
}}
ref={(ref) => { if (ref != null) { this.svggroup = ref; } }}
viewBox={`${x} ${y} ${viewBoxWidth} ${viewBoxHeight}`}
version="1.1"
@ -246,18 +272,20 @@ export default class PresentationArea extends Component {
svgHeight={height}
/>
<AnnotationGroupContainer
width={width}
height={height}
{...{
width,
height,
}}
whiteboardId={slideObj.id}
/>
<CursorWrapperContainer
podId={this.props.podId}
podId={podId}
whiteboardId={slideObj.id}
widthRatio={slideObj.widthRatio}
physicalWidthRatio={adjustedSizes.width / width}
slideWidth={width}
slideHeight={height}
radius={this.state.fitToWidth ? 2 : 5}
radius={fitToWidth ? 2 : 5}
/>
</g>
{this.renderOverlays(slideObj, adjustedSizes)}
@ -269,9 +297,14 @@ export default class PresentationArea extends Component {
}
renderOverlays(slideObj, adjustedSizes) {
if (!this.props.userIsPresenter && !this.props.multiUser) {
return null;
}
const {
userIsPresenter, multiUser, podId, currentSlide,
} = this.props;
const {
delta, zoom, touchZoom, fitToWidth,
} = this.state;
if (!userIsPresenter && !multiUser) return null;
// retrieving the pre-calculated data from the slide object
const {
@ -285,24 +318,31 @@ export default class PresentationArea extends Component {
return (
<PresentationOverlayContainer
podId={this.props.podId}
currentSlideNum={this.props.currentSlide.num}
slide={slideObj}
{...{
podId,
touchZoom,
fitToWidth,
zoom,
delta,
viewBoxWidth,
viewBoxHeight,
adjustedSizes,
}}
currentSlideNum={currentSlide.num}
whiteboardId={slideObj.id}
slide={slideObj}
slideWidth={width}
slideHeight={height}
delta={this.state.delta}
viewBoxWidth={viewBoxWidth}
viewBoxHeight={viewBoxHeight}
zoom={this.state.zoom}
zoomChanger={this.zoomChanger}
adjustedSizes={adjustedSizes}
getSvgRef={this.getSvgRef}
presentationSize={this.getPresentationSizesAvailable()}
touchZoom={this.state.touchZoom}
fitToWidth={this.state.fitToWidth}
>
<WhiteboardOverlayContainer
{...{
zoom,
viewBoxWidth,
viewBoxHeight,
}}
getSvgRef={this.getSvgRef}
whiteboardId={slideObj.id}
slideWidth={width}
@ -310,11 +350,8 @@ export default class PresentationArea extends Component {
viewBoxX={x}
viewBoxY={y}
pointChanger={this.pointUpdate}
viewBoxWidth={viewBoxWidth}
viewBoxHeight={viewBoxHeight}
physicalSlideWidth={(adjustedSizes.width / slideObj.widthRatio) * 100}
physicalSlideHeight={(adjustedSizes.height / slideObj.heightRatio) * 100}
zoom={this.state.zoom}
zoomChanger={this.zoomChanger}
touchUpdate={this.touchUpdate}
/>
@ -322,42 +359,34 @@ export default class PresentationArea extends Component {
);
}
isPresentationAccessible() {
// sometimes tomcat publishes the slide url, but the actual file is not accessible (why?)
return this.props.currentSlide && this.props.currentSlide.calculatedData;
};
isFullscreen() {
return document.fullscreenElement !== null;
}
renderPresentationClose() {
if (!MediaService.shouldEnableSwapLayout() || this.isFullscreen()) {
return null;
}
return <PresentationCloseButton toggleSwapLayout={MediaService.toggleSwapLayout} />;
};
renderPresentationFullscreen() {
if (this.isFullscreen()) {
return null;
}
const { intl } = this.props;
if (isFullscreen()) return null;
const full = () => this.refPresentationContainer.requestFullscreen();
return <FullscreenButton handleFullscreen={full} dark />;
return (
<FullscreenButton
handleFullscreen={full}
elementName={intl.formatMessage(intlMessages.presentationLabel)}
dark
/>
);
}
renderPresentationToolbar() {
if (!this.props.currentSlide) {
return null;
}
const { zoom } = this.state;
const { currentSlide, podId } = this.props;
if (!currentSlide) return null;
return (
<PresentationToolbarContainer
podId={this.props.podId}
currentSlideNum={this.props.currentSlide.num}
presentationId={this.props.currentSlide.presentationId}
zoom={this.state.zoom}
{...{
podId,
zoom,
}}
currentSlideNum={currentSlide.num}
presentationId={currentSlide.presentationId}
zoomChanger={this.zoomChanger}
fitToWidthHandler={this.fitToWidthHandler}
/>
@ -365,24 +394,27 @@ export default class PresentationArea extends Component {
}
renderWhiteboardToolbar() {
if (!this.isPresentationAccessible()) {
return null;
}
const { currentSlide } = this.props;
if (!this.isPresentationAccessible()) return null;
const adjustedSizes = this.calculateSize();
return (
<WhiteboardToolbarContainer
whiteboardId={this.props.currentSlide.id}
whiteboardId={currentSlide.id}
height={adjustedSizes.height}
/>
);
}
render() {
const { showSlide } = this.state;
const { userIsPresenter, multiUser } = this.props;
return (
<div
ref={(ref) => { this.refPresentationContainer = ref; }}
className={styles.presentationContainer}>
className={styles.presentationContainer}
>
<div
ref={(ref) => { this.refPresentationArea = ref; }}
className={styles.presentationArea}
@ -391,12 +423,8 @@ export default class PresentationArea extends Component {
ref={(ref) => { this.refWhiteboardArea = ref; }}
className={styles.whiteboardSizeAvailable}
/>
{this.state.showSlide
? this.renderPresentationArea()
: null }
{this.props.userIsPresenter || this.props.multiUser
? this.renderWhiteboardToolbar()
: null }
{showSlide ? this.renderPresentationArea() : null }
{userIsPresenter || multiUser ? this.renderWhiteboardToolbar() : null }
</div>
{this.renderPresentationToolbar()}
</div>
@ -404,7 +432,10 @@ export default class PresentationArea extends Component {
}
}
export default injectIntl(PresentationArea);
PresentationArea.propTypes = {
intl: intlShape.isRequired,
podId: PropTypes.string.isRequired,
// Defines a boolean value to detect whether a current user is a presenter
userIsPresenter: PropTypes.bool.isRequired,

View File

@ -1,5 +1,13 @@
@import "/imports/ui/stylesheets/variables/_all";
.visuallyhidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px; width: 1px;
margin: -1px; padding: 0; border: 0;
}
.presentationToolbarWrapper,
.presentationControls,
.zoomWrapper {

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import { styles } from '../styles.scss';
import HoldButton from './holdButton/component';
@ -8,48 +8,30 @@ import HoldButton from './holdButton/component';
const DELAY_MILLISECONDS = 200;
const STEP_TIME = 100;
export default class ZoomTool extends Component {
static renderAriaLabelsDescs() {
return (
<div hidden key="hidden-div">
<div id="zoomInLabel">
<FormattedMessage
id="app.presentation.presentationToolbar.zoomInLabel"
description="Aria label for increment zoom level"
defaultMessage="Increment zoom"
/>
</div>
<div id="zoomInDesc">
<FormattedMessage
id="app.presentation.presentationToolbar.zoomInDesc"
description="Aria description for increment zoom level"
defaultMessage="Increment zoom"
/>
</div>
<div id="zoomOutLabel">
<FormattedMessage
id="app.presentation.presentationToolbar.zoomOutLabel"
description="Aria label for decrement zoom level"
defaultMessage="Decrement zoom"
/>
</div>
<div id="zoomOutDesc">
<FormattedMessage
id="app.presentation.presentationToolbar.zoomOutDesc"
description="Aria description for decrement zoom level"
defaultMessage="Decrement zoom"
/>
</div>
<div id="zoomIndicator">
<FormattedMessage
id="app.presentation.presentationToolbar.zoomIndicator"
description="Aria label for current zoom level"
defaultMessage="Current zoom level"
/>
</div>
</div>
);
}
const intlMessages = defineMessages({
zoomInLabel: {
id: 'app.presentation.presentationToolbar.zoomInLabel',
description: 'Aria label for increment zoom level',
},
zoomInDesc: {
id: 'app.presentation.presentationToolbar.zoomInDesc',
description: 'Aria description for increment zoom level',
},
zoomOutLabel: {
id: 'app.presentation.presentationToolbar.zoomOutLabel',
description: 'Aria label for decrement zoom level',
},
zoomOutDesc: {
id: 'app.presentation.presentationToolbar.zoomOutDesc',
description: 'Aria description for decrement zoom level',
},
zoomIndicator: {
id: 'app.presentation.presentationToolbar.zoomIndicator',
description: 'Aria label for current zoom level',
},
});
class ZoomTool extends Component {
constructor(props) {
super(props);
this.increment = this.increment.bind(this);
@ -64,6 +46,7 @@ export default class ZoomTool extends Component {
mouseHolding: false,
};
}
componentDidUpdate() {
const isDifferent = this.props.value !== this.state.value;
if (isDifferent) this.onChanger(this.props.value);
@ -100,6 +83,7 @@ export default class ZoomTool extends Component {
const increaseZoom = this.state.value + step;
this.onChanger(increaseZoom);
}
decrement() {
const {
step,
@ -144,10 +128,10 @@ export default class ZoomTool extends Component {
value,
minBound,
maxBound,
intl,
} = this.props;
return (
[
ZoomTool.renderAriaLabelsDescs(),
(
<HoldButton
key="zoom-tool-1"
@ -157,10 +141,8 @@ export default class ZoomTool extends Component {
>
<Button
key="zoom-tool-1"
aria-labelledby="zoomOutLabel"
aria-describedby="zoomOutDesc"
role="button"
label="-"
aria-label={intl.formatMessage(intlMessages.zoomOutLabel)}
label={intl.formatMessage(intlMessages.zoomOutLabel)}
icon="minus"
onClick={() => {}}
disabled={(value <= minBound)}
@ -172,11 +154,12 @@ export default class ZoomTool extends Component {
(
<span
key="zoom-tool-2"
aria-labelledby="zoomIndicator"
aria-describedby={this.state.value}
className={styles.zoomPercentageDisplay}
>
{`${this.state.value}%`}
<span className={styles.visuallyhidden}>
{`${intl.formatMessage(intlMessages.zoomIndicator)} ${this.state.value}%`}
</span>
<span aria-hidden>{`${this.state.value}%`}</span>
</span>
),
(
@ -188,10 +171,8 @@ export default class ZoomTool extends Component {
>
<Button
key="zoom-tool-3"
aria-labelledby="zoomInLabel"
aria-describedby="zoomInDesc"
role="button"
label="+"
aria-label={intl.formatMessage(intlMessages.zoomInLabel)}
label={intl.formatMessage(intlMessages.zoomInLabel)}
icon="plus"
onClick={() => {}}
disabled={(value >= maxBound)}
@ -205,6 +186,8 @@ export default class ZoomTool extends Component {
}
}
export default injectIntl(ZoomTool);
const propTypes = {
value: PropTypes.number.isRequired,
change: PropTypes.func.isRequired,

View File

@ -1,8 +1,17 @@
import React from 'react';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import PropTypes from 'prop-types';
import FullscreenButton from '../video-provider/fullscreen-button/component';
import { styles } from './styles';
export default class ScreenshareComponent extends React.Component {
const intlMessages = defineMessages({
screenShareLabel: {
id: 'app.screenshare.screenShareLabel',
description: 'screen share area element label',
},
});
class ScreenshareComponent extends React.Component {
constructor() {
super();
this.state = {
@ -13,18 +22,21 @@ export default class ScreenshareComponent extends React.Component {
}
componentDidMount() {
this.props.presenterScreenshareHasStarted();
const { presenterScreenshareHasStarted } = this.props;
presenterScreenshareHasStarted();
}
componentWillReceiveProps(nextProps) {
if (this.props.isPresenter && !nextProps.isPresenter) {
this.props.unshareScreen();
const { isPresenter, unshareScreen } = this.props;
if (isPresenter && !nextProps.isPresenter) {
unshareScreen();
}
}
componentWillUnmount() {
this.props.presenterScreenshareHasEnded();
this.props.unshareScreen();
const { presenterScreenshareHasEnded, unshareScreen } = this.props;
presenterScreenshareHasEnded();
unshareScreen();
}
onVideoLoad() {
@ -32,24 +44,29 @@ export default class ScreenshareComponent extends React.Component {
}
renderFullscreenButton() {
const { intl } = this.props;
const full = () => {
if (!this.videoTag) {
return;
}
if (!this.videoTag) return;
this.videoTag.requestFullscreen();
};
return <FullscreenButton handleFullscreen={full} />;
return (
<FullscreenButton
handleFullscreen={full}
elementName={intl.formatMessage(intlMessages.screenShareLabel)}
/>
);
}
render() {
const { loaded } = this.state;
const style = {
right: 0,
bottom: 0,
};
return (
[!this.state.loaded ? (<div key="screenshareArea" innerStyle={style} className={styles.connecting} />) : null,
[!loaded ? (<div key="screenshareArea" innerStyle={style} className={styles.connecting} />) : null,
this.renderFullscreenButton(),
(
<video
@ -65,3 +82,13 @@ export default class ScreenshareComponent extends React.Component {
);
}
}
export default injectIntl(ScreenshareComponent);
ScreenshareComponent.propTypes = {
intl: intlShape.isRequired,
isPresenter: PropTypes.bool.isRequired,
unshareScreen: PropTypes.func.isRequired,
presenterScreenshareHasEnded: PropTypes.func.isRequired,
presenterScreenshareHasStarted: PropTypes.func.isRequired,
};

View File

@ -1,7 +1,8 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import cx from 'classnames';
import PropTypes from 'prop-types';
import { styles } from './styles';
const intlMessages = defineMessages({
@ -11,22 +12,43 @@ const intlMessages = defineMessages({
},
});
const FullscreenButtonComponent = ({ intl, handleFullscreen, dark }) => (
<div className={cx(styles.wrapper, dark ? styles.dark : null)}>
<Button
role="button"
aria-labelledby="fullscreenButtonLabel"
aria-describedby="fullscreenButtonDesc"
color="default"
icon="fullscreen"
size="sm"
onClick={handleFullscreen}
label={intl.formatMessage(intlMessages.fullscreenButton)}
hideLabel
circle
className={styles.button}
/>
</div>
);
const propTypes = {
intl: intlShape.isRequired,
handleFullscreen: PropTypes.func.isRequired,
dark: PropTypes.bool,
elementName: PropTypes.string,
};
const defaultProps = {
dark: false,
elementName: '',
};
const FullscreenButtonComponent = ({
intl, handleFullscreen, dark, elementName,
}) => {
const formattedLabel = intl.formatMessage(
intlMessages.fullscreenButton,
({ 0: elementName ? elementName.toLowerCase() : '' }),
);
return (
<div className={cx(styles.wrapper, dark ? styles.dark : null)}>
<Button
color="default"
icon="fullscreen"
size="sm"
onClick={handleFullscreen}
label={formattedLabel}
hideLabel
circle
className={styles.button}
/>
</div>
);
};
FullscreenButtonComponent.propTypes = propTypes;
FullscreenButtonComponent.defaultProps = defaultProps;
export default injectIntl(FullscreenButtonComponent);

View File

@ -10,7 +10,7 @@ import DropdownListTitle from '/imports/ui/components/dropdown/list/title/compon
import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component';
import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
import Icon from '/imports/ui/components/icon/component';
import Button from '/imports/ui/components/button/component';
import logger from '/imports/startup/client/logger';
import VideoListItemStats from './video-list-item-stats/component';
import FullscreenButton from '../../fullscreen-button/component';
import { styles } from '../styles';
@ -36,7 +36,8 @@ class VideoListItem extends Component {
}
componentDidMount() {
this.props.onMount(this.videoTag);
const { onMount } = this.props;
onMount(this.videoTag);
}
componentDidUpdate() {
@ -45,7 +46,12 @@ class VideoListItem extends Component {
const p = elem.play();
if (p && (typeof Promise !== 'undefined') && (p instanceof Promise)) {
// Catch exception when playing video
p.catch((e) => {});
p.catch((e) => {
logger.error(
{ logCode: 'videolistitem_component_play_error' },
`Error playing video: ${JSON.stringify(e)}`,
);
});
}
}
};
@ -58,20 +64,10 @@ class VideoListItem extends Component {
}
}
toggleStats() {
const { getStats, stopGettingStats } = this.props;
if (this.state.showStats) {
stopGettingStats();
} else {
getStats(this.videoTag, this.setStats);
}
this.setState({ showStats: !this.state.showStats });
}
setStats(updatedStats) {
const { stats } = this.state;
const { audio, video } = updatedStats;
this.setState({ stats: { ...this.state.stats, video, audio } });
this.setState({ stats: { ...stats, video, audio } });
}
getAvailableActions() {
@ -98,11 +94,23 @@ class VideoListItem extends Component {
]);
}
toggleStats() {
const { getStats, stopGettingStats } = this.props;
const { showStats } = this.state;
if (showStats) {
stopGettingStats();
} else {
getStats(this.videoTag, this.setStats);
}
this.setState({ showStats: !showStats });
}
renderFullscreenButton() {
const { user } = this.props;
const full = () => {
this.videoTag.requestFullscreen();
};
return <FullscreenButton handleFullscreen={full} />;
return <FullscreenButton handleFullscreen={full} elementName={user.name} />;
}
render() {

View File

@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import cx from 'classnames';
import Button from '/imports/ui/components/button/component';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import Dropdown from '/imports/ui/components/dropdown/component';
import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component';
import DropdownContent from '/imports/ui/components/dropdown/content/component';
import DropdownList from '/imports/ui/components/dropdown/list/component';
import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
import { styles } from './styles';
const intlMessages = defineMessages({
videoMenu: {
id: 'app.video.videoMenu',
description: 'video menu label',
},
videoMenuDesc: {
id: 'app.video.videoMenuDesc',
description: 'video menu description',
},
videoMenuDisabled: {
id: 'app.video.videoMenuDisabled',
description: 'video menu label',
},
});
const propTypes = {
intl: intlShape.isRequired,
isSharingVideo: PropTypes.bool.isRequired,
videoItems: PropTypes.arrayOf(PropTypes.object).isRequired,
};
const JoinVideoOptions = ({
intl,
isSharingVideo,
videoItems,
videoShareAllowed,
}) => {
const menuItems = videoItems
.filter(item => !item.disabled)
.map(item => (
<DropdownListItem
key={_.uniqueId('video-menu-')}
className={styles.item}
description={item.description}
onClick={item.click}
tabIndex={-1}
id={item.id}
>
<img src={item.iconPath} className={styles.imageSize} alt="video menu icon" aria-hidden />
<span className={styles.label}>{item.label}</span>
</DropdownListItem>
));
return (
<Dropdown
autoFocus
>
<DropdownTrigger tabIndex={0}>
<Button
label={!videoShareAllowed
? intl.formatMessage(intlMessages.videoMenuDisabled)
: intl.formatMessage(intlMessages.videoMenu)
}
className={cx(styles.button, isSharingVideo || styles.ghostButton)}
onClick={() => null}
hideLabel
aria-label={intl.formatMessage(intlMessages.videoMenuDesc)}
color={isSharingVideo ? 'primary' : 'default'}
icon={isSharingVideo ? 'video' : 'video_off'}
ghost={!isSharingVideo}
size="lg"
circle
disabled={!videoShareAllowed}
/>
</DropdownTrigger>
<DropdownContent placement="top">
<DropdownList horizontal>
{menuItems}
</DropdownList>
</DropdownContent>
</Dropdown>
);
};
JoinVideoOptions.propTypes = propTypes;
export default injectIntl(JoinVideoOptions);

View File

@ -61,7 +61,11 @@
"app.media.screenshare.end": "Screenshare has ended",
"app.media.screenshare.safariNotSupported": "Screenshare is currently not supported by Safari. Please, use Firefox or Google Chrome.",
"app.meeting.ended": "This session has ended",
"app.meeting.meetingTimeRemaining": "Meeting time remaining: {0}",
"app.meeting.meetingTimeHasEnded": "Time ended. Meeting will close soon",
"app.meeting.endedMessage": "You will be forwarded back to the home screen",
"app.meeting.alertMeetingEndsUnderOneMinute": "Meeting is closing in a minute.",
"app.meeting.alertBreakoutEndsUnderOneMinute": "Breakout is closing in a minute.",
"app.presentation.close": "Close presentation",
"app.presentation.presentationToolbar.prevSlideLabel": "Previous slide",
"app.presentation.presentationToolbar.prevSlideDesc": "Change the presentation to the previous slide",
@ -79,7 +83,7 @@
"app.presentation.presentationToolbar.zoomInDesc": "Zoom in the presentation",
"app.presentation.presentationToolbar.zoomOutLabel": "Zoom out",
"app.presentation.presentationToolbar.zoomOutDesc": "Zoom out of the presentation",
"app.presentation.presentationToolbar.zoomIndicator": "Show the zoom percentage",
"app.presentation.presentationToolbar.zoomIndicator": "Current zoom percentage",
"app.presentation.presentationToolbar.fitToWidth": "Fit to width",
"app.presentation.presentationToolbar.goToSlide": "Slide {0}",
"app.presentationUploder.title": "Presentation",
@ -174,6 +178,7 @@
"app.actionsBar.label": "Actions bar",
"app.actionsBar.actionsDropdown.restorePresentationLabel": "Restore presentation",
"app.actionsBar.actionsDropdown.restorePresentationDesc": "Button to restore presentation after it has been closed",
"app.screenshare.screenShareLabel" : "Screen share",
"app.submenu.application.applicationSectionTitle": "Application",
"app.submenu.application.animationsLabel": "Animations",
"app.submenu.application.audioAlertLabel": "Audio Alerts for Chat",
@ -429,7 +434,7 @@
"app.video.stats.rtt": "RTT",
"app.video.stats.encodeUsagePercent": "Encode usage",
"app.video.stats.currentDelay": "Current delay",
"app.fullscreenButton.label": "Make element fullscreen",
"app.fullscreenButton.label": "Make {0} fullscreen",
"app.deskshare.iceConnectionStateError": "Error 1108: ICE connection failed when sharing screen",
"app.sfu.mediaServerConnectionError2000": "Error 2000: Unable to connect to media server",
"app.sfu.mediaServerOffline2001": "Error 2001: Media server is offline. Please try again later.",
@ -481,8 +486,9 @@
"app.videoDock.webcamUnfocusDesc": "Unfocus the selected webcam",
"app.invitation.title": "Breakout room invitation",
"app.invitation.confirm": "Invite",
"app.createBreakoutRoom.title": "Breakout rooms",
"app.createBreakoutRoom.breakoutRoomLabel": "Breakout rooms {0}",
"app.createBreakoutRoom.title": "Breakout Rooms",
"app.createBreakoutRoom.ariaTitle": "Hide Breakout Rooms",
"app.createBreakoutRoom.breakoutRoomLabel": "Breakout Rooms {0}",
"app.createBreakoutRoom.generatingURL": "Generating URL",
"app.createBreakoutRoom.generatedURL": "Generated",
"app.createBreakoutRoom.duration": "Duration {0}",

View File

@ -14,12 +14,47 @@ sdk install gradle 5.1.1
sdk install grails 3.3.9
```
To run the application from its source code : `grails prod run-app`
### Development
To run the application on a different port use : `grails -port=8989 prod run-app`
Build `bbb-common-message`
```
cd /bigbluebutton/bbb-common-message
./deploy.sh
```
Build `bbb-common-web`
```
cd bigbluebutton/bbb-common-web
./deploy.sh
```
Build and run `bbb-web`
```
cd bigbluebutton/bigbluebutton-web
# Make sure you don't have old libs lying around. Might cause issues.
# You need to to this only once to cleanup lib dir.
rm lib/*
./build.sh
# This will listen on port 8989 so you need to adjust your nginx config.
# If you've setup your nginx config to bbb-web dev, you don't need to do anything.
./run.sh
```
To run unit tests: `grails test-app --stacktrace`
### Production
To package the application for production:
1. Compile the application and package it use `grails assemble`