Merge pull request #3159 from oswaldoacauan/feature-chat

Add Chat functionality to HTML5 client
This commit is contained in:
Anton Georgiev 2016-06-02 16:46:22 -04:00
commit 0cf7d88154
41 changed files with 1007 additions and 131 deletions

View File

@ -21,6 +21,16 @@ a {
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
/* DEBUG ONLY
* {

View File

@ -16,10 +16,12 @@ addLocaleData([...en, ...es, ...pt]);
// Safari sends us en-us instead of en-US
let locale = navigator.language.split('-');
locale = locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : navigator.language;
// locale = 'pt-BR'; // Set a locale for testing
// locale = 'pt-BR'; // Set a locale for testing
/* TODO: We should load the en first then merge with the en-US
(eg: load country translation then region) */
let messages = Locales[locale] || Locales['en'] || {};
let messages = Locales[locale] || Locales.en || {};
// Helper to load javascript libraries from the BBB server
function loadLib(libname, success, fail) {

View File

@ -5,6 +5,32 @@ import { translateHTML5ToFlash } from '/imports/startup/server/helpers';
import { logger } from '/imports/startup/server/logger';
import { redisConfig } from '/config';
import RegexWebUrl from '/imports/utils/regex-weburl';
const HTML_SAFE_MAP = {
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const parseMessage = (message) => {
message = message || '';
message = message.trim();
// Replace <br/> with \n\r
message = message.replace(/<br\s*[\/]?>/gi, '\n\r');
// Sanitize. See: http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/
message = message.replace(/[<>'"]/g, c => HTML_SAFE_MAP[c]);
// Replace flash links to flash valid ones
message = message.replace(RegexWebUrl, "<a href='event:$&'><u>$&</u></a>");
return message;
};
Meteor.methods({
// meetingId: the id of the meeting
// chatObject: the object including info on the chat message, including the text
@ -17,6 +43,7 @@ Meteor.methods({
const chatType = chatObject.chat_type;
const recipient = chatObject.to_userid;
let eventName = null;
const action = function () {
if (chatType === 'PUBLIC_CHAT') {
eventName = 'send_public_chat_message';
@ -31,8 +58,9 @@ Meteor.methods({
}
};
chatObject.message = parseMessage(chatObject.message);
if (isAllowedTo(action(), credentials) && chatObject.from_userid === requesterUserId) {
chatObject.message = translateHTML5ToFlash(chatObject.message);
let message = {
payload: {
message: chatObject,

View File

@ -1,14 +1,31 @@
import Chat from '/imports/api/chat';
import { logger } from '/imports/startup/server/logger';
const BREAK_TAG = '<br/>';
const parseMessage = (message) => {
message = message || '';
// Replace \r and \n to <br/>
message = message.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1' + BREAK_TAG + '$2');
// Replace flash links to html valid ones
message = message.split(`<a href='event:`).join(`<a target="_blank" href='`);
message = message.split(`<a href="event:`).join(`<a target="_blank" href="`);
return message;
};
export function addChatToCollection(meetingId, messageObject) {
let id;
// manually convert time from 1.408645053653E12 to 1408645053653 if necessary
// (this is the time_from that the Flash client outputs)
messageObject.from_time = messageObject.from_time.toString().split('.').join('').split('E')[0];
messageObject.message = parseMessage(messageObject.message);
if ((messageObject.from_userid != null) && (messageObject.to_userid != null)) {
messageObject.message = translateFlashToHTML5(messageObject.message);
return id = Chat.upsert({
meetingId: meetingId,
'message.message': messageObject.message,
@ -42,11 +59,3 @@ export function addChatToCollection(meetingId, messageObject) {
});
}
};
// translate '<br/>' breakline character to '\r' carriage return character for HTML5
const translateFlashToHTML5 = function (message) {
let result;
result = message;
result = result.replace(new RegExp(BREAK_LINE, 'g'), CARRIAGE_RETURN);
return result;
};

View File

@ -1,6 +1,6 @@
import Users from '/imports/api/users';
import Meetings from '/imports/api/meetings';
import {getInStorage, setInStorage} from '/imports/ui/components/app/service';
import Auth from '/imports/ui/services/auth';
import {callServer} from '/imports/ui/services/api';
import {clientConfig} from '/config';
import {createVertoUserName, joinVertoAudio} from '/imports/api/verto';
@ -12,7 +12,7 @@ function getVoiceBridge() {
}
function amIListenOnly() {
const uid = getInStorage('userID');
const uid = Auth.getUser();
return Users.findOne({ userId: uid }).user.listenOnly;
}
@ -88,7 +88,7 @@ function joinVoiceCall(options) {
window.BBB = {};
window.BBB.getMyUserInfo = function (callback) {
const uid = getInStorage('userID');
const uid = Auth.getUser();
const result = {
myUserID: uid,
myUsername: Users.findOne({ userId: uid }).user.name,

View File

@ -2,7 +2,22 @@ import Chat from '/imports/api/chat';
import Users from '/imports/api/users';
import Meetings from '/imports/api/meetings';
import { logger } from '/imports/startup/server/logger';
import {clientConfig} from '/config';
import { clientConfig } from '/config';
const BREAK_TAG = '<br/>';
const parseMessage = (message) => {
message = message || '';
// Replace \r and \n to <br/>
message = message.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1' + BREAK_TAG + '$2');
// Replace flash links to html valid ones
message = message.split(`<a href='event:`).join(`<a target="_blank" href='`);
message = message.split(`<a href="event:`).join(`<a target="_blank" href="`);
return message;
};
export function userJoined(meetingId, user, callback) {
let userObject, userId, welcomeMessage, meetingObject;
@ -85,7 +100,7 @@ export function userJoined(meetingId, user, callback) {
userId: userId,
message: {
chat_type: 'SYSTEM_MESSAGE',
message: welcomeMessage,
message: parseMessage(welcomeMessage),
from_color: '0x3399FF',
to_userid: userId,
from_userid: 'SYSTEM_MESSAGE',

View File

@ -1,8 +1,9 @@
import {clientConfig} from '/config';
import {getVoiceBridge} from '/imports/api/phone';
import Auth from '/imports/ui/services/auth';
import { clientConfig } from '/config';
import { getVoiceBridge } from '/imports/api/phone';
function createVertoUserName() {
const uid = getInStorage('userID');
const uid = Auth.getUser();
const uName = Users.findOne({ userId: uid }).user.name;
const conferenceUsername = 'FreeSWITCH User - ' + encodeURIComponent(uName);
return conferenceUsername;

View File

@ -3,5 +3,7 @@
"app.userlist.participantsTitle": "Participants",
"app.userlist.messagesTitle": "Messages",
"app.userlist.presenter": "Presenter",
"app.userlist.you": "You"
"app.userlist.you": "You",
"app.chat.submitLabel": "Send Message",
"app.chat.inputLabel": "Message input for chat {name}"
}

View File

@ -3,5 +3,7 @@
"app.userlist.participantsTitle": "Participantes",
"app.userlist.messagesTitle": "Mensagens",
"app.userlist.presenter": "Apresentador",
"app.userlist.you": "Você"
"app.userlist.you": "Você",
"app.chat.submitLabel": "Enviar Mensagem",
"app.chat.inputLabel": "Campo de mensagem para conversa {name}"
}

View File

@ -6,8 +6,9 @@ import { createHistory } from 'history';
// route components
import AppContainer from '../../ui/components/app/container';
import {setCredentials, subscribeForData} from '../../ui/components/app/service';
import ChatContainer from '../../ui/components/chat/container';
import UserListContainer from '../../ui/components/user-list/container';
import ChatContainer from '../../ui/components/chat/ChatContainer';
const browserHistory = useRouterHistory(createHistory)({
basename: '/html5client',

View File

@ -1,26 +1,17 @@
import { Meteor } from 'meteor/meteor';
import Auth from '/imports/ui/services/auth';
import Users from '/imports/api/users';
import Chat from '/imports/api/chat';
import Meetings from '/imports/api/meetings';
import Cursor from '/imports/api/cursor';
import Polls from '/imports/api/polls';
function setInStorage(key, value) {
if (!!value) {
console.log('in setInStorage', key, value);
localStorage.setItem(key, value);
}
};
function getInStorage(key) {
return localStorage.getItem(key);
};
function setCredentials(nextState, replace) {
if (!!nextState && !!nextState.params) {
setInStorage('meetingID', nextState.params.meetingID);
setInStorage('userID', nextState.params.userID);
setInStorage('authToken', nextState.params.authToken);
if (nextState && nextState.params.authToken) {
const { meetingID, userID, authToken } = nextState.params;
Auth.setCredentials(meetingID, userID, authToken);
}
};
@ -47,11 +38,7 @@ function subscribeForData() {
};
function subscribeFor(collectionName) {
const credentials = {
meetingId: getInStorage('meetingID'),
requesterUserId: getInStorage('userID'),
requesterToken: getInStorage('authToken'),
};
const credentials = Auth.getCredentials();
// console.log("subscribingForData", collectionName, meetingID, userID, authToken);
@ -74,5 +61,4 @@ export {
pollExists,
subscribeForData,
setCredentials,
getInStorage,
};

View File

@ -126,12 +126,12 @@ $actionsbar-height: 50px; // TODO: Change to ActionsBar real height
}
@include mq($medium-up) {
flex: 0 20vw;
flex: 0 25vw;
order: 1;
}
@include mq($xlarge-up) {
flex-basis: 15vw;
flex-basis: 20vw;
}
}

View File

@ -16,19 +16,19 @@ $btn-danger-color: $color-white;
$btn-danger-bg: $color-danger;
$btn-danger-border: $color-danger;
$btn-border-size: 2px;
$btn-border-radius: .2rem;
$btn-border-size: $border-size;
$btn-border-radius: $border-radius;
$btn-font-weight: 600;
$btn-spacing: .35rem;
$btn-sm-font-size: $font-size-small * .85;
$btn-sm-padding: .25rem .75rem;
$btn-sm-padding: $sm-padding-y $sm-padding-x;
$btn-md-font-size: $font-size-base * .85;
$btn-md-padding: .375rem 1rem;
$btn-md-padding: $md-padding-y $md-padding-x;
$btn-lg-font-size: $font-size-large * .85;
$btn-lg-padding: .5rem 1.25rem;
$btn-lg-padding: $lg-padding-y $lg-padding-x;
/* Base
* ==========

View File

@ -1,12 +0,0 @@
import React, { Component } from 'react';
import { Link } from 'react-router';
export default class Chat extends Component {
render() {
return (
<div>
You are chatting with {this.props.currentChat}
</div>
);
}
}

View File

@ -1,31 +0,0 @@
import React, { Component, PropTypes } from 'react';
import { createContainer } from 'meteor/react-meteor-data';
import Chat from './Chat';
class ChatContainer extends Component {
constructor(props) {
super(props);
this.state = {
currentChat: null,
};
}
componentDidMount() {
const chatID = this.props.params.id || 'public';
this.setState({ currentChat: chatID });
}
render() {
const { chatID, ...props } = this.props.params;
return (
<Chat currentChat={chatID} {...props}>
{this.props.children}
</Chat>
);
}
}
export default createContainer(() => {
return {};
}, ChatContainer);

View File

@ -0,0 +1,46 @@
import React, { Component } from 'react';
import { Link } from 'react-router';
import styles from './styles';
import MessageForm from './message-form/component';
import MessageList from './message-list/component';
import Icon from '../icon/component';
const ELEMENT_ID = 'chat-messages';
export default class Chat extends Component {
constructor(props) {
super(props);
}
render() {
const {
title,
messages,
actions,
} = this.props;
return (
<section className={styles.chat}>
<header className={styles.header}>
<Link className={styles.closeChat} to="/users">
<Icon iconName="left-arrow" /> {title}
</Link>
</header>
<MessageList
messages={messages}
id={ELEMENT_ID}
tabindex="0"
role="log"
aria-atomic="true"
aria-relevant="additions"
/>
<MessageForm
chatAreaId={ELEMENT_ID}
chatTitle={title}
handleSendMessage={actions.handleSendMessage}
/>
</section>
);
}
}

View File

@ -0,0 +1,43 @@
import React, { Component, PropTypes } from 'react';
import { createContainer } from 'meteor/react-meteor-data';
import Chat from './component';
import ChatService from './service';
const PUBLIC_CHAT_KEY = 'public';
class ChatContainer extends Component {
constructor(props) {
super(props);
}
render() {
return (
<Chat {...this.props}>
{this.props.children}
</Chat>
);
}
}
export default createContainer(({ params }) => {
const chatID = params.chatID || PUBLIC_CHAT_KEY;
let messages = [];
if (chatID === PUBLIC_CHAT_KEY) {
messages = ChatService.getPublicMessages();
title = 'Public';
} else {
messages = ChatService.getPrivateMessages(chatID);
title = ChatService.getChatTitle(chatID);
}
return {
title,
messages,
actions: {
handleSendMessage: message => ChatService.sendMessage(chatID, message),
},
};
}, ChatContainer);

View File

@ -0,0 +1,118 @@
import React, { Component, PropTypes } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { findDOMNode } from 'react-dom';
import cx from 'classnames';
import styles from './styles';
import Button from '../../button/component';
import TextareaAutosize from 'react-autosize-textarea';
const propTypes = {
};
const defaultProps = {
};
const messages = defineMessages({
submitLabel: {
id: 'app.chat.submitLabel',
defaultMessage: 'Send Message',
description: 'Chat submit button label',
},
inputLabel: {
id: 'app.chat.inputLabel',
defaultMessage: 'Message input for chat {name}',
description: 'Chat message input label',
},
});
class MessageForm extends Component {
constructor(props) {
super(props);
this.state = {
message: '',
};
this.handleMessageChange = this.handleMessageChange.bind(this);
this.handleMessageKeyUp = this.handleMessageKeyUp.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleMessageKeyUp(e) {
if (e.keyCode === 13 && !e.shiftKey) {
this.refs.btnSubmit.click();
// FIX: I dont know why the live bellow dont trigger the handleSubmit function
// this.refs.form.submit();
}
}
handleMessageChange(e) {
this.setState({ message: e.target.value });
}
handleSubmit(e) {
e.preventDefault();
let message = this.state.message.trim();
// Sanitize. See: http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/
let div = document.createElement('div');
div.appendChild(document.createTextNode(message));
message = div.innerHTML;
if (!message) {
return;
}
this.setState({ message: '' });
this.props.handleSendMessage(message);
}
render() {
const { intl, chatTitle } = this.props;
return (
<form
{...this.props}
ref="form"
className={cx(this.props.className, styles.form)}
onSubmit={this.handleSubmit}>
<div className={styles.actions}>
<Button
onClick={() => alert('Not supported yet...')}
icon={'circle-add'}
size={'sm'}
circle={true}
/>
</div>
<TextareaAutosize
className={styles.input}
id="message-input"
maxlength=""
aria-controls={this.props.chatAreaId}
aria-label={ intl.formatMessage(messages.inputLabel, { name: chatTitle }) }
autocorrect="off"
autocomplete="off"
spellcheck="true"
value={this.state.message}
onChange={this.handleMessageChange}
onKeyUp={this.handleMessageKeyUp}
/>
<input
ref="btnSubmit"
className={'sr-only'}
type="submit"
value={ intl.formatMessage(messages.submitLabel) }
/>
</form>
);
}
}
MessageForm.propTypes = propTypes;
MessageForm.defaultProps = defaultProps;
export default injectIntl(MessageForm);

View File

@ -0,0 +1,43 @@
@import "imports/ui/stylesheets/variables/_all";
.form {
flex-grow: 0;
flex-shrink: 0;
align-self: flex-end;
display: flex;
flex-direction: row;
width: 100%;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 0;
flex-shrink: 0;
border: $border-size solid $color-gray-lighter;
background: #fff;
border-radius: $border-radius 0 0 $border-radius;
color: $color-gray-lighter;
padding: $sm-padding-y $sm-padding-x;
}
.input {
flex: 1;
background: #fff;
border: $border-size solid $color-gray-lighter;
border-left: none;
background-clip: padding-box;
margin: 0;
color: $color-gray;
-webkit-appearance: none;
box-shadow: none;
outline: 0;
padding: $sm-padding-y $sm-padding-x;
resize: none;
transition: none;
border-radius: 0 $border-radius $border-radius 0;
font-size: $font-size-base * .90;
min-height: 4rem;
max-height: 10rem;
}

View File

@ -0,0 +1,50 @@
import React, { Component, PropTypes } from 'react';
import { findDOMNode } from 'react-dom';
import styles from './styles';
import MessageListItem from './message-list-item/component';
const propTypes = {
messages: PropTypes.array.isRequired,
};
export default class MessageList extends Component {
_scrollBottom() {
const node = findDOMNode(this);
node.scrollTop = node.scrollHeight;
}
componentWillUpdate() {
const node = findDOMNode(this);
this.shouldScrollBottom = node.scrollTop + node.offsetHeight === node.scrollHeight;
}
componentDidUpdate() {
if (this.shouldScrollBottom) {
this._scrollBottom();
}
}
componentDidMount() {
this._scrollBottom();
}
render() {
const { messages } = this.props;
return (
<div {...this.props} className={styles.messageList}>
{messages.map((message, index) => (
<MessageListItem
className={styles.messageListItem}
key={index}
message={message.content}
user={message.sender}
time={message.time}
/>
))}
</div>
);
}
}
MessageList.propTypes = propTypes;

View File

@ -0,0 +1,89 @@
import React, { Component, PropTypes } from 'react';
import { FormattedTime } from 'react-intl';
import cx from 'classnames';
import UserAvatar from '../../../user-avatar/component';
import styles from './styles';
const propTypes = {
user: React.PropTypes.shape({
name: React.PropTypes.string.isRequired,
isPresenter: React.PropTypes.bool.isRequired,
isVoiceUser: React.PropTypes.bool.isRequired,
isModerator: React.PropTypes.bool.isRequired,
image: React.PropTypes.string,
}),
message: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.arrayOf(React.PropTypes.string),
]).isRequired,
time: PropTypes.number.isRequired,
};
const defaultProps = {
};
export default class MessageListItem extends Component {
render() {
const {
user,
message,
time,
} = this.props;
const dateTime = new Date(time);
if (!user) {
return this.renderSystemMessage();
}
return (
<div className={styles.item}>
<div className={styles.avatar}>
<UserAvatar user={user} />
</div>
<div className={styles.content}>
<div className={styles.meta}>
<div className={styles.name}>
<span>{user.name}</span>
</div>
<time className={styles.time} datetime={dateTime}>
<FormattedTime value={dateTime}/>
</time>
</div>
{message.map((text, i) => (
<p
className={styles.message}
key={i}
dangerouslySetInnerHTML={ { __html: text } } >
</p>
))}
</div>
</div>
);
}
renderSystemMessage() {
const {
message,
} = this.props;
return (
<div className={cx(styles.item, styles.systemMessage)}>
<div className={styles.content}>
{message.map((text, i) => (
<p
className={styles.message}
key={i}
dangerouslySetInnerHTML={ { __html: text } } >
</p>
))}
</div>
</div>
);
}
}
MessageListItem.propTypes = propTypes;
MessageListItem.defaultProps = defaultProps;

View File

@ -0,0 +1,74 @@
@import "imports/ui/stylesheets/variables/_all";
.item {
display: flex;
flex-flow: row;
flex: 1;
margin-bottom: $line-height-computed;
font-size: $font-size-base * .90;
}
.systemMessage {
font-weight: 600;
.item + &,
& + .item {
margin-bottom: $line-height-computed * 1.5;
}
}
.avatar {
flex-basis: 1.7rem;
flex-shrink: 0;
flex-grow: 0;
}
.content {
flex: 1;
display: flex;
flex-flow: column;
overflow: hidden;
margin-left: $line-height-computed / 2;
}
.meta {
display: flex;
flex: 1;
flex-flow: row;
& + .message {
margin-top: 0;
}
}
.name {
display: flex;
min-width: 0;
font-weight: 600;
color: $color-gray;
> span {
@extend %text-elipsis;
}
}
.time {
flex-shrink: 0;
flex-grow: 0;
flex-basis: 3.5rem;
color: $color-gray-light;
text-transform: uppercase;
font-size: 75%;
margin-left: $line-height-computed / 2;
> span {
vertical-align: sub;
}
}
.message {
flex: 1;
margin-top: $line-height-computed / 2;
margin-bottom: 0;
color: $color-text;
}

View File

@ -0,0 +1,13 @@
@import "imports/ui/stylesheets/variables/_all";
@import "imports/ui/stylesheets/mixins/_scrollable";
.messageList {
@include scrollbox-vertical();
flex-flow: column;
flex-grow: 1;
flex-grow: 1;
flex-shrink: 1;
margin-right: -$line-height-computed;
padding-right: $line-height-computed;
padding-bottom: $line-height-computed;
}

View File

@ -0,0 +1,144 @@
import Chats from '/imports/api/chat';
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import { callServer } from '/imports/ui/services/api';
const GROUPING_MESSAGES_WINDOW = 60000;
const SYSTEM_CHAT_TYPE = 'SYSTEM_MESSAGE';
const PUBLIC_CHAT_TYPE = 'PUBLIC_CHAT';
const PRIVATE_CHAT_TYPE = 'PRIVATE_CHAT';
const PUBLIC_CHAT_ID = 'public';
/* TODO: Same map is done in the user-list/service we should share this someway */
const mapUser = (user) => ({
id: user.userid,
name: user.name,
isPresenter: user.presenter,
isModerator: user.role === 'MODERATOR',
isCurrent: user.userid === Auth.getUser(),
isVoiceUser: user.voiceUser.joined,
isMuted: user.voiceUser.muted,
isListenOnly: user.listenOnly,
isSharingWebcam: user.webcam_stream.length,
});
const mapMessage = (message) => {
let mappedMessage = {
content: [message.message],
time: +(message.from_time), //+ message.from_tz_offset,
sender: null,
};
if (message.chat_type !== SYSTEM_CHAT_TYPE) {
mappedMessage.sender = getUser(message.from_userid);
}
return mappedMessage;
};
const reduceMessages = (previous, current, index, array) => {
let lastMessage = previous[previous.length - 1];
if (!lastMessage || !lastMessage.sender || !current.sender) { // Skip system messages
return previous.concat(current);
}
// Check if the last message is from the same user and time discrepancy
// between the two messages exceeds window and then group current message
// with the last one
if (lastMessage.sender.id === current.sender.id
&& (current.time - lastMessage.time) <= GROUPING_MESSAGES_WINDOW) {
lastMessage.content = lastMessage.content.concat(current.content);
return previous;
} else {
return previous.concat(current);
}
};
const getUser = (userID) => {
const user = Users.findOne({ userId: userID });
if (user) {
return mapUser(user.user);
} else {
throw `User ${userID} not found`;
}
};
const getPublicMessages = () => {
let publicMessages = Chats.find({
'message.chat_type': { $in: [PUBLIC_CHAT_TYPE, SYSTEM_CHAT_TYPE] },
}, {
sort: ['message.from_time'],
})
.fetch();
let systemMessage = Chats.findOne({ 'message.chat_type': SYSTEM_CHAT_TYPE });
return publicMessages
.map(m => m.message)
.map(mapMessage)
.reduce(reduceMessages, []);
};
const getPrivateMessages = (userID) => {
let messages = Chats.find({
'message.chat_type': PRIVATE_CHAT_TYPE,
$or: [
{ 'message.to_userid': userID },
{ 'message.from_userid': userID },
],
}, {
sort: ['message.from_time'],
}).fetch();
return messages
.map(m => m.message)
.map(mapMessage)
.reduce(reduceMessages, []);
};
const getChatTitle = (userID) => {
const user = getUser(userID);
return user.name;
};
const sendMessage = (receiverID, message) => {
const isPublic = receiverID === PUBLIC_CHAT_ID;
const sender = getUser(Auth.getUser());
const receiver = !isPublic ? getUser(receiverID) : {
id: 'public_chat_userid',
name: 'public_chat_username',
};
/* FIX: Why we need all this payload to send a message?
* The server only really needs the message, from_userid, to_userid and from_lang
*/
let messagePayload = {
message: message,
chat_type: isPublic ? PUBLIC_CHAT_TYPE : PRIVATE_CHAT_TYPE,
from_userid: sender.id,
from_username: sender.name,
from_tz_offset: (new Date()).getTimezoneOffset(),
to_username: receiver.name,
to_userid: receiver.id,
from_lang: window.navigator.userLanguage || window.navigator.language,
from_time: Date.now(),
from_color: 0,
};
return callServer('sendChatMessagetoServer', messagePayload);
};
export default {
getPublicMessages,
getPrivateMessages,
getChatTitle,
sendMessage,
};

View File

@ -0,0 +1,17 @@
@import "imports/ui/stylesheets/variables/_all";
.chat {
background-color: #fff;
margin: $line-height-computed;
display: flex;
flex-grow: 1;
flex-direction: column;
}
.closeChat {
text-decoration: none;
}
.header {
margin-bottom: $line-height-computed;
}

View File

@ -1,6 +1,6 @@
import Deskshare from '/imports/api/deskshare';
import {conferenceUsername, joinVertoAudio, watchVertoVideo} from '/imports/api/verto';
import {getInStorage} from '/imports/ui/components/app/service';
import Auth from '/imports/ui/services/auth';
import {getVoiceBridge} from '/imports/api/phone';
// when the meeting information has been updated check to see if it was
@ -16,7 +16,7 @@ function videoIsBroadcasting() {
if (ds.broadcasting) {
console.log('Deskshare is now broadcasting');
if (ds.startedBy != getInStorage('userID')) {
if (ds.startedBy != Auth.getUser()) {
console.log('deskshare wasn\'t initiated by me');
presenterDeskshareHasStarted();
return true;

View File

@ -1,5 +1,5 @@
import { Polls } from '/imports/api/polls';
import {callServer} from '/imports/ui/services/api';
import { callServer } from '/imports/ui/services/api';
let mapPolls = function () {
let poll = Polls.findOne({});

View File

@ -2,6 +2,19 @@ import React, { Component, PropTypes } from 'react';
import styles from './styles.scss';
import cx from 'classnames';
const propTypes = {
user: React.PropTypes.shape({
name: React.PropTypes.string.isRequired,
isPresenter: React.PropTypes.bool.isRequired,
isVoiceUser: React.PropTypes.bool.isRequired,
isModerator: React.PropTypes.bool.isRequired,
image: React.PropTypes.string,
}).isRequired,
};
const defaultProps = {
};
export default class UserAvatar extends Component {
render() {
let user = this.props.user;
@ -10,14 +23,18 @@ export default class UserAvatar extends Component {
avatarClasses[styles.presenter] = user.isPresenter;
avatarClasses[styles.voiceUser] = user.isVoiceUser;
avatarClasses[styles.moderator] = user.isModerator;
// avatarClasses[styles.image] = user.image;
return (
return (
<div className={cx(avatarClasses, styles.userAvatar)}>
<span>
{user.name.slice(0, 1)}
</span>
</div>
)
);
}
}
UserAvatar.propTypes = propTypes;
UserAvatar.defaultProps = defaultProps;

View File

@ -5,9 +5,8 @@ import classNames from 'classnames';
export default class ChatListItem extends Component {
render() {
return (
<li tabIndex='0' className={styles.chatListItem}>
<li tabIndex='0' className={styles.chatListItem} {...this.props}>
<div className={styles.chatThumbnail}>
<i className="icon-bbb-group-chat"></i>
</div>

View File

@ -1,11 +1,28 @@
import React, { Component } from 'react';
import styles from './styles.scss';
import { Link } from 'react-router';
import UserListItem from './user-list-item/component.jsx';
import ChatListItem from './chat-list-item/component.jsx';
import { withRouter } from 'react-router';
import { FormattedMessage } from 'react-intl';
export default class UserList extends Component {
import UserListItem from './user-list-item/component.jsx';
import ChatListItem from './chat-list-item/component.jsx';
class UserList extends Component {
constructor(props) {
super(props);
this.handleMessageClick = this.handleMessageClick.bind(this);
this.handleParticipantDblClick = this.handleParticipantDblClick.bind(this);
}
handleMessageClick(chatID) {
this.props.router.push(`/users/chat/${chatID}`);
}
handleParticipantDblClick(userID) {
this.props.router.push(`/users/chat/${userID}`);
}
render() {
return (
<div className={styles.userList}>
@ -26,7 +43,7 @@ export default class UserList extends Component {
/>
</h2>
</div>
)
);
}
renderContent() {
@ -49,10 +66,10 @@ export default class UserList extends Component {
/>
</h3>
<ul className={styles.chatsList} tabIndex="1">
<ChatListItem />
<ChatListItem onClick={() => this.handleMessageClick('public')} />
</ul>
</div>
)
);
}
renderParticipants() {
@ -68,10 +85,20 @@ export default class UserList extends Component {
</h3>
<div className={styles.scrollableList}>
<ul className={styles.participantsList} tabIndex="1">
{this.props.users.map(user => <UserListItem accessibilityLabel={'Status abc'} accessible={true} key={user.id} user={user}/>)}
{this.props.users.map(user => (
<UserListItem
onDoubleClick={() => this.handleParticipantDblClick(user.id)}
accessibilityLabel={'Status abc'}
accessible={true}
key={user.id}
user={user}
/>
))}
</ul>
</div>
</div>
)
);
}
}
export default withRouter(UserList);

View File

@ -9,7 +9,7 @@ let cx = classNames.bind(styles);
export default class ChatListItem extends Component {
render() {
return (
<li tabIndex='0' className={styles.userListItem}>
<li tabIndex='0' className={styles.userListItem} {...this.props}>
<UserAvatar user={this.props.user}/>
{this.renderUserName()}
{this.renderUserIcons()}
@ -21,7 +21,7 @@ export default class ChatListItem extends Component {
let user = this.props.user;
let userNameSub = null;
if(user.isPresenter) {
if (user.isPresenter) {
userNameSub = (
<p className={styles.userNameSub}>
<FormattedMessage
@ -31,7 +31,7 @@ export default class ChatListItem extends Component {
/>
</p>
);
} else if(user.isCurrent) {
} else if (user.isCurrent) {
userNameSub = (
<p className={styles.userNameSub}>
(<FormattedMessage
@ -50,7 +50,7 @@ export default class ChatListItem extends Component {
</h3>
{userNameSub}
</div>
)
);
}
renderUserIcons() {
@ -58,7 +58,7 @@ export default class ChatListItem extends Component {
let audioChatIcon = null;
if (user.isVoiceUser || user.isListenOnly) {
if(user.isMuted) {
if (user.isMuted) {
audioChatIcon = 'icon-bbb-audio-off';
} else {
audioChatIcon = user.isListenOnly ? 'icon-bbb-listen' : 'icon-bbb-audio';
@ -77,6 +77,6 @@ export default class ChatListItem extends Component {
<i className='icon-bbb-more rotate-quarter'></i>
</span>
</div>
)
);
}
}

View File

@ -1,4 +1,4 @@
import {getInStorage} from '/imports/ui/components/app/service';
import { getCredentials } from '/imports/ui/services/auth';
function callServer(name) {
if (!name || !(typeof (name) === 'string' || name instanceof String) || name.length === 0 ||
@ -7,11 +7,7 @@ function callServer(name) {
return false;
}
const credentials = {
meetingId: getInStorage('meetingID'),
requesterUserId: getInStorage('userID'),
requesterToken: getInStorage('authToken'),
};
const credentials = getCredentials();
// slice off the first element. That is the function name but we already have that.
const args = Array.prototype.slice.call(arguments, 1);

View File

@ -0,0 +1,27 @@
import Storage from '/imports/ui/services/storage';
export const setCredentials = (meeting, user, token) => {
Storage.set('meetingID', meeting);
Storage.set('userID', user);
Storage.set('authToken', token);
};
export const getCredentials = () => ({
meetingId: Storage.get('meetingID'),
requesterUserId: Storage.get('userID'),
requesterToken: Storage.get('authToken'),
});
export const getMeeting = () => getCredentials().meetingId;
export const getUser = () => getCredentials().requesterUserId;
export const getToken = () => getCredentials().requesterToken;
export default {
setCredentials,
getCredentials,
getMeeting,
getUser,
getToken,
};

View File

@ -1,15 +1,11 @@
const STORAGE = localStorage;
const PREFIX = 'bbb_';
const PREFIX = 'BBB_';
function get(key) {
return STORAGE.getItem(key);
}
const get = (key) => STORAGE.getItem(`${PREFIX}${key}`);
function set(key) {
STORAGE.setItem(key);
}
const set = (key, value) => STORAGE.setItem(`${PREFIX}${key}`, value);
export default {
get,
set
}
set,
};

View File

@ -0,0 +1,27 @@
@mixin scrollbox-vertical($bg-color: white) {
overflow-y: auto;
background:
/* Shadow covers */
linear-gradient($bg-color 30%, rgba(255,255,255,0)),
linear-gradient(rgba(255,255,255,0), $bg-color 70%) 0 100%,
/* Shadows */
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
background-repeat: no-repeat;
background-color: transparent;
background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
background-attachment: local, local, scroll, scroll;
// Fancy scroll
&::-webkit-scrollbar{width:5px;height:5px}
&::-webkit-scrollbar-button{width:0;height:0}
&::-webkit-scrollbar-thumb{background:rgba(0,0,0,.25);border:none;border-radius:50px}
&::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.5)}
&::-webkit-scrollbar-thumb:active{background:rgba(0,0,0,.25)}
&::-webkit-scrollbar-track{background:rgba(0,0,0,.25);border:none;border-radius:50px}
&::-webkit-scrollbar-track:hover{background:rgba(0,0,0,.25)}
&::-webkit-scrollbar-track:active{background:rgba(0,0,0,.25)}
&::-webkit-scrollbar-corner{background:0 0}
}

View File

@ -1,3 +1,4 @@
@import "./general";
@import "./breakpoints";
@import "./palette";
@import "./typography";

View File

@ -0,0 +1,11 @@
$border-size: 2px;
$border-radius: .2rem;
$sm-padding-x: .75rem;
$sm-padding-y: .25rem;
$md-padding-x: 1rem;
$md-padding-y: .375rem;
$lg-padding-x: 1.25rem;
$lg-padding-y: .5rem;

View File

@ -3,6 +3,7 @@ $color-white: #F3F6F9 !default;
$color-gray: #353B42 !default;
$color-gray-dark: #2A2D33 !default;
$color-gray-light: #8B9AA8 !default;
$color-gray-lighter: lighten($color-gray-light, 25%) !default;
$color-primary: #299AD5 !default;
$color-success: #4DC0A2 !default;

View File

@ -15,3 +15,16 @@ $headings-font-family: inherit !default;
$headings-font-weight: 500 !default;
$headings-line-height: 1.1 !default;
$headings-color: inherit !default;
/*
* Placeholders
* ===============
*/
%text-elipsis {
min-width: 0;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,110 @@
//
// Regular Expression for URL validation
//
// Author: Diego Perini
// Updated: 2010/12/05
// License: MIT
//
// Copyright (c) 2010-2013 Diego Perini (http://www.iport.it)
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
//
// the regular expression composed & commented
// could be easily tweaked for RFC compliance,
// it was expressly modified to fit & satisfy
// these test for an URL shortener:
//
// http://mathiasbynens.be/demo/url-regex
//
// Notes on possible differences from a standard/generic validation:
//
// - utf-8 char class take in consideration the full Unicode range
// - TLDs have been made mandatory so single names like "localhost" fails
// - protocols have been restricted to ftp, http and https only as requested
//
// Changes:
//
// - IP address dotted notation validation, range: 1.0.0.0 - 223.255.255.255
// first and last IP address of each class is considered invalid
// (since they are broadcast/network addresses)
//
// - Added exclusion of private, reserved and/or local networks ranges
//
// - Made starting path slash optional (http://example.com?foo=bar)
//
// - Allow a dot (.) at the end of hostnames (http://example.com.)
//
// Compressed one-line versions:
//
// Javascript version
//
// /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/i
//
// PHP version
//
// _^(?:(?:https?|ftp)://)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]-*)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]-*)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$_iuS
//
export default new RegExp(
// protocol identifier
'(?:(?:https?|ftp)://)' +
// user:pass authentication
'(?:\\S+(?::\\S*)?@)?' +
'(?:' +
// IP address exclusion
// private & local networks
'(?!(?:10|127)(?:\\.\\d{1,3}){3})' +
'(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' +
'(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broacast addresses
// (first & last IP address of each class)
'(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
'(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
'(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
'|' +
// host name
'(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' +
// domain name
'(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' +
// TLD identifier
'(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' +
// TLD may end with dot
'\\.?' +
')' +
// port number
'(?::\\d{2,5})?' +
// resource path
'(?:[/?#]\\S*)?', 'img'
);

View File

@ -18,7 +18,8 @@
"react-intl": "^2.1.2",
"react-modal": "^1.2.1",
"react-router": "^2.4.0",
"underscore": "~1.8.3"
"underscore": "~1.8.3",
"react-autosize-textarea": "~0.3.1"
},
"devDependencies": {
"autoprefixer": "^6.3.6",