Merge branch 'v2.0.x-release' of https://github.com/bigbluebutton/bigbluebutton into reopen-private-chat-bug

This commit is contained in:
Maxim Khlobystov 2017-12-18 13:04:42 -05:00
commit 3937a01ffd
50 changed files with 1005 additions and 589 deletions

View File

@ -15,26 +15,33 @@
You should have received a copy of the GNU Lesser General Public License along
with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*/
$(document).ready( function () {
$(document).ready(function() {
if (typeof jQuery !== 'undefined') {
(function($) {
$('[data-toggle="confirmation"]').confirmation({popout: true});
$('#recordings').dataTable({
columnDefs: [ {
targets: 3,
render: $.fn.dataTable.render.moment('X', 'LLL', locale)
targets: 4,
render: $.fn.dataTable.render.moment('X', 'lll', locale)
} ],
sPaginationType : "full_numbers",
"columns": [
null,
null,
null,
{ "width": "90px" },
null,
null,
{ "width": "160px" }
{ "width": "40px" },
{ "width": "120px" }
],
"order": [[ 3, "desc" ]]
"order": [[ 4, "desc" ]]
});
$(".glyphicon-eye-open").hover(function() {
$(this).toggleClass('glyphicon-eye-open glyphicon-eye-close');
});
$(".glyphicon-eye-close").hover(function() {
$(this).toggleClass('glyphicon-eye-close glyphicon-eye-open');
});
})(jQuery);
}
};
});

View File

@ -0,0 +1,14 @@
.thumbnail {
height: 51px;
padding: 4px;
width: 66px;
float: left;
}
.thumbnail:hover {
display: inline-block;
height: auto;
position: absolute;
width: auto;
z-index: 99999;
}

View File

@ -309,9 +309,7 @@ class ToolController {
recordings = response
}
// Sanitize recordings
Iterator i = recordings.iterator();
while (i.hasNext()) {
def recording = i.next()
for (recording in recordings) {
// Calculate duration.
long endTime = Long.parseLong((String)recording.get("endTime"))
endTime -= (endTime % 1000)
@ -326,10 +324,22 @@ class ToolController {
// Add reportDate.
recording.put("reportDate", reportDate)
recording.put("unixDate", startTime / 1000)
// Add sanitized thumbnails
recording.put("thumbnails", sanitizeThumbnails(recording.playback.format))
}
return recordings
}
private List<Object> sanitizeThumbnails(Object format) {
if (format.preview == null || format.preview.images == null || format.preview.images.image == null) {
return new ArrayList()
}
if (format.preview.images.image instanceof Map<?,?>) {
return new ArrayList(format.preview.images.image)
}
return format.preview.images.image
}
private String getCartridgeXML(){
def lti_endpoint = ltiService.retrieveBasicLtiEndpoint() + '/' + grailsApplication.metadata['app.name']
def launch_url = 'http://' + lti_endpoint + '/tool'

View File

@ -42,6 +42,6 @@ tool.view.preview=Preview
tool.view.date=Date
tool.view.duration=Duration
tool.view.actions=Actions
tool.view.dateFormat=E, MMM dd, yyyy HH:mm:ss Z
tool.view.dateFormat=E, MM dd, yyyy HH:mm:ss Z
tool.error.general=Connection could not be established.

View File

@ -43,6 +43,6 @@ tool.view.preview=Vista preliminar
tool.view.date=Fecha
tool.view.duration=Duraci&#243;n
tool.view.actions=Acciones
tool.view.dateFormat=E, MMM dd, yyyy HH:mm:ss Z
tool.view.dateFormat=E, MM dd, yyyy HH:mm:ss Z
tool.error.general=No pudo estableserce la conexi&#243;n.

View File

@ -38,6 +38,6 @@ tool.view.preview=Apre&#231;u
tool.view.date=Date
tool.view.duration=Dur&#233;e
tool.view.actions=Actions
tool.view.dateFormat=E, MMM dd, yyyy HH:mm:ss Z
tool.view.dateFormat=E, MM dd, yyyy HH:mm:ss Z
tool.error.general=Pas possible &#233;tablir la connection.

View File

@ -4,6 +4,7 @@
<link rel="shortcut icon" href="${assetPath(src: 'favicon.ico')}" type="image/x-icon">
<asset:stylesheet src="bootstrap.css"/>
<asset:stylesheet src="dataTables.bootstrap.min.css"/>
<asset:stylesheet src="tool.css"/>
<asset:javascript src="jquery.js"/>
<asset:javascript src="jquery.dataTables.min.js"/>
<asset:javascript src="dataTables.bootstrap.min.js"/>
@ -44,16 +45,24 @@
</td>
<td class="cell c1" style="text-align:left;">${r.name}</td>
<td class="cell c2" style="text-align:left;">${r.metadata.contextactivitydescription}</td>
<td class="cell c3" style="text-align:left;">${r.metadata.contextactivitydescription}</td>
<td class="cell c4" style="text-align:left;">${r.reportDate}</td>
<td class="cell c3" style="text-align:left;">
<g:if test="${r.published}">
<div>
<g:each in="${r.thumbnails}" var="thumbnail">
<img src="${thumbnail.content}" class="thumbnail"></img>
</g:each>
</div>
</g:if>
</td>
<td class="cell c4" style="text-align:left;">${r.unixDate}</td>
<td class="cell c5" style="text-align:right;">${r.duration}</td>
<g:if test="${ismoderator}">
<td class="cell c6 lastcol" style="text-align:center;">
<g:if test="${r.published == 'true'}">
<a title="<g:message code="tool.view.recording.unpublish" />" class="btn btn-default btn-sm glyphicon glyphicon-eye-close" name="unpublish_recording" type="submit" value="${r.recordID}" href="${createLink(controller:'tool',action:'publish',id: '0')}?bbb_recording_published=${r.published}&bbb_recording_id=${r.recordID}"></a>
<g:if test="${r.published}">
<a title="<g:message code="tool.view.recording.unpublish" />" class="btn btn-default btn-sm glyphicon glyphicon-eye-open" name="unpublish_recording" type="submit" value="${r.recordID}" href="${createLink(controller:'tool',action:'publish',id: '0')}?bbb_recording_published=${r.published}&bbb_recording_id=${r.recordID}"></a>
</g:if>
<g:else>
<a title="<g:message code="tool.view.recording.publish" />" class="btn btn-default btn-sm glyphicon glyphicon-eye-open" name="publish_recording" type="submit" value="${r.recordID}" href="${createLink(controller:'tool',action:'publish',id: '0')}?bbb_recording_published=${r.published}&bbb_recording_id=${r.recordID}"></a>
<a title="<g:message code="tool.view.recording.publish" />" class="btn btn-default btn-sm glyphicon glyphicon-eye-close" name="publish_recording" type="submit" value="${r.recordID}" href="${createLink(controller:'tool',action:'publish',id: '0')}?bbb_recording_published=${r.published}&bbb_recording_id=${r.recordID}"></a>
</g:else>
<a title="<g:message code="tool.view.recording.delete" />" class="btn btn-danger btn-sm glyphicon glyphicon-trash" name="delete_recording" value="${r.recordID}"
data-toggle="confirmation"

View File

@ -85,12 +85,12 @@
<div >
<h2>BigBlueButton HTML5 client test server</h2>
<p> <a href="http://bigbluebutton.org/" target="_blank">BigBlueButton</a> is an open source web conferencing system for on-line learning. This is a public test server for the BigBlueButton <a href="http://docs.bigbluebutton.org/html/html5-overview.html">HTML5 client</a> currently under development.</p>
<p> Our goal for the upcoming release of the HTML5 client is to implement all the <a href="https://youtu.be/oh0bEk3YSwI">viewer capabilities</a> of the Flash client. Students join online classes as a viewer. The HTML5 client will give remote students the ability to join from their Android mobile devices. Users using the Flash and HTML5 clients can join the same meeting (hence the two choices above). We built the HTML5 client using web real-time communication (WebRTC), <a href="https://facebook.github.io/react/">React</a>, and <a href="https://www.mongodb.com/">MongoDB</a>.</p>
<p> The HTML5 works well with desktop and Android devices (phone and tablets) as they all support WebRTC. Apple does not (yet) support WebRTC in Safari for iOS devices, but don't worry -- we are working in parallel on app for iOS devices. What can this developer build of the HTML5 client do right now? Pretty much everything the Flash client can do for viewers except (a) view a desktop sharing stream from the presenter and (b) send/receive webcam streams. We're working on (a) and (b). For now, we are really happy to share with you our progress and get <a href="https://docs.google.com/forms/d/1gFz5JdN3vD6jxhlVskFYgtEKEcexdDnUzpkwUXwQ4OY/viewform?usp=send_for">your feedback</a> on what has been implemeted so far. Enjoy!</p>
<p> Our goal for the upcoming release of the HTML5 client is to implement all the <a href="https://youtu.be/oh0bEk3YSwI">viewer capabilities</a> of the Flash client. Students join online classes as a viewer. The HTML5 client will give remote students the ability to join from their Android and Apple (iOS 11+) devices. Users using the Flash and HTML5 clients can join the same meeting (hence the two choices above). We built the HTML5 client using web real-time communication (WebRTC), <a href="https://facebook.github.io/react/">React</a>, and <a href="https://www.mongodb.com/">MongoDB</a>.</p>
<p> What can this developer build of the HTML5 client do right now? Pretty much everything the Flash client can do for viewers except (a) view a desktop sharing stream from the presenter and (b) send/receive webcam streams. We're working on (a) and (b). For now, we are really happy to share with you our progress and get <a href="https://docs.google.com/forms/d/1gFz5JdN3vD6jxhlVskFYgtEKEcexdDnUzpkwUXwQ4OY/viewform?usp=send_for">your feedback</a> on what has been implemeted so far. Enjoy!</p>
<h4>For Developers</h4>
<p> The BigBlueButton project is <a href="http://bigbluebutton.org/support">supported</a> by a community of developers that care about good design and a streamlined user experience. </p>
<p>See <a href="/demo/demo1.jsp" target="_blank">API examples </a> for how to integrate BigBlueButton with your project.</p>
<p>See <a href="http://docs.bigblubutton.org" target="_blank">Documentation</a> for more information on how you can integrate BigBlueButton with your project.</p>
</div>
<div class="span one"></div>
@ -98,6 +98,7 @@
<hr class="featurette-divider">
<!-- BigBlueButton Features -->

View File

@ -7,4 +7,4 @@ import handleUserVoted from './handlers/userVoted';
RedisPubSub.on('PollShowResultEvtMsg', handlePollPublished);
RedisPubSub.on('PollStartedEvtMsg', handlePollStarted);
RedisPubSub.on('PollStoppedEvtMsg', handlePollStopped);
RedisPubSub.on('UserRespondedToPollEvtMsg', handleUserVoted);
RedisPubSub.on('PollUpdatedEvtMsg', handleUserVoted);

View File

@ -3,11 +3,20 @@ import updateVotes from '../modifiers/updateVotes';
export default function userVoted({ body }, meetingId) {
const { poll } = body;
const { presenterId } = body;
check(meetingId, String);
check(poll, Object);
check(presenterId, String);
check(poll, {
id: String,
answers: [
{
id: Number,
key: String,
numVotes: Number,
},
],
numRespondents: Number,
numResponders: Number,
});
return updateVotes(poll, meetingId, presenterId);
return updateVotes(poll, meetingId);
}

View File

@ -10,6 +10,11 @@ export default function publishVote(credentials, id, pollAnswerId) { // TODO dis
const { meetingId, requesterUserId } = credentials;
/*
We keep an array of people who were in the meeting at the time the poll
was started. The poll is published to them only.
Once they vote - their ID is removed and they cannot see the poll anymore
*/
const currentPoll = Polls.findOne({
users: requesterUserId,
meetingId,

View File

@ -3,19 +3,17 @@ import { check } from 'meteor/check';
import Logger from '/imports/startup/server/logger';
import flat from 'flat';
export default function updateVotes(poll, meetingId, requesterId) {
export default function updateVotes(poll, meetingId) {
check(meetingId, String);
check(requesterId, String);
check(poll, Object);
const {
id,
answers,
numResponders,
numRespondents,
} = poll;
const { numResponders } = poll;
const { numRespondents } = poll;
check(id, String);
check(answers, Array);
@ -24,15 +22,11 @@ export default function updateVotes(poll, meetingId, requesterId) {
const selector = {
meetingId,
requester: requesterId,
id,
};
const modifier = {
$set: Object.assign(
{ requester: requesterId },
flat(poll, { safe: true }),
),
$set: flat(poll, { safe: true }),
};
const cb = (err) => {

View File

@ -7,6 +7,7 @@ import handlePresentationConversionUpdate from './handlers/presentationConversio
RedisPubSub.on('SyncGetPresentationInfoRespMsg', handlePresentationInfoReply);
RedisPubSub.on('PresentationPageGeneratedEvtMsg', handlePresentationConversionUpdate);
RedisPubSub.on('PresentationPageCountErrorEvtMsg', handlePresentationConversionUpdate);
RedisPubSub.on('PresentationConversionUpdateEvtMsg', handlePresentationConversionUpdate);
RedisPubSub.on('PresentationConversionCompletedEvtMsg', handlePresentationAdded);
RedisPubSub.on('RemovePresentationEvtMsg', handlePresentationRemove);

View File

@ -49,6 +49,7 @@ export default function handlePresentationConversionUpdate({ body }, meetingId)
statusModifier.id = presentationId;
statusModifier.name = presentationName;
statusModifier['conversion.error'] = true;
statusModifier['conversion.done'] = true;
break;
case GENERATED_SLIDE_KEY:

View File

@ -1,5 +1,7 @@
import { check } from 'meteor/check';
import Logger from '/imports/startup/server/logger';
import Users from '/imports/api/users';
import addVoiceUser from '../modifiers/addVoiceUser';
export default function handleJoinVoiceUser({ body }, meetingId) {
@ -7,6 +9,74 @@ export default function handleJoinVoiceUser({ body }, meetingId) {
voiceUser.joined = true;
check(meetingId, String);
check(voiceUser, {
voiceConf: String,
intId: String,
voiceUserId: String,
callerName: String,
callerNum: String,
muted: Boolean,
talking: Boolean,
callingWith: String,
listenOnly: Boolean,
joined: Boolean,
});
const {
intId,
callerName,
} = voiceUser;
if (intId.toString().startsWith('v_')) {
/* voice-only user - called into the conference */
const selector = {
meetingId,
userId: intId,
};
const USER_CONFIG = Meteor.settings.public.user;
const ROLE_VIEWER = USER_CONFIG.role_viewer;
const modifier = {
$set: {
meetingId,
connectionStatus: 'online',
roles: [ROLE_VIEWER.toLowerCase()],
sortName: callerName.trim().toLowerCase(),
color: '#ffffff', // TODO
intId,
extId: intId, // TODO
name: callerName,
role: ROLE_VIEWER.toLowerCase(),
guest: false,
authed: true,
waitingForAcceptance: false,
emoji: 'none',
presenter: false,
locked: false, // TODO
avatar: '',
},
};
const cb = (err, numChanged) => {
if (err) {
return Logger.error(`Adding call-in user to VoiceUser collection: ${err}`);
}
const { insertedId } = numChanged;
if (insertedId) {
return Logger.info(`Added a call-in user id=${intId} meeting=${meetingId}`);
}
return Logger.info(`Upserted a call-in user id=${intId} meeting=${meetingId}`);
};
Users.upsert(selector, modifier, cb);
} else {
/* there is a corresponding web user in Users collection -- no need to add new one */
}
return addVoiceUser(meetingId, voiceUser);
}

View File

@ -1,11 +1,20 @@
import { check } from 'meteor/check';
import removeVoiceUser from '../modifiers/removeVoiceUser';
import removeVoiceUser from '/imports/api/voice-users/server/modifiers/removeVoiceUser';
import removeUser from '/imports/api/users/server/modifiers/removeUser';
export default function handleVoiceUpdate({ body }, meetingId) {
const voiceUser = body;
check(meetingId, String);
check(voiceUser, {
voiceConf: String,
intId: String,
voiceUserId: String,
});
const { intId } = voiceUser;
removeUser(meetingId, intId);
return removeVoiceUser(meetingId, voiceUser);
}

View File

@ -2,10 +2,12 @@ import { Meteor } from 'meteor/meteor';
import mapToAcl from '/imports/startup/mapToAcl';
import listenOnlyToggle from './methods/listenOnlyToggle';
import muteToggle from './methods/muteToggle';
import ejectUserFromVoice from './methods/ejectUserFromVoice';
Meteor.methods(mapToAcl(['methods.listenOnlyToggle', 'methods.toggleSelfVoice', 'methods.toggleVoice',
], {
Meteor.methods(mapToAcl(['methods.listenOnlyToggle', 'methods.toggleSelfVoice',
'methods.toggleVoice', 'methods.ejectUserFromVoice'], {
listenOnlyToggle,
toggleSelfVoice: (credentials) => { muteToggle(credentials, credentials.requesterUserId); },
toggleVoice: muteToggle,
ejectUserFromVoice,
}));

View File

@ -0,0 +1,22 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import RedisPubSub from '/imports/startup/server/redis';
export default function ejectUserFromVoice(credentials, userId) {
const REDIS_CONFIG = Meteor.settings.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'EjectUserFromVoiceCmdMsg';
const { requesterUserId, meetingId } = credentials;
check(meetingId, String);
check(requesterUserId, String);
check(userId, String);
const payload = {
userId,
ejectedBy: requesterUserId,
};
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -61,6 +61,8 @@ class ActionsDropdown extends Component {
<Dropdown ref={(ref) => { this._dropdown = ref; }} >
<DropdownTrigger tabIndex={0} >
<Button
hideLabel
aria-label={intl.formatMessage(intlMessages.actionsLabel)}
className={styles.button}
label={intl.formatMessage(intlMessages.actionsLabel)}
icon="plus"

View File

@ -42,17 +42,21 @@ const EmojiSelect = ({
const statuses = Object.keys(options);
const lastStatus = statuses.pop();
const statusLabel = statuses.indexOf(selected) === -1 ?
intl.formatMessage(intlMessages.statusTriggerLabel)
: intl.formatMessage({ id: `app.actionsBar.emojiMenu.${selected}Label` });
return (
<Dropdown autoFocus>
<DropdownTrigger tabIndex={0}>
<Button
className={styles.button}
label={intl.formatMessage(intlMessages.statusTriggerLabel)}
aria-label={intl.formatMessage(intlMessages.changeStatusLabel)}
label={statusLabel}
aria-label={statusLabel}
aria-describedby="currentStatus"
icon={options[selected !== lastStatus ? selected : statuses[1]]}
ghost={false}
hideLabel={false}
hideLabel
circle
size="lg"
color="primary"

View File

@ -1,9 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, intlShape, injectIntl } from 'react-intl';
import Button from '/imports/ui/components/button/component';
import styles from './styles';
import cx from 'classnames';
const intlMessages = defineMessages({
joinAudio: {
id: 'app.audio.joinAudio',
description: 'Join audio button label',
},
leaveAudio: {
id: 'app.audio.leaveAudio',
description: 'Leave audio button label',
},
muteAudio: {
id: 'app.actionsBar.muteLabel',
description: 'Mute audio button label',
},
unmuteAudio: {
id: 'app.actionsBar.unmuteLabel',
description: 'Unmute audio button label',
},
});
const propTypes = {
handleToggleMuteMicrophone: PropTypes.func.isRequired,
handleJoinAudio: PropTypes.func.isRequired,
@ -12,6 +33,7 @@ const propTypes = {
unmute: PropTypes.bool.isRequired,
mute: PropTypes.bool.isRequired,
join: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
};
const AudioControls = ({
@ -23,6 +45,7 @@ const AudioControls = ({
disable,
glow,
join,
intl,
}) => (
<span className={styles.container}>
{mute ?
@ -30,7 +53,9 @@ const AudioControls = ({
className={glow ? cx(styles.button, styles.glow) : styles.button}
onClick={handleToggleMuteMicrophone}
disabled={disable}
label={unmute ? 'Unmute' : 'Mute'}
hideLabel
label={unmute ? intl.formatMessage(intlMessages.unmuteAudio) : intl.formatMessage(intlMessages.muteAudio)}
aria-label={unmute ? intl.formatMessage(intlMessages.unmuteAudio) : intl.formatMessage(intlMessages.muteAudio)}
color={'primary'}
icon={unmute ? 'mute' : 'unmute'}
size={'lg'}
@ -40,7 +65,9 @@ const AudioControls = ({
className={styles.button}
onClick={join ? handleLeaveAudio : handleJoinAudio}
disabled={disable}
label={join ? 'Leave Audio' : 'Join Audio'}
hideLabel
aria-label={join ? intl.formatMessage(intlMessages.leaveAudio) : intl.formatMessage(intlMessages.joinAudio)}
label={join ? intl.formatMessage(intlMessages.leaveAudio) : intl.formatMessage(intlMessages.joinAudio)}
color={join ? 'danger' : 'primary'}
icon={join ? 'audio_off' : 'audio_on'}
size={'lg'}
@ -50,4 +77,4 @@ const AudioControls = ({
AudioControls.propTypes = propTypes;
export default AudioControls;
export default injectIntl(AudioControls);

View File

@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Tooltip from '/imports/ui/components/tooltip/component';
import styles from './styles';
import Icon from '../icon/component';
import BaseButton from './base/component';
@ -86,7 +87,6 @@ const defaultProps = {
};
export default class Button extends BaseButton {
_getClassNames() {
const {
size,
@ -113,8 +113,26 @@ export default class Button extends BaseButton {
}
render() {
const renderFuncName = this.props.circle ?
'renderCircle' : 'renderDefault';
const {
circle,
hideLabel,
label,
'aria-label' : ariaLabel
} = this.props;
const renderFuncName = circle ? 'renderCircle' : 'renderDefault';
if (hideLabel) {
const tooltipLabel = label ? label : ariaLabel;
return (
<Tooltip
title={tooltipLabel}
>
{this[renderFuncName]()}
</Tooltip>
);
}
return this[renderFuncName]();
}

View File

@ -43,7 +43,7 @@ $btn-jumbo-padding: $jumbo-padding-y $jumbo-padding-x;
display: inline-block;
border-radius: $border-size;
font-weight: $btn-font-weight;
line-height: 1.5;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: middle;

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { Link } from 'react-router';
import { defineMessages, injectIntl } from 'react-intl';
import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
import Button from '/imports/ui/components/button/component';
import styles from './styles';
import MessageForm from './message-form/component';
import MessageList from './message-list/component';
@ -92,15 +93,21 @@ class Chat extends Component {
</div>
{
chatID !== 'public' ?
<Link
to="/users"
role="button"
className={styles.closeIcon}
<Link
to="/users"
role="button"
tabIndex={-1}
>
<Button
className={styles.closeBtn}
icon="close"
size="md"
hideLabel
onClick={() => actions.handleClosePrivateChat(chatID)}
aria-label={intl.formatMessage(intlMessages.closeChatLabel, { 0: title })}
>
<Icon iconName="close" onClick={() => actions.handleClosePrivateChat(chatID)} />
</Link> :
<ChatDropdown />
/>
</Link> :
<ChatDropdown />
}
</header>
<MessageList

View File

@ -27,7 +27,11 @@ $padding: $md-padding-x;
margin-right: -$padding;
padding-right: $padding;
padding-top: 0;
padding-bottom: $padding;
&:after {
content: "";
display: block;
height: $padding;
}
}
.unreadButton {

View File

@ -1,6 +1,8 @@
@import "/imports/ui/stylesheets/mixins/focus";
@import "/imports/ui/stylesheets/variables/_all";
$icon-offset: -.4em;
.chat {
background-color: #fff;
padding: $md-padding-x;
@ -36,7 +38,22 @@
flex: 1;
}
.closeIcon {
.closeBtn {
background-color: $color-white;
flex: 0 0;
margin-left: $sm-padding-x / 2;
padding: 0 0.25rem !important;
i {
font-size: 0.85em;
color: $color-gray-dark !important;
top: $icon-offset;
}
&:focus,
&:hover{
background-color: $color-white !important;
i{
color: $color-gray;
}
}
}

View File

@ -4,7 +4,9 @@ import { defineMessages, injectIntl, intlShape } from 'react-intl';
import Dropzone from 'react-dropzone';
import update from 'immutability-helper';
import cx from 'classnames';
import _ from 'lodash';
import { notify } from '/imports/ui/services/notification';
import ModalFullscreen from '/imports/ui/components/modal/fullscreen/component';
import Icon from '/imports/ui/components/icon/component';
import ButtonBase from '/imports/ui/components/button/base/component';
@ -28,10 +30,12 @@ const propTypes = {
};
const defaultProps = {
defaultFileName: 'default.pdf',
};
const intlMessages = defineMessages({
current: {
id: 'app.presentationUploder.currentBadge',
},
title: {
id: 'app.presentationUploder.title',
description: 'title of the modal',
@ -68,6 +72,10 @@ const intlMessages = defineMessages({
id: 'app.presentationUploder.fileToUpload',
description: 'message used in the file selected for upload',
},
genericError: {
id: 'app.presentationUploder.genericError',
description: 'generic error while uploading/converting',
},
uploadProcess: {
id: 'app.presentationUploder.upload.progress',
description: 'message that indicates the percentage of the upload',
@ -84,9 +92,12 @@ const intlMessages = defineMessages({
id: 'app.presentationUploder.conversion.genericConversionStatus',
description: 'indicates that file is being converted',
},
TIMEOUT: {
id: 'app.presentationUploder.conversion.timeout',
},
GENERATING_THUMBNAIL: {
id: 'app.presentationUploder.conversion.generatingThumbnail',
description: 's that it is generating thumbnails',
description: 'indicatess that it is generating thumbnails',
},
GENERATING_SVGIMAGES: {
id: 'app.presentationUploder.conversion.generatingSvg',
@ -96,14 +107,21 @@ const intlMessages = defineMessages({
id: 'app.presentationUploder.conversion.generatedSlides',
description: 'warns that were slides generated',
},
PAGE_COUNT_EXCEEDED: {
id: 'app.presentationUploder.conversion.pageCountExceeded',
description: 'warns the user that the conversion failed because of the page count',
},
});
class PresentationUploader extends Component {
constructor(props) {
super(props);
const currentPres = props.presentations.find(p => p.isCurrent);
this.state = {
presentations: props.presentations,
oldCurrentId: currentPres ? currentPres.id : -1,
preventClosing: false,
disableActions: false,
};
@ -118,27 +136,19 @@ class PresentationUploader extends Component {
this.deepMergeUpdateFileKey = this.deepMergeUpdateFileKey.bind(this);
}
componentWillReceiveProps(nextProps) {
const nextPresentations = nextProps.presentations;
// Update only the conversion state when receiving new props
nextPresentations.forEach((file) => {
this.updateFileKey(file.filename, 'id', file.id);
this.deepMergeUpdateFileKey(file.id, 'conversion', file.conversion);
});
}
updateFileKey(id, key, value, operation = '$set') {
this.setState(({ presentations }) => {
// Compare id and filename since non-uploaded files dont have a real id
const fileIndex = presentations.findIndex(f => f.id === id || f.filename === id);
const fileIndex = presentations.findIndex(f => f.id === id);
return fileIndex === -1 ? false : {
presentations: update(presentations, {
[fileIndex]: { $apply: file =>
update(file, { [key]: {
[operation]: value,
} }),
[fileIndex]: {
$apply: file =>
update(file, {
[key]: {
[operation]: value,
},
}),
},
}),
};
@ -150,26 +160,54 @@ class PresentationUploader extends Component {
this.updateFileKey(id, key, applyValue, '$apply');
}
isDefault(presentation) {
const { defaultFileName } = this.props;
return presentation.filename === defaultFileName
&& !presentation.id.includes(defaultFileName);
}
handleConfirm() {
const { presentations } = this.state;
const presentationsToSave = this.state.presentations
.filter(p => !p.upload.error && !p.conversion.error);
this.setState({
disableActions: true,
preventClosing: true,
presentations: presentationsToSave,
});
return this.props.handleSave(presentations)
return this.props.handleSave(presentationsToSave)
.then(() => {
this.setState({
disableActions: false,
preventClosing: false,
});
})
.catch((error) => {
const hasError = this.state.presentations.some(p => p.upload.error || p.conversion.error);
if (!hasError) {
this.setState({
disableActions: false,
preventClosing: false,
});
return;
}
// if theres error we dont want to close the modal
this.setState({
disableActions: false,
preventClosing: true,
}, () => {
// if the selected current has error we revert back to the old one
const newCurrent = this.state.presentations.find(p => p.isCurrent);
if (newCurrent.upload.error || newCurrent.conversion.error) {
this.handleCurrentChange(this.state.oldCurrentId);
}
});
})
.catch((error) => {
notify(this.props.intl.formatMessage(intlMessages.genericError), 'error');
console.error(error);
this.setState({
disableActions: false,
preventClosing: true,
error,
});
});
}
@ -184,52 +222,62 @@ class PresentationUploader extends Component {
}
handleFiledrop(files) {
const presentationsToUpload = files.map(file => ({
file,
id: file.name,
filename: file.name,
isCurrent: false,
conversion: { done: false, error: false },
upload: { done: false, error: false, progress: 0 },
onProgress: (event) => {
if (!event.lengthComputable) {
this.deepMergeUpdateFileKey(file.name, 'upload', {
progress: 100,
done: true,
const presentationsToUpload = files.map((file) => {
const id = _.uniqueId(file.name);
return {
file,
id,
filename: file.name,
isCurrent: false,
conversion: { done: false, error: false },
upload: { done: false, error: false, progress: 0 },
onProgress: (event) => {
if (!event.lengthComputable) {
this.deepMergeUpdateFileKey(id, 'upload', {
progress: 100,
done: true,
});
return;
}
this.deepMergeUpdateFileKey(id, 'upload', {
progress: (event.loaded / event.total) * 100,
done: event.loaded === event.total,
});
return;
}
this.deepMergeUpdateFileKey(file.name, 'upload', {
progress: (event.loaded / event.total) * 100,
done: event.loaded === event.total,
});
},
onError: (error) => {
this.deepMergeUpdateFileKey(file.name, 'upload', { error });
},
}));
},
onConversion: (conversion) => {
this.deepMergeUpdateFileKey(id, 'conversion', conversion);
},
onUpload: (upload) => {
this.deepMergeUpdateFileKey(id, 'upload', upload);
},
onDone: (newId) => {
this.updateFileKey(id, 'id', newId);
},
};
});
this.setState(({ presentations }) => ({
presentations: presentations.concat(presentationsToUpload),
}));
}
handleCurrentChange(item) {
handleCurrentChange(id) {
const { presentations, disableActions } = this.state;
if (disableActions) return;
const currentIndex = presentations.findIndex(p => p.isCurrent);
const newCurrentIndex = presentations.indexOf(item);
const newCurrentIndex = presentations.findIndex(p => p.id === id);
const commands = {};
// we can end up without a current presentation
if (currentIndex !== -1) {
commands[currentIndex] = {
$apply: (_) => {
const p = _;
$apply: (presentation) => {
const p = presentation;
p.isCurrent = false;
return p;
},
@ -237,8 +285,8 @@ class PresentationUploader extends Component {
}
commands[newCurrentIndex] = {
$apply: (_) => {
const p = _;
$apply: (presentation) => {
const p = presentation;
p.isCurrent = true;
return p;
},
@ -256,14 +304,6 @@ class PresentationUploader extends Component {
if (disableActions) return;
const toRemoveIndex = presentations.indexOf(item);
const toRemove = presentations[toRemoveIndex];
if (toRemove.isCurrent) {
const defaultPresentation =
presentations.find(_ => _.filename === this.props.defaultFileName);
this.handleCurrentChange(defaultPresentation);
}
this.setState({
presentations: update(presentations, {
@ -276,7 +316,17 @@ class PresentationUploader extends Component {
const { presentations } = this.state;
const presentationsSorted = presentations
.sort((a, b) => b.filename === this.props.defaultFileName);
.sort((a, b) => {
// Sort by ID first so files with the same name have the same order
if (a.id > b.id) {
return 1;
}
if (a.id < b.id) {
return -1;
}
return 0;
})
.sort((a, b) => this.isDefault(b));
return (
<div className={styles.fileList}>
@ -303,12 +353,12 @@ class PresentationUploader extends Component {
}
if (item.upload.done && item.upload.error) {
const errorMessage = intlMessages[item.upload.error.code] || intlMessages.genericError;
const errorMessage = intlMessages[item.upload.status] || intlMessages.genericError;
return intl.formatMessage(errorMessage);
}
if (!item.conversion.done && item.conversion.error) {
const errorMessage = intlMessages[status] || intlMessages.genericError;
if (item.conversion.done && item.conversion.error) {
const errorMessage = intlMessages[item.conversion.status] || intlMessages.genericError;
return intl.formatMessage(errorMessage);
}
@ -329,19 +379,23 @@ class PresentationUploader extends Component {
}
renderPresentationItem(item) {
const { disableActions } = this.state;
const { disableActions, oldCurrentId } = this.state;
const isProcessing = (!item.conversion.done && item.upload.done)
|| (!item.upload.done && item.upload.progress > 0);
const itemClassName = {};
const isActualCurrent = item.id === oldCurrentId;
const isUploading = !item.upload.done && item.upload.progress > 0;
const isConverting = !item.conversion.done && item.upload.done;
const hasError = item.conversion.error || item.upload.error;
const isProcessing = (isUploading || isConverting) && !hasError;
itemClassName[styles.tableItemNew] = item.id === item.filename;
itemClassName[styles.tableItemUploading] = !item.upload.done;
itemClassName[styles.tableItemProcessing] = !item.conversion.done && item.upload.done;
itemClassName[styles.tableItemError] = item.conversion.error || item.upload.error;
itemClassName[styles.tableItemAnimated] = isProcessing;
const itemClassName = {
[styles.tableItemNew]: item.id.indexOf(item.filename) !== -1,
[styles.tableItemUploading]: isUploading,
[styles.tableItemConverting]: isConverting,
[styles.tableItemError]: hasError,
[styles.tableItemAnimated]: isProcessing,
};
const hideRemove = isProcessing || item.filename === this.props.defaultFileName;
const hideRemove = this.isDefault(item);
return (
<tr
@ -349,33 +403,44 @@ class PresentationUploader extends Component {
className={cx(itemClassName)}
>
<td className={styles.tableItemIcon}>
<Icon iconName={'file'} />
<Icon iconName="file" />
</td>
<th className={styles.tableItemName}>
{
isActualCurrent ?
<th className={styles.tableItemCurrent}>
<span className={styles.currentLabel}>
{this.props.intl.formatMessage(intlMessages.current)}
</span>
</th>
: null
}
<th className={styles.tableItemName} colSpan={!isActualCurrent ? 2 : 0}>
<span>{item.filename}</span>
</th>
<td className={styles.tableItemStatus}>
<td className={styles.tableItemStatus} colSpan={hasError ? 2 : 0}>
{this.renderPresentationItemStatus(item)}
</td>
<td className={styles.tableItemActions}>
<Checkbox
disabled={disableActions}
ariaLabel={'Set as current presentation'}
className={styles.itemAction}
checked={item.isCurrent}
onChange={() => this.handleCurrentChange(item)}
/>
{ hideRemove ? null : (
<ButtonBase
{ hasError ? null : (
<td className={styles.tableItemActions}>
<Checkbox
disabled={disableActions}
className={cx(styles.itemAction, styles.itemActionRemove)}
label={'Remove presentation'}
onClick={() => this.handleRemove(item)}
>
<Icon iconName={'delete'} />
</ButtonBase>
)}
</td>
ariaLabel="Set as current presentation"
className={styles.itemAction}
checked={item.isCurrent}
onChange={() => this.handleCurrentChange(item.id)}
/>
{ hideRemove ? null : (
<ButtonBase
disabled={disableActions}
className={cx(styles.itemAction, styles.itemActionRemove)}
label="Remove presentation"
onClick={() => this.handleRemove(item)}
>
<Icon iconName="delete" />
</ButtonBase>
)}
</td>
)}
</tr>
);
}
@ -404,7 +469,7 @@ class PresentationUploader extends Component {
disablePreview
onDrop={this.handleFiledrop}
>
<Icon className={styles.dropzoneIcon} iconName={'upload'} />
<Icon className={styles.dropzoneIcon} iconName="upload" />
<p className={styles.dropzoneMessage}>
{intl.formatMessage(intlMessages.dropzoneLabel)}&nbsp;
<span className={styles.dropzoneLink}>

View File

@ -1,6 +1,7 @@
import Presentations from '/imports/api/presentations';
import Auth from '/imports/ui/services/auth';
import { makeCall } from '/imports/ui/services/api';
import _ from 'lodash';
const CONVERSION_TIMEOUT = 300000;
@ -29,7 +30,9 @@ const futch = (url, opts = {}, onProgress) => new Promise((res, rej) => {
const getPresentations = () =>
Presentations
.find()
.find({
'conversion.error': false,
})
.fetch()
.map(presentation => ({
id: presentation.id,
@ -39,36 +42,40 @@ const getPresentations = () =>
conversion: presentation.conversion || { done: true, error: false },
}));
const observePresentationConversion = (meetingId, filename) => new Promise((resolve, reject) => {
const conversionTimeout = setTimeout(() => {
reject({
filename,
message: 'Conversion timeout.',
});
}, CONVERSION_TIMEOUT);
const observePresentationConversion = (meetingId, filename, onConversion) =>
new Promise((resolve) => {
const conversionTimeout = setTimeout(() => {
onConversion({
done: true,
error: true,
status: 'TIMEOUT',
});
}, CONVERSION_TIMEOUT);
const didValidate = (doc) => {
clearTimeout(conversionTimeout);
resolve(doc);
};
const didValidate = (doc) => {
clearTimeout(conversionTimeout);
resolve(doc);
};
Tracker.autorun((c) => {
/* FIXME: With two presentations with the same name this will not work as expected */
const query = Presentations.find({ meetingId });
Tracker.autorun((c) => {
const query = Presentations.find({ meetingId });
query.observe({
changed: (newDoc) => {
if (newDoc.name !== filename) return;
if (newDoc.conversion.done) {
c.stop();
didValidate(newDoc);
}
},
query.observe({
changed: (newDoc) => {
if (newDoc.name !== filename) return;
onConversion(newDoc.conversion);
if (newDoc.conversion.done) {
c.stop();
didValidate(newDoc);
}
},
});
});
});
});
const uploadAndConvertPresentation = (file, meetingID, endpoint, onError, onProgress) => {
const uploadAndConvertPresentation = (file, meetingID, endpoint, onUpload, onProgress, onConversion) => {
const data = new FormData();
data.append('presentation_name', file.name);
data.append('Filename', file.name);
@ -84,19 +91,17 @@ const uploadAndConvertPresentation = (file, meetingID, endpoint, onError, onProg
};
return futch(endpoint, opts, onProgress)
.then(() => observePresentationConversion(meetingID, file.name))
.then(() => observePresentationConversion(meetingID, file.name, onConversion))
// Trap the error so we can have parallel upload
.catch((error) => {
onError(error);
return observePresentationConversion(meetingID, file.name);
onUpload({ error: true, done: true, status: error.code });
return Promise.resolve();
});
};
const uploadAndConvertPresentations = (presentationsToUpload, meetingID, uploadEndpoint) =>
Promise.all(
presentationsToUpload.map(p =>
uploadAndConvertPresentation(p.file, meetingID, uploadEndpoint, p.onError, p.onProgress)),
);
Promise.all(presentationsToUpload.map(p =>
uploadAndConvertPresentation(p.file, meetingID, uploadEndpoint, p.onUpload, p.onProgress, p.onConversion)));
const setPresentation = presentationID => makeCall('setPresentation', presentationID);
@ -106,27 +111,42 @@ const removePresentations = presentationsToRemove =>
Promise.all(presentationsToRemove.map(p => removePresentation(p.id)));
const persistPresentationChanges = (oldState, newState, uploadEndpoint) => {
const presentationsToUpload = newState.filter(_ => !oldState.includes(_));
const presentationsToRemove = oldState.filter(_ => !newState.includes(_));
const currentPresentation = newState.find(_ => _.isCurrent);
const presentationsToUpload = newState.filter(p => !p.upload.done);
const presentationsToRemove = oldState.filter(p => !_.find(newState, ['id', p.id]));
return new Promise((resolve, reject) =>
uploadAndConvertPresentations(presentationsToUpload, Auth.meetingID, uploadEndpoint)
.then((presentations) => {
if (!presentations.length && !currentPresentation) return Promise.resolve();
let currentPresentation = newState.find(p => p.isCurrent);
// If its a newly uploaded presentation we need to get its id from promise result
const currentPresentationId =
currentPresentation.id !== currentPresentation.filename ?
currentPresentation.id :
presentations[presentationsToUpload.findIndex(_ => _ === currentPresentation)].id;
return uploadAndConvertPresentations(presentationsToUpload, Auth.meetingID, uploadEndpoint)
.then((presentations) => {
if (!presentations.length && !currentPresentation) return Promise.resolve();
return setPresentation(currentPresentationId);
})
.then(removePresentations.bind(null, presentationsToRemove))
.then(resolve)
.catch(reject),
);
// Update the presentation with their new ids
presentations.forEach((p, i) => {
if (p === undefined) return;
presentationsToUpload[i].onDone(p.id);
});
return Promise.resolve(presentations);
})
.then((presentations) => {
if (currentPresentation === undefined) {
return Promise.resolve();
}
// If its a newly uploaded presentation we need to get it from promise result
if (!currentPresentation.conversion.done) {
const currentIndex = presentationsToUpload.findIndex(p => p === currentPresentation);
currentPresentation = presentations[currentIndex];
}
// skip setting as current if error happened
if (currentPresentation.conversion.error) {
return Promise.resolve();
}
return setPresentation(currentPresentation.id);
})
.then(removePresentations.bind(null, presentationsToRemove));
};
export default {

View File

@ -57,12 +57,14 @@ $item-height: 1rem;
.tableItemIcon,
.tableItemActions,
.tableItemStatus {
.tableItemStatus,
.tableItemCurrent {
width: 1%;
}
.tableItemActions {
min-width: 68px; // size of the 2 icons (check/trash)
text-align: right;
}
.tableItemIcon > i {
@ -75,7 +77,7 @@ $item-height: 1rem;
position: relative;
&:before {
content: '&nbsp;';
content: "\00a0";
visibility: hidden;
}
@ -84,10 +86,13 @@ $item-height: 1rem;
position: absolute;
left: 0;
right: 0;
padding: 0 $sm-padding-x;
}
}
.tableItemCurrent {
padding-left: 0;
}
.tableItemStatus {
text-align: right;
}
@ -100,7 +105,7 @@ $item-height: 1rem;
background-color: transparentize($color-primary, .75);
}
.tableItemProcessing {
.tableItemConverting {
background-color: transparentize($color-success, .75);
}
@ -183,3 +188,18 @@ $item-height: 1rem;
font-size: 80%;
display: block;
}
.currentLabel {
display: inline;
padding: .25em .5em;
font-size: 75%;
font-weight: 700;
line-height: 1;
color: $color-white;
background: $color-primary;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: .25em;
text-transform: uppercase;
}

View File

@ -195,8 +195,7 @@ class ApplicationMenu extends BaseMenu {
{ availableLocales.map((locale, index) =>
(<option key={index} value={locale.locale}>
{locale.name}
</option>),
) }
</option>)) }
</select>
: null }
</label>

View File

@ -0,0 +1,86 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Tippy from 'tippy.js';
import _ from 'lodash';
import cx from 'classnames';
import { ESCAPE } from '/imports/utils/keyCodes';
const propTypes = {
title: PropTypes.string.isRequired,
position: PropTypes.oneOf(['bottom']),
children: PropTypes.element.isRequired,
className: PropTypes.string,
};
const defaultProps = {
position: 'bottom',
className: null,
};
class Tooltip extends Component {
constructor(props) {
super(props);
this.tippySelectorId = _.uniqueId('tippy-');
this.onShow = this.onShow.bind(this);
this.onHide = this.onHide.bind(this);
this.handleEscapeHide = this.handleEscapeHide.bind(this);
this.delay = [250, 100];
this.dynamicTitle = true;
}
componentDidMount() {
const {
position,
} = this.props;
const options = {
position,
dynamicTitle: this.dynamicTitle,
delay: this.delay,
onShow: this.onShow,
onHide: this.onHide,
};
this.tooltip = Tippy(`#${this.tippySelectorId}`, options);
}
onShow() {
document.addEventListener('keyup', this.handleEscapeHide);
}
onHide() {
document.removeEventListener('keyup', this.handleEscapeHide);
}
handleEscapeHide(e) {
if (e.keyCode !== ESCAPE) return;
this.tooltip.tooltips[0].hide();
}
render() {
const {
children,
className,
title,
...restProps
} = this.props;
const WrappedComponent = React.Children.only(children);
const WrappedComponentBound = React.cloneElement(WrappedComponent, {
...restProps,
title,
id: this.tippySelectorId,
className: cx(children.props.className, className),
});
return WrappedComponentBound;
}
}
export default Tooltip;
Tooltip.defaultProps = defaultProps;
Tooltip.propTypes = propTypes;

View File

@ -5,7 +5,6 @@ import { withRouter } from 'react-router';
import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
import styles from './styles';
import UserListHeader from './user-list-header/component';
import UserContent from './user-list-content/component';
const propTypes = {
@ -27,6 +26,7 @@ const propTypes = {
kickUser: PropTypes.func.isRequired,
toggleVoice: PropTypes.func.isRequired,
changeRole: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
};
const defaultProps = {
@ -54,10 +54,6 @@ class UserList extends Component {
render() {
return (
<div className={styles.userList}>
{/* <UserListHeader
intl={this.props.intl}
compact={this.state.compact}
/> */}
{<UserContent
intl={this.props.intl}
openChats={this.props.openChats}
@ -75,6 +71,7 @@ class UserList extends Component {
normalizeEmojiName={this.props.normalizeEmojiName}
isMeetingLocked={this.props.isMeetingLocked}
isPublicChat={this.props.isPublicChat}
roving={this.props.roving}
/>}
</div>
);

View File

@ -1,10 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { createContainer } from 'meteor/react-meteor-data';
import { meetingIsBreakout } from '/imports/ui/components/app/service';
import Meetings from '/imports/api/meetings';
import Service from './service';
import UserList from './component';
const propTypes = {
openChats: PropTypes.arrayOf(String).isRequired,
openChat: PropTypes.string.isRequired,
users: PropTypes.arrayOf(Object).isRequired,
currentUser: PropTypes.shape({}).isRequired,
meeting: PropTypes.shape({}).isRequired,
isBreakoutRoom: PropTypes.bool.isRequired,
getAvailableActions: PropTypes.func.isRequired,
normalizeEmojiName: PropTypes.func.isRequired,
isMeetingLocked: PropTypes.func.isRequired,
isPublicChat: PropTypes.func.isRequired,
setEmojiStatus: PropTypes.func.isRequired,
assignPresenter: PropTypes.func.isRequired,
kickUser: PropTypes.func.isRequired,
toggleVoice: PropTypes.func.isRequired,
changeRole: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
userActions: PropTypes.func.isRequired,
children: PropTypes.Object,
};
const defaultProps = {
children: {},
};
const UserListContainer = (props) => {
const {
users,
@ -24,7 +50,8 @@ const UserListContainer = (props) => {
kickUser,
toggleVoice,
changeRole,
} = props;
roving,
} = props;
return (
<UserList
@ -44,12 +71,16 @@ const UserListContainer = (props) => {
normalizeEmojiName={normalizeEmojiName}
isMeetingLocked={isMeetingLocked}
isPublicChat={isPublicChat}
roving={roving}
>
{children}
</UserList>
);
};
UserListContainer.propTypes = propTypes;
UserListContainer.defaultProps = defaultProps;
export default createContainer(({ params }) => ({
users: Service.getUsers(),
meeting: Meetings.findOne({}),
@ -67,4 +98,5 @@ export default createContainer(({ params }) => ({
kickUser: Service.kickUser,
toggleVoice: Service.toggleVoice,
changeRole: Service.changeRole,
roving: Service.roving,
}), UserListContainer);

View File

@ -8,6 +8,7 @@ import mapUser from '/imports/ui/services/user/mapUser';
import { EMOJI_STATUSES } from '/imports/utils/statuses';
import { makeCall } from '/imports/ui/services/api';
import _ from 'lodash';
import KEY_CODES from '/imports/utils/keyCodes';
const APP_CONFIG = Meteor.settings.public.app;
const ALLOW_MODERATOR_TO_UNMUTE_AUDIO = APP_CONFIG.allowModeratorToUnmuteAudio;
@ -41,18 +42,21 @@ const sortUsersByName = (a, b) => {
};
const sortUsersByEmoji = (a, b) => {
const emojiA = a in EMOJI_STATUSES ? EMOJI_STATUSES[a] : a;
const emojiB = b in EMOJI_STATUSES ? EMOJI_STATUSES[b] : b;
const { status: statusA } = a.emoji;
const { status: statusB } = b.emoji;
if (emojiA && emojiB) {
const emojiA = statusA in EMOJI_STATUSES ? EMOJI_STATUSES[statusA] : statusA;
const emojiB = statusB in EMOJI_STATUSES ? EMOJI_STATUSES[statusB] : statusB;
if (emojiA && emojiB && (emojiA !== EMOJI_STATUSES.none && emojiB !== EMOJI_STATUSES.none)) {
if (a.emoji.changedAt < b.emoji.changedAt) {
return -1;
} else if (a.emoji.changedAt > b.emoji.changedAt) {
return 1;
}
} else if (emojiA) {
} else if (emojiA && emojiA !== EMOJI_STATUSES.none) {
return -1;
} else if (emojiB) {
} else if (emojiB && emojiB !== EMOJI_STATUSES.none) {
return 1;
}
return 0;
@ -72,7 +76,7 @@ const sortUsersByModerator = (a, b) => {
const sortUsersByPhoneUser = (a, b) => {
if (!a.isPhoneUser && !b.isPhoneUser) {
return sortUsersByName(a, b);
return 0;
} else if (!a.isPhoneUser) {
return -1;
} else if (!b.isPhoneUser) {
@ -211,27 +215,46 @@ const getOpenChats = (chatID) => {
.sort(sortChats);
};
const isVoiceOnlyUser = userId => userId.toString().startsWith('v_');
const getAvailableActions = (currentUser, user, router, isBreakoutRoom) => {
const isDialInUser = isVoiceOnlyUser(user.id) || user.isPhoneUser;
const hasAuthority = currentUser.isModerator || user.isCurrent;
const allowedToChatPrivately = !user.isCurrent;
const allowedToChatPrivately = !user.isCurrent && !isDialInUser;
const allowedToMuteAudio = hasAuthority
&& user.isVoiceUser
&& !user.isMuted
&& !user.isListenOnly;
const allowedToUnmuteAudio = hasAuthority
&& user.isVoiceUser
&& !user.isListenOnly
&& user.isMuted
&& (ALLOW_MODERATOR_TO_UNMUTE_AUDIO || user.isCurrent);
const allowedToResetStatus = hasAuthority && user.emoji.status !== EMOJI_STATUSES.none;
const allowedToResetStatus = hasAuthority
&& user.emoji.status !== EMOJI_STATUSES.none
&& !isDialInUser;
// if currentUser is a moderator, allow kicking other users
const allowedToKick = currentUser.isModerator && !user.isCurrent && !isBreakoutRoom;
const allowedToSetPresenter = currentUser.isModerator && !user.isPresenter;
const allowedToSetPresenter = currentUser.isModerator
&& !user.isPresenter
&& !isDialInUser;
const allowedToPromote = currentUser.isModerator && !user.isCurrent && !user.isModerator;
const allowedToDemote = currentUser.isModerator && !user.isCurrent && user.isModerator;
const allowedToPromote = currentUser.isModerator
&& !user.isCurrent
&& !user.isModerator
&& !isDialInUser;
const allowedToDemote = currentUser.isModerator
&& !user.isCurrent
&& user.isModerator
&& !isDialInUser;
return {
allowedToChatPrivately,
@ -278,12 +301,54 @@ const setEmojiStatus = (userId) => { makeCall('setEmojiStatus', userId, 'none');
const assignPresenter = (userId) => { makeCall('assignPresenter', userId); };
const kickUser = (userId) => { makeCall('kickUser', userId); };
const kickUser = (userId) => {
if (isVoiceOnlyUser(userId)) {
makeCall('ejectUserFromVoice', userId);
} else {
makeCall('kickUser', userId);
}
};
const toggleVoice = (userId) => { makeCall('toggleVoice', userId); };
const changeRole = (userId, role) => { makeCall('changeRole', userId, role); };
const roving = (event, itemCount, changeState) => {
if (this.selectedIndex === undefined) {
this.selectedIndex = -1;
}
if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.keyCode)) {
document.activeElement.blur();
this.selectedIndex = -1;
changeState(this.selectedIndex);
}
if (event.keyCode === KEY_CODES.ARROW_DOWN) {
this.selectedIndex += 1;
if (this.selectedIndex === itemCount) {
this.selectedIndex = 0;
}
changeState(this.selectedIndex);
}
if (event.keyCode === KEY_CODES.ARROW_UP) {
this.selectedIndex -= 1;
if (this.selectedIndex < 0) {
this.selectedIndex = itemCount - 1;
}
changeState(this.selectedIndex);
}
if ([KEY_CODES.ARROW_RIGHT, KEY_CODES.SPACE].includes(event.keyCode)) {
document.activeElement.firstChild.click();
}
};
export default {
setEmojiStatus,
assignPresenter,
@ -297,4 +362,5 @@ export default {
normalizeEmojiName,
isMeetingLocked,
isPublicChat,
roving,
};

View File

@ -1,6 +1,5 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import KEY_CODES from '/imports/utils/keyCodes';
import styles from './styles';
import UserParticipants from './user-participants/component';
import UserMessages from './user-messages/component';
@ -24,6 +23,7 @@ const propTypes = {
kickUser: PropTypes.func.isRequired,
toggleVoice: PropTypes.func.isRequired,
changeRole: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
};
const defaultProps = {
@ -35,82 +35,6 @@ const defaultProps = {
};
class UserContent extends Component {
static focusElement(active, element) {
const modifiedActive = active;
const modifiedElement = element;
if (!modifiedActive.getAttribute('role') === 'tabpanel') {
modifiedActive.tabIndex = -1;
}
modifiedElement.tabIndex = 0;
modifiedElement.focus();
}
static removeFocusFromChildren(children, numberOfItems) {
const modifiedChildren = children;
for (let i = 0; i < numberOfItems; i += 1) {
modifiedChildren.childNodes[i].tabIndex = -1;
}
}
constructor(props) {
super(props);
this.rovingIndex = this.rovingIndex.bind(this);
this.focusList = this.focusList.bind(this);
this.focusedItemIndex = -1;
}
focusList(list) {
const focusList = list;
document.activeElement.tabIndex = -1;
this.focusedItemIndex = -1;
focusList.tabIndex = 0;
focusList.focus();
}
rovingIndex(event, list, items, numberOfItems) {
const active = document.activeElement;
const changedItems = items;
if (event.keyCode === KEY_CODES.TAB) {
if (this.focusedItemIndex !== -1) {
this.focusedItemIndex = 0;
UserContent.removeFocusFromChildren(changedItems, numberOfItems);
}
}
if (event.keyCode === KEY_CODES.ESCAPE
|| this.focusedItemIndex < 0
|| this.focusedItemIndex > numberOfItems) {
this.focusList(list);
}
if ([KEY_CODES.ARROW_RIGHT, KEY_CODES.ARROW_SPACE].includes(event.keyCode)) {
active.firstChild.click();
}
if (event.keyCode === KEY_CODES.ARROW_DOWN) {
this.focusedItemIndex += 1;
if (this.focusedItemIndex === numberOfItems) {
this.focusedItemIndex = 0;
}
UserContent.focusElement(active, changedItems.childNodes[this.focusedItemIndex]);
}
if (event.keyCode === KEY_CODES.ARROW_UP) {
this.focusedItemIndex -= 1;
if (this.focusedItemIndex < 0) {
this.focusedItemIndex = numberOfItems - 1;
}
UserContent.focusElement(active, changedItems.childNodes[this.focusedItemIndex]);
}
}
render() {
return (
<div className={styles.content}>
@ -119,7 +43,7 @@ class UserContent extends Component {
openChats={this.props.openChats}
compact={this.props.compact}
intl={this.props.intl}
rovingIndex={this.rovingIndex}
roving={this.props.roving}
/>
<UserParticipants
users={this.props.users}
@ -135,8 +59,8 @@ class UserContent extends Component {
changeRole={this.props.changeRole}
getAvailableActions={this.props.getAvailableActions}
normalizeEmojiName={this.props.normalizeEmojiName}
rovingIndex={this.rovingIndex}
isMeetingLocked={this.props.isMeetingLocked}
roving={this.props.roving}
/>
</div>
);

View File

@ -10,14 +10,18 @@
}
.scrollableList {
pointer-events: none;
@include elementFocus($list-item-bg-hover);
@include scrollbox-vertical($user-list-bg);
&:active {
box-shadow: none;
border-radius: none;
}
}
.list {
margin-left: $md-padding-y;
pointer-events: all;
margin-bottom: 1px;
}
.smallTitle {

View File

@ -13,8 +13,8 @@ const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
rovingIndex: PropTypes.func.isRequired,
isPublicChat: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
};
const defaultProps = {
@ -32,80 +32,102 @@ const listTransition = {
};
const intlMessages = defineMessages({
usersTitle: {
id: 'app.userList.usersTitle',
description: 'Title for the Header',
},
messagesTitle: {
id: 'app.userList.messagesTitle',
description: 'Title for the messages list',
},
participantsTitle: {
id: 'app.userList.participantsTitle',
description: 'Title for the Users list',
},
toggleCompactView: {
id: 'app.userList.toggleCompactView.label',
description: 'Toggle user list view mode',
},
ChatLabel: {
id: 'app.userList.menu.chat.label',
description: 'Save the changes and close the settings menu',
},
ClearStatusLabel: {
id: 'app.userList.menu.clearStatus.label',
description: 'Clear the emoji status of this user',
},
MakePresenterLabel: {
id: 'app.userList.menu.makePresenter.label',
description: 'Set this user to be the presenter in this meeting',
},
KickUserLabel: {
id: 'app.userList.menu.kickUser.label',
description: 'Forcefully remove this user from the meeting',
},
MuteUserAudioLabel: {
id: 'app.userList.menu.muteUserAudio.label',
description: 'Forcefully mute this user',
},
UnmuteUserAudioLabel: {
id: 'app.userList.menu.unmuteUserAudio.label',
description: 'Forcefully unmute this user',
},
PromoteUserLabel: {
id: 'app.userList.menu.promoteUser.label',
description: 'Forcefully promote this viewer to a moderator',
},
DemoteUserLabel: {
id: 'app.userList.menu.demoteUser.label',
description: 'Forcefully demote this moderator to a viewer',
},
});
class UserMessages extends Component {
constructor() {
super();
this.state = {
index: -1,
};
this.openChatRefs = [];
this.selectedIndex = -1;
this.focusOpenChatItem = this.focusOpenChatItem.bind(this);
this.changeState = this.changeState.bind(this);
}
componentDidMount() {
if (!this.props.compact) {
this._msgsList.addEventListener(
'keydown',
event => this.props.rovingIndex(
event => this.props.roving(
event,
this._msgsList,
this._msgItems,
this.props.openChats.length,
this.changeState,
),
);
}
}
render() {
componentDidUpdate(prevProps, prevState) {
if (this.state.index === -1) {
return;
}
if (this.state.index !== prevState.index) {
this.focusOpenChatItem(this.state.index);
}
}
getOpenChats() {
const {
openChats,
openChat,
intl,
compact,
isPublicChat,
} = this.props;
let index = -1;
return openChats.map(chat => (
<CSSTransition
classNames={listTransition}
appear
enter
exit={false}
timeout={0}
component="div"
className={cx(styles.chatsList)}
key={chat.id}
>
<div ref={(node) => { this.openChatRefs[index += 1] = node; }}>
<ChatListItem
isPublicChat={isPublicChat}
compact={compact}
openChat={openChat}
chat={chat}
tabIndex={-1}
/>
</div>
</CSSTransition>
));
}
changeState(newIndex) {
this.setState({ index: newIndex });
}
focusOpenChatItem(index) {
if (!this.openChatRefs[index]) {
return;
}
this.openChatRefs[index].firstChild.focus();
}
render() {
const {
intl,
compact,
} = this.props;
return (
<div className={styles.messages}>
{
@ -120,28 +142,9 @@ class UserMessages extends Component {
className={styles.scrollableList}
ref={(ref) => { this._msgsList = ref; }}
>
<div ref={(ref) => { this._msgItems = ref; }} className={styles.list}>
<TransitionGroup>
{openChats.map(chat => (
<CSSTransition
classNames={listTransition}
appear
enter
exit={false}
timeout={0}
component="div"
className={cx(styles.chatsList)}
key={chat.id}
>
<ChatListItem
isPublicChat={isPublicChat}
compact={compact}
openChat={openChat}
chat={chat}
tabIndex={-1}
/>
</CSSTransition>
))}
<div className={styles.list}>
<TransitionGroup ref={(ref) => { this._msgItems = ref; }} >
{ this.getOpenChats() }
</TransitionGroup>
</div>
</div>

View File

@ -23,7 +23,7 @@ const propTypes = {
getAvailableActions: PropTypes.func.isRequired,
normalizeEmojiName: PropTypes.func.isRequired,
isMeetingLocked: PropTypes.func.isRequired,
rovingIndex: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
};
const defaultProps = {
@ -48,18 +48,6 @@ const intlMessages = defineMessages({
id: 'app.userList.usersTitle',
description: 'Title for the Header',
},
messagesTitle: {
id: 'app.userList.messagesTitle',
description: 'Title for the messages list',
},
participantsTitle: {
id: 'app.userList.participantsTitle',
description: 'Title for the Users list',
},
toggleCompactView: {
id: 'app.userList.toggleCompactView.label',
description: 'Toggle user list view mode',
},
ChatLabel: {
id: 'app.userList.menu.chat.label',
description: 'Save the changes and close the settings menu',
@ -98,88 +86,157 @@ class UserParticipants extends Component {
constructor() {
super();
this.state = {
index: -1,
};
this.userRefs = [];
this.selectedIndex = -1;
this.getScrollContainerRef = this.getScrollContainerRef.bind(this);
this.focusUserItem = this.focusUserItem.bind(this);
this.changeState = this.changeState.bind(this);
this.getUsers = this.getUsers.bind(this);
}
componentDidMount() {
if (!this.props.compact) {
this.refScrollContainer.addEventListener(
'keydown',
event => this.props.rovingIndex(
event => this.props.roving(
event,
this.refScrollContainer,
this.refScrollItems,
this.props.users.length,
this.changeState,
),
);
}
}
componentDidUpdate(prevProps, prevState) {
if (this.state.index === -1) {
return;
}
if (this.state.index !== prevState.index) {
this.focusUserItem(this.state.index);
}
}
getScrollContainerRef() {
return this.refScrollContainer;
}
render() {
getUsers() {
const {
users,
currentUser,
compact,
isBreakoutRoom,
intl,
currentUser,
meeting,
getAvailableActions,
normalizeEmojiName,
isMeetingLocked,
compact,
setEmojiStatus,
users,
intl,
changeRole,
assignPresenter,
setEmojiStatus,
kickUser,
toggleVoice,
changeRole,
} = this.props;
const userActions =
{
openChat: {
label: () => intl.formatMessage(intlMessages.ChatLabel),
handler: (router, user) => router.push(`/users/chat/${user.id}`),
icon: 'chat',
},
clearStatus: {
label: () => intl.formatMessage(intlMessages.ClearStatusLabel),
handler: user => setEmojiStatus(user.id, 'none'),
icon: 'clear_status',
},
setPresenter: {
label: () => intl.formatMessage(intlMessages.MakePresenterLabel),
handler: user => assignPresenter(user.id),
icon: 'presentation',
},
kick: {
label: user => intl.formatMessage(intlMessages.KickUserLabel, { 0: user.name }),
handler: user => kickUser(user.id),
icon: 'circle_close',
},
mute: {
label: () => intl.formatMessage(intlMessages.MuteUserAudioLabel),
handler: user => toggleVoice(user.id),
icon: 'audio_off',
},
unmute: {
label: () => intl.formatMessage(intlMessages.UnmuteUserAudioLabel),
handler: user => toggleVoice(user.id),
icon: 'audio_on',
},
promote: {
label: user => intl.formatMessage(intlMessages.PromoteUserLabel, { 0: user.name }),
handler: user => changeRole(user.id, 'MODERATOR'),
icon: 'promote',
},
demote: {
label: user => intl.formatMessage(intlMessages.DemoteUserLabel, { 0: user.name }),
handler: user => changeRole(user.id, 'VIEWER'),
icon: 'user',
},
};
{
openChat: {
label: () => intl.formatMessage(intlMessages.ChatLabel),
handler: (router, user) => router.push(`/users/chat/${user.id}`),
icon: 'chat',
},
clearStatus: {
label: () => intl.formatMessage(intlMessages.ClearStatusLabel),
handler: user => setEmojiStatus(user.id, 'none'),
icon: 'clear_status',
},
setPresenter: {
label: () => intl.formatMessage(intlMessages.MakePresenterLabel),
handler: user => assignPresenter(user.id),
icon: 'presentation',
},
kick: {
label: user => intl.formatMessage(intlMessages.KickUserLabel, { 0: user.name }),
handler: user => kickUser(user.id),
icon: 'circle_close',
},
mute: {
label: () => intl.formatMessage(intlMessages.MuteUserAudioLabel),
handler: user => toggleVoice(user.id),
icon: 'audio_off',
},
unmute: {
label: () => intl.formatMessage(intlMessages.UnmuteUserAudioLabel),
handler: user => toggleVoice(user.id),
icon: 'audio_on',
},
promote: {
label: user => intl.formatMessage(intlMessages.PromoteUserLabel, { 0: user.name }),
handler: user => changeRole(user.id, 'MODERATOR'),
icon: 'promote',
},
demote: {
label: user => intl.formatMessage(intlMessages.DemoteUserLabel, { 0: user.name }),
handler: user => changeRole(user.id, 'VIEWER'),
icon: 'user',
},
};
let index = -1;
return users.map(user => (
<CSSTransition
classNames={listTransition}
appear
enter
exit
timeout={0}
component="div"
className={cx(styles.participantsList)}
key={user.id}
>
<div ref={(node) => { this.userRefs[index += 1] = node; }}>
<UserListItem
compact={compact}
isBreakoutRoom={isBreakoutRoom}
user={user}
currentUser={currentUser}
userActions={userActions}
meeting={meeting}
getAvailableActions={getAvailableActions}
normalizeEmojiName={normalizeEmojiName}
isMeetingLocked={isMeetingLocked}
getScrollContainerRef={this.getScrollContainerRef}
/>
</div>
</CSSTransition>
));
}
focusUserItem(index) {
if (!this.userRefs[index]) {
return;
}
this.userRefs[index].firstChild.focus();
}
changeState(newIndex) {
this.setState({ index: newIndex });
}
render() {
const {
users,
intl,
compact,
} = this.props;
return (
<div className={styles.participants}>
@ -196,33 +253,9 @@ class UserParticipants extends Component {
tabIndex={0}
ref={(ref) => { this.refScrollContainer = ref; }}
>
<div ref={(ref) => { this.refScrollItems = ref; }} className={styles.list}>
<TransitionGroup>
{ users.map(user => (
<CSSTransition
classNames={listTransition}
appear
enter
exit
timeout={0}
component="div"
className={cx(styles.participantsList)}
key={user.id}
>
<UserListItem
compact={compact}
isBreakoutRoom={isBreakoutRoom}
user={user}
currentUser={currentUser}
userActions={userActions}
meeting={meeting}
getAvailableActions={getAvailableActions}
normalizeEmojiName={normalizeEmojiName}
isMeetingLocked={isMeetingLocked}
getScrollContainerRef={this.getScrollContainerRef}
/>
</CSSTransition>
))}
<div className={styles.list}>
<TransitionGroup ref={(ref) => { this.refScrollItems = ref; }}>
{ this.getUsers() }
</TransitionGroup>
</div>
</div>

View File

@ -25,7 +25,7 @@ const propTypes = {
}).isRequired,
userActions: PropTypes.shape({}).isRequired,
router: PropTypes.shape({}).isRequired,
isBreakoutRoom: PropTypes.bool.isRequired,
isBreakoutRoom: PropTypes.bool,
getAvailableActions: PropTypes.func.isRequired,
meeting: PropTypes.shape({}).isRequired,
isMeetingLocked: PropTypes.func.isRequired,
@ -34,12 +34,10 @@ const propTypes = {
};
const defaultProps = {
shouldShowActions: false,
isBreakoutRoom: false,
};
class UserListItem extends Component {
static createAction(action, ...options) {
return (
<UserAction

View File

@ -57,7 +57,6 @@ const propTypes = {
class UserListContent extends Component {
/**
* Return true if the content fit on the screen, false otherwise.
*
@ -140,8 +139,10 @@ class UserListContent extends Component {
};
const isDropdownVisible =
UserListContent.checkIfDropdownIsVisible(dropdownContent.offsetTop,
dropdownContent.offsetHeight);
UserListContent.checkIfDropdownIsVisible(
dropdownContent.offsetTop,
dropdownContent.offsetHeight,
);
if (!isDropdownVisible) {
const offsetPageTop =
@ -201,13 +202,15 @@ class UserListContent extends Component {
? intl.formatMessage(messages.presenter)
: '';
const userAriaLabel = intl.formatMessage(messages.userAriaLabel,
const userAriaLabel = intl.formatMessage(
messages.userAriaLabel,
{
0: user.name,
1: presenter,
2: you,
3: user.emoji.status,
});
},
);
const contents = (
<div

View File

@ -1,43 +0,0 @@
import React from 'react';
import { defineMessages } from 'react-intl';
import PropTypes from 'prop-types';
import styles from './styles';
const intlMessages = defineMessages({
participantsTitle: {
id: 'app.userList.participantsTitle',
description: 'Title for the Users list',
},
});
const propTypes = {
compact: PropTypes.bool,
intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired }).isRequired,
};
const defaultProps = {
compact: false,
};
const UserListHeader = props => (
<div className={styles.header}>
{
!props.compact ?
<div className={styles.headerTitle} role="banner">
{props.intl.formatMessage(intlMessages.participantsTitle)}
</div> : null
}
{/* <Button
label={intl.formatMessage(intlMessages.toggleCompactView)}
hideLabel
icon={!this.state.compact ? 'left_arrow' : 'right_arrow'}
className={styles.btnToggle}
onClick={this.handleToggleCompactView}
/> */}
</div>
);
UserListHeader.propTypes = propTypes;
UserListHeader.defaultProps = defaultProps;
export default UserListHeader;

View File

@ -1,18 +0,0 @@
@import "/imports/ui/components/user-list/styles.scss";
.header {
@extend %flex-column;
justify-content: left;
flex-grow: 0;
display: flex;
flex-direction: row;
padding: 0 $md-padding-x;
margin: $md-padding-x 0;
}
.headerTitle {
flex: 0;
font-size: 1rem;
font-weight: 600;
color: $color-heading;
}

View File

@ -80,7 +80,7 @@ export default class ToolbarSubmenu extends Component {
{objectsToRender ? objectsToRender.map(obj =>
(
<ToolbarSubmenuItem
label={label}
label={obj.value}
icon={!customIcon ? obj.icon : null}
customIcon={customIcon ? ToolbarSubmenu.getCustomIcon(type, obj) : null}
onItemClick={this.onItemClick}

View File

@ -32,7 +32,7 @@ class Settings {
});
// Sets default locale to browser locale
defaultValues.application.locale = navigator.languages[0] ||
defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false ||
navigator.language ||
defaultValues.application.locale;

View File

@ -4,13 +4,13 @@ $border-size-large: 3px;
$border-radius: .2rem;
$sm-padding-x: .75rem;
$sm-padding-y: .25rem;
$sm-padding-y: .3rem;
$md-padding-x: 1rem;
$md-padding-y: .375rem;
$md-padding-y: .45rem;
$lg-padding-x: 1.25rem;
$lg-padding-y: .5rem;
$lg-padding-y: 0.6rem;
$jumbo-padding-x: 3.025rem;
$jumbo-padding-y: 1.25rem;
$jumbo-padding-y: 1.5rem;

View File

@ -215,10 +215,6 @@
"integrity": "sha1-lfE2KbEsOlGl0hWr3OKqnzL4B3M=",
"dev": true
},
"attr-accept": {
"version": "https://registry.npmjs.org/attr-accept/-/attr-accept-1.1.0.tgz",
"integrity": "sha1-tc01In8WOTWo8d4Q7T66FpQfa+Y="
},
"autoprefixer": {
"version": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-7.1.6.tgz",
"integrity": "sha1-+5MwOfdK90qD5xIlznjZ/Vi6hNc=",
@ -1282,7 +1278,7 @@
},
"fibers": {
"version": "https://registry.npmjs.org/fibers/-/fibers-2.0.0.tgz",
"integrity": "sha1-8m0Krx+ZmV++HLPzQO+sCL2p3Es=",
"integrity": "sha512-sLxo4rZVk7xLgAjb/6zEzHJfSALx6u6coN1z61XCOF7i6CyTdJawF4+RdpjCSeS8AP66eR2InScbYAz9RAVOgA==",
"dev": true
},
"figures": {
@ -1427,7 +1423,7 @@
},
"glob": {
"version": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
"integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=",
"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
"requires": {
"fs.realpath": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"inflight": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -1593,7 +1589,7 @@
},
"humanize-duration": {
"version": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.10.1.tgz",
"integrity": "sha1-ZbVQwKoJUVbst8NA20TuC99xr0s=",
"integrity": "sha512-FHD+u5OKj8TSsSdMHJxSCC78N5Rt4ecil6sWvI+xPbUKhxvHmkKo/V8imbR1m2dXueZYLIl7PcSYX9i/oEiOIA==",
"dev": true
},
"husky": {
@ -1629,7 +1625,7 @@
},
"immutability-helper": {
"version": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-2.4.0.tgz",
"integrity": "sha1-ANQh4pV8F/DweBR18F/9g35zRY0=",
"integrity": "sha512-rW/L/56ZMo9NStMK85kFrUFFGy4NeJbCdhfrDHIZrFfxYtuwuxD+dT3mWMcdmrNO61hllc60AeGglCRhfZ1dZw==",
"requires": {
"invariant": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz"
}
@ -3503,6 +3499,12 @@
"integrity": "sha1-KYuJ34uTsCIdv0Ia0rGx6iP8Z3c=",
"dev": true
},
"popper.js": {
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.12.9.tgz",
"integrity": "sha1-DfvC3/lsRRuzMu3Pz6r1ZtMx1bM=",
"dev": true
},
"postcss": {
"version": "https://registry.npmjs.org/postcss/-/postcss-6.0.13.tgz",
"integrity": "sha1-ueyrTuAMids+yTEUW9lZC78/El8=",
@ -3745,14 +3747,6 @@
"prop-types": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz"
}
},
"react-dropzone": {
"version": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-4.2.1.tgz",
"integrity": "sha1-aV6AvQsGXxGB5p8tD20dXMcmZMk=",
"requires": {
"attr-accept": "https://registry.npmjs.org/attr-accept/-/attr-accept-1.1.0.tgz",
"prop-types": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz"
}
},
"react-intl": {
"version": "https://registry.npmjs.org/react-intl/-/react-intl-2.4.0.tgz",
"integrity": "sha1-ZsFNyd+ac7L7v71gIXJugKYT6xU=",
@ -3784,20 +3778,13 @@
"warning": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz"
}
},
"react-tabs": {
"version": "https://registry.npmjs.org/react-tabs/-/react-tabs-2.1.0.tgz",
"integrity": "sha1-uhhKUZ4KCAPPeQoesZvE/bpf0Oo=",
"react-tippy": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-tippy/-/react-tippy-1.2.2.tgz",
"integrity": "sha512-xqmymAhKub1JGtLJ+HncUauBpwJjHAp6EkKBLeGtuhneaGQ3GnRp5aEd/YRNc4NmIb6o1lbf/Z6R9G3/VjnjYA==",
"dev": true,
"requires": {
"classnames": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz",
"prop-types": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz"
}
},
"react-toastify": {
"version": "https://registry.npmjs.org/react-toastify/-/react-toastify-2.1.6.tgz",
"integrity": "sha1-Gkh/rSekjZ6u9FaDXpVevnmxp5A=",
"requires": {
"prop-types": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz",
"react-transition-group": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.2.1.tgz"
"popper.js": "1.12.9"
}
},
"react-toggle": {
@ -4396,7 +4383,7 @@
},
"tiny-emitter": {
"version": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
"integrity": "sha1-gtJ0aKylrejl/R5tIrV91D69+3w="
"integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
},
"tinycolor2": {
"version": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz",

View File

@ -41,11 +41,12 @@
"react-modal": "~3.0.4",
"react-router": "~3.0.2",
"react-tabs": "~2.1.0",
"react-toggle": "~4.0.2",
"react-toastify": "~2.1.2",
"react-toggle": "~4.0.2",
"react-transition-group": "~2.2.1",
"redis": "~2.8.0",
"string-hash": "~1.1.3",
"tippy.js": "^2.0.2",
"winston": "~2.4.0",
"xml2js": "~0.4.19"
},

View File

@ -31,6 +31,7 @@ acl:
- 'toggleVoice'
- 'clearPublicChatHistory'
- 'changeRole'
- 'ejectUserFromVoice'
presenter:
methods:
- 'assignPresenter'

View File

@ -12,3 +12,4 @@ redis:
- 'from-akka-apps-wb-redis-channel'
ignored:
- 'CheckAlivePongSysMsg'
- 'DoLatencyTracerMsg'

View File

@ -62,13 +62,17 @@
"app.presentationUploder.dropzoneLabel": "Drag files here to upload",
"app.presentationUploder.browseFilesLabel": "or browse for files",
"app.presentationUploder.fileToUpload": "To be uploaded...",
"app.presentationUploder.currentBadge": "Current",
"app.presentationUploder.genericError": "Ops, something went wrong",
"app.presentationUploder.upload.progress": "Uploading ({progress}%)",
"app.presentationUploder.upload.413": "File is too large.",
"app.presentationUploder.upload.413": "File is too large",
"app.presentationUploder.conversion.conversionProcessingSlides": "Processing page {current} of {total}",
"app.presentationUploder.conversion.genericConversionStatus": "Converting file...",
"app.presentationUploder.conversion.generatingThumbnail": "Generating thumbnails...",
"app.presentationUploder.conversion.generatedSlides": "Slides generated...",
"app.presentationUploder.conversion.generatingSvg": "Generating SVG images...",
"presentationUploder.conversion.generatedSlides": "Slides generated...",
"app.presentationUploder.conversion.pageCountExceeded": "Ops, the page count exceeded the limit",
"app.presentationUploder.conversion.timeout": "Ops, the conversion is taking too long",
"app.polling.pollingTitle": "Polling Options",
"app.failedMessage": "Apologies, trouble connecting to the server.",
"app.connectingMessage": "Connecting...",