This commit is contained in:
Bobak Oftadeh 2019-01-09 11:42:06 -08:00
commit 6317647b23
22 changed files with 536 additions and 41 deletions

View File

@ -80,6 +80,7 @@ public class ParamsProcessorUtil {
private String html5ClientUrl;
private Boolean moderatorsJoinViaHTML5Client;
private Boolean attendeesJoinViaHTML5Client;
private Boolean allowRequestsWithoutSession;
private String defaultAvatarURL;
private String defaultConfigURL;
private String defaultGuestPolicy;
@ -421,6 +422,10 @@ public class ParamsProcessorUtil {
return moderatorsJoinViaHTML5Client;
}
public Boolean getAllowRequestsWithoutSession() {
return allowRequestsWithoutSession;
}
public String getDefaultConfigXML() {
defaultConfigXML = getConfig(defaultConfigURL);
@ -775,6 +780,10 @@ public class ParamsProcessorUtil {
this.moderatorsJoinViaHTML5Client = moderatorsJoinViaHTML5Client;
}
public void setAllowRequestsWithoutSession(Boolean allowRequestsWithoutSession) {
this.allowRequestsWithoutSession = allowRequestsWithoutSession;
}
public void setAttendeesJoinViaHTML5Client(Boolean attendeesJoinViaHTML5Client) {
this.attendeesJoinViaHTML5Client = attendeesJoinViaHTML5Client;
}

View File

@ -123,6 +123,7 @@
.poll,
.breakoutRoom,
.note,
.chat {
@extend %full-page;

View File

@ -153,6 +153,7 @@ class MessageList extends Component {
<Button
aria-hidden="true"
className={styles.unreadButton}
color="primary"
size="sm"
label={intl.formatMessage(intlMessages.moreMessages)}
onClick={() => this.scrollTo()}

View File

@ -37,8 +37,6 @@
flex-shrink: 0;
width: 100%;
text-transform: uppercase;
// margin-top: .25rem;
margin-bottom: .25rem;
background-color: #808080;
@extend %text-elipsis;
}

View File

@ -53,7 +53,7 @@ const propTypes = {
const defaultProps = {
children: null,
isOpen: false,
keepOpen: null,
onShow: noop,
onHide: noop,
autoFocus: false,
@ -78,10 +78,12 @@ class Dropdown extends Component {
onShow,
onHide,
} = this.props;
const { isOpen } = this.state;
if (this.state.isOpen && !prevState.isOpen) { onShow(); }
if (isOpen && !prevState.isOpen) { onShow(); }
if (!this.state.isOpen && prevState.isOpen) { onHide(); }
if (!isOpen && prevState.isOpen) { onHide(); }
}
handleShow() {
@ -99,16 +101,29 @@ class Dropdown extends Component {
}
handleWindowClick(event) {
const { keepOpen, onHide } = this.props;
const { isOpen } = this.state;
const triggerElement = findDOMNode(this.trigger);
const contentElement = findDOMNode(this.content);
const closeDropdown = this.props.isOpen && this.state.isOpen && triggerElement.contains(event.target);
const preventHide = this.props.isOpen && contentElement.contains(event.target) || !triggerElement;
if (closeDropdown) {
return this.props.onHide();
if (keepOpen === null) {
if (triggerElement.contains(event.target)) {
return;
}
}
if (contentElement && preventHide) {
if (triggerElement && triggerElement.contains(event.target)) {
if (keepOpen) return onHide();
if (isOpen) return this.handleHide();
}
if (keepOpen && isOpen && !contentElement.contains(event.target)) {
onHide();
this.handleHide();
return;
}
if (keepOpen !== null) {
return;
}
@ -116,7 +131,8 @@ class Dropdown extends Component {
}
handleToggle() {
return this.state.isOpen ? this.handleHide() : this.handleShow();
const { isOpen } = this.state;
return isOpen ? this.handleHide() : this.handleShow();
}
render() {
@ -125,15 +141,18 @@ class Dropdown extends Component {
className,
style,
intl,
keepOpen,
...otherProps
} = this.props;
const { isOpen } = this.state;
let trigger = children.find(x => x.type === DropdownTrigger);
let content = children.find(x => x.type === DropdownContent);
trigger = React.cloneElement(trigger, {
ref: (ref) => { this.trigger = ref; },
dropdownIsOpen: this.state.isOpen,
dropdownIsOpen: isOpen,
dropdownToggle: this.handleToggle,
dropdownShow: this.handleShow,
dropdownHide: this.handleHide,
@ -141,12 +160,14 @@ class Dropdown extends Component {
content = React.cloneElement(content, {
ref: (ref) => { this.content = ref; },
'aria-expanded': this.state.isOpen,
dropdownIsOpen: this.state.isOpen,
'aria-expanded': isOpen,
dropdownIsOpen: isOpen,
dropdownToggle: this.handleToggle,
dropdownShow: this.handleShow,
dropdownHide: this.handleHide,
});
const showCloseBtn = (isOpen && keepOpen) || (isOpen && keepOpen === null);
return (
<div
@ -162,7 +183,7 @@ class Dropdown extends Component {
>
{trigger}
{content}
{this.state.isOpen ?
{showCloseBtn ?
<Button
className={styles.close}
label={intl.formatMessage(intlMessages.close)}

View File

@ -81,10 +81,10 @@
color: var(--color-white);
.verticalList & {
margin-left: calc((var(--line-height-computed) / 2) * -1);
margin-right: calc((var(--line-height-computed) / 2) * -1);
padding-left: calc(var(--line-height-computed) / 2);
padding-right: calc(var(--line-height-computed) / 2);
margin-left: -.25rem;
margin-right: -.25rem;
padding-left: .25rem;
padding-right: .25rem;
}
.horizontalList & {

View File

@ -23,7 +23,7 @@
background: var(--dropdown-bg);
border-radius: var(--border-radius);
box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
border: 1px solid rgba(0, 0, 0, .15);
border: 0;
padding: calc(var(--line-height-computed) / 2);
z-index: 1000;

View File

@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Session } from 'meteor/session';
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';
const intlMessages = defineMessages({
hideNoteLabel: {
id: 'app.note.hideNoteLabel',
description: 'Label for hiding note button',
},
title: {
id: 'app.note.title',
description: 'Title for the shared notes',
},
});
const propTypes = {
url: PropTypes.string.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
};
const defaultProps = {
};
const Note = (props) => {
const {
url,
intl,
} = props;
return (
<div
data-test="note"
className={styles.note}
>
<header className={styles.header}>
<div
data-test="noteTitle"
className={styles.title}
>
<Button
onClick={() => {
Session.set('openPanel', 'userlist');
}}
aria-label={intl.formatMessage(intlMessages.hideNoteLabel)}
label={intl.formatMessage(intlMessages.title)}
icon="left_arrow"
className={styles.hideBtn}
/>
</div>
</header>
<iframe
title="etherpad"
src={url}
/>
</div>
);
};
Note.propTypes = propTypes;
Note.defaultProps = defaultProps;
export default injectWbResizeEvent(injectIntl(Note));

View File

@ -0,0 +1,22 @@
import React, { PureComponent } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Note from './component';
import NoteService from './service';
class NoteContainer extends PureComponent {
render() {
return (
<Note {...this.props}>
{this.props.children}
</Note>
);
}
}
export default withTracker(() => {
const url = NoteService.getNoteURL();
return {
url,
};
})(NoteContainer);

View File

@ -0,0 +1,74 @@
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import Settings from '/imports/ui/services/settings';
const NOTE_CONFIG = Meteor.settings.public.note;
/**
* Calculate a 32 bit FNV-1a hash
* Found here: https://gist.github.com/vaiorabbit/5657561
* Ref.: http://isthe.com/chongo/tech/comp/fnv/
*
* @param {string} str the input value
* @param {boolean} [asString=false] set to true to return the hash value as
* 8-digit hex string instead of an integer
* @param {integer} [seed] optionally pass the hash of the previous chunk
* @returns {integer | string}
*/
const hashFNV32a = (str, asString, seed) => {
let hval = (seed === undefined) ? 0x811c9dc5 : seed;
for (let i = 0, l = str.length; i < l; i++) {
hval ^= str.charCodeAt(i);
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}
if (asString) {
return ("0000000" + (hval >>> 0).toString(16)).substr(-8);
}
return hval >>> 0;
}
const generateNoteId = () => {
const meetingId = Auth.meetingID;
const noteId = hashFNV32a(meetingId, true);
return noteId;
};
const getLang = () => {
const locale = Settings.application.locale;
const lang = locale.toLowerCase();
return lang;
};
const getCurrentUser = () => {
const userId = Auth.userID;
const User = Users.findOne({ userId });
return User;
};
const getNoteParams = () => {
let config = NOTE_CONFIG.config;
const User = getCurrentUser();
config.userName = User.name;
config.userColor = User.color;
config.lang = getLang();
let params = [];
for (var key in config) {
if (config.hasOwnProperty(key)) {
params.push(key + '=' + encodeURIComponent(config[key]));
}
}
return params.join('&');
}
const getNoteURL = () => {
const noteId = generateNoteId();
const params = getNoteParams();
let url = NOTE_CONFIG.url + '/p/' + noteId + '?' + params;
return url;
};
export default {
getNoteURL,
};

View File

@ -0,0 +1,73 @@
@import "/imports/ui/stylesheets/mixins/focus";
@import "/imports/ui/stylesheets/variables/_all";
.note {
background-color: var(--color-white);
padding: var(--md-padding-x);
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: space-around;
overflow: hidden;
height: 100vh;
transform: translateZ(0);
}
.header {
display: flex;
flex-direction: row;
align-items: left;
flex-shrink: 0;
a {
@include elementFocus(var(--color-primary));
padding-bottom: var(--sm-padding-y);
padding-left: var(--sm-padding-y);
text-decoration: none;
display: block;
}
[class^="icon-bbb-"],
[class*=" icon-bbb-"] {
font-size: 85%;
}
}
.title {
@extend %text-elipsis;
flex: 1;
& > button, button:hover {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
}
.hideBtn {
position: relative;
background-color: var(--color-white);
display: block;
margin: 4px;
margin-bottom: 2px;
padding-left: 0px;
> i {
color: black;
}
&:hover {
background-color: var(--color-white);
}
}
iframe {
display: flex;
flex-flow: column;
flex-grow: 1;
flex-shrink: 1;
position: relative;
overflow-x: hidden;
overflow-y: auto;
border-style: none;
}

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import BreakoutRoomContainer from '/imports/ui/components/breakout-room/container';
import UserListContainer from '/imports/ui/components/user-list/container';
import ChatContainer from '/imports/ui/components/chat/container';
import NoteContainer from '/imports/ui/components/note/container';
import PollContainer from '/imports/ui/components/poll/container';
import { defineMessages, injectIntl } from 'react-intl';
import Resizable from 're-resizable';
@ -14,6 +15,10 @@ const intlMessages = defineMessages({
id: 'app.chat.label',
description: 'Aria-label for Chat Section',
},
noteLabel: {
id: 'app.note.label',
description: 'Aria-label for Note Section',
},
userListLabel: {
id: 'app.userList.label',
description: 'Aria-label for Userlist Nav',
@ -36,6 +41,10 @@ const USERLIST_MAX_WIDTH_PX = 240;
const CHAT_MIN_WIDTH = 150;
const CHAT_MAX_WIDTH = 350;
// I like big notes and I can not lie
const NOTE_MIN_WIDTH = 400;
const NOTE_MAX_WIDTH = 800;
class PanelManager extends Component {
constructor() {
super();
@ -45,10 +54,12 @@ class PanelManager extends Component {
this.breakoutroomKey = _.uniqueId('breakoutroom-');
this.chatKey = _.uniqueId('chat-');
this.pollKey = _.uniqueId('poll-');
this.noteKey = _.uniqueId('note-');
this.state = {
chatWidth: 340,
userlistWidth: 180,
noteWidth: 400,
};
}
@ -149,6 +160,53 @@ class PanelManager extends Component {
);
}
renderNote() {
const { intl, enableResize } = this.props;
return (
<section
className={styles.note}
aria-label={intl.formatMessage(intlMessages.noteLabel)}
key={enableResize ? null : this.noteKey}
>
<NoteContainer />
</section>
);
}
renderNoteResizable() {
const { noteWidth } = this.state;
const resizableEnableOptions = {
top: false,
right: true,
bottom: false,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
minWidth={NOTE_MIN_WIDTH}
maxWidth={NOTE_MAX_WIDTH}
ref={(node) => { this.resizableNote = node; }}
enable={resizableEnableOptions}
key={this.noteKey}
size={{ width: noteWidth }}
onResizeStop={(e, direction, ref, d) => {
this.setState({
noteWidth: noteWidth + d.width,
});
}}
>
{this.renderNote()}
</Resizable>
);
}
renderPoll() {
return (
<div className={styles.poll} key={this.pollKey}>
@ -183,6 +241,14 @@ class PanelManager extends Component {
}
}
if (openPanel === 'note') {
if (enableResize) {
resizablePanels.push(this.renderNoteResizable());
} else {
panels.push(this.renderNote());
}
}
if (openPanel === 'poll') {
if (enableResize) {
resizablePanels.push(this.renderPoll());

View File

@ -15,6 +15,22 @@
.presentationControls,
.zoomWrapper {
box-shadow: 0 0 10px -2px rgba(0, 0, 0, .25);
span:first-of-type button,
button:first-of-type {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
span:last-of-type button,
button:last-of-type {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
}
.presentationToolbarWrapper {
@ -47,18 +63,6 @@
i {
color: var(--toolbar-button-color);
}
button:first-of-type {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button:last-of-type {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
}
.zoomWrapper {

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { styles } from './styles';
import UserParticipantsContainer from './user-participants/container';
import UserMessages from './user-messages/component';
import UserNotes from './user-notes/component';
import UserPolls from './user-polls/component';
import BreakoutRoomItem from './breakout-room/component';
@ -83,6 +84,11 @@ class UserContent extends PureComponent {
roving,
}}
/>
<UserNotes
{...{
intl,
}}
/>
<UserPolls
isPresenter={currentUser.isPresenter}
{...{

View File

@ -0,0 +1,67 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { defineMessages } from 'react-intl';
import Icon from '/imports/ui/components/icon/component';
import { Session } from 'meteor/session';
import { styles } from './styles';
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
};
const intlMessages = defineMessages({
notesTitle: {
id: 'app.userList.notesTitle',
description: 'Title for the notes list',
},
title: {
id: 'app.note.title',
description: 'Title for the shared notes',
},
});
class UserNotes extends PureComponent {
render() {
const {
intl,
} = this.props;
if (!Meteor.settings.public.note.enabled) return null;
const toggleNotePanel = () => {
Session.set(
'openPanel',
Session.get('openPanel') === 'note'
? 'userlist'
: 'note',
);
};
return (
<div className={styles.messages}>
{
<h2 className={styles.smallTitle}>
{intl.formatMessage(intlMessages.notesTitle)}
</h2>
}
<div className={styles.scrollableList}>
<div
role='button'
tabIndex={0}
className={styles.noteLink}
onClick={toggleNotePanel}
>
<Icon iconName='copy' className={styles.icon} />
<span className={styles.label} >{intl.formatMessage(intlMessages.title)}</span>
</div>
</div>
</div>
);
}
}
UserNotes.propTypes = propTypes;
export default UserNotes;

View File

@ -0,0 +1,60 @@
@import "../../styles.scss";
@import "/imports/ui/stylesheets/variables/_all";
.smallTitle {
font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
padding: 0 var(--sm-padding-x);
color: var(--color-gray-light);
margin-bottom: var(--sm-padding-x);
margin-top: var(--sm-padding-x);
}
.scrollableList {
margin-left: 0.45rem;
margin-bottom: 1px;
outline: none;
}
.noteLink {
@extend %list-item;
cursor: pointer;
display: flex;
flex-flow: row;
flex-grow: 0;
flex-shrink: 0;
padding-top: var(--lg-padding-y);
padding-bottom: var(--lg-padding-y);
padding-left: var(--lg-padding-y);
text-decoration: none;
width: 100%;
color: var(--color-gray-dark);
background-color: var(--color-off-white);
> i {
display: flex;
font-size: 175%;
color: var(--color-gray-light);
flex: 0 0 2.2rem;
}
> span {
font-size: 0.9rem;
font-weight: 400;
color: black;
position: relative;
flex-grow: 1;
line-height: 2;
text-align: left;
padding-left: var(--lg-padding-y);
text-overflow: ellipsis;
}
&:active {
background-color: var(--list-item-bg-hover);
box-shadow: inset 0 0 0 var(--border-size) var(--item-focus-border), inset 1px 0 0 1px var(--item-focus-border);
outline: none;
}
}

View File

@ -448,6 +448,7 @@ class UserDropdown extends PureComponent {
dropdownVisible,
dropdownDirection,
dropdownOffset,
showNestedOptions,
} = this.state;
const actions = this.getUsersActions();
@ -509,7 +510,7 @@ class UserDropdown extends PureComponent {
return (
<Dropdown
ref={(ref) => { this.dropdown = ref; }}
isOpen={isActionsOpen}
keepOpen={isActionsOpen || showNestedOptions}
onShow={this.onActionsShow}
onHide={this.onActionsHide}
className={userItemContentsStyle}

View File

@ -120,6 +120,13 @@ public:
storage_key: UNREAD_CHATS
system_messages_keys:
chat_clear: PUBLIC_CHAT_CLEAR
note:
enabled: false
url: ETHERPAD_HOST
config:
noColors: true
showControls: true
rtl: false
layout:
autoSwapLayout: false
hidePresentation: false

View File

@ -18,9 +18,13 @@
"app.chat.label": "Chat",
"app.chat.emptyLogLabel": "Chat log empty",
"app.chat.clearPublicChatMessage": "The public chat history was cleared by a moderator",
"app.note.title": "Shared Notes",
"app.note.label": "Note",
"app.note.hideNoteLabel": "Hide note",
"app.userList.usersTitle": "Users",
"app.userList.participantsTitle": "Participants",
"app.userList.messagesTitle": "Messages",
"app.userList.notesTitle": "Notes",
"app.userList.presenter": "Presenter",
"app.userList.you": "You",
"app.userList.locked": "Locked",

View File

@ -200,6 +200,9 @@ bigbluebutton.web.logoutURL=default
# successfully joining the meeting.
defaultClientUrl=${bigbluebutton.web.serverURL}/client/BigBlueButton.html
# Allow requests without JSESSIONID to be handled (default = false)
allowRequestsWithoutSession=false
# Force all attendees to join the meeting using the HTML5 client
attendeesJoinViaHTML5Client=false

View File

@ -109,6 +109,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
<property name="defaultClientUrl" value="${defaultClientUrl}"/>
<property name="defaultGuestWaitURL" value="${defaultGuestWaitURL}"/>
<property name="html5ClientUrl" value="${html5ClientUrl}"/>
<property name="allowRequestsWithoutSession" value="${allowRequestsWithoutSession}"/>
<property name="moderatorsJoinViaHTML5Client" value="${moderatorsJoinViaHTML5Client}"/>
<property name="attendeesJoinViaHTML5Client" value="${attendeesJoinViaHTML5Client}"/>
<property name="defaultMeetingDuration" value="${defaultMeetingDuration}"/>

View File

@ -1419,10 +1419,15 @@ class ApiController {
Meeting meeting = null;
UserSession userSession = null;
Boolean allowEnterWithoutSession = false;
// Depending on configuration, allow ENTER requests to proceed without session
if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
allowEnterWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
}
String respMessage = "Session " + sessionToken + " not found."
if (!session[sessionToken]) {
reject = true;
} else if (meetingService.getUserSessionWithAuthToken(sessionToken) == null) {
if (meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowEnterWithoutSession && !session[sessionToken])) {
reject = true;
respMessage = "Session " + sessionToken + " not found."
} else {
@ -1562,11 +1567,15 @@ class ApiController {
println("Session token = [" + sessionToken + "]")
}
if (!session[sessionToken]) {
Boolean allowStunsWithoutSession = false;
// Depending on configuration, allow STUNS requests to proceed without session
if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
allowStunsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
}
if (meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowStunsWithoutSession && !session[sessionToken])) {
reject = true;
} else if (meetingService.getUserSessionWithAuthToken(sessionToken) == null)
reject = true;
else {
} else {
us = meetingService.getUserSessionWithAuthToken(sessionToken);
meeting = meetingService.getMeeting(us.meetingID);
if (meeting == null || meeting.isForciblyEnded()) {