Files
im_webapp/index.html
T

4245 lines
148 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tinode example: chat web application in react.js</title>
<link rel="manifest" href="manifest.json">
<link id="shortcut-icon" rel="shortcut icon" href="img/logo32x32.png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<!-- Browser styling for Chrome, FF, Opera -->
<meta name="theme-color" content="#3949AB">
<!-- Google's Roboto font -->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700&subset=latin,cyrillic"
rel="stylesheet" type="text/css" />
<!-- Google's material design icons -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- Styles for local elements -->
<link rel="stylesheet" href="css/base.css" />
<!-- ReactJs scripts -->
<script src="https://unpkg.com/react@15/dist/react.min.js"></script>
<script src="https://unpkg.com/react-dom@15/dist/react-dom.min.js"></script>
<script src="https://unpkg.com/babel-standalone@6.26.0/babel.min.js"></script>
<script src="tinode.js"></script>
<script src="drafty.js"></script>
</head>
<body>
<div id="mountPoint"></div>
<script type="text/babel">
// Name of this application, used in the User-Agent
var APP_NAME = "TinodeWeb/0.14";
var KNOWN_HOSTS = {hosted: "api.tinode.co", local: "localhost:6060"};
// Default host name and port to connect to.
var DEFAULT_HOST = KNOWN_HOSTS.hosted;
// var DEFAULT_HOST = KNOWN_HOSTS.local;
// Sound to play on message received.
var POP_SOUND = new Audio('audio/msg.mp3');
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
var API_KEY = "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K";
// Minimum time between two keypress notifications, milliseconds.
var KEYPRESS_DELAY = 3*1000;
// Delay before sending a {note} for reciving a message, milliseconds.
var RECEIVED_DELAY = 500;
// Delay before sending a read notification, milliseconds.
var READ_DELAY = 1000;
// The shortest allowed tag length. Matches one on the server.
var MIN_TAG_LENGTH = 4;
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
// in @meadia (max-size: 640px) in base.css
var MEDIA_BREAKPOINT = 640;
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
var REM_SIZE = 13;
// Size of the avatar image
var AVATAR_SIZE = 128;
// Number of chat messages to fetch in one call.
var MESSAGES_PAGE = 24;
// Maximum allowed attachment size = 128K. Increade this limit to a
// greater value in production. Also increase max_message_size in server config.
var MAX_ATTACHMENT_SIZE = 1 << 17;
// Maximum allowed linear dimension of an inline image in pixels. You may want
// to adjust it to 1600 or 2400 for production.
var MAX_IMAGE_SIZE = 768;
// Supported image MIME types and corresponding file extensions.
var SUPPORTED_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
var MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
// Short representation of time in the past.
function shortDateFormat(then) {
var locale = window.navigator.userLanguage || window.navigator.language;
var now = new Date();
if (then.getFullYear() == now.getFullYear()) {
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
} else {
return then.toLocaleDateString(locale,
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
}
return then.toLocaleDateString(locale,
{hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
// Make a data URL from public.photo
function makeImageUrl(photo) {
return (photo && photo.type && photo.data) ?
'data:image/' + photo.type + ';base64,' + photo.data : null;
}
// Scale liner dimensions down to fit uder certain size.
function fitImageSize(width, height, maxWidth, maxHeight) {
if (!width || !height || !maxWidth || !maxHeight) {
return null;
}
var scale = Math.min(
Math.min(width, maxWidth) / width,
Math.min(height, maxHeight) / height
);
var size = {width: (width * scale) | 0, height: (height * scale) | 0};
if (maxWidth == maxHeight) {
// Also calculate parameters for making the image square.
size.square = Math.min(width, height);
size.xoffset = ((width - size.square) / 2) | 0;
size.yoffset = ((height - size.square) / 2) | 0;
}
return size;
}
// Ensure file's extension matches mime content type
function fileNameForMime(fname, mime) {
var idx = SUPPORTED_FORMATS.indexOf(mime);
var ext = MIME_EXTENSIONS[idx];
var at = fname.lastIndexOf('.');
if (at >= 0) {
fname = fname.substring(0, at);
}
return fname + '.' + ext;
}
// Get mime type from data URL header.
function getMimeType(header) {
var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
return (mime && mime.length > 1) ? mime[1] : null;
}
// Convert uploaded image into a base64-encoded string possibly scaling
// linear dimensions.
function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) {
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onerror = function(err) {
onError("Image format unrecognized");
}
img.onload = function() {
var size = fitImageSize(this.width, this.height, width, height);
if (!size) {
onError("Invalid image");
return;
}
var canvas = document.createElement('CANVAS');
if (forceSquare) {
canvas.width = canvas.height = width;
} else {
canvas.width = size.width;
canvas.height = size.height;
}
var ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
if (forceSquare) {
ctx.drawImage(this, size.xoffset, size.yoffset, size.square, size.square,
0, 0, canvas.width, canvas.height);
} else {
ctx.drawImage(this, 0, 0, this.width, this.height,
0, 0, canvas.width, canvas.height);
}
var mime = (forceSquare || SUPPORTED_FORMATS.indexOf(file.type) < 0) ? "image/jpeg" : file.type;
var imageBits = canvas.toDataURL(mime);
var parts = imageBits.split(',');
// Get actual image type: 'data:image/png;base64,'
mime = getMimeType(parts[0]);
if (!mime) {
onError("Unsupported image format");
return;
}
// Ensure the image is not too large
var quality = 0.78;
while (mime == "image/jpeg" && imageBits.length * 0.75 > MAX_ATTACHMENT_SIZE && quality > 0.15) {
imageBits = canvas.toDataURL(mime, quality);
quality *= 0.84;
}
if (imageBits.length * 0.75 > MAX_ATTACHMENT_SIZE) {
onError("The image size " + bytesToHumanSize(imageBits.length * 0.75) +
" exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err");
return;
}
canvas = null;
onSuccess(imageBits.split(',')[1], mime, size.width, size.height, fileNameForMime(file.name, mime));
};
img.src = URL.createObjectURL(file);
}
// Convert uploaded image file to base64-encoded string without scaling/converting the image
function imageFileToBase64(file, onSuccess, onError) {
if (file.size > MAX_ATTACHMENT_SIZE) {
onError("The file size " + bytesToHumanSize(file.size) +
" exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " limit.", "err");
return;
}
var reader = new FileReader();
reader.addEventListener("load", function() {
var parts = reader.result.split(',');
var mime = getMimeType(parts[0]);
if (!mime) {
onError("Failed to process image file");
return;
}
// Get image size.
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = function() {
onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime));
}
img.onerror = function(err) {
onError("Image format unrecognized");
}
img.src = URL.createObjectURL(file);
}, false);
reader.readAsDataURL(file);
}
// Make shortcut icon appear with a green dot.
function updateFavicon(on) {
var oldIcon = document.getElementById("shortcut-icon");
if (oldIcon) {
var head = document.head || document.getElementsByTagName('head')[0];
var newIcon = document.createElement('link');
newIcon.type = "image/png";
newIcon.id = "shortcut-icon";
newIcon.rel = "shortcut icon";
newIcon.href = "img/logo32x32" + (on ? 'a' : '') + ".png";
head.removeChild(oldIcon);
head.appendChild(newIcon);
}
}
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
// as Java's String#hash().
function stringHash(value) {
var hash = 0;
value = "" + value;
for (var i = 0; i < value.length; i++) {
hash = ((hash<<5)-hash) + value.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
// Detect server address from the URL
function detectServerAddress() {
var host = DEFAULT_HOST;
if (window.location.protocol === 'file:' || window.location.hostname === 'localhost') {
host = KNOWN_HOSTS.local;
} else if (window.location.hostname) {
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
}
return host;
}
// Create VCard which represents topic 'public' info
function vcard(fn, imageDataUrl) {
var card = null;
if ((fn && fn.trim()) || imageDataUrl) {
card = {};
if (fn) {
card.fn = fn.trim();
}
if (imageDataUrl) {
var dataStart = imageDataUrl.indexOf(",");
card.photo = {
data: imageDataUrl.substring(dataStart+1),
type: "jpg"
};
}
}
return card;
}
// Construct a user discovery tag
function to_tags(tagsObj) {
if (tagsObj && Object.keys(tagsObj).length > 0) {
var tags = [];
for (var key in tagsObj) {
tags.push(key + ":" + tagsObj[key]);
}
return tags;
}
return undefined;
}
// Deep-shallow compare two arrays.
function arrayEqual(a, b) {
// Compare lengths first.
if (a.length != b.length) {
return false;
}
// Order of elements is ignored.
a.sort();
b.sort();
for (var i = 0, l = a.length; i < l; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// Convert a number of bytes to human-readable format.
function bytesToHumanSize(bytes) {
if (!bytes || bytes == 0) {
return '0 Bytes';
}
var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
var bucket = Math.floor(Math.log2(bytes) / 10) | 0;
var count = bytes / Math.pow(1024, bucket);
var round = bucket > 0 ? (count < 10 ? 2 : (count < 100 ? 1 : 0)) : 0;
return count.toFixed(round) + ' ' + sizes[bucket];
}
/* BEGIN Context Menu: popup/dropdown menu */
// Function is used by context menu to set permissions.
function topicPermissionSetter(mode, params, errorHandler) {
var topic = Tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: " + params.topicName);
return;
}
var am, user;
if (params.user) {
user = topic.subscriber(params.user);
if (!user) {
console.log("Subscriber not found: " + params.topicName + "[" + params.user + "]");
return;
}
am = user.acs.updateGiven(mode).getGiven();
} else {
am = topic.getAccessMode().updateWant(mode).getWant();
}
var instance = this;
topic.setMeta({sub: {user: params.user, mode: am}}).catch(function(err) {
if (errorHandler) {
errorHandler(err.message, "err");
} else {
console.log(err);
}
});
}
function deleteMessages(all, hard, params, errorHandler) {
var topic = Tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: " + params.topicName);
return;
}
var promise = all ?
topic.delMessagesAll(hard) :
topic.delMessagesList([params.seq], hard);
promise.catch(function(err) {
if (errorHandler) {
errorHandler(err.message, "err");
} else {
console.log(err);
}
});
}
// Context menu items.
var ContextMenuItems = {
"topic_info": {title: "Info", handler: null},
"messages_clear": {title: "Clear messages", handler: function(params, errorHandler) {
deleteMessages(true, false, params, errorHandler);
}},
"messages_clear_hard": {title: "Clear for All", handler: function(params, errorHandler) {
deleteMessages(true, true, params, errorHandler);
}},
"message_delete": {title: "Delete", handler: function(params, errorHandler) {
deleteMessages(false, false, params, errorHandler);
}},
"message_delete_hard": {title: "Delete for All", handler: function(params, errorHandler) {
deleteMessages(false, true, params, errorHandler);
}},
"topic_unmute": {title: "Unmute", handler: topicPermissionSetter.bind(this, "+P")},
"topic_mute": {title: "Mute", handler: topicPermissionSetter.bind(this, "-P")},
"topic_unblock": {title: "Unblock", handler: topicPermissionSetter.bind(this, "+J")},
"topic_block": {title: "Block", handler: topicPermissionSetter.bind(this, "-J")},
"topic_delete": {title: "Delete", handler: function(params, errorHandler) {
var topic = Tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: " + params.topicName);
return;
}
topic.delTopic().catch(function(err) {
if (errorHandler) {
errorHandler(err.message, "err");
} else {
console.log(err);
}
});
}},
"permissions": {title: "Edit permissions", handler: null},
"member_delete": {title: "Remove", handler: function(params, errorHandler) {
var topic = Tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
topic.delSubscription(params.user).catch(function(err) {
if (errorHandler) {
errorHandler(err.message, "err");
} else {
console.log(err);
}
});
}},
"member_mute": {title: "Mute", handler: topicPermissionSetter.bind(this, "-P")},
"member_unmute": {title: "Unmute", handler: topicPermissionSetter.bind(this, "+P")},
"member_block": {title: "Block", handler: topicPermissionSetter.bind(this, "-J")},
"member_unblock": {title: "Unblock", handler: topicPermissionSetter.bind(this, "+J")},
};
var ContextMenu = React.createClass({
componentWillUnmount: function() {
this.toggle(false);
},
componentWillReceiveProps: function(nextProps) {
this.toggle(nextProps.visible);
},
toggle: function(visible) {
if (visible) {
document.addEventListener('mousedown', this.handlePageClick, false);
document.addEventListener('keyup', this.handleEscapeKey, false);
} else {
document.removeEventListener('mousedown', this.handlePageClick, false);
document.removeEventListener('keyup', this.handleEscapeKey, false);
}
},
handlePageClick: function(e) {
if (ReactDOM.findDOMNode(this).contains(e.target)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.props.hide();
},
handleEscapeKey: function(e) {
if (e.keyCode === 27) {
this.props.hide();
}
},
handleClick: function(e) {
e.preventDefault();
e.stopPropagation();
this.props.hide();
var item = this.props.items[e.currentTarget.dataset.id];
item.handler(this.props.params, this.props.onError);
},
render: function() {
if (!this.props.visible) {
return null;
}
var count = 0;
var instance = this;
var menu = [];
this.props.items.map(function(item) {
if (item && item.title) {
menu.push(
item.title === "-" ?
<li className="separator" key={count} />
:
<li onClick={instance.handleClick} data-id={count} key={count}>{item.title}</li>
);
}
count++;
});
// Ensure that menu is inside the app-container.
var hSize = 12 * REM_SIZE;
var vSize = REM_SIZE * (0.7 + menu.length * 2.5);
var left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
(this.props.clickAt.x - this.props.bounds.left - hSize) :
(this.props.clickAt.x - this.props.bounds.left);
var top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
(this.props.clickAt.y - this.props.bounds.top - vSize) :
(this.props.clickAt.y - this.props.bounds.top);
var position = {
left: left + "px",
top: top + "px"
};
return (
<ul className="menu" style={position}>
{menu}
</ul>
);
}
});
/* END Popup/dropdown menu */
/* The X menu to be displayed in title bars */
class MenuCancel extends React.PureComponent {
constructor(props) {
super(props);
}
render() {
return (
<a href="#" onClick={this.props.onCancel}><i className="material-icons">close</i></a>
);
}
}
class LoadSpinner extends React.PureComponent {
render() {
return (this.props.show ?
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null);
}
}
// Toggle [Title text >] -> [Title text v]
class MoreButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
open: this.props.open
};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle() {
var open = !this.state.open;
this.setState({open: open});
if (this.props.onToggle) {
this.props.onToggle(open);
}
}
render() {
return (<label className="small" onClick={this.handleToggle}>{this.props.title}...
{this.state.open ? <i className="material-icons">expand_more</i> :
<i className="material-icons">chevron_right</i>}
</label>);
}
}
/* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */
class LetterTile extends React.PureComponent {
render() {
var avatar;
if (this.props.avatar === true) {
if (this.props.topic && this.props.title && this.props.title.trim()) {
var letter = this.props.title.trim().charAt(0);
var color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16);
avatar = (<div className={color}><div>{letter}</div></div>)
} else {
avatar = (Tinode.getTopicType(this.props.topic) === "grp") ?
<i className="material-icons">group</i> :
<i className="material-icons">person</i>;
}
} else if (this.props.avatar) {
avatar = <img className="avatar" alt="avatar" src={this.props.avatar} />;
} else {
avatar = null;
}
return avatar;
}
}
/* END Lettertile */
/* BEGIN In-place text editor. Shows text with an icon
* which toggles it into an input field */
var InPlaceEdit = React.createClass({
getInitialState: function() {
return {
active: this.props.state,
text: this.props.text || ""
};
},
componentWillReceiveProps: function(newProps) {
// If text has changed while in read mode, update text and discard changes.
// Ignore update if in edit mode.
if (this.props.text != newProps.text && !this.state.active) {
this.setState({text: newProps.text || ""});
}
},
handeTextChange: function(e) {
this.setState({text: e.target.value});
},
handleKeyDown: function(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({text: this.props.text, active: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleEditingFinished();
}
},
handleStartEditing: function() {
if (!this.props.readOnly) {
ReactDOM.findDOMNode(this).focus();
this.setState({active: true});
}
},
handleEditingFinished: function() {
this.setState({active: false});
var text = this.state.text.trim();
if ((text || this.props.text) && (text !== this.props.text)) {
this.props.onFinished(text);
}
},
render: function() {
var spanText = this.props.type === "password" ?
"••••••••" : this.state.text;
var spanClass = "in-place-edit" +
(this.props.readOnly ? " disabled" : "");
if (!spanText) {
spanText = this.props.placeholder;
spanClass += " placeholder";
}
if (spanText.length > 20) {
spanText = spanText.substring(0, 19) + "...";
}
return (
this.state.active ?
<input type={this.props.type || "text"}
value={this.state.text}
placeholder={this.props.placeholder}
onChange={this.handeTextChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleEditingFinished}
autoFocus />
:
<span className={spanClass} onClick={this.handleStartEditing}>
<span className="content">{spanText}</span>
</span>
);
}
});
/* END InPlaceEdit */
/* BEGIN Combobox for selecting host name */
class HostSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hostName: this.props.serverAddress,
changed: false
};
this.handleHostNameChange = this.handleHostNameChange.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handleHostNameChange(e) {
this.setState({hostName: e.target.value, changed: true});
}
handleEditingFinished() {
if (this.state.changed) {
this.setState({changed: false});
this.props.onServerAddressChange(this.state.hostName.trim());
}
}
render() {
var hostOptions = [];
for (var key in KNOWN_HOSTS) {
var item = KNOWN_HOSTS[key];
hostOptions.push(
<option key={item} value={item} />
);
}
return (
<div>
<label htmlFor="host-name">Server address:</label>
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
value={this.state.hostName} onChange={this.handleHostNameChange}
onBlur={this.handleEditingFinished} required />
<datalist id="known-hosts">
{hostOptions}
</datalist>
</div>);
}
}
/* END Combobox for selecting host name */
/* BEGIN CheckBox: styled checkbox */
class CheckBox extends React.PureComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange() {
this.props.onChange(this.props.name, !this.props.checked);
}
render() {
return (
this.props.onChange ? (
this.props.checked ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i>
) : (
this.props.checked ?
<i className="material-icons">check_box</i> :
<i className="material-icons">check_box_outline_blank</i>
)
);
}
}
/* END CheckBox */
/* BEGIN PermissionsEditor: Component for editing permissions */
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
var PermissionsEditor = React.createClass({
getInitialState: function() {
return {
mode: (this.props.mode || "").replace("N", "")
};
},
handleChange: function(val) {
var mode = this.state.mode;
var idx = mode.indexOf(val);
if (idx == -1) {
mode += val;
} else {
mode = mode.replace(val, "");
}
this.setState({mode: mode});
},
handleSubmit: function() {
var mode = this.state.mode || "N";
if (mode !== this.props.mode) {
this.props.onSubmit(mode);
} else {
this.props.onCancel();
}
},
handleCancel: function() {
this.props.onCancel();
},
render: function() {
var all = 'JRWPASDO';
var names = {
'J': 'Join (J)',
'R': 'Read (R)',
'W': 'Write (W)',
'P': 'Get notified (P)',
'A': 'Approve (A)',
'S': 'Share (S)',
'D': 'Delete (D)',
'O': 'Owner (O)'
};
var skip = this.props.skip || "";
var mode = this.state.mode;
var compare = (this.props.compare || "").replace("N", "");
var items = [];
for (var i=0; i<all.length; i++) {
var c = all.charAt(i);
if (skip.indexOf(c) >= 0 && mode.indexOf(c) < 0) {
// Permission is marked as inactive: hide unchecked permissions, disable checked permissions
continue;
}
items.push(
<tr key={c}>
<td>{names[c]}</td>
<td className="checkbox">{skip.indexOf(c) < 0 ?
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} onChange={this.handleChange}/>
:
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} />
}</td>{this.props.compare ? <td className="checkbox">
<CheckBox name={c} checked={(compare.indexOf(c) >= 0)}/>
</td> : null}
</tr>
);
}
return (
<div className="panel-form-column">
{this.props.userTitle ?
<ul className="contact-box"><Contact
item={this.props.item}
title={this.props.userTitle}
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
<label className="small">Permissions</label>
<table className="permission-editor">
{this.props.compare ?
<thead><tr>
<th></th><th>{this.props.modeTitle}</th>
<th>{this.props.compareTitle}</th>
</tr></thead> :
null}
<tbody>
{items}
</tbody></table>
<br />
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>Ok</button>
<button className="white" onClick={this.handleCancel}>Cancel</button>
</div>
</div>
);
}
});
/* END PermissionsEditor */
/* BEGIN ChipInput: group membership widget */
var ChipInput = React.createClass({
getInitialState: function() {
return {
placeholder: this.props.chips ? "" : this.props.prompt,
sortedChips: this.sortChips(this.props.chips, this.props.required),
chipIndex: this.indexChips(this.props.chips),
input: '',
focused: false
};
},
componentWillReceiveProps: function(newProps) {
this.setState({
sortedChips: this.sortChips(newProps.chips, newProps.required),
chipIndex: this.indexChips(newProps.chips)
});
if (newProps.chips.length > this.props.chips.length) {
// Chip added: clear input.
this.setState({input: ''});
}
},
// Map chip index to user name
indexChips: function(chips) {
var index = {};
var count = 0;
chips.map(function(item) {
index[item.user] = count;
count ++;
});
return index;
},
// Have non-removable chips appear before all other chips.
sortChips: function(chips, keep) {
var required = [];
var normal = [];
chips.map(function(item) {
if (item.user === keep) {
required.push(item);
} else {
normal.push(item);
}
});
return required.concat(normal);
},
handleTextInput: function(e) {
this.setState({input: e.target.value});
if (this.props.filterFunc) {
this.props.filterFunc(e.target.value);
}
},
removeChipAt: function(idx) {
var removed = this.state.sortedChips[idx];
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
},
handleChipCancel: function(item, idx) {
this.removeChipAt(idx);
},
handleFocusGained: function() {
this.setState({focused: true});
},
handleFocusLost: function() {
this.setState({focused: false});
},
handleKeyDown: function(e) {
if (e.key === 'Backspace') {
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
var at = this.state.sortedChips.length - 1;
if (this.state.sortedChips[at].user !== this.props.required) {
this.removeChipAt(at);
}
}
} else if (e.key === 'Enter') {
if (this.props.onEnter) {
this.props.onEnter(this.state.input);
}
} else if (e.keyCode === 27) {
if (this.props.onCancel) {
this.props.onCancel();
}
}
},
render: function() {
var chips = [];
var instance = this;
var count = 0;
this.state.sortedChips.map(function(item) {
chips.push(
<ChipInput.Chip
onCancel={instance.handleChipCancel}
avatar={makeImageUrl(item.public ? item.public.photo : null)}
title={item.public ? item.public.fn : undefined}
noAvatar={instance.props.avatarDisabled}
topic={item.user}
required={item.user === instance.props.required}
index={count}
key={item.user} />
);
count++;
});
var className = "chip-input" + (this.state.focused ? " focused" : "");
return (
<div className={className}>
{chips}
<input type="text"
placeholder={this.state.placeholder}
onChange={this.handleTextInput}
onFocus={this.handleFocusGained}
onBlur={this.handleFocusLost}
onKeyDown={this.handleKeyDown}
value={this.state.input}
autoFocus />
</div>
);
}
});
ChipInput.Chip = React.createClass({
handleCancel: function() {
this.props.onCancel(this.props.topic, this.props.index);
},
render: function() {
var title = this.props.title || this.props.topic;
return (
<div className="chip">
{this.props.noAvatar ?
<span className="spacer" /> :
<div className="avatar-box">
<LetterTile
avatar={this.props.avatar || true}
topic={this.props.topic}
title={this.props.title} />
</div>
}
<span>{title}</span>
{this.props.onCancel && !this.props.required ?
<a href="#" onClick={this.handleCancel} >&times;</a>
: <span className="spacer" />}
</div>
);
}
});
/* END ChipInput */
/* BEGIN GroupSubs: a list of group subscribers currently online */
var GroupSubs = React.createClass({
getInitialState: function() {
return {
onlineSubs: this.props.subscribers ? this.props.subscribers : []
};
},
componentWillReceiveProps: function(nextProps) {
this.setState({onlineSubs: nextProps.subscribers ? nextProps.subscribers : []});
},
render: function() {
var usersOnline = [];
this.state.onlineSubs.map(function(sub) {
usersOnline.push(
<div className="avatar-box">
<LetterTile
topic={sub.user}
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
title={sub.public ? sub.public.fn : null}
key={sub.user} />
</div>
);
});
return (
<div id="topic-users">{usersOnline}</div>
);
}
});
/* END GroupSubs */
/* BEGIN Login: a login form */
var LoginView = React.createClass({
getInitialState: function() {
return {
login: this.props.login,
password: '',
hostName: this.props.serverAddress,
};
},
handleLoginChange: function(e) {
this.setState({login: e.target.value});
},
handlePasswordChange: function(e) {
this.setState({password: e.target.value});
},
handleServerAddressChange: function(name) {
this.setState({hostName: name});
this.props.onServerAddressChange(name);
},
handleSubmit: function(e) {
e.preventDefault();
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
},
render: function() {
var submitClasses = "blue";
if (this.props.disabled) {
submitClasses += " disabled";
}
return (
<form id="login-form" onSubmit={this.handleSubmit}>
<input type="text" id="inputLogin"
placeholder="Login (alice, bob, carol, dave, frank)"
autoComplete="username"
value={this.state.login}
onChange={this.handleLoginChange}
required autoFocus />
<input type="password" id="inputPassword"
placeholder="Password (alice123, bob123, ...)"
autoComplete="current-password"
value={this.state.password}
onChange={this.handlePasswordChange}
required />
<HostSelector serverAddress={this.props.serverAddress}
onServerAddressChange={this.handleServerAddressChange} />
<div className="dialog-buttons">
<button className={submitClasses} type="submit">Sign in</button>
</div>
</form>
);
}
});
/* END Login */
/* BEGIN Account registration */
var CreateAccountView = React.createClass({
getInitialState: function() {
return {
login: '',
password: '',
password2: '',
email: '',
fn: '', // full/formatted name
imageDataUrl: null,
errorCleared: false
};
},
handleLoginChange: function(e) {
this.setState({login: e.target.value});
},
handlePasswordChange: function(e) {
this.setState({password: e.target.value});
},
handlePassword2Change: function(e) {
this.setState({password2: e.target.value});
},
handleEmailChange: function(e) {
this.setState({email: e.target.value})
},
handleFnChange: function(e) {
this.setState({fn: e.target.value});
},
handleImageChanged: function(img) {
this.setState({imageDataUrl: img});
},
handleSubmit: function(e) {
e.preventDefault();
if (this.state.password.trim() != this.state.password2.trim()) {
this.props.onError("Passwords don't match", "warn");
} else {
// TODO: check email for validity
this.setState({errorCleared: false});
this.props.onCreateAccount(
this.state.login.trim(),
this.state.password.trim(),
vcard(this.state.fn, this.state.imageDataUrl),
{"meth": "email", "val": this.state.email});
}
},
render: function() {
var submitClasses = "blue";
if (this.props.disabled) {
submitClasses += " disabled";
}
return (
<form className="panel-form-column" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<input type="text" placeholder="Login" autoComplete="user-name"
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
<input type="password" placeholder="Password" autoComplete="new-password"
value={this.state.password} onChange={this.handlePasswordChange} required />
<input type="password" placeholder="Repeat password" autoComplete="new-password"
value={this.state.password2} onChange={this.handlePassword2Change} required />
</div>
<AvatarUpload
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="panel-form-row">
<input type="email" placeholder="Email, e.g john.doe@example.com"
autoComplete="email" value={this.state.email} onChange={this.handleEmailChange} required/>
</div>
<div className="panel-form-row">
<input type="text" placeholder="Full name, e.g. John Doe" autoComplete="name"
value={this.state.fn} onChange={this.handleFnChange} required/>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">Sign up</button>
</div>
</form>
);
}
});
var AvatarUpload = React.createClass({
getInitialState: function() {
return {
dataUrl: this.props.avatar
};
},
handleFileUpload(e) {
var instance = this;
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
// Success
function(base64bits, mime) {
var du = makeImageUrl({data: base64bits, type: mime});
instance.setState({dataUrl: du});
instance.props.onImageChanged(du);
},
// Failure
function(err) {
this.props.onError(err, "err");
});
// Clear the value so the same file can be uploaded again.
e.target.value = '';
},
render: function() {
// Randomize id value in case more than one AvatarUpload is shown
// at the same time.
var randId = "file-input-avatar-" + (Math.random() + '').substr(2);
return (
<div className="avatar-upload">
{this.state.dataUrl ?
<img src={this.state.dataUrl} className="preview" /> :
this.props.readOnly && this.props.uid ?
<div className="avatar-box">
<LetterTile avatar={true} topic={this.props.uid} title={this.props.title} />
</div>
:
<div className="blank">128&times;128</div>}
{this.props.readOnly ? null :
<input type="file" id={randId} className="inputfile hidden"
accept="image/*" onChange={this.handleFileUpload} />}
{this.props.readOnly ? null :
<label htmlFor={randId} className="round">
<i className="material-icons">file_upload</i>
</label>}
</div>
);
}
});
/* END Account registration */
/* BEGIN Tinode config panel */
var SettingsView = React.createClass({
getInitialState: function() {
return {
transport: "def"
};
},
handleSubmit: function(e) {
e.preventDefault();
this.props.onUpdate(null, this.state.transport);
},
handleTransportSelected: function(e) {
this.setState({transport: e.currentTarget.value});
},
render: function() {
var names = {def: "default", ws: "websocket", lp: "long polling"};
var transportOptions = [];
var instance = this;
["def", "ws", "lp"].map(function(item){
var id = "transport-" + item;
var name = names[item];
transportOptions.push(
<li key={item}>
<input type="radio" id={id} name="transport-select" value={item}
checked={instance.state.transport === item}
onChange={instance.handleTransportSelected} />
<label htmlFor={id}>{name}</label>
</li>
);
});
return (
<form id="settings-form" onSubmit={this.handleSubmit}>
<label className="small">Wire transport:</label>
<ul className="quoted">
{transportOptions}
</ul>
<div className="dialog-buttons">
<button type="submit" className="blue">Update</button>
</div>
</form>
);
}
});
/* END Tinode config panel */
/* BEGIN Manage side panel - handle Login, Account Registration, Contacts, NewTopic views */
var SideNavbar = React.createClass({
render: function() {
return (
<div id="side-caption-panel" className="caption-panel">
<div id="self-avatar" className="avatar-box">
<LetterTile avatar={this.props.avatar} topic={this.props.myUserId} title={this.props.title} />
</div>
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
{this.props.state === 'login' ?
<SideNavbar.MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
this.props.state === 'contacts' ?
<SideNavbar.MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
null}
{this.props.onCancel ?
<MenuCancel onCancel={this.props.onCancel} /> : null}
</div>
);
}
})
SideNavbar.MenuStart = React.createClass({
render: function() {
return (
<div>
<a href="#" onClick={this.props.onSignUp}><i className="material-icons">person_add</i></a>
&nbsp;
<a href="#" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
</div>
);
}
});
SideNavbar.MenuContacts = React.createClass({
render: function() {
return (
<div>
<a href="#" onClick={this.props.onNewTopic}><i className="material-icons">chat</i></a>
&nbsp;
<a href="#" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
</div>
);
}
});
var SidepanelView = React.createClass({
handleLoginRequested: function(login, password) {
this.props.onLoginRequest(login, password);
},
render: function() {
var title = null;
var avatar = false;
var onCancel = undefined;
switch (this.props.state) {
case 'login':
title = "Sign In";
break;
case 'register':
title = "Create Account";
onCancel = this.props.onCancel;
break;
case 'settings':
title = "Settings";
onCancel = this.props.onCancel;
break;
case 'edit-account':
title = "Edit Account";
onCancel = this.props.onCancel;
break;
case 'contacts':
title = this.props.title;
avatar = this.props.avatar ? this.props.avatar : true;
break;
case 'new-topic':
title = "Start New Chat";
onCancel = this.props.onCancel;
break;
case 'validation':
title = "Confirm Credentials";
onCancel = this.props.onCancel;
break;
default:;
};
return (
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
<SideNavbar state={this.props.state}
title={title} avatar={avatar}
myUserId={this.props.myUserId}
onSignUp={this.props.onSignUp}
onSettings={this.props.onSettings}
onNewTopic={this.props.onNewTopic}
onCancel={onCancel} />
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} />
{this.props.state === 'login' ?
<LoginView login={this.props.login}
disabled={this.props.loginDisabled}
serverAddress={this.props.serverAddress}
onLogin={this.handleLoginRequested}
onServerAddressChange={this.props.onConnectionSettings} /> :
this.props.state === 'register' ?
<CreateAccountView
onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
this.props.state === 'settings' ?
<SettingsView onCancel={this.props.onCancel}
onUpdate={this.props.onConnectionSettings}
/> :
this.props.state === 'edit-account' ?
<EditAccountView
login={this.props.login}
onSubmit={this.props.onUpdateAccount}
onUpdateTags={this.props.onUpdateAccountTags}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
this.props.state === 'contacts' ?
<ContactsView
topicSelected={this.props.topicSelected}
showContextMenu={this.props.showContextMenu}
onTopicSelected={this.props.onTopicSelected}
onOnlineChange={this.props.onOnlineChange} /> :
this.props.state === 'new-topic' ?
<NewTopicView
contactsSearchQuery={this.props.contactsSearchQuery}
foundContacts={this.props.foundContacts}
onInitFind={this.props.onInitFind}
onSearchContacts={this.props.onSearchContacts}
onCreateTopic={this.props.onCreateTopic}
onError={this.props.onError} /> :
this.props.state === 'validation' ?
<ValidationView
onSubmit={this.props.onValidateCredentials}
validation={this.props.validation}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
null}
</div>
);
}
});
var ErrorPanel = React.createClass({
getInitialState: function() {
return {
show: false,
};
},
componentWillReceiveProps: function(newProps) {
this.setState({show: !(!newProps.level)});
},
hide: function() {
this.setState({show: false});
if (this.props.onClearError) {
this.props.onClearError();
}
},
render: function() {
var icon = this.props.level == "err" ? "error": "warning";
return (
<div className={this.state.show ?
(this.props.level == "err" ? "alert-box error" : "alert-box warning") :
"alert-box"}>
<div className="icon"><i className="material-icons">{icon}</i></div>
{this.props.text}
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
</div>
);
}
});
/* END Side panel */
/* EditAccount parameters */
var EditAccountView = React.createClass({
getInitialState: function() {
return {
address: null,
fullName: undefined,
avatar: null,
login: undefined,
password: undefined,
previousOnTags: undefined,
tags: []
};
},
componentWillMount: function() {
var me = Tinode.getMeTopic();
var defacs = me.getDefaultAccess();
this.setState({
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageUrl(me.public ? me.public.photo : null),
address: Tinode.getCurrentUserID(),
auth: defacs.auth,
anon: defacs.anon,
tags: me.tags(),
previousOnTags: me.onTagsUpdated
});
me.onTagsUpdated = this.tnNewTags;
},
componentWillUnmount: function() {
var me = Tinode.getMeTopic();
me.onTagsUpdated = this.state.previousOnTags;
},
tnNewTags: function(tags) {
this.setState({tags: tags});
},
handleFullNameUpdate: function(fn) {
this.setState({fullName: fn});
this.props.onSubmit(null, vcard(fn, this.state.avatar));
},
handlePasswordUpdate: function(pwd) {
this.setState({password: pwd});
this.props.onSubmit(pwd, null);
},
handleImageChanged: function(img) {
this.setState({avatar: img});
this.props.onSubmit(null, vcard(this.state.fullName, img));
},
handleTagsUpdated: function(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
},
render: function() {
var tags = [];
this.state.tags.map(function(tag) {
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
});
if (tags.length == 0) {
tags = <i>No tags defined. Add some.</i>;
}
return (
<div id="edit-account" className="panel-form">
<div className="panel-form-row">
<div className="panel-form-column">
<div><label className="small">Your name</label></div>
<div><InPlaceEdit
placeholder="Full name, e.g. John Doe"
text={this.state.fullName}
onFinished={this.handleFullNameUpdate} /></div>
<div><label className="small">Password</label></div>
<div><InPlaceEdit
placeholder="Unchanged"
type="password"
onFinished={this.handlePasswordUpdate} /></div>
</div>
<AvatarUpload
avatar={this.state.avatar}
uid={this.state.address}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label>Login:</label>
<tt>{this.props.login}</tt>
</div>
<div className="panel-form-row">
<label>Address:</label>
<tt>{this.state.address}</tt>
</div>
<div>
<label className="small">Default access mode:</label>
</div>
<div className="quoted">
<div>Auth: <tt>{this.state.auth}</tt></div>
<div>Anon: <tt>{this.state.anon}</tt></div>
</div>
</div>
<div className="hr" />
<TagManager
title="Tags (user discovery)"
activated={false}
tags={this.state.tags}
onSubmit={this.handleTagsUpdated} />
</div>
);
}
});
/* END EditAccount */
/* BEGIN TagManager: edit topic or user tags */
var TagManager = React.createClass({
getInitialState: function() {
return {
tags: this.props.tags,
tagInput: '',
activated: this.props.activated,
noTagsMessage: 'Add some tags'
};
},
handleShowTagManager: function() {
this.setState({activated: true});
},
handleTagInput: function(text) {
this.setState({tagInput: text});
// Check if user entered ',', ' ' or ';'
if (text.length > 0) {
var last = text[text.length-1];
if (last == ',' || last == ' ' || last == ';') {
var tag = text.substr(0, text.length-1).trim();
if (tag.length >= MIN_TAG_LENGTH) {
this.handleAddTag(tag);
}
}
}
},
handleAddTag: function(tag) {
tag = tag.trim();
if (tag.length > 0) {
var tags = this.state.tags.slice(0);
tags.push(tag);
this.setState({tags: tags, tagInput: ''});
}
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
},
handleRemoveTag: function(tag, index) {
var tags = this.state.tags.slice(0);
tags.splice(index, 1);
this.setState({tags: tags});
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
},
handleSubmit: function() {
var tags = this.state.tags.slice(0);
var inp = this.state.tagInput.trim();
if (inp.length > 0) {
tags.push(inp);
}
this.props.onSubmit(tags);
this.setState({activated: false, tagInput: '', tags: tags});
},
handleCancel: function() {
this.setState({activated: false, tagInput: '', tags: this.props.tags});
if (this.props.onCancel) {
this.props.onCancel();
}
},
render: function() {
var tags = [];
if (this.state.activated) {
this.state.tags.map(function(tag) {
tags.push({user: tag});
});
} else {
this.state.tags.map(function(tag) {
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
});
if (tags.length == 0) {
tags = <i>No tags defined. Add some.</i>;
}
}
return (
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small">{this.props.title}</label>
</div>
{this.state.activated ?
<div>
<ChipInput
chips={tags}
avatarDisabled={true}
prompt={this.state.noTagsMessage}
onEnter={this.handleAddTag}
onChipRemoved={this.handleRemoveTag}
filterFunc={this.handleTagInput} />
{this.props.onSubmit || this.props.onCancel ?
<div id="tag-manager-buttons" className="panel-form-row">
<button className="blue" onClick={this.handleSubmit}>OK</button>
<button className="white" onClick={this.handleCancel}>Cancel</button>
</div>
: null}
</div>
:
<div>
<a href="#" className="flat-button" onClick={this.handleShowTagManager}>
<i className="material-icons">edit</i> Manage tags
</a>
<span>{tags}</span>
</div>
}
</div>
);
}
});
/* END TagManager */
/* BEGIN Contact list (list of topics) */
/* A single topic */;
var Contact = React.createClass({
handleClick: function(e) {
e.preventDefault();
e.stopPropagation();
if (this.props.onSelected) {
this.props.onSelected(this.props.item, this.props.index, this.props.now);
}
},
handleContextClick: function(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
},
render: function() {
var title = this.props.title;
if (!title) {
title = <i>unknown</i>;
} else if (title.length > 30) {
title = title.substring(0, 28) + "...";
}
var online = this.props.now ? "online" : "offline";
var avatar = this.props.avatar ? this.props.avatar : true;
var badges = [];
if (this.props.badges && this.props.badges.length > 0) {
var count = 0;
this.props.badges.map(function(b) {
var style = "badge" + (b.color ? " " + b.color : "");
badges.push(<span className={style} key={count}>{b.name}</span>);
count ++;
});
}
if (this.props.mode) {
badges.push(<span className="badge" key="mode">{this.props.mode}</span>);
}
return (
<li className={!this.props.showCheckmark && this.props.selected ? "selected" : null}
onClick={this.handleClick}>
<div className="avatar-box">
<LetterTile
avatar={avatar} title={this.props.title} topic={this.props.item} />
{this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected ?
<i className="checkmark material-icons">check_circle</i>
: null)}
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
{this.props.unread > 0 ? <Contact.Unread count={this.props.unread} /> : null}
</div>
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
<span>{badges}</span>
</div>
{this.props.showContextMenu ?
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span> : null}
</li>
);
}
});
/* The counter of unread messages in the topic */
Contact.Unread = React.createClass({
render: function() {
var showUnreadBadge = null;
if (this.props.count > 0) {
var count = this.props.count > 9 ? "9+" : this.props.count;
showUnreadBadge = <span className="unread">{count}</span>;
}
return showUnreadBadge;
}
});
/* Contact's labels: [you], [muted], [blocked], etc */
Contact.Badges = React.createClass({
render: function() {
var badges = null;
if (this.props.badges && this.props.badges.length > 0) {
var count = 0;
badges = [];
this.props.badges.map(function(b) {
var style = "badge" + (b.color ? " " + b.color : "");
badges.push(<span className={style} key={count}>{b.name}</span>);
count ++;
});
}
return badges;
}
});
/* ContactsView holds all contacts-related stuff */
var ContactsView = React.createClass({
getInitialState: function() {
return {
contactList: this.prepareContactList()
};
},
componentWillMount: function() {
var me = Tinode.getMeTopic();
me.onContactUpdate = this.tnMeContactUpdate;
me.onSubsUpdated = this.tnMeSubsUpdated;
},
componentWillUnmount: function() {
var me = Tinode.getMeTopic();
me.onContactUpdate = undefined;
me.onSubsUpdated = undefined;
},
// Reactions to updates to the contact list.
tnMeContactUpdate: function(what, cont) {
if (what === "on" || what === "off") {
this.resetContactList();
if (this.props.topicSelected === cont.topic) {
this.props.onOnlineChange(what === "on");
}
} else if (what === "read") {
this.resetContactList();
} else if (what === "msg") {
// New message received
// Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
if (this.props.topicSelected !== cont.topic) {
// Disable sound for now. Need to add a config option.
// POP_SOUND.play();
this.resetContactList();
} else if (document.hidden) {
// Disable sound for now.
// POP_SOUND.play();
}
} else if (what === "recv") {
// Explicitly ignoring "recv" -- it causes no visible updates to contact list.
} else if (what === "gone" || what === "unsub") {
// Topic deleted or user unsubscribed. Remove topic from view.
// If the currently selected topic is gone, clear the selection.
if (this.props.topicSelected === cont.topic) {
this.props.onTopicSelected(null);
}
// Redraw without the deleted topic.
this.resetContactList();
} else {
// TODO(gene): handle other types of notifications:
// * ua -- user agent changes (may display a pictogram for mobile/desktop)
// * upd -- topic 'public' updated
// * del -- messages deleted (hard or soft) -- reset pill counter
console.log("changed (not updating for it): " + what + " in: " + cont.topic);
}
},
tnMeSubsUpdated: function(names) {
this.resetContactList();
},
prepareContactList: function() {
var contacts = [];
var totalUnread = 0;
Tinode.getMeTopic().contacts(function(c) {
// Force to integers;
c.seq = ~~c.seq;
c.read = ~~c.read;
c.unread = c.seq - c.read;
totalUnread += c.unread;
contacts.push(c);
}, this);
updateFavicon(totalUnread > 0);
// Sort: most recent to less recent.
//contacts.sort(function(a, b) {
// return a.updated - b.updated;
//});
return contacts;
},
resetContactList: function() {
this.setState({contactList: this.prepareContactList()});
},
render: function() {
return (
<ContactList
contacts={this.state.contactList}
emptyListMessage={<span>You have no chats<br />¯\_(ツ)_/¯</span>}
topicSelected={this.props.topicSelected}
showOnline={true}
showUnread={true}
onTopicSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu} />
);
}
});
/* END Contact list */
/* BEGIN ContactList: component for showing a list of contacts,
* such as a list of group members in a group chat */
var ContactList = React.createClass({
render: function() {
var me = Tinode.getCurrentUserID();
var contactNodes = [];
var instance = this;
var showCheckmark = Array.isArray(this.props.topicSelected);
if (this.props.contacts && this.props.contacts.length > 0) {
this.props.contacts.map(function(c) {
var key = c.topic ? c.topic : c.user;
// If filter function is provided, filter out the items
// which don't satisfy the condition.
if (instance.props.filterFunc && instance.props.filter) {
var content = [key];
if (c.private) {
content.push(("" + c.private).toLowerCase());
}
if (c.public && c.public.fn) {
content.push(("" + c.public.fn).toLowerCase());
}
if (!instance.props.filterFunc(instance.props.filter, content)) {
return;
}
}
var selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
var mode = this.props.showMode && c.acs ? c.acs.getMode() : null;
var badges = (key === me ? [{name:'you',color:'green'}] : null);
var comment = Array.isArray(c.private) ? c.private.join(", ") : c.private;
contactNodes.push(
<Contact
title={c.public ? c.public.fn : null}
avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment}
unread={this.props.showUnread ? c.unread : 0}
now={c.online}
mode={mode}
badges={badges}
showCheckmark={showCheckmark}
selected={selected}
showOnline={this.props.showOnline}
onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
item={key}
index={contactNodes.length}
key={key} />
);
}, this);
}
return (
<div className={this.props.noScroll ? null : "scrollable-panel"}>
{contactNodes.length > 0 ?
<ul className="contact-box">
{contactNodes}
</ul>
:
<div className="center-medium-text">
{this.props.emptyListMessage}
</div>}
</div>
);
}
});
/* END ContactList */
/* BEGIN GroupMembers: control for managing a list of group members */
var GroupManager = React.createClass({
getInitialState: function() {
return {
members: this.props.members,
index: this.indexMembers(this.props.members),
contactFilter: '',
noContactsMessage: 'You have no contacts :-(',
selectedContacts: this.selectedContacts(this.props.members)
};
},
indexMembers: function(members) {
var index = {};
members.map(function(m) {
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
});
return index;
},
selectedContacts: function(members) {
var sel = [];
members.map(function(m) {
sel.push(m.user);
});
return sel;
},
handleContactSelected: function(userId, index) {
var status = this.state.index[userId];
if (status) {
if (status.present) {
// Prevent duplicate members
return;
}
status.delta += 1;
status.present = true;
} else {
status = {delta: 1, present: true};
}
var m = this.state.members.slice();
m.push(this.props.contacts[index]);
var sel = this.selectedContacts(m);
var i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
},
handleMemberRemoved: function(userId, index) {
var status = this.state.index[userId];
if (!status || !status.present) {
return;
}
status.present = false;
status.delta -= 1;
var m = this.state.members.slice();
m.splice(index, 1);
var sel = this.selectedContacts(m);
var i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
},
handleContactFilter: function(val) {
var msg = !val ?
"You have no contacts :-(" :
"No contacts match '" + val + "'";
this.setState({contactFilter: val, noContactsMessage: msg});
},
doContactFiltering: function(filter, values) {
if (filter) {
for (var i=0; i<values.length; i++) {
if (values[i].indexOf(filter) >= 0) {
return true;
}
}
return false;
}
return true;
},
handleSubmit: function() {
var instance = this;
var members = [];
var added = [];
var removed = [];
var keys = Object.keys(this.state.index);
keys.map(function(k) {
if (instance.state.index[k].present) {
members.push(k);
}
if (instance.state.index[k].delta > 0) {
added.push(k);
} else if (instance.state.index[k].delta < 0) {
removed.push(k);
}
});
this.props.onSubmit(members, added, removed);
},
handleCancel: function() {
this.props.onCancel();
},
render: function() {
return (
<div id="group-manager">
<div className="panel-form-row">
<label className="small">Group members</label>
</div>
<div className="panel-form-row">
<ChipInput
chips={this.state.members}
required={this.props.requiredMember}
prompt="add members"
filterFunc={this.handleContactFilter}
onChipRemoved={this.handleMemberRemoved} />
</div>
<div className="panel-form-row">
<label className="small">All contacts</label>
</div>
<ContactList
contacts={this.props.contacts}
topicSelected={this.state.selectedContacts}
filter={this.state.contactFilter}
filterFunc={this.doContactFiltering}
emptyListMessage={this.state.noContactsMessage}
showOnline={false}
showUnread={false}
onTopicSelected={this.handleContactSelected} />
<div id="group-manager-buttons" className="panel-form-row">
<button className="blue" onClick={this.handleSubmit}>OK</button>
<button className="white" onClick={this.handleCancel}>Cancel</button>
</div>
</div>
);
}
});
/* END GroupManager */
/* BEGIN Create new topic and invite users or send an invite */
var NewTopicView = React.createClass({
getInitialState: function() {
return {
tabSelected: "p2p",
searchQuery: this.props.contactsSearchQuery,
contactList: this.props.foundContacts,
contactSelected: null
};
},
componentWillMount: function() {
this.props.onInitFind();
},
componentWillReceiveProps: function(nextProps) {
this.setState({
searchQuery: nextProps.contactsSearchQuery,
contactList: nextProps.foundContacts
});
},
handleTabClick: function(e) {
e.preventDefault();
this.setState({tabSelected: e.currentTarget.dataset.id, contactSelected: null});
},
handleContactSelected: function(sel) {
if (this.state.tabSelected === "p2p") {
this.props.onCreateTopic(sel, undefined);
}
},
handleNewGroupSubmit: function(name, dataUrl, priv, tags) {
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
},
handleGroupByID: function(topicName) {
this.props.onCreateTopic(topicName);
},
render: function() {
return (
<div className="flex-column">
<ul className="tabbar">
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
<a href="#" data-id="p2p" onClick={this.handleTabClick}>1:1</a>
</li>
<li className={this.state.tabSelected === "grp" ? "active" : null}>
<a href="#" data-id="grp" onClick={this.handleTabClick}>group</a>
</li>
<li className={this.state.tabSelected === "byId" ? "active" : null}>
<a href="#" data-id="byId" onClick={this.handleTabClick}>by id</a>
</li>
</ul>
{this.state.tabSelected === "grp" ?
<NewTopicView.Group onSubmit={this.handleNewGroupSubmit} /> :
this.state.tabSelected === "byId" ?
<NewTopicView.ById onSubmit={this.handleGroupByID} /> :
<div className="flex-column">
<NewTopicView.SearchContacts type="p2p"
searchQuery={this.state.searchQuery}
onSearchContacts={this.props.onSearchContacts} />
<ContactList
contacts={this.state.contactList}
emptyListMessage="Use search to find contacts"
topicSelected={this.state.selectedContact}
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
</div>}
</div>
);
}
});
NewTopicView.Group = React.createClass({
getInitialState: function() {
return {
fn: '', // full/formatted name
private: '',
imageDataUrl: null,
tags: []
};
},
handleFnChange: function(e) {
this.setState({fn: e.target.value});
},
handlePrivateChange: function(e) {
this.setState({private: e.target.value});
},
handleImageChanged: function(img) {
this.setState({imageDataUrl: img});
},
handleTagsChanged: function(tags) {
this.setState({tags: tags});
},
handleSubmit: function(e) {
e.preventDefault();
if (this.state.fn && this.state.fn.trim()) {
this.props.onSubmit(this.state.fn.trim(),
this.state.imageDataUrl, this.state.private.trim(),
this.state.tags);
}
},
render: function() {
var submitClasses = "blue";
if (this.props.disabled) {
submitClasses += " disabled";
}
return (
<form className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<label className="small" htmlFor="new-topic-fn">Group name</label>
<input type="text" id="new-topic-fn" placeholder="Freeform name of the group"
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
<br />
<label className="small" htmlFor="new-topic-priv">Private comment</label>
<input type="text" id="new-topic-priv" placeholder="Visible to you only"
value={this.state.private} onChange={this.handlePrivateChange} />
</div>
<AvatarUpload
onError={this.props.onError}
onImageChanged={this.handleImageChanged} />
</div>
<TagManager
tags={[]}
activated={true}
onTagsChanged={this.handleTagsChanged}
title="Optional tags (search and discovery)" />
<div className="dialog-buttons">
<button className={submitClasses}>Create</button>
</div>
</form>
);
}
});
NewTopicView.SearchContacts = React.createClass({
getInitialState: function() {
return {
search: this.props.searchQuery
};
},
componentWillReceiveProps: function(nextProps) {
this.setState({search: nextProps.searchQuery});
},
handleSearchChange: function(e) {
this.setState({search: e.target.value});
},
handleSearch: function(e) {
e.preventDefault();
// Split query string by either space or comma, filter out empty strings.
var query = this.state.search.split(/[,\s]/).filter(Boolean);
if (query.length > 0) {
this.props.onSearchContacts(query);
}
},
handleKeyPress: function(e) {
if (e.key === 'Enter') {
this.handleSearch(e);
}
},
render: function() {
return (
<div className="panel-form">
<div className="panel-form-row">
<input type="text" placeholder="List like email:alice@example.com, tel:17025550003..."
value={this.state.search} onChange={this.handleSearchChange}
onKeyPress={this.handleKeyPress} required autoFocus />
<a href="#" onClick={this.handleSearch}>
<i className="material-icons">search</i>
</a>
</div>
</div>
);
}
});
NewTopicView.ById = React.createClass({
getInitialState: function() {
return {
groupId: '',
};
},
handleChange: function(e) {
this.setState({groupId: e.target.value});
},
handleKeyPress: function(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
},
handleSubmit: function(e) {
e.preventDefault();
if (this.state.groupId) {
var name = this.state.groupId.trim();
if (name.length > 3 && (name.substr(0, 3) == 'usr' || name.substr(0, 3) == 'grp')) {
this.props.onSubmit(name);
}
}
},
render: function() {
return (
<div className="panel-form">
<div className="panel-form-row">
<input type="text" placeholder="Group or User ID"
value={this.state.groupId} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
</div>
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>Subscribe</button>
</div>
</div>
);
}
});
/* END Create new topic and invite users or send an invite */
/* BEGIN ValidationView: panel for confirming credentials, like email or phone */
var ValidationView = React.createClass({
getInitialState: function() {
return {
code: '',
};
},
handleChange: function(e) {
this.setState({code: e.target.value});
},
handleKeyPress: function(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
},
handleSubmit: function(e) {
e.preventDefault();
if (this.state.code && this.state.code.trim()) {
this.props.onSubmit(this.props.validation[0], this.state.code.trim());
}
},
render: function() {
var methods = {'email': 'email', 'tel': 'phone'};
var method = methods[this.props.validation[0]] || this.props.validation[0];
return (
<div className="panel-form">
<div className="panel-form-row">
<label className="small" htmlFor="enter-confirmation-code">
Enter confirmation code sent to you by {method}:
</label>
</div>
<div className="panel-form-row">
<input type="text" id="enter-confirmation-code" placeholder="The code is 123456"
value={this.state.code} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
</div>
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>Confirm</button>
</div>
</div>
);
}
});
/* END ValidationView */
/* BEGIN InfoView: panel with topic/user info */
var InfoView = React.createClass({
getInitialState: function() {
return {
owner: false,
admin: false,
sharer: false,
muted: false,
address: null,
groupTopic: undefined,
fullName: undefined,
avatar: null,
private: null,
selectedContact: null,
access: null,
modeGiven: null,
modeWant: null,
modeGiven2: null, // P2P topic, the other user mode given
modeWant2: null, // P2P topic, the other user mode want
auth: null,
anon: null,
contactList: [],
tags: [],
showMemberPanel: this.props.showMemberPanel,
showPermissionEditorFor: undefined,
moreInfoExpanded: false
};
},
componentWillMount: function() {
var topic = Tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
var instance = this;
this.previousMetaDesc = topic.onMetaDesc;
topic.onMetaDesc = function(desc) {
instance.onMetaDesc(desc);
if (instance.previousMetaDesc) {
instance.previousMetaDesc(desc);
}
};
this.previousSubsUpdated = topic.onSubsUpdated;
topic.onSubsUpdated = function(subs) {
instance.onSubsUpdated(subs);
if (instance.previousSubsUpdated) {
instance.previousSubsUpdated(subs);
}
}
this.resetDesc(topic);
this.resetSubs(topic);
if (topic.getType() === "grp") {
this.props.onInitFind();
this.previousTagsUpdated = topic.onTagsUpdated;
topic.onTagsUpdated = function(tags) {
instance.onTagsUpdated(tags);
if (instance.previousTagsUpdated) {
instance.previousTagsUpdated(tags);
}
}
this.setState({tags: topic.tags()});
} else {
this.previousTagsUpdated = undefined;
}
},
componentWillUnmount: function() {
var topic = Tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
topic.onMetaDesc = this.previousMetaDesc;
topic.onSubsUpdated = this.previousSubsUpdated;
topic.onTagsUpdated = this.previousTagsUpdated;
},
resetSubs: function(topic) {
var contacts = [];
if (topic.getType() === "p2p") {
// Fetch the other party in the p2p conversation.
var user2 = topic.subscriber(this.props.topic);
this.setState({
modeGiven2: user2.acs.getGiven(),
modeWant2: user2.acs.getWant(),
});
} else {
topic.subscribers(function(sub){
contacts.push(sub);
}, this);
this.setState({
contactList: contacts
});
}
},
resetDesc: function(topic) {
var defacs = topic.getDefaultAccess() || {};
var acs = topic.getAccessMode();
this.setState({
owner: acs && acs.isOwner(),
admin: acs && acs.isAdmin(),
sharer: acs && (acs.isAdmin() || acs.isSharer()),
muted: acs && acs.isMuted(),
fullName: topic.public ? topic.public.fn : undefined,
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
private: topic.private,
address: topic.name,
groupTopic: (topic.getType() === "grp"),
access: acs.getMode(),
modeGiven: acs.getGiven(),
modeWant: acs.getWant(),
auth: defacs.auth,
anon: defacs.anon
});
},
onMetaDesc: function(desc) {
var topic = Tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic);
},
onSubsUpdated: function() {
var topic = Tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetSubs(topic);
},
onTagsUpdated: function(tags) {
this.setState({tags: tags});
},
handleFullNameUpdate: function(fn) {
if (this.state.fullName !== fn) {
this.setState({fullName: fn});
this.props.onTopicDescUpdate(this.props.topic,
vcard(fn, this.state.avatar),
null);
}
},
handlePrivateUpdate: function(priv) {
if (this.state.priv !== priv) {
this.setState({private: priv});
this.props.onTopicDescUpdate(this.props.topic,
null,
priv);
}
},
handleImageChanged: function(img) {
this.setState({avatar: img});
this.props.onTopicDescUpdate(this.props.topic,
vcard(this.state.fullName, img),
null);
},
handleMuted: function(ignored, checked) {
this.setState({muted: checked});
this.props.onChangePermissions(this.props.topic, checked ? "-P" : "+P");
},
handlePermissionsChanged: function(perm) {
switch (this.state.showPermissionEditorFor) {
case 'auth':
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
break;
case 'anon':
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
break;
case 'mode':
case 'want':
this.props.onChangePermissions(this.props.topic, perm);
break;
case 'given':
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
break;
case 'user':
this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
break;
}
this.setState({showPermissionEditorFor: undefined});
},
handleLaunchPermissionsEditor: function(which, uid) {
var toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar
switch (which) {
case 'mode':
toEdit = this.state.access;
break;
case 'want':
toEdit = this.state.modeWant;
toCompare = this.state.modeGiven;
toSkip = this.state.groupTopic ? 'O' : 'ASDO';
titleEdit = 'Requested';
titleCompare = 'Granted';
break;
case 'given':
toEdit = this.state.modeGiven2;
toCompare = this.state.modeWant2;
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
titleEdit = 'Granted';
titleCompare = 'Requested';
break;
case 'auth':
toEdit = this.state.auth;
toSkip = 'O';
break;
case 'anon':
toEdit = this.state.anon;
toSkip = 'O';
break;
case 'user': {
var topic = Tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
var user = topic.subscriber(uid);
if (!user || !user.acs) {
return;
}
toEdit = user.acs.getGiven();
toCompare = user.acs.getWant();
toSkip = this.state.owner ? '' : 'O';
titleEdit = 'Granted';
titleCompare = 'Requested';
if (user.public) {
userTitle = user.public.fn;
userAvatar = user.public.photo;
}
break;
}
default:
console.log("Unknown permission editing mode '" + which + "'");
break;
}
this.setState({
showPermissionEditorFor: which,
userPermissionsEdited: uid,
userPermissionsTitle: userTitle,
userPermissionsAvatar: userAvatar,
editedPermissions: toEdit,
immutablePermissions: toCompare,
editedPermissionsTitle: titleEdit,
immutablePermissionsTitle: titleCompare,
editedPermissionsSkipped: toSkip,
});
},
handleHidePermissionsEditor: function() {
this.setState({showPermissionEditorFor: undefined});
},
handleShowAddMembers: function() {
// this.props.onAddMember(this.props.topic);
this.setState({showMemberPanel: true});
},
handleHideAddMembers: function() {
// this.props.onAddMember(this.props.topic);
this.setState({showMemberPanel: false});
},
handleMemberUpdateRequest: function(members, added, removed) {
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
this.setState({showMemberPanel: false});
},
handleLeave: function() {
this.props.onLeaveTopic(this.props.topic);
},
handleMemberSelected: function(uid) {
this.setState({selectedContact: uid});
},
handleMoreInfo: function(open) {
this.setState({moreInfoExpanded: open});
},
handleTagsUpdated: function(tags) {
this.props.onTopicTagsUpdate(this.props.topic, tags);
},
handleContextMenu: function(params) {
var instance = this;
var topic = Tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
var user = topic.subscriber(params.topicName);
if (!user || !user.acs) {
return;
}
var menuItems = [
{title: "Edit permissions", handler: function() {
instance.handleLaunchPermissionsEditor("user", params.topicName);
}},
ContextMenuItems["member_delete"],
user.acs.isMuted() ? ContextMenuItems["member_unmute"] : ContextMenuItems["member_mute"],
user.acs.isJoiner() ? ContextMenuItems["member_block"] : ContextMenuItems["member_unblock"]
];
this.props.showContextMenu({
topicName: this.props.topic,
x: params.x,
y: params.y,
user: params.topicName}, menuItems);
},
render: function() {
return (
<div id="info-view">
<div className="caption-panel" id="info-caption-panel">
<div className="panel-title" id="info-title">Info</div>
<div>
<MenuCancel onCancel={this.props.onCancel} />
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} /> : null}
{this.state.showMemberPanel ?
<GroupManager
members={this.state.contactList}
requiredMember={this.props.myUserId}
contacts={this.props.foundContacts}
onCancel={this.handleHideAddMembers}
onSubmit={this.handleMemberUpdateRequest} />
:
this.state.showPermissionEditorFor ?
<PermissionsEditor
mode={this.state.editedPermissions}
compare={this.state.immutablePermissions}
skip={this.state.editedPermissionsSkipped}
modeTitle={this.state.editedPermissionsTitle}
compareTitle={this.state.immutablePermissionsTitle}
userTitle={this.state.userPermissionsTitle}
item={this.state.userPermissionsEdited}
userAvatar={this.state.userPermissionsAvatar}
skip={this.state.editedPermissionsSkipped}
onSubmit={this.handlePermissionsChanged}
onCancel={this.handleHidePermissionsEditor}
/>
:
<div id="info-view-content" className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<div><label className="small">Name</label></div>
<div><InPlaceEdit
placeholder="Group name"
readOnly={!this.state.owner}
text={this.state.fullName}
onFinished={this.handleFullNameUpdate} /></div>
<div><label className="small">Private comment</label></div>
<div><InPlaceEdit
placeholder="Visible to you only"
text={this.state.private}
onFinished={this.handlePrivateUpdate} /></div>
</div>
<AvatarUpload
avatar={this.state.avatar}
readOnly={!this.state.owner}
uid={this.props.topic}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label>Muted:</label>
<CheckBox name="P" checked={this.state.muted}
onChange={this.handleMuted} />
</div>
<MoreButton
title="More"
open={this.state.moreInfoExpanded}
onToggle={this.handleMoreInfo} />
{this.state.moreInfoExpanded ?
<div className="panel-form-column">
<div className="panel-form-row">
<label>Address:</label>
<tt>{this.state.address}</tt>
</div>
{this.state.groupTopic ?
<div className="panel-form-row">
<label>Your permissions:</label>
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt>
</div>
:
<div>
<div><label className="small">Permissions:</label></div>
<div className="quoted">
<div>Yours: &nbsp;<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt></div>
<div>{this.state.fullName}&prime;s: &nbsp;<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
{this.state.modeGiven2}
</tt></div>
</div>
</div>
}
{this.state.sharer && (this.state.auth || this.state.anon) ?
<div>
<div><label className="small">Default access mode:</label></div>
<div className="quoted">
<div>Auth: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
{this.state.auth}
</tt>
:
<tt>{this.state.auth}</tt>
}
</div>
<div>Anon: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
{this.state.anon}
</tt>
:
<tt>{this.state.anon}</tt>
}
</div>
</div>
</div>
:
null
}
</div>
:
null
}
</div>
<div className="hr" />
<TagManager
title="Tags"
tags={this.state.tags}
activated={false}
onSubmit={this.handleTagsUpdated} />
<div className="hr" />
{this.state.groupTopic ?
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small">Group members:</label>
</div>
<div className="panel-form-row">
{this.state.sharer ?
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
<i className="material-icons">person_add</i> Add members
</a>
: null}
{!this.state.owner ?
<a href="#" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> Leave
</a>
: null}
</div>
<ContactList
contacts={this.state.contactList}
emptyListMessage="No members"
topicSelected={this.state.selectedContact}
showOnline={false}
showUnread={false}
showMode={true}
noScroll={true}
onTopicSelected={this.handleMemberSelected}
showContextMenu={this.state.admin ? this.handleContextMenu : false}
/>
</div>
:
<div className="panel-form-row">
<a href="#" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> Leave
</a>
</div>
}
</div>
}
</div>
);
}
});
/* END InfoView */
/* BEGIN Conversation panel */
var Received = {
UNDEF: -2,
SENDING: -1,
SERVER: 0,
CLIENT: 1,
READ: 2
};
var Message = React.createClass({
handlePreviewImage: function(e) {
e.preventDefault();
this.props.onImagePreview({
url: e.target.src,
filename: e.target.title,
width: e.target.dataset.width,
height: e.target.dataset.height,
size: e.target.dataset.size,
type: e.target.dataset.mime
});
},
handleContextClick: function(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX });
},
render: function() {
var elementKey = 0;
var formatter = function(style, data, values) {
elementKey += 1;
var el = Drafty.tagName(style);
if (el) {
var attr = Drafty.attrValue(style, data) || {};
attr.key = elementKey;
if (style == "IM") {
// Additional processing for images
var size = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24);
attr.className = "inline-image";
attr.style = size ? { width: size.width + "px", height: size.height + "px" } : null;
attr.onClick = this.handlePreviewImage;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
var sideClass = this.props.sequence + " " + (this.props.response ? "left" : "right");
var bubbleClass = (this.props.sequence === "single" || this.props.sequence === "last") ?
"bubble tip" : "bubble";
var avatar = this.props.userAvatar || true;
var fullDisplay = (this.props.userFrom && this.props.response &&
(this.props.sequence === "single" || this.props.sequence === "last"));
var content = this.props.content;
var attachments = [];
if (this.props.mimeType === Drafty.getContentType()) {
Drafty.attachments(content, function(att, i) {
attachments.push(<Message.Attachment
filename={att.name} blobUrl={Drafty.getBlobUrl(att)}
size={bytesToHumanSize(Drafty.getAttachmentSize(att))} key={i} />);
}, this);
content = React.createElement('span', null, Drafty.format(content, formatter, this));
}
return (
<li className={sideClass}>
{this.props.userFrom && this.props.response ?
<div className="avatar-box">
{fullDisplay ?
<LetterTile topic={this.props.userFrom}
title={this.props.userName} avatar={avatar} /> :
null}
</div> :
null}
<div>
<div className={bubbleClass}>
<p>{content}
{attachments}
<Message.ReceivedMarker response={this.props.response}
timestamp={this.props.timestamp} received={this.props.received} />
</p>
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span>
</div>
{fullDisplay ? <div className="author">{this.props.userName}</div> : null}
</div>
</li>
);
}
});
/* Received/read indicator */
Message.ReceivedMarker = React.createClass({
render: function() {
var timestamp = (this.props.received === Received.SENDING) ?
"sending ..." :
shortDateFormat(this.props.timestamp);
var marker = null;
if (this.props.received === Received.SENDING) {
marker = (<i className="material-icons small">access_time</i>); // watch face
} else if (this.props.received === Received.SERVER) {
marker = (<i className="material-icons small">done</i>); // checkmark
} else if (this.props.received === Received.CLIENT) {
marker = (<i className="material-icons small">done_all</i>); // double checkmark
} else if (this.props.received === Received.READ) {
marker = (<i className="material-icons small blue">done_all</i>); // open eye
}
return (
<span className="timestamp">
{timestamp}{'\u00a0'}{marker}
</span>
);
}
});
Message.Attachment = React.createClass({
render: function() {
var filename = this.props.filename || "file_attachment";
if (filename.length > 36) {
filename = filename.substr(0, 16) + "..." + filename.substr(-16);
}
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column">
<div>{filename} <span className="small gray">({this.props.size})</span></div>
<div><a href={this.props.blobUrl} download={this.props.filename}>
<i className="material-icons">file_download</i> save
</a></div>
</div>
</div>
);
}
});
var MessagesView = React.createClass({
getInitialState: function() {
return {
messages: [],
onlineSubs: [],
topic: '',
title: '',
avatar: null,
scrollPosition: 0,
readOnly: false,
writeOnly: false,
typingIndicator: false,
imagePreview: null
};
},
// Scroll last message into view on component update e.g. on message received.
componentDidUpdate: function(prevProps, prevState) {
if (this.messagesScroller &&
(prevState.title != this.state.title || prevState.messages.length != this.state.messages.length)) {
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
}
},
componentWillMount: function() {
this.propsChange(this.props);
if (this.messagesScroller) {
this.messagesScroller.addEventListener('scroll', this.fetchMoreMessages);
}
},
componentWillUnmount: function() {
this.leave();
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.fetchMoreMessages);
}
},
componentWillReceiveProps: function(nextProps) {
this.propsChange(nextProps);
},
propsChange: function(props) {
if (!props || !props.topic) {
this.setState({messages: [], onlineSubs: [], topic: null});
this.leave();
return;
}
if (!props.connected) {
// connection lost, clear online subs
this.setState({onlineSubs: []});
return;
}
var tryToResubscribe = !this.props.connected && props.connected;
var topic = Tinode.getTopic(props.topic);
if (props.topic != this.state.topic) {
var msgs = [];
var subs = [];
// Bind the new topic to component.
topic.onData = this.handleNewMessage;
topic.onInfo = this.handleInfoReceipt;
topic.onMetaDesc = this.handleDescChange;
topic.onSubsUpdated = this.handleSubsUpdated;
topic.onPres = this.handleSubsUpdated;
// Unbind the previous topic from this component.
this.leave();
this.handleDescChange(topic);
var myId = this.props.myUserId;
topic.subscribers(function(sub) {
if (sub.online && sub.user != myId) {
subs.push(sub);
}
});
topic.messages(function(msg) {
if (!msg.deleted) {
msgs = msgs.concat(msg);
}
});
this.setState({
messages: msgs,
onlineSubs: subs,
topic: props.topic,
imagePreview: null,
scrollPosition: 0
});
tryToResubscribe = true;
// The user switched to the new topic before the timer for
// the previous topic has triggered, kill it.
clearTimeout(this.readTimer);
}
if (!topic.isSubscribed() && tryToResubscribe) {
var instance = this;
topic.subscribe(topic.startMetaQuery()
.withLaterDesc()
.withLaterSub()
.withLaterData(MESSAGES_PAGE)
.withLaterDel()
.withTags()
.build()).catch(function(err){
instance.props.onError(err.message, "err");
});
}
},
leave: function() {
if (this.state.topic) {
var oldTopic = Tinode.getTopic(this.state.topic);
if (oldTopic) {
if (oldTopic.isSubscribed()) {
oldTopic.leave(false).catch(function(err) {
// do nothing
console.log(err);
});
}
oldTopic.onData = undefined;
oldTopic.onInfo = undefined;
oldTopic.onMetaDesc = undefined;
oldTopic.onSubsUpdated = undefined;
oldTopic.onPres = undefined;
}
}
},
handleScrollReference: function(node) {
if (node) {
node.addEventListener('scroll', this.fetchMoreMessages);
this.messagesScroller = node;
}
},
fetchMoreMessages: function(event) {
var instance = this;
if (event.target.scrollTop <= 0) {
var started = false;
var newState = {scrollPosition: event.target.scrollHeight - event.target.scrollTop};
this.setState(function(prevState, props) {
if (!prevState.fetchingMessages) {
var topic = Tinode.getTopic(instance.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMore()) {
newState.fetchingMessages = true;
topic.getMessagesPage(MESSAGES_PAGE).then(function() {
instance.setState({fetchingMessages: false});
}).catch(function(err) {
instance.setState({fetchingMessages: false});
instance.props.onError(err.message, "err");
});
}
}
return newState;
});
}
},
handleDescChange: function(desc) {
if (desc.public) {
this.setState({
title: desc.public.fn,
avatar: makeImageUrl(desc.public.photo)
});
}
if (desc.acs) {
this.setState({
readOnly: !desc.acs.isWriter(),
writeOnly: !desc.acs.isReader()
});
}
},
handleSubsUpdated: function() {
if (this.state.topic) {
var subs = [];
var myId = this.props.myUserId;
var topic = Tinode.getTopic(this.state.topic);
topic.subscribers(function(sub) {
if (sub.online && sub.user != myId) {
subs.push(sub);
}
});
this.setState({onlineSubs: subs});
}
},
handleNewMessage: function(msg) {
// Regenerate messages list
var topic = Tinode.getTopic(this.state.topic);
var newState = {messages: []};
topic.messages(function(m) {
if (!m.deleted) {
newState.messages = newState.messages.concat(m);
}
});
// msg could be null if one or more messages were deleted.
if (msg && !msg.deleted) {
clearTimeout(this.readTimer);
// If the message is added to the end of the message list,
// scroll to the bottom.
if (topic.isNewMessage(msg.seq)) {
newState.scrollPosition = 0;
}
// Aknowledge all messages, including own messges.
this.readTimer = setTimeout(function() {
topic.noteRead(msg.seq);
}, READ_DELAY);
this.props.onData(msg);
}
this.setState(newState);
},
handleInfoReceipt: function(info) {
switch (info.what) {
case "kp": {
clearTimeout(this.keyPressTimer);
var instance = this;
this.keyPressTimer = setTimeout(function() {
instance.setState({typingIndicator: false});
}, KEYPRESS_DELAY + 1000);
if (!this.state.typingIndicator) {
this.setState({typingIndicator: true});
}
break;
}
case "read":
case "recv":
// Redraw due to changed recv/read status.
this.forceUpdate();
break;
default:
console.log("other change in topic: " + info.what);
}
},
handleImagePreview: function(content) {
this.setState({ imagePreview: content });
},
handleCloseImagePreview: function() {
this.setState({ imagePreview: null });
},
handleContextClick: function(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
},
handleShowContextMenuMessage: function(params) {
params.topicName = this.state.topic;
var menuItems = [ContextMenuItems["message_delete"]];
var topic = Tinode.getTopic(params.topicName);
if (topic) {
var acs = topic.getAccessMode();
if (acs && acs.isDeleter()) {
menuItems.push(ContextMenuItems["message_delete_hard"]);
}
}
this.props.showContextMenu(params, menuItems);
},
handleBackNavigation: function() {
this.props.onHideMessagesView();
},
render: function() {
var component = null;
if (this.state.topic) {
var messageNodes = [];
var topic = Tinode.getTopic(this.state.topic);
var groupTopic = topic.getType() === "grp";
var previousFrom = null;
for (var i=0; i<this.state.messages.length; i++) {
var msg = this.state.messages[i];
var nextFrom = (i + 1 < this.state.messages.length) ? this.state.messages[i+1].from : null;
var sequence = "single";
if (msg.from === previousFrom) {
if (msg.from === nextFrom) {
sequence = "middle";
} else {
sequence = "last";
}
} else if (msg.from === nextFrom) {
sequence = "first";
}
previousFrom = msg.from;
var isReply = !(msg.from === this.props.myUserId);
var msgReceived = Received.UNDEF;
if (!isReply) {
if (topic.msgReadCount(msg.seq) > 0) {
msgReceived = Received.READ;
} else if (topic.msgRecvCount(msg.seq) > 0) {
msgReceived = Received.CLIENT;
} else {
msgReceived = Received.SERVER;
}
}
var userName, userAvatar, userFrom, chatBoxClass;
if (groupTopic) {
var user = topic.userDesc(msg.from);
if (user && user.public) {
userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo);
}
userFrom = msg.from;
chatBoxClass="chat-box group";
} else {
chatBoxClass="chat-box";
}
messageNodes.push(
<Message content={msg.content} mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply} seq={msg.seq}
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
sequence={sequence} received={msgReceived}
viewportWidth={this.props.viewportWidth}
showContextMenu={this.handleShowContextMenuMessage}
onImagePreview={this.handleImagePreview} key={msg.seq} />
);
}
var lastSeen = null;
var cont = Tinode.getMeTopic().getContact(this.state.topic);
if (cont && Tinode.getTopicType(cont.topic) === "p2p") {
if (cont.online) {
lastSeen = "online now";
} else if (cont.seen) {
lastSeen = "Last active: " + shortDateFormat(cont.seen.when);
// TODO(gene): also handle user agent in c.seen.ua
}
}
var avatar = this.state.avatar || true;
var online = this.props.online ? "online" + (this.state.typingIndicator ? " typing" : "") : "offline";
component = (
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
<div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ?
<a href="#" id="hide-message-view" onClick={this.handleBackNavigation}>
<i className="material-icons">arrow_back</i>
</a>
:
null}
<div className="avatar-box">
<LetterTile avatar={avatar}
topic={this.state.topic} title={this.state.title} />
<span className={online} />
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{this.state.title}</div>
<div id="topic-last-seen">{lastSeen}</div>
</div>
{groupTopic ?
<GroupSubs
subscribers={this.state.onlineSubs} /> :
<div id="topic-users" />
}
<div>
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">more_vert</i>
</a>
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} /> : null}
<LoadSpinner show={this.state.fetchingMessages} />
<div id="messages-panel"
className={this.state.writeOnly ? "write-only" : null}
ref={this.handleScrollReference}>
<ul id="scroller" className={chatBoxClass}>
{messageNodes}
</ul>
</div>
{this.state.writeOnly ?
<div id="write-only-note">no access to messages</div>
: null}
<MessagesView.Send
topic={this.props.topic}
disabled={this.state.readOnly}
sendMessage={this.props.sendMessage}
onError={this.props.onError} />
{this.state.imagePreview ?
<MessagesView.ImagePreview content={this.state.imagePreview}
onClose={this.handleCloseImagePreview} /> : null}
</div>
);
} else {
component = (
<MessagesView.Dummy hideSelf={this.props.hideSelf}
serverVersion={this.props.serverVersion}
serverAddress={this.props.serverAddress} />
);
}
return component;
}
});
/* Send message form */
MessagesView.Send = React.createClass({
getInitialState:function() {
return {
message: '',
// Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
};
},
handleAttachImage: function(e) {
if (e.target.files && e.target.files.length > 0) {
var instance = this;
var files = e.target.files;
// Check if the uploaded file is indeed an image and if it isn't too large.
if (files[0].size > MAX_ATTACHMENT_SIZE || SUPPORTED_FORMATS.indexOf(files[0].type) < 0) {
// Convert image for size or format.
imageFileScaledToBase64(files[0], MAX_IMAGE_SIZE, MAX_IMAGE_SIZE, false,
// Success
function(bits, mime, width, height, fname) {
instance.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
// Failure
function(err) {
instance.props.onError(err, "err");
});
} else {
// Image can be uploaded as is. No conversion is needed.
imageFileToBase64(files[0],
// Success
function(bits, mime, width, height, fname) {
instance.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
// Failure
function(err) {
instance.props.onError(err, "err");
});
}
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
},
handleAttachFile: function(e) {
if (e.target.files && e.target.files.length > 0) {
var file = e.target.files[0];
if (file.size > MAX_ATTACHMENT_SIZE) {
this.props.onError("The file size " + bytesToHumanSize(file.size) +
" exceeds the " + bytesToHumanSize(MAX_ATTACHMENT_SIZE) + " attachment limit.", "err");
return;
}
var reader = new FileReader();
var instance = this;
reader.addEventListener("load", function() {
instance.props.sendMessage(Drafty.attachFile(null, file.type, reader.result.split(',')[1], file.name));
}, false);
reader.readAsDataURL(file);
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
},
handleSend: function() {
var message = this.state.message.trim();
if (message) {
this.props.sendMessage(this.state.message.trim());
this.setState({message: ''});
}
},
/* Send on Enter key */
handleKeyPress: function(e) {
// Remove this if you don't want Enter to trigger send
if (e.key === 'Enter') {
// Have Shift-Enter insert a line break instead
if (!e.shiftKey) {
e.preventDefault();
e.stopPropagation();
this.handleSend();
}
}
},
handleMessageTyping: function(e) {
var newState = {message: e.target.value};
var now = new Date().getTime();
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
var topic = Tinode.getTopic(this.props.topic);
if (topic.isSubscribed()) {
topic.noteKeyPress();
}
newState.keypressTimestamp = now;
}
this.setState(newState);
},
render: function() {
var prompt = this.props.disabled ? "Publishing disabled" : "New message";
var instance = this;
return (
<div id="send-message-panel">
{this.props.disabled ?
<i className="material-icons disabled">photo</i> :
<a href="#" onClick={function(e) {instance.attachImage.click();}} title="Add image">
<i className="material-icons secondary">photo</i>
</a>}
{this.props.disabled ?
<i className="material-icons disabled">attach_file</i> :
<a href="#" onClick={function(e) {instance.attachFile.click();}} title="Attach file">
<i className="material-icons secondary">attach_file</i>
</a>}
<textarea id="sendMessage" placeholder={prompt}
disabled={this.props.disabled} value={this.state.message}
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
autoFocus />
{this.props.disabled ?
<i className="material-icons disabled">send</i> :
<a href="#" onClick={this.handleSend} title="Send"><i className="material-icons">send</i></a>}
<input type="file" ref={function(ref) {instance.attachFile = ref;}}
onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={function(ref) {instance.attachImage = ref;}} accept="image/*"
onChange={this.handleAttachImage} style={{display: 'none'}} />
</div>
);
}
});
/* This is just a static page to display when no conversation is selected. */
MessagesView.Dummy = React.createClass({
render: function() {
var version = Tinode.getVersion() + " build " + document.lastModified;
return (
<div id="dummy-view" className={this.props.hideSelf ? 'nodisplay' : null}>
<div>
<a href="https://github.com/tinode/chat/">
<img id="logo" alt="logo" src="img/logo.svg" />
<h2>Tinode Demo Chat</h2>
</a>
<p>Client: {version}</p>
<p>Server: {this.props.serverVersion} ({this.props.serverAddress})</p>
</div>
</div>
);
}
});
MessagesView.ImagePreview = React.createClass({
getInitialState: function() {
return {};
},
componentDidMount: function() {
this.setState({
width: this.container.clientWidth,
height: this.container.clientHeight - REM_SIZE * 7
});
},
render: function() {
if (!this.props.content) {
return null;
}
var instance = this;
var size = fitImageSize(this.props.content.width, this.props.content.height,
this.state.width, this.state.hight);
if (size) {
size.width += "px";
size.height += "px";
} else {
size = (this.props.content.width > this.props.content.height) ?
{width: '100%'} : {height: '100%'};
}
var filename = this.props.content.filename;
if (filename.length > 20) {
filename = filename.slice(0, 8) + "..." + filename.slice(-8);
}
return (
<div id="image-preview" ref={function(ref) {instance.container = ref;}}
onClick={this.props.onClose}>
<div id="image-preview-caption-panel">
<a href="#" download={this.props.content.filename}>
<i className="material-icons">file_download</i> download
</a>
<a href="#" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container">
<img src={this.props.content.url} style={size} />
</div>
<div id="image-preview-footer">
<div><div><b>File name</b>:</div><div><span title={this.props.content.filename}>{filename}</span></div></div>
<div><div><b>Content type</b>:</div><div>{this.props.content.type}</div></div>
<div>
<div><b>Size</b>:</div>
<div>{this.props.content.width} &times; {this.props.content.height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>
</div>
);
}
});
/* END Conversation panel */
/* The top-level class to hold all fuinctionality together */
var AppView = React.createClass({
getInitialState: function() {
return {
connected: false,
transport: null,
serverAddress: detectServerAddress(),
sidePanelSelected: 'login',
sidePanelTitle: null,
sidePanelAvatar: null,
dialogSelected: null,
contextMenuVisible: false,
login: '',
password: '',
myUserId: null,
errorText: '',
errorLevel: null,
liveConnection: navigator.onLine,
topicSelected: '',
topicSelectedOnline: false,
loginDisabled: false,
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
showInfoPanel: false,
infoPanelShowMembers: false,
mobilePanel: 'sidepanel',
contextMenuVisible: false,
contextMenuBounds: null,
contextMenuClickAt: null,
contextMenuParams: null,
contextMenuItems: [],
serverVersion: 'no connection',
contactsSearchQuery: '',
foundContacts: [],
validation: []
};
},
componentDidMount: function() {
var instance = this;
window.addEventListener('resize', this.handleResize);
window.addEventListener('online', function(e) { instance.handleOnline(true); });
window.addEventListener('offline', function(e) { instance.handleOnline(false); });
window.addEventListener('hashchange', this.handleHashRoute);
this.setState({viewportWidth: document.documentElement.clientWidth});
Tinode.enableLogging(true, true);
Tinode.onConnect = this.handleConnected;
Tinode.onDisconnect = this.handleDisconnect;
this.tnSetup(this.state.serverAddress, this.state.transport);
},
// Setup transport (usually websocket) and server address. This will terminate connection with the server.
tnSetup: function(serverAddress, transport) {
Tinode.setup(APP_NAME, serverAddress, API_KEY, transport);
},
handleResize: function() {
var mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
this.setState({viewportWidth: document.documentElement.clientWidth});
if (this.state.displayMobile != mobile) {
this.setState({displayMobile: mobile});
}
},
handleHashRoute: function(e) {
console.log("hashchange", location.hash, e.oldURL, e.newURL);
},
handleOnline: function(online) {
var newState = {liveConnection: online};
if (online) {
this.handleError("", null);
} else {
this.handleError("No connection", "warn");
}
this.setState({liveConnection: online});
},
handleError: function(err, level) {
this.setState({errorText: err, errorLevel: level});
},
// User clicked Login button in the side panel.
handleLoginRequest: function(login, password) {
var instance = this;
this.setState({loginDisabled: true, login: login, password: password});
this.handleError("", null);
if (Tinode.isConnected()) {
this.doLogin(login, password);
} else {
Tinode.connect().catch(function(err) {
// Socket error
instance.setState({loginDisabled: false});
instance.handleError(err.message, "err");
});
}
},
// Connection succeeded.
handleConnected: function() {
var params = Tinode.getServerInfo();
this.setState({
serverVersion: params.ver + " " + (params.build ? params.build : "none") + "; "
});
this.doLogin(this.state.login, this.state.password);
},
doLogin: function(login, password, cred) {
var instance = this;
cred = cred ? Tinode.addCredential(null, cred).cred : undefined;
// Try to login with token. If token is not available, try password. If no password, ask for it.
var promise = null;
var token = Tinode.getLoginToken();
if (login && password) {
this.setState({password: null});
promise = Tinode.loginBasic(login, password, cred);
} else if (token) {
promise = Tinode.loginToken(token.token, cred);
} else {
this.setState({loginDisabled: false});
}
if (promise) {
promise.then(function(ctrl) {
if (ctrl.code >= 300 && ctrl.text === "validate credentials") {
instance.handleCredentialsRequest(ctrl.params);
} else {
instance.handleLoginSuccessful(instance);
}
}).catch(function(err) {
// Login failed, report error
instance.setState({loginDisabled: false});
instance.handleError(err.message, "err");
});
}
},
handleCredentialsRequest: function(params) {
this.setState({
sidePanelSelected: 'validation',
validation: params.cred
});
},
handleLoginSuccessful: function(instance) {
instance.handleError("", null);
// Logged in fine, subscribe to 'me' attaching callbacks from the contacts view.
var me = Tinode.getMeTopic();
me.onMetaDesc = instance.tnMeMetaDesc;
instance.setState({
connected: true,
sidePanelSelected: "contacts",
myUserId: Tinode.getCurrentUserID()
});
// Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched.
me.subscribe(
me.startMetaQuery().
withLaterSub().
withDesc().
withTags().
build()
).catch(function(err){
instance.handleError(err.message, "err");
});
},
handleDisconnect: function(err) {
this.setState({
connected: false,
topicSelectedOnline: false,
dialogSelected: null,
errorText: err && err.message ? err.message : 'Disconnected',
errorLevel: err && err.message ? "err" : "warn",
loginDisabled: false,
contextMenuVisible: false,
serverVersion: 'no connection'
});
},
tnMeMetaDesc: function(desc) {
if (desc && desc.public) {
this.setState({
sidePanelTitle: desc.public.fn,
sidePanelAvatar: makeImageUrl(desc.public.photo)
});
}
},
// Sending "received" notifications
tnData: function(data) {
clearTimeout(this.receivedTimer);
this.receivedTimer = setTimeout(function() {
this.receivedTimer = undefined;
Tinode.getTopic(data.topic).noteRecv(data.seq);
}, RECEIVED_DELAY);
},
/* Fnd topic: find contacts by tokens */
tnInitFind: function() {
var fnd = Tinode.getFndTopic();
fnd.onSubsUpdated = this.tnFndSubsUpdated;
fnd.onMetaDesc = this.tnFndMetaDesc;
var instance = this;
if (fnd.isSubscribed()) {
this.setState({contactsSearchQuery: fnd.private ? fnd.private.toString() : ''});
this.tnFndSubsUpdated();
} else {
fnd.subscribe(fnd.startMetaQuery().withSub().withLaterDesc().build()).catch(function(err){
instance.handleError(err.message, "err");
});
}
},
tnFndMetaDesc: function(desc) {
if (desc.private) {
this.setState({contactsSearchQuery: desc.private.toString()});
}
},
tnFndSubsUpdated: function() {
var contacts = [];
// Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
Tinode.getFndTopic().contacts(function(s) {
contacts.push(s);
});
this.setState({foundContacts: contacts});
},
/** Called when the user enters a contact into the contact search field in the NewAccount panel
@param query {Array} is an array of contacts to search for
*/
handleSearchContacts: function(query) {
var fnd = Tinode.getFndTopic();
var instance = this;
fnd.setMeta({desc: {private: query}}).then(function(ctrl) {
fnd.getMeta(fnd.startMetaQuery().withSub().withLaterDesc().build());
}).catch(function(err){
instance.handleError(err.message, "err");
});
},
// User clicked on a contact in the side panel or deleted a contact.
handleTopicSelected: function(topicName, unused_index, online) {
if (topicName) {
// Contact selected
this.setState({
topicSelected: topicName,
mobilePanel: 'topic-view',
showInfoPanel: false,
topicSelectedOnline: online
});
} else {
// Currently selected contact deleted
this.setState({
topicSelected: null,
mobilePanel: 'sidepanel',
showInfoPanel: false,
topicSelectedOnline: false
});
}
},
handleTopicSelectedOnline: function(online) {
this.setState({topicSelectedOnline: online});
},
// In mobile view user requested to show sidepanel
handleHideMessagesView: function() {
this.setState({
mobilePanel: 'sidepanel',
topicSelected: null,
showInfoPanel: false
});
},
// User clicked on a message send button.
handleSendMessage: function(msg) {
var topic = Tinode.getTopic(this.state.topicSelected);
var instance = this;
// Check if the message is formatted.
var dft = Drafty.parse(msg) || msg;
if (Drafty.isPlainText(dft)) {
// The message is simple text. Sned if as text/plain.
topic.publish(msg).catch(function(err) {
instance.handleError(err.message, "err");
});;
} else {
// Formatted message. Send it as a Drafty object.
topic.publish(dft, undefined, Drafty.getContentType()).catch(function(err) {
instance.handleError(err.message, "err");
});
}
},
// User chose a Sign Up menu item.
handleNewAccount: function() {
this.setState({sidePanelSelected: 'register'});
},
// Actual registration of a new account.
handleNewAccountRequest: function(login_, password_, public_, cred_, tags_) {
var instance = this;
Tinode.connect(this.state.serverAddress)
.then(function() {
var params = Tinode.addCredential({public: public_, tags: tags_}, cred_);
return Tinode.createAccountBasic(login_, password_, params);
}).then(function(ctrl) {
if (ctrl.code >= 300 && ctrl.text === "validate credentials") {
instance.handleCredentialsRequest(ctrl.params);
} else {
instance.handleLoginSuccessful(instance);
}
}).catch(function(err) {
instance.handleError(err.message, "err");
});
},
handleUpdateAccountRequest: function(password, pub, priv) {
var instance = this;
if (pub || priv) {
Tinode.getMeTopic().setMeta({desc: {public: pub, private: priv}}).catch(function(err) {
instance.handleError(err.message, "err");
});
}
if (password) {
Tinode.updateAccountBasic(null, Tinode.getCurrentLogin(), password).catch(function(err) {
instance.handleError(err.message, "err");
});
}
},
handleUpdateAccountTagsRequest: function(tags) {
var instance = this;
Tinode.getMeTopic().setMeta({tags: tags}).catch(function(err) {
instance.handleError(err.message, "err");
});
},
// User chose Settings menu item.
handleSettings: function() {
this.setState({sidePanelSelected: this.state.myUserId ? 'edit-account' : 'settings'});
},
// User updated connection parameters - transport and server address
handleConnectionSettings: function(serverAddress, transport) {
transport = transport || this.state.transport;
serverAddress = serverAddress || this.state.serverAddress;
this.setState({serverAddress: serverAddress, transport: transport, sidePanelSelected: 'login'});
this.tnSetup(serverAddress, transport);
},
// User clicked Cancel button in Setting or Sign Up panel.
handleSidepanelCancel: function() {
if (this.state.myUserId) {
this.setState({sidePanelSelected: 'contacts', errorText: '', errorLevel: null});
} else {
this.setState({sidePanelSelected: 'login', errorText: '', errorLevel: null});
}
},
// User clicked a (+) menu item.
handleNewTopic: function() {
this.setState({sidePanelSelected: 'new-topic'});
},
// Create of a new topic. New P2P topic requires peer's name.
handleNewTopicRequest: function(peerName, pub, priv, tags) {
var instance = this;
var topic = peerName ? Tinode.newTopicWith(peerName) : Tinode.newTopic();
var query = topic.startMetaQuery().withDesc().withSub().withData();
var setParams;
if (!peerName) {
setParams = {desc: {public: pub, private: priv}, tags: tags};
}
topic.subscribe(query.build(), setParams).then(function() {
instance.setState({
topicSelected: topic.name,
sidePanelSelected: 'contacts',
mobilePanel: 'topic-view',
showInfoPanel: false,
infoPanelShowMembers: peerName ? false : true
});
}).catch(function(err) {
instance.handleError(err.message, "err");
});
},
handleTopicUpdateRequest: function(topicName, pub, priv, permissions) {
var instance = this;
var topic = Tinode.getTopic(topicName);
if (topic) {
var params = {};
if (pub) {
params.public = pub;
}
if (priv) {
params.private = priv;
}
if (permissions) {
params.defacs = permissions;
}
topic.setMeta({desc: params}).catch(function(err) {
instance.handleError(err.message, "err");
});
}
},
handleChangePermissions: function(topicName, mode, uid) {
var instance = this;
var topic = Tinode.getTopic(topicName);
if (topic) {
var am = topic.getAccessMode();
if (uid) {
am.updateGiven(mode);
mode = am.getGiven();
} else {
am.updateWant(mode);
mode = am.getWant();
}
topic.setMeta({sub: {user: uid, mode: mode}}).catch(function(err) {
instance.handleError(err.message, "err");
});
}
},
handleTagsUpdated: function(topicName, tags) {
var topic = Tinode.getTopic(topicName);
if (topic) {
var instance = this;
topic.setMeta({tags: tags}).catch(function(err) {
instance.handleError(err.message, "err");
});
}
},
handleLeaveUnsubRequest: function(topicName) {
var topic = Tinode.getTopic(topicName);
if (!topic) {
return;
}
var instance = this;
topic.leave(true).then(function(ctrl) {
// Hide MessagesView and InfoView panels.
instance.setState({
topicSelected: '',
showInfoPanel: false,
infoPanelShowMembers: false
});
}).catch(function(err) {
instance.handleError(err.message, "err");
});
},
handleDialogCancel: function() {
this.setState({dialogSelected: null});
},
handleShowContextMenu: function(params, menuItems) {
this.setState({
contextMenuVisible: true,
contextMenuClickAt: {x: params.x, y: params.y},
contextMenuParams: params,
contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect()
});
},
defaultTopicContextMenu: function(topicName) {
var muted = false, blocked = false, subscribed = false, deleter = false;
var topic = Tinode.getTopic(topicName);
if (topic) {
if (topic.isSubscribed()) {
subscribed = true;
var acs = topic.getAccessMode();
muted = acs && acs.isMuted();
blocked = acs && !acs.isJoiner();
deleter = acs && acs.isDeleter();
}
}
return [
subscribed ? {title: "Info", handler: this.handleShowInfoView} : null,
subscribed ? ContextMenuItems["messages_clear"] : null,
subscribed && deleter ? ContextMenuItems["messages_clear_hard"] : null,
subscribed ? (muted ? ContextMenuItems["topic_unmute"] : ContextMenuItems["topic_mute"]) : null,
subscribed ? (blocked ? ContextMenuItems["topic_unblock"] : ContextMenuItems["topic_block"]) : null,
ContextMenuItems["topic_delete"]
];
},
handleHideContextMenu: function() {
this.setState({
contextMenuVisible: false,
contextMenuClickAt: null,
contextMenuParams: null,
contextMenuBounds: null
});
},
handleShowInfoView: function() {
this.setState({showInfoPanel: true, infoPanelShowMembers: false});
},
handleHideInfoView: function() {
this.setState({showInfoPanel: false, infoPanelShowMembers: false});
},
handleMemberUpdateRequest: function(topicName, added, removed) {
if (!topicName) {
return;
}
var topic = Tinode.getTopic(topicName);
if (!topic) {
return;
}
var instance = this;
if (added && added.length > 0) {
added.map(function(uid) {
topic.invite(uid, null).catch(function(err) {
instance.handleError(err.message, "err");
});
});
}
if (removed && removed.length > 0) {
removed.map(function(uid) {
topic.delSubscription(uid).catch(function(err) {
instance.handleError(err.message, "err");
});
});
}
},
handleValidateCredentialsRequest: function(cred, code) {
this.doLogin(null, null, {meth: cred, resp: code});
},
render: function() {
return (
<div id="app-container">
<ContextMenu
bounds={this.state.contextMenuBounds}
clickAt={this.state.contextMenuClickAt}
visible={this.state.contextMenuVisible}
params={this.state.contextMenuParams}
items={this.state.contextMenuItems}
hide={this.handleHideContextMenu}
onAction={this.handleContextMenuAction}
onError={this.handleError} />
<SidepanelView
connected={this.state.connected}
displayMobile={this.state.displayMobile}
hideSelf={this.state.displayMobile && this.state.mobilePanel !== 'sidepanel'}
state={this.state.sidePanelSelected}
title={this.state.sidePanelTitle}
avatar={this.state.sidePanelAvatar}
login={this.state.login}
myUserId={this.state.myUserId}
loginDisabled={this.state.loginDisabled}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
topicSelected={this.state.topicSelected}
validation={this.state.validation}
serverAddress={this.state.serverAddress}
onConnectionSettings={this.handleConnectionSettings}
onSignUp={this.handleNewAccount}
onSettings={this.handleSettings}
onLoginRequest={this.handleLoginRequest}
onCreateAccount={this.handleNewAccountRequest}
onUpdateAccount={this.handleUpdateAccountRequest}
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
onTopicSelected={this.handleTopicSelected}
onCreateTopic={this.handleNewTopicRequest}
onNewTopic={this.handleNewTopic}
onCancel={this.handleSidepanelCancel}
onOnlineChange={this.handleTopicSelectedOnline}
onError={this.handleError}
onValidateCredentials={this.handleValidateCredentialsRequest}
onInitFind={this.tnInitFind}
contactsSearchQuery={this.state.contactsSearchQuery}
foundContacts={this.state.foundContacts}
onSearchContacts={this.handleSearchContacts}
showContextMenu={this.handleShowContextMenu} />
<MessagesView
connected={this.state.connected}
online={this.state.topicSelectedOnline}
displayMobile={this.state.displayMobile}
viewportWidth={this.state.viewportWidth}
hideSelf={this.state.displayMobile &&
(this.state.mobilePanel !== 'topic-view' || this.state.showInfoPanel)}
topic={this.state.topicSelected}
myUserId={this.state.myUserId}
serverVersion={this.state.serverVersion}
serverAddress={this.state.serverAddress}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
onHideMessagesView={this.handleHideMessagesView}
onData={this.tnData}
onError={this.handleError}
showContextMenu={this.handleShowContextMenu}
sendMessage={this.handleSendMessage} />
{this.state.showInfoPanel ?
<InfoView
connected={this.state.connected}
displayMobile={this.state.displayMobile}
topic={this.state.topicSelected}
foundContacts={this.state.foundContacts}
myUserId={this.state.myUserId}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
showMemberPanel={this.state.infoPanelShowMembers}
onTopicDescUpdate={this.handleTopicUpdateRequest}
onCancel={this.handleHideInfoView}
onChangePermissions={this.handleChangePermissions}
onMemberUpdateRequest={this.handleMemberUpdateRequest}
onLeaveTopic={this.handleLeaveUnsubRequest}
onAddMember={this.handleManageGroupMembers}
onTopicTagsUpdate={this.handleTagsUpdated}
onInitFind={this.tnInitFind}
onError={this.handleError}
showContextMenu={this.handleShowContextMenu}
/>
:
null
}
</div>
);
}
})
ReactDOM.render(
<AppView />,
document.getElementById('mountPoint')
);
</script>
</body>
</html>