// Babel JSX
'use strict';
if (typeof require == 'function') {
if (typeof React == 'undefined') {
var React = require('react');
var ReactDOM = require('react-dom');
}
if (typeof Tinode == 'undefined') {
var Tinode = require('tinode-sdk');
var Drafty = Tinode.Drafty;
}
if (typeof firebase == 'undefined') {
var firebase = require('firebase/app');
require('firebase/messaging');
}
var package_version = require('../version.json').version;
}
// Name of this application, used in the User-Agent.
const APP_NAME = 'TinodeWeb/' + (package_version || '0.15');
const KNOWN_HOSTS = {hosted: 'api.tinode.co', local: 'localhost:6060'};
// Default host name and port to connect to.
const DEFAULT_HOST = KNOWN_HOSTS.hosted;
// var DEFAULT_HOST = KNOWN_HOSTS.local;
// Sound to play on message received.
const POP_SOUND = new Audio('audio/msg.mp3');
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
// Minimum time between two keypress notifications, milliseconds.
const KEYPRESS_DELAY = 3*1000;
// Delay before sending a {note} for reciving a message, milliseconds.
const RECEIVED_DELAY = 500;
// Delay before sending a read notification, milliseconds.
const READ_DELAY = 1000;
// The shortest allowed tag length. Matches one on the server.
const MIN_TAG_LENGTH = 4;
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
// in @meadia (max-size: 640px) in base.css
const MEDIA_BREAKPOINT = 640;
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
const REM_SIZE = 13;
// Size of the avatar image
const AVATAR_SIZE = 128;
// Number of chat messages to fetch in one call.
const MESSAGES_PAGE = 24;
// Maximum in-band (included directly into the message) attachment size which fits into
// a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead.
// This is size of an object *before* base64 encoding is applied.
// Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in server config.
// MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size - overhead);
const MAX_INBAND_ATTACHMENT_SIZE = 195840;
// Absolute maximum attachment size to be used with the server = 8MB. Increase to
// something like 100MB in production.
const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
// Maximum allowed linear dimension of an inline image in pixels. You may want
// to adjust it to 1600 or 2400 for production.
const MAX_IMAGE_DIM = 768;
// Supported image MIME types and corresponding file extensions.
const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
// Helper functions for storing values in localStorage.
// By default localStorage can store only strings, not objects or other types.
// Replace old object with the new one.
Storage.prototype.setObject = function(key, value) {
this.setItem(key, JSON.stringify(value));
}
// Get stored object.
Storage.prototype.getObject = function(key) {
let value = this.getItem(key);
return value && JSON.parse(value);
}
// Partially or wholly update stored object.
Storage.prototype.updateObject = function(key, value) {
let oldVal = this.getObject(key);
this.setObject(key, Object.assign(oldVal || {}, value));
}
// 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'});
}
// 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', 'PB'];
var bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1);
var count = bytes / Math.pow(1024, bucket);
var round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
return count.toFixed(round) + ' ' + sizes[bucket];
}
// Make a data URL from public.photo
function makeImageUrl(photo) {
return (photo && photo.type && photo.data) ?
'data:image/' + photo.type + ';base64,' + photo.data : null;
}
// Calculate linear dimensions for scaling image down to fit under a certain size.
// Returns an object which contains destination sizes, source sizes, and offsets
// into source (when making square images).
function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {
if (!width || !height || !maxWidth || !maxHeight) {
return null;
}
if (forceSquare) {
maxWidth = maxHeight = Math.min(maxWidth, maxHeight);
}
let scale = Math.min(
Math.min(width, maxWidth) / width,
Math.min(height, maxHeight) / height
);
let size = {
dstWidth: (width * scale) | 0,
dstHeight: (height * scale) | 0,
};
if (forceSquare) {
// Also calculate parameters for making the image square.
size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight);
size.srcWidth = size.srcHeight = Math.min(width, height);
size.xoffset = ((width - size.srcWidth) / 2) | 0;
size.yoffset = ((height - size.srcWidth) / 2) | 0;
} else {
size.xoffset = size.yoffset = 0;
size.srcWidth = width;
size.srcHeight = height;
}
return size;
}
// Ensure file's extension matches mime content type
function fileNameForMime(fname, mime) {
var idx = SUPPORTED_IMAGE_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;
}
// Given length of a binary object in bytes, calculate the length after
// base64 encoding.
function base64EncodedLen(n) {
return Math.floor((n + 2) / 3) * 4;
}
// Given length of a base64-encoded object, calculate decoded size of the
// pbject in bytes.
function base64DecodedLen(n) {
return Math.floor(n / 4) * 3;
}
// Re-encode string to standard base64 encoding with padding.
// The string may be base64-URL encoded without the padding.
function base64ReEncode(str) {
if (str) {
str = str.replace('-', '+').replace('_', '/');
try {
str = btoa(atob(str));
} catch(err) {
console.log("Failed to base64 re-decode string");
str = null;
}
}
return str;
}
// Convert uploaded image into a base64-encoded string possibly scaling
// linear dimensions or constraining to a square.
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 dim = fitImageSize(this.width, this.height, width, height, forceSquare);
if (!dim) {
onError("Invalid image");
return;
}
var canvas = document.createElement('canvas');
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
var ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight);
var mime = (this.width != dim.dstWidth ||
this.height != dim.dstHeight ||
SUPPORTED_IMAGE_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;
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
mime = 'image/jpeg';
}
if (mime == 'image/jpeg') {
// Reduce size of the jpeg by reducing image quality
while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) {
imageBits = canvas.toDataURL(mime, quality);
quality *= 0.84;
}
}
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) +
" exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err");
return;
}
canvas = null;
onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, 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) {
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 not recognized");
}
img.src = URL.createObjectURL(file);
}, false);
reader.readAsDataURL(file);
}
function fileToBase64(file, onSuccess, onError) {
var reader = new FileReader();
reader.addEventListener('load', function() {
onSuccess(file.type, reader.result.split(',')[1], file.name);
});
reader.readAsDataURL(file);
}
// File pasted from the clipboard. It's either an inline image or a file attachment.
// FIXME: handle large files out of band.
function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
for (var i in items) {
var item = items[i];
if (item.kind === 'file') {
var file = item.getAsFile();
if (!file) {
console.log("Failed to get file object from pasted file item", item.kind, item.type);
continue;
}
if (file.type && file.type.split('/')[0] == 'image') {
// Handle inline image
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError);
} else {
imageFileToBase64(file, onImageSuccess, onError);
}
} else {
// Handle file attachment
fileToBase64(file, onAttachmentSuccess, onError)
}
// Indicate that the pasted data contains a file.
return true;
}
}
// No file found.
return false;
}
// Make shortcut icon appear with a green dot + show unread count in title.
function updateFavicon(count) {
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' + (count > 0 ? 'a' : '') + '.png';
head.removeChild(oldIcon);
head.appendChild(newIcon);
}
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
}
// 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;
}
// Helper functions for hash navigation.
// Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments.
function parseUrlHash(hash) {
// Split path from args, path -> parts[0], args->path[1]
let parts = hash.split('?', 2);
let params = {};
let path = [];
if (parts[0]) {
path = parts[0].substr(1).split('/');
}
if (parts[1]) {
parts[1].split('&').forEach(function(part) {
let item = part.split('=');
if (item[0]) {
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
}
});
}
return {path: path, params: params};
}
function navigateTo(url) {
window.location.hash = url;
}
function composeUrlHash(path, params) {
var url = path.join('/');
var args = [];
for (var key in params) {
if (params.hasOwnProperty(key)) {
args.push(key + '=' + params[key]);
}
}
if (args.length > 0) {
url += '?' + args.join('&');
}
return url;
}
function addUrlParam(hash, key, value) {
var parsed = parseUrlHash(hash);
parsed.params[key] = value;
return composeUrlHash(parsed.path, parsed.params);
}
function removeUrlParam(hash, key) {
var parsed = parseUrlHash(hash);
delete parsed.params[key];
return composeUrlHash(parsed.path, parsed.params);
}
function setUrlSidePanel(hash, sidepanel) {
var parsed = parseUrlHash(hash);
parsed.path[0] = sidepanel;
return composeUrlHash(parsed.path, parsed.params);
}
function setUrlTopic(hash, topic) {
var parsed = parseUrlHash(hash);
parsed.path[1] = topic;
// Close InfoView on topic change.
delete parsed.params.info;
return composeUrlHash(parsed.path, parsed.params);
}
// END of hash navigation helper functions
// Detect server address from the URL
function detectServerAddress() {
let host = DEFAULT_HOST;
if (typeof window.location == 'object') {
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;
}
// Detect if the page is served over HTTPS.
function isSecureConnection() {
if (typeof window.location == 'object') {
return window.location.protocol == 'https:';
}
return false;
}
function isLocalHost() {
if (typeof window.location == 'object') {
return window.location.hostname == 'localhost';
}
return false;
}
// 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;
}
// Deep-shallow compare two arrays: shallow compare each element.
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;
}
/* BEGIN Context Menu: popup/dropdown menu */
class ContextMenu extends React.Component {
constructor(props) {
super(props);
this.handlePageClick = this.handlePageClick.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClick = this.handleClick.bind(this);
// Preconfigured menu items.
this.MenuItems = {
'topic_info': {title: "Info", handler: null},
'messages_clear': {title: "Clear messages", handler: (params, errorHandler) => {
this.deleteMessages(true, false, params, errorHandler);
}},
'messages_clear_hard': {title: "Clear for All", handler: (params, errorHandler) => {
this.deleteMessages(true, true, params, errorHandler);
}},
'message_delete': {title: "Delete", handler: (params, errorHandler) => {
this.deleteMessages(false, false, params, errorHandler);
}},
'message_delete_hard': {title: "Delete for All", handler: (params, errorHandler) => {
this.deleteMessages(false, true, params, errorHandler);
}},
'topic_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')},
'topic_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')},
'topic_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')},
'topic_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')},
'topic_delete': {title: "Delete", handler: (params, errorHandler) => {
let topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
topic.delTopic().catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}},
'permissions': {title: "Edit permissions", handler: null},
'member_delete': {title: "Remove", handler: (params, errorHandler) => {
let topic = this.props.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((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}},
'member_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')},
'member_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')},
'member_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')},
'member_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')},
};
}
componentDidMount() {
document.addEventListener('mousedown', this.handlePageClick, false);
document.addEventListener('keyup', this.handleEscapeKey, false);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.handlePageClick, false);
document.removeEventListener('keyup', this.handleEscapeKey, false);
}
handlePageClick(e) {
if (ReactDOM.findDOMNode(this).contains(e.target)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.props.hide();
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
this.props.hide();
}
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.hide();
let item = this.props.items[e.currentTarget.dataset.id];
if (typeof item == 'string') {
item = this.MenuItems[item];
}
item.handler(this.props.params, this.props.onError);
}
// Menu Actions
deleteMessages(all, hard, params, errorHandler) {
var topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
// We don't know if the message is still pending (e.g. attachment is being uploaded),
// so try cancelling first. No harm if we can't cancel.
if (topic.cancelSend(params.seq)) {
return Promise.resolve();
}
// Can't cancel. Delete instead.
var promise = all ?
topic.delMessagesAll(hard) :
topic.delMessagesList([params.seq], hard);
promise.catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Function is used by context menu to set permissions.
topicPermissionSetter(mode, params, errorHandler) {
var topic = this.props.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();
}
topic.setMeta({sub: {user: params.user, mode: am}}).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
render() {
let count = 0;
let menu = [];
this.props.items.map((item) => {
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (item && item.title) {
menu.push(
item.title == '-' ?
:
{item.title}
);
}
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 (
);
}
}
/* END Popup/dropdown menu */
/* The X menu to be displayed in title bars */
class MenuCancel extends React.PureComponent {
constructor(props) {
super(props);
}
render() {
return (
close
);
}
}
class LoadSpinner extends React.PureComponent {
render() {
return (this.props.show ?
: null);
}
}
// Toggle [Title text >] -> [Title text v]
class MoreButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
open: props.open
};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle() {
let open = !this.state.open;
this.setState({open: open});
if (this.props.onToggle) {
this.props.onToggle(open);
}
}
render() {
return ({this.props.title}...
{this.state.open ? expand_more :
chevron_right }
);
}
}
/* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */
class LetterTile extends React.PureComponent {
render() {
let avatar;
if (this.props.avatar === true) {
if (this.props.topic && this.props.title && this.props.title.trim()) {
let letter = this.props.title.trim().charAt(0);
let color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16);
avatar = ()
} else {
avatar = (Tinode.topicType(this.props.topic) == 'grp') ?
group :
person ;
}
} else if (this.props.avatar) {
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 */
class InPlaceEdit extends React.Component {
constructor(props) {
super(props);
this.state = {
active: props.active,
initialValue: props.value || '',
value: props.value || ''
};
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEditing = this.handleStartEditing.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
this.handlePasswordFinished = this.handlePasswordFinished.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
// If text has changed while in read mode, update text and discard changes.
// Ignore update if in edit mode.
if (prevState.initialValue != nextProps.value && !prevState.active) {
return {
initialValue: nextProps.value || '',
value: nextProps.value || ''
};
}
return null;
}
handeTextChange(e) {
this.setState({value: e.target.value});
}
handleKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({value: this.props.value, active: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleEditingFinished();
}
}
handleStartEditing() {
if (!this.props.readOnly) {
ReactDOM.findDOMNode(this).focus();
this.setState({active: true});
}
}
handleEditingFinished() {
this.setState({active: false});
let value = this.state.value.trim();
if ((value || this.props.value) && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
handlePasswordFinished(value) {
this.setState({active: false});
if (value && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
render() {
if (this.state.active) {
var fieldType = this.props.type || 'text';
} else {
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
var spanClass = 'in-place-edit' +
(this.props.readOnly ? ' disabled' : '');
if (!spanText) {
spanText = this.props.placeholder;
spanClass += ' placeholder';
}
if (spanText.length > 20) {
// FIXME: this is wrong for RTL languages.
spanText = spanText.substring(0, 19) + '...';
}
}
return (
this.state.active ?
(fieldType == 'password' ?
:
)
:
{spanText}
);
}
};
class VisiblePassword extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
value: this.props.value,
visible: false
};
this.handleVisibility = this.handleVisibility.bind(this);
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handeTextChange(e) {
this.setState({value: e.target.value});
if (this.props.onChange) {
this.props.onChange(e);
}
}
handleVisibility(e) {
e.preventDefault();
this.setState({visible: !this.state.visible});
}
handleKeyDown(e) {
if (e.keyCode == 27) {
// Escape pressed
this.setState({value: this.props.value, visible: false});
if (this.props.onFinished) {
this.props.onFinished();
}
} else if (e.keyCode == 13) {
// Enter pressed
this.handleEditingFinished();
}
}
handleEditingFinished(e) {
if (e) {
let currentTarget = e.currentTarget;
setTimeout(() => {
if (!currentTarget.contains(document.activeElement)) {
if (this.props.onFinished) {
this.props.onFinished(this.state.value);
}
}
}, 0);
} else if (this.props.onFinished) {
this.props.onFinished(this.state.value.trim());
}
}
render() {
return (
{this.state.visible ? 'visibility' : 'visibility_off'}
);
}
}
/* END InPlaceEdit */
/* BEGIN Combobox for selecting host name */
class HostSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hostName: 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(
);
}
return (
{hostOptions}
);
}
}
/* 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 ?
check_box :
check_box_outline_blank
) : (
this.props.checked ?
check_box :
check_box_outline_blank
)
);
}
}
/* END CheckBox */
/* BEGIN PermissionsEditor: Component for editing permissions */
//
class PermissionsEditor extends React.Component {
constructor(props) {
super(props);
this.state = {
mode: (props.mode || '').replace('N', '')
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleChange(val) {
let mode = this.state.mode;
let idx = mode.indexOf(val);
if (idx == -1) {
mode += val;
} else {
mode = mode.replace(val, '');
}
this.setState({mode: mode});
}
handleSubmit() {
// Normalize string, otherwise cannot check if mode has changed.
var mode = (this.state.mode || 'N').split('').sort().join('');
var before = (this.props.mode || 'N').split('').sort().join('')
if (mode !== before) {
this.props.onSubmit(mode);
} else {
this.props.onCancel();
}
}
handleCancel() {
this.props.onCancel();
}
render() {
const all = 'JRWPASDO';
const 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)'
};
let skip = this.props.skip || '';
let mode = this.state.mode;
let compare = (this.props.compare || '').replace('N', '');
let items = [];
for (let i=0; i= 0 && mode.indexOf(c) < 0) {
// Permission is marked as inactive: hide unchecked permissions, disable checked permissions
continue;
}
items.push(
{names[c]}
{skip.indexOf(c) < 0 ?
= 0)} onChange={this.handleChange}/>
:
= 0)} />
} {this.props.compare ?
= 0)}/>
: null}
);
}
return (
{this.props.userTitle ?
: null}
Permissions
{this.props.compare ?
{this.props.modeTitle}
{this.props.compareTitle}
:
null}
{items}
Ok
Cancel
);
}
};
/* END PermissionsEditor */
/* BEGIN ChipInput: group membership widget */
class ChipInput extends React.Component {
constructor(props) {
super(props);
this.state = {
placeholder: props.chips ? '' : props.prompt,
sortedChips: ChipInput.sortChips(props.chips, props.required),
chipIndex: ChipInput.indexChips(props.chips),
input: '',
focused: false
};
this.handleTextInput = this.handleTextInput.bind(this);
this.removeChipAt = this.removeChipAt.bind(this);
this.handleChipCancel = this.handleChipCancel.bind(this);
this.handleFocusGained = this.handleFocusGained.bind(this);
this.handleFocusLost = this.handleFocusLost.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.setState({
sortedChips: ChipInput.sortChips(nextProps.chips, nextProps.required),
chipIndex: ChipInput.indexChips(nextProps.chips)
});
if (nextProps.chips.length > this.props.chips.length) {
// Chip added: clear input.
this.setState({input: ''});
}
}
// Map chip index to user name
static indexChips(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.
static sortChips(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(e) {
this.setState({input: e.target.value});
if (this.props.filterFunc) {
this.props.filterFunc(e.target.value);
}
}
removeChipAt(idx) {
var removed = this.state.sortedChips[idx];
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
}
handleChipCancel(item, idx) {
this.removeChipAt(idx);
}
handleFocusGained() {
this.setState({focused: true});
}
handleFocusLost() {
this.setState({focused: false});
if (this.props.onFocusLost) {
this.props.onFocusLost(this.state.input);
}
}
handleKeyDown(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.key === 'Escape') {
if (this.props.onCancel) {
this.props.onCancel();
}
}
}
render() {
var chips = [];
var count = 0;
this.state.sortedChips.map((item) => {
chips.push(
);
count++;
});
var className = "chip-input" + (this.state.focused ? ' focused' : '');
return (
{chips}
);
}
};
class Chip extends React.PureComponent {
constructor(props) {
super(props);
this.handleCancel = this.handleCancel.bind(this);
}
handleCancel() {
this.props.onCancel(this.props.topic, this.props.index);
}
render() {
var title = this.props.title || this.props.topic;
return (
{this.props.noAvatar ?
:
}
{title}
{this.props.onCancel && !this.props.required ?
×
:
}
);
}
};
/* END ChipInput */
/* BEGIN GroupSubs: a list of group subscribers currently online */
class GroupSubs extends React.Component {
constructor(props) {
super(props);
this.state = {
onlineSubs: props.subscribers || []
};
}
static getDerivedStateFromProps(nextProps, prevState) {
return {
onlineSubs: nextProps.subscribers ? nextProps.subscribers : []
};
}
render() {
var usersOnline = [];
this.state.onlineSubs.map((sub) => {
usersOnline.push(
);
});
return (
{usersOnline}
);
}
};
/* END GroupSubs */
/* BEGIN Login: a login form */
class LoginView extends React.Component {
constructor(props) {
super(props);
this.state = {
login: props.login,
password: '',
hostName: props.serverAddress,
saveToken: localStorage.getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
handleToggleSaveToken() {
localStorage.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
}
render() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
);
}
};
/* END Login */
/* BEGIN Account registration */
class CreateAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
login: '',
password: '',
email: '',
fn: '', // full/formatted name
imageDataUrl: null,
errorCleared: false,
saveToken: localStorage.getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(password) {
this.setState({password: password});
}
handleEmailChange(e) {
this.setState({email: e.target.value})
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleToggleSaveToken() {
localStorage.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
// 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() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
);
}
};
class AvatarUpload extends React.Component {
constructor(props) {
super(props);
this.state = {
dataUrl: props.avatar
};
this.handleFileUpload = this.handleFileUpload.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
if (prevState.dataUrl != nextProps.avatar) {
return {
dataUrl: nextProps.avatar
};
}
return null;
}
handleFileUpload(e) {
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
// Success
(base64bits, mime) => {
var du = makeImageUrl({data: base64bits, type: mime});
this.setState({dataUrl: du});
this.props.onImageChanged(du);
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
render() {
// 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 (
{this.state.dataUrl ?
:
this.props.readOnly && this.props.uid ?
:
128×128
}
{this.props.readOnly ? null :
}
{this.props.readOnly ? null :
file_upload
}
);
}
};
/* END Account registration */
/* BEGIN PasswordResetView: a password reset form */
class PasswordResetView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
email: '',
password: ''
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
}
componentDidMount() {
let parsed = parseUrlHash(window.location.hash);
this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
}
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
} else {
this.props.onRequest("email", this.state.email.trim());
}
}
handleEmailChange(e) {
this.setState({email: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
render() {
let reset = (this.state.token && this.state.scheme);
return (
);
}
}
/* END PasswordResetView */
/* BEGIN Tinode config panel */
class SettingsView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
transport: props.transport || 'def',
serverAddress: props.serverAddress,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleTransportSelected = this.handleTransportSelected.bind(this);
this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
}
handleSubmit(e) {
e.preventDefault();
this.props.onUpdate({
transport: this.state.transport,
serverAddress: this.state.serverAddress
});
}
handleTransportSelected(e) {
this.setState({transport: e.currentTarget.value});
}
handleServerAddressChange(name) {
this.setState({serverAddress: name});
this.props.onServerAddressChange(name);
}
render() {
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(
{name}
);
});
return (
);
}
};
/* END Tinode config panel */
/* BEGIN Manage side panel - handle Login, Account Registration, Contacts, NewTopic views */
class SideNavbar extends React.PureComponent {
render() {
return (
{this.props.title}
{this.props.state === 'login' ?
:
this.props.state === 'contacts' ?
:
null}
{this.props.onCancel ?
: null}
);
}
};
class MenuStart extends React.PureComponent {
render() {
return (
);
}
};
class MenuContacts extends React.PureComponent {
render() {
return (
);
}
};
class SidepanelView extends React.Component {
constructor(props) {
super(props);
this.handleLoginRequested = this.handleLoginRequested.bind(this);
}
handleLoginRequested(login, password) {
this.props.onLoginRequest(login, password);
}
render() {
var title = null;
var avatar = false;
var onCancel = undefined;
var view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
switch (view) {
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':
title = "Edit Account";
onCancel = this.props.onCancel;
break;
case 'contacts':
title = this.props.title;
avatar = this.props.avatar ? this.props.avatar : true;
break;
case 'newtpk':
title = "Start New Chat";
onCancel = this.props.onCancel;
break;
case 'cred':
title = "Confirm Credentials";
onCancel = this.props.onCancel;
break;
case 'reset':
title = "Reset Password";
onCancel = this.props.onCancel;
break;
default:;
};
return (
{view === 'login' ?
:
view === 'register' ?
:
view === 'settings' ?
:
view === 'edit' ?
:
view === 'contacts' ?
:
view === 'newtpk' ?
:
view === 'cred' ?
:
view === 'reset' ?
:
null}
);
}
};
class ErrorPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
show: false,
};
this.hide = this.hide.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
return {
show: !(!nextProps.level)
};
}
hide() {
this.setState({show: false});
if (this.props.onClearError) {
this.props.onClearError();
}
}
render() {
var icon = this.props.level == 'err' ? 'error': 'warning';
return (
);
}
};
/* END Side panel */
/* EditAccount parameters */
class EditAccountView extends React.Component {
constructor(props) {
super(props);
let me = this.props.tinode.getMeTopic();
let defacs = me.getDefaultAccess();
let fnd = this.props.tinode.getFndTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageUrl(me.public ? me.public.photo : null),
auth: defacs ? defacs.auth : null,
anon: defacs ? defacs.anon : null,
tags: fnd.tags(),
showPermissionEditorFor: undefined,
previousOnTags: fnd.onTagsUpdated
};
this.tnNewTags = this.tnNewTags.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
}
componentDidMount() {
let fnd = this.props.tinode.getFndTopic();
fnd.onTagsUpdated = this.tnNewTags;
if (!fnd.isSubscribed()) {
fnd.subscribe(fnd.startMetaQuery().withLaterDesc().withTags().build()).catch((err) => {
this.props.onError(err.message, 'err');
});
}
}
componentWillUnmount() {
var fnd = this.props.tinode.getFndTopic();
fnd.onTagsUpdated = this.state.previousOnTags;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
handleFullNameUpdate(fn) {
this.setState({fullName: fn});
this.props.onUpdateAccount(undefined, vcard(fn, this.state.avatar));
}
handlePasswordUpdate(pwd) {
this.setState({password: pwd});
this.props.onUpdateAccount(pwd);
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onUpdateAccount(undefined, vcard(this.state.fullName, img));
}
handleCheckboxClick(what, checked) {
if (what == 'sound') {
this.props.onToggleMessageSounds(checked);
} else if (what == 'alert') {
this.props.onTogglePushNotifications(checked);
}
}
handleLaunchPermissionsEditor(which) {
this.setState({
showPermissionEditorFor: which,
editedPermissions: this.state[which]
});
}
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
}
handlePermissionsChanged(perm) {
let defacs = {};
defacs[this.state.showPermissionEditorFor] = perm;
this.props.onUpdateAccount(undefined, undefined, defacs);
let newState = {showPermissionEditorFor: undefined};
newState[this.state.showPermissionEditorFor] = perm;
this.setState(newState);
}
handleTagsUpdated(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
}
render() {
var tags = [];
this.state.tags.map(function(tag) {
tags.push({tag} );
});
if (tags.length == 0) {
tags = No tags defined. Add some. ;
}
return (
{this.state.showPermissionEditorFor ?
:
Address:
{this.props.myUserId}
Default access mode:
Auth: {this.state.auth}
Anon: {this.state.anon}
Message sound:
Notification
alerts{!this.props.desktopAlertsEnabled ? ' (requires HTTPS)' : null}:
}
);
}
};
/* END EditAccount */
/* BEGIN TagManager: edit topic or user tags */
class TagManager extends React.Component {
constructor(props) {
super(props);
this.state = {
tags: this.props.tags,
tagInput: '',
activated: this.props.activated,
noTagsMessage: "Add some tags"
};
this.handleShowTagManager = this.handleShowTagManager.bind(this);
this.handleTagInput = this.handleTagInput.bind(this);
this.handleAddTag = this.handleAddTag.bind(this);
this.handleRemoveTag = this.handleRemoveTag.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
return {tags: nextProps.tags};
}
return null;
}
handleShowTagManager() {
this.setState({activated: true});
}
handleTagInput(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(tag) {
tag = tag.trim();
if (tag.length > 0) {
let tags = this.state.tags.slice(0);
tags.push(tag);
this.setState({tags: tags, tagInput: ''});
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
}
}
handleRemoveTag(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() {
let tags = this.state.tags.slice(0);
let inp = this.state.tagInput.trim();
if (inp.length > 0) {
tags.push(inp);
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
}
this.props.onSubmit(tags);
this.setState({activated: false, tagInput: '', tags: this.props.tags});
}
handleCancel() {
this.setState({activated: false, tagInput: '', tags: this.props.tags});
if (this.props.onCancel) {
this.props.onCancel();
}
}
render() {
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({tag} );
});
if (tags.length == 0) {
tags = No tags defined. Add some. ;
}
}
return (
{this.props.title}
{this.state.activated ?
{this.props.onSubmit || this.props.onCancel ?
OK
Cancel
: null}
:
}
);
}
};
/* END TagManager */
/* BEGIN Contact list (list of topics) */
/* A single topic */;
class Contact extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
if (this.props.onSelected) {
this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs);
}
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
}
render() {
var title = this.props.title;
if (!title) {
title = unknown ;
} else if (title.length > 30) {
// FIXME: this is wrong for RTL languages.
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({b.name} );
count ++;
});
}
if (this.props.showMode && this.props.acs) {
badges.push({this.props.acs.getMode()} );
}
return (
{this.props.showOnline ? :
(this.props.showCheckmark && this.props.selected ?
check_circle
: null)}
{title}
{this.props.unread > 0 ? : null}
{this.props.comment ?
{this.props.comment}
: null}
{badges}
{this.props.showContextMenu ?
expand_more
: null}
);
}
};
/* The counter of unread messages in the topic */
class UnreadBadge extends React.PureComponent {
render() {
var showUnreadBadge = null;
if (this.props.count > 0) {
var count = this.props.count > 9 ? "9+" : this.props.count;
showUnreadBadge = {count} ;
}
return showUnreadBadge;
}
};
/* Contact's labels: [you], [muted], [blocked], etc */
// FIXME: this class is unused.
class ContactBadges_UNUSED_REMOVE extends React.PureComponent {
render() {
let badges = null;
if (this.props.badges && this.props.badges.length > 0) {
badges = [];
this.props.badges.map(function(b) {
var style = 'badge' + (b.color ? ' ' + b.color : '');
// Badge names are expected to be unique, so using the name as the key.
badges.push({b.name} );
});
}
return badges;
}
};
/* ContactsView holds all contacts-related stuff */
class ContactsView extends React.Component {
constructor(props) {
super(props);
this.state = ContactsView.getDerivedStateFromProps(props, {});
}
static getDerivedStateFromProps(nextProps, prevState) {
let contacts = [];
let unreadThreads = 0;
nextProps.chatList.map((c) => {
unreadThreads += c.unread > 0 ? 1 : 0;
contacts.push(c);
});
contacts.sort(function(a, b){
return (b.touched || 0) - (a.touched || 0);
});
updateFavicon(unreadThreads);
return {contactList: contacts};
}
render() {
return (
You have no chats ¯\_(ツ)_/¯}
topicSelected={this.props.topicSelected}
myUserId={this.props.myUserId}
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 */
class ContactList extends React.Component {
render() {
var contactNodes = [];
var showCheckmark = Array.isArray(this.props.topicSelected);
if (this.props.contacts && this.props.contacts.length > 0) {
this.props.contacts.map((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 (this.props.filterFunc && this.props.filter) {
var content = [key];
if (c.private && c.private.comment) {
content.push(('' + c.private.comment).toLowerCase());
}
if (c.public && c.public.fn) {
content.push(('' + c.public.fn).toLowerCase());
}
if (!this.props.filterFunc(this.props.filter, content)) {
return;
}
}
var selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
var badges = [];
if (this.props.showMode) {
if (key === this.props.myUserId) {
badges.push({name: "you", color: 'green'});
}
if (c.acs && c.acs.isOwner()) {
badges.push({name: "owner"});
}
}
var comment = Array.isArray(c.private) ?
c.private.join(', ') : (c.private ? c.private.comment : null);
contactNodes.push(
);
}, this);
}
return (
{contactNodes.length > 0 ?
:
{this.props.emptyListMessage}
}
);
}
};
/* END ContactList */
/* BEGIN GroupMembers: control for managing a list of group members */
class GroupManager extends React.Component {
constructor(props) {
super(props);
this.state = {
members: props.members,
index: GroupManager.indexMembers(props.members),
contactFilter: '',
noContactsMessage: "You have no contacts :-(",
selectedContacts: GroupManager.selectedContacts(props.members)
};
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
this.handleContactFilter = this.handleContactFilter.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static indexMembers(members) {
var index = {};
members.map(function(m) {
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
});
return index;
}
static selectedContacts(members) {
var sel = [];
members.map(function(m) {
sel.push(m.user);
});
return sel;
}
handleContactSelected(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 = GroupManager.selectedContacts(m);
var i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleMemberRemoved(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 = GroupManager.selectedContacts(m);
var i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleContactFilter(val) {
var msg = !val ?
"You have no contacts :-(" :
"No contacts match '" + val + "'";
this.setState({contactFilter: val, noContactsMessage: msg});
}
static doContactFiltering(filter, values) {
if (filter) {
for (var i=0; i= 0) {
return true;
}
}
return false;
}
return true;
}
handleSubmit() {
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() {
this.props.onCancel();
}
render() {
return (
Group members
All contacts
OK
Cancel
);
}
};
/* END GroupManager */
/* BEGIN Create new topic and invite users or send an invite */
class NewTopicView extends React.Component {
constructor(props) {
super(props);
this.state = {
tabSelected: 'p2p',
contactList: props.searchResults
};
this.handleTabClick = this.handleTabClick.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
this.handleGroupByID = this.handleGroupByID.bind(this);
}
componentDidMount() {
this.props.onInitFind();
}
static getDerivedStateFromProps(nextProps, prevState) {
return {
contactList: nextProps.searchResults
};
}
handleTabClick(e) {
e.preventDefault();
navigateTo(addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
handleContactSelected(sel) {
if (this.state.tabSelected === 'p2p') {
navigateTo(removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(sel, undefined);
}
}
handleNewGroupSubmit(name, dataUrl, priv, tags) {
navigateTo(removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
}
handleGroupByID(topicName) {
navigateTo(removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(topicName);
}
render() {
return (
{this.state.tabSelected === 'grp' ?
:
this.state.tabSelected === 'byid' ?
:
}
);
}
};
class NewTopicGroup extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
fn: '', // full/formatted name
private: '',
imageDataUrl: null,
tags: []
};
this.handleFnChange = this.handleFnChange.bind(this);
this.handlePrivateChange = this.handlePrivateChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handlePrivateChange(e) {
this.setState({private: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleTagsChanged(tags) {
this.setState({tags: tags});
}
handleSubmit(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() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
);
}
};
class SearchContacts extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
edited: false,
search: ''
};
this.handleSearchChange = this.handleSearchChange.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleClear = this.handleClear.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
componentWillUnmount() {
if (this.state.edited) {
this.setState({search: '', edited: false});
this.props.onSearchContacts(Tinode.DEL_CHAR);
}
}
handleSearchChange(e) {
this.setState({search: e.target.value});
}
handleSearch(e) {
e.preventDefault();
var query = this.state.search.trim();
this.setState({edited: (query.length > 0)});
this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
}
handleClear() {
if (this.state.edited) {
this.props.onSearchContacts(Tinode.DEL_CHAR);
}
this.setState({search: '', edited: false});
}
handleKeyDown(e) {
if (e.key === 'Enter') {
this.handleSearch(e);
} else if (e.key === 'Escape') {
this.handleClear();
}
}
render() {
return (
);
}
};
class NewTopicById extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
groupId: '',
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(e) {
this.setState({groupId: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
}
handleSubmit(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);
} else {
this.props.onError("Invalid ID", 'err');
}
}
}
render() {
return (
);
}
};
/* END Create new topic and invite users or send an invite */
/* BEGIN ValidationView: panel for confirming credentials, like email or phone */
class ValidationView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
code: props.credCode || ''
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(e) {
this.setState({code: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.code && this.state.code.trim()) {
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
}
}
render() {
var methods = {'email': 'email', 'tel': 'phone'};
var method = methods[this.props.credMethod] || this.props.credMethod;
return (
Enter confirmation code sent to you by {method}:
Confirm
);
}
};
/* END ValidationView */
/* BEGIN InfoView: panel with topic/user info */
class InfoView extends React.Component {
constructor(props) {
super(props);
this.state = {
topic: null,
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: false,
showPermissionEditorFor: undefined,
moreInfoExpanded: false,
previousMetaDesc: undefined,
previousSubsUpdated: undefined,
previousTagsUpdated: undefined
};
this.resetSubs = this.resetSubs.bind(this);
this.resetDesc = this.resetDesc.bind(this);
this.onMetaDesc = this.onMetaDesc.bind(this);
this.onSubsUpdated = this.onSubsUpdated.bind(this);
this.onTagsUpdated = this.onTagsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleMuted = this.handleMuted.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleLeave = this.handleLeave.bind(this);
this.handleMemberSelected = this.handleMemberSelected.bind(this);
this.handleMoreInfo = this.handleMoreInfo.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
}
// No need to separately handle component mount.
UNSAFE_componentWillReceiveProps(props) {
var topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
if (this.onMetaDesc != topic.onMetaDesc) {
this.previousMetaDesc = topic.onMetaDesc;
topic.onMetaDesc = this.onMetaDesc;
this.previousSubsUpdated = topic.onSubsUpdated;
topic.onSubsUpdated = this.onSubsUpdated;
if (topic.getType() == 'grp') {
this.previousTagsUpdated = topic.onTagsUpdated;
topic.onTagsUpdated = this.onTagsUpdated;
} else {
this.previousTagsUpdated = undefined;
}
}
if (this.state.topic != props.topic) {
this.setState({topic: props.topic});
this.resetDesc(topic, props);
this.resetSubs(topic, props);
}
}
componentWillUnmount() {
var topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.setState({topic: null});
topic.onMetaDesc = this.previousMetaDesc;
topic.onSubsUpdated = this.previousSubsUpdated;
topic.onTagsUpdated = this.previousTagsUpdated;
}
resetSubs(topic, props) {
var newState = {contactList: []};
if (topic.getType() == 'p2p') {
// Fetch the other party in the p2p conversation.
// Topic may not be ready yet, so check if user is found.
var user2 = topic.subscriber(props.topic);
if (user2) {
newState.modeGiven2 = user2.acs.getGiven();
newState.modeWant2 = user2.acs.getWant();
}
} else {
topic.subscribers(function(sub) {
newState.contactList.push(sub);
}, this);
}
this.setState(newState);
}
resetDesc(topic, props) {
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 ? topic.private.comment : null,
address: topic.name,
groupTopic: (topic.getType() == 'grp'),
showMemberPanel: false,
access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined,
modeWant: acs ? acs.getWant() : undefined,
auth: defacs.auth,
anon: defacs.anon
});
if (topic.getType() == 'grp' && acs && acs.isOwner()) {
// Requesting tags: owner is editing the topic.
topic.getMeta(topic.startMetaQuery().withTags().build());
}
}
onMetaDesc(desc) {
var topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic, this.props);
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
this.previousMetaDesc(desc);
}
}
onSubsUpdated() {
var topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetSubs(topic, this.props);
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
this.previousSubsUpdated();
}
}
onTagsUpdated(tags) {
this.setState({tags: tags});
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
this.previousTagsUpdated();
}
}
handleFullNameUpdate(fn) {
if (this.state.fullName !== fn) {
this.setState({fullName: fn});
this.props.onTopicDescUpdate(this.props.topic,
vcard(fn, this.state.avatar),
null);
}
}
handlePrivateUpdate(priv) {
if (this.state.priv !== priv) {
this.setState({private: priv});
this.props.onTopicDescUpdate(this.props.topic,
null,
priv);
}
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onTopicDescUpdate(this.props.topic,
vcard(this.state.fullName, img),
null);
}
handleMuted(ignored, checked) {
this.setState({muted: checked});
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
}
handlePermissionsChanged(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(which, uid) {
let 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': {
let topic = this.props.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() {
this.setState({showPermissionEditorFor: undefined});
}
handleShowAddMembers() {
this.props.onInitFind();
this.setState({showMemberPanel: true});
}
handleHideAddMembers() {
this.setState({showMemberPanel: false});
}
handleMemberUpdateRequest(members, added, removed) {
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
this.setState({showMemberPanel: false});
}
handleLeave() {
this.props.onLeaveTopic(this.props.topic);
}
handleMemberSelected(uid) {
this.setState({selectedContact: uid});
}
handleMoreInfo(open) {
this.setState({moreInfoExpanded: open});
}
handleTagsUpdated(tags) {
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
this.props.onTopicTagsUpdate(this.props.topic, tags);
}
}
handleContextMenu(params) {
var instance = this;
var topic = this.props.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);
}},
'member_delete',
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
];
this.props.showContextMenu({
topicName: this.props.topic,
x: params.x,
y: params.y,
user: params.topicName}, menuItems);
}
render() {
return (
{this.props.displayMobile ?
: null}
{this.state.showMemberPanel ?
:
this.state.showPermissionEditorFor ?
:
Name
Unknown}
readOnly={!this.state.owner}
value={this.state.fullName}
onFinished={this.handleFullNameUpdate} />
Private comment
Muted:
{this.state.moreInfoExpanded ?
Address:
{this.state.address}
{this.state.groupTopic ?
Your permissions:
{this.state.access}
:
Permissions:
Yours:
{this.state.access}
{this.state.fullName ? this.state.fullName : "Other"}′s:
{this.state.modeGiven2}
}
{this.state.sharer && (this.state.auth || this.state.anon) ?
Default access mode:
Auth: {this.state.admin ?
{this.state.auth}
:
{this.state.auth}
}
Anon: {this.state.admin ?
{this.state.anon}
:
{this.state.anon}
}
:
null
}
:
null
}
{this.state.owner ?
:
null
}
{this.state.owner ?
: null }
{this.state.groupTopic ?
:
}
}
);
}
};
/* END InfoView */
/* BEGIN Conversation panel */
class ChatMessage extends React.Component {
constructor(props) {
super(props);
this.state = {
progress: 0
};
if (props.uploader) {
props.uploader.onProgress = this.handleProgress.bind(this);
}
this.handlePreviewImage = this.handlePreviewImage.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
}
handlePreviewImage(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(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX });
}
handleProgress(ratio) {
this.setState({progress: ratio});
}
handleCancelUpload() {
this.props.uploader.cancel();
}
render() {
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 dim = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false);
attr.className = 'inline-image';
attr.style = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + '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( );
}, this);
content = React.createElement('span', null, Drafty.format(content, formatter, this));
} else if (typeof content != 'string') {
content = error_outline invalid content
}
return (
{this.props.userFrom && this.props.response ?
{fullDisplay ?
:
null}
:
null}
{fullDisplay ?
{this.props.userName}
: null}
);
}
};
/* Received/read indicator */
class ReceivedMarker extends React.PureComponent {
render() {
let timestamp;
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
timestamp = "sending ...";
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
timestamp = "failed";
} else {
timestamp = shortDateFormat(this.props.timestamp);
}
let marker = null;
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
marker = (access_time ); // watch face
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
marker = (error_outline ); // icon (!)
} else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
marker = (done ); // checkmark
} else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
marker = (done_all ); // double checkmark
} else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
marker = (done_all ); // blue double checkmark
}
return (
{timestamp}{'\u00a0'}{marker}
);
}
};
/* File uload/download progress indicator with a cancel inside */
class FileProgress extends React.PureComponent {
render() {
return (
{this.props.progress < 0.999 ?
close cancel
:
finishing...
}
);
}
}
class Attachment extends React.Component {
constructor(props) {
super(props);
this.state = {
downloader: null,
progress: 0
};
this.downloadFile = this.downloadFile.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
downloadFile(url, filename, mimetype) {
var downloader = this.props.tinode.getLargeFileHelper();
this.setState({downloader: downloader});
downloader.download(url, filename, mimetype, (loaded) => {
this.setState({progress: loaded / this.props.size});
}).then(() => {
this.setState({downloader: null, progress: 0});
}).catch((err) => {
if (err) {
this.props.onError("Error downloading file: " + err.message, 'err');
}
this.setState({downloader: null, progress: 0});
});
}
handleCancel() {
if (this.props.uploader) {
this.props.onCancelUpload();
} else if (this.state.downloader) {
this.state.downloader.cancel();
}
}
render() {
let filename = this.props.filename || 'file_attachment';
if (filename.length > 36) {
filename = filename.substr(0, 16) + '...' + filename.substr(-16);
}
let size = this.props.size > 0 ?
({bytesToHumanSize(this.props.size)}) :
null;
// Detect if the download URL is relative or absolute.
// If the URL is relative use LargeFileHelper to attach authentication
// credentials to the request.
let url, helperFunc;
if (!this.props.uploader && !this.state.downloader &&
!(/^(?:(?:[a-z]+:)?\/\/)/i.test(this.props.downloadUrl))) {
// Relative URL. Use download helper.
url = "javascript:;";
helperFunc = (e) => {
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
};
} else {
url = this.props.downloadUrl;
helperFunc = null;
}
return (
insert_drive_file
{filename} {size}
{this.props.uploader || this.state.downloader ?
:
}
);
}
};
const NOT_FOUND_TOPIC_TITLE = "Not found";
class MessagesView extends React.Component {
constructor(props) {
super(props);
this.state = {
messages: [],
onlineSubs: [],
topic: '',
title: '',
avatar: null,
scrollPosition: 0,
readOnly: props.acs ? !props.acs.isWriter() : true,
writeOnly: props.acs ? !props.acs.isReader() : true,
typingIndicator: false,
imagePreview: null
};
this.propsChange = this.propsChange.bind(this);
this.leave = this.leave.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this);
this.fetchMoreMessages = this.fetchMoreMessages.bind(this);
this.handleDescChange = this.handleDescChange.bind(this);
this.handleSubsUpdated = this.handleSubsUpdated.bind(this);
this.handleNewMessage = this.handleNewMessage.bind(this);
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
this.handleImagePreview = this.handleImagePreview.bind(this);
this.handleCloseImagePreview = this.handleCloseImagePreview.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
this.handleBackNavigation = this.handleBackNavigation.bind(this);
}
// Scroll last message into view on component update e.g. on message received.
componentDidUpdate(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;
}
}
componentDidMount() {
this.propsChange(this.props);
if (this.messagesScroller) {
this.messagesScroller.addEventListener('scroll', this.fetchMoreMessages);
}
}
componentWillUnmount() {
this.leave(this.state.topic);
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.fetchMoreMessages);
}
}
UNSAFE_componentWillReceiveProps(nextProps) {
this.propsChange(nextProps);
}
propsChange(props) {
if (!props || !props.topic) {
let oldTopic = this.state.topic;
this.setState({messages: [], onlineSubs: [], topic: null}, this.leave(oldTopic));
return;
}
if (!props.connected) {
// connection lost, clear online subs
this.setState({onlineSubs: []});
return;
}
let topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
let newGroupTopic = Tinode.isNewGroupTopicName(props.topic);
let tryToResubscribe = !this.props.connected && props.connected;
if (props.topic != this.state.topic) {
let msgs = [];
let subs = [];
// Bind the new topic to component.
topic.onData = this.handleNewMessage;
topic.onAllMessagesReceived = this.handleAllMessagesReceived;
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.state.topic);
this.handleDescChange(topic);
topic.subscribers((sub) => {
if (sub.online && sub.user != this.props.myUserId) {
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.
this.props.readTimerHandler(null);
}
this.setState({
readOnly: props.acs ? !props.acs.isWriter() : true,
writeOnly: props.acs ? !props.acs.isReader() : true
});
if (!topic.isSubscribed() && tryToResubscribe) {
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery()
.withLaterDesc()
.withLaterSub()
.withLaterData(MESSAGES_PAGE)
.withLaterDel();
let setQuery = newGroupTopic ? props.newGroupTopicParams : undefined;
// Show "loading" spinner.
this.setState({ fetchingMessages: true });
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
this.setState({topic: ctrl.topic});
this.props.onNewTopicCreated(props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
})
.catch((err) => {
this.props.onError(err.message, 'err');
this.setState({
title: NOT_FOUND_TOPIC_TITLE,
avatar: null,
readOnly: true,
writeOnly: true,
fetchingMessages: false,
messages: [],
onlineSubs: [],
typingIndicator: false,
imagePreview: null
});
});
}
}
leave(oldTopicName) {
if (!oldTopicName) {
return;
}
let oldTopic = this.props.tinode.getTopic(oldTopicName);
if (oldTopic && oldTopic.isSubscribed()) {
oldTopic.leave(false)
.catch(() => { /* do nothing here */ })
.finally(() => {
// We don't care if the request succeeded or failed.
// The topic is dead regardless.
this.setState({fetchingMessages: false});
oldTopic.onData = undefined;
oldTopic.onAllMessagesReceived = undefined;
oldTopic.onInfo = undefined;
oldTopic.onMetaDesc = undefined;
oldTopic.onSubsUpdated = undefined;
oldTopic.onPres = undefined;
});
}
}
handleScrollReference(node) {
if (node) {
node.addEventListener('scroll', this.fetchMoreMessages);
this.messagesScroller = node;
}
}
// Get older messages
fetchMoreMessages(event) {
if (event.target.scrollTop <= 0) {
let newState = {scrollPosition: event.target.scrollHeight - event.target.scrollTop};
this.setState((prevState, props) => {
if (!prevState.fetchingMessages) {
var topic = this.props.tinode.getTopic(this.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
newState.fetchingMessages = true;
topic.getMessagesPage(MESSAGES_PAGE).catch((err) => {
this.setState({fetchingMessages: false});
this.props.onError(err.message, 'err');
});
}
}
return newState;
});
}
}
handleDescChange(desc) {
if (desc.public) {
this.setState({
title: desc.public.fn,
avatar: makeImageUrl(desc.public.photo)
});
} else {
this.setState({
title: '',
avatar: null
});
}
if (desc.acs) {
this.setState({
readOnly: !desc.acs.isWriter(),
writeOnly: !desc.acs.isReader()
});
}
}
handleSubsUpdated() {
if (this.state.topic) {
var subs = [];
var topic = this.props.tinode.getTopic(this.state.topic);
topic.subscribers((sub) => {
if (sub.online && sub.user != this.props.myUserId) {
subs.push(sub);
}
});
this.setState({onlineSubs: subs});
}
}
handleNewMessage(msg) {
// Regenerate messages list
var topic = this.props.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) {
// 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 messages.
let status = topic.msgStatus(msg);
if (status >= Tinode.MESSAGE_STATUS_SENT) {
this.props.readTimerHandler(() => {
topic.noteRead(msg.seq);
});
}
this.props.onData(msg);
}
this.setState(newState);
}
handleAllMessagesReceived(count) {
this.setState({fetchingMessages: false});
}
handleInfoReceipt(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(content) {
this.setState({ imagePreview: content });
}
handleCloseImagePreview() {
this.setState({ imagePreview: null });
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
}
handleShowContextMenuMessage(params) {
params.topicName = this.state.topic;
var menuItems = ['message_delete'];
var topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
var acs = topic.getAccessMode();
if (acs && acs.isDeleter()) {
menuItems.push('message_delete_hard');
}
}
this.props.showContextMenu(params, menuItems);
}
handleBackNavigation() {
this.props.onHideMessagesView();
}
render() {
var component = null;
if (this.state.topic) {
var messageNodes = [];
var topic = this.props.tinode.getTopic(this.state.topic);
var groupTopic = topic.getType() == 'grp';
var previousFrom = null;
for (var i=0; i
);
}
let lastSeen = null;
let cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
if (cont && Tinode.topicType(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 = (
{this.props.displayMobile ?
arrow_back
:
null}
{this.state.title || Unknown }
{lastSeen}
{groupTopic ?
:
}
{this.props.displayMobile ?
: null}
{this.state.writeOnly ?
:
null
}
{this.state.imagePreview ?
: null}
);
} else {
component = (
);
}
return component;
}
};
/* Send message form */
class SendMessage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
message: '',
// Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
};
this.handlePasteEvent = this.handlePasteEvent.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleSend = this.handleSend.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleMessageTyping = this.handleMessageTyping.bind(this);
}
componentDidMount() {
this.pasteFile.addEventListener('paste', this.handlePasteEvent, false);
}
componentWillUnmount() {
this.pasteFile.removeEventListener('paste', this.handlePasteEvent, false)
}
handlePasteEvent(e) {
if (this.props.disabled) {
return;
}
// FIXME: handle large files too.
if (filePasted(e,
(bits, mime, width, height, fname) => {
this.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
(mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
},
this.props.onError)) {
// If a file was pasted, don't paste base64 data into input field.
e.preventDefault();
}
}
handleAttachImage(e) {
if (e.target.files && e.target.files.length > 0) {
let file = e.target.files[0];
// Check if the uploaded file is indeed an image and if it isn't too large.
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
// Convert image for size or format.
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
// Success
(bits, mime, width, height, fname) => {
this.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
} else {
// Image can be uploaded as is. No conversion is needed.
imageFileToBase64(file,
// Success
(bits, mime, width, height, fname) => {
this.props.sendMessage(Drafty.insertImage(null,
0, mime, bits, width, height, fname));
},
// Failure
(err) => {
this.props.onError(err, 'err');
}
);
}
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleAttachFile(e) {
if (e.target.files && e.target.files.length > 0) {
var file = e.target.files[0];
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
// Too large.
this.props.onError("The file size " + bytesToHumanSize(file.size) +
" exceeds the " + bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE) + " limit.", 'err');
} else if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
// Too large to send inband - uploading out of band and sending as a link.
let uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError("Cannot initiate file upload.");
return;
}
// Format data and initiate upload.
let uploadCompletionPromise = uploader.upload(file);
let msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
// Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
fileToBase64(file,
(mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
},
this.props.onError
);
}
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleSend() {
var message = this.state.message.trim();
if (message) {
this.props.sendMessage(this.state.message.trim());
this.setState({message: ''});
}
}
/* Send on Enter key */
handleKeyPress(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(e) {
var newState = {message: e.target.value};
var now = new Date().getTime();
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
var topic = this.props.tinode.getTopic(this.props.topic);
if (topic.isSubscribed()) {
topic.noteKeyPress();
}
newState.keypressTimestamp = now;
}
this.setState(newState);
}
render() {
var prompt = this.props.disabled ? "Messaging disabled" : "New message";
var instance = this;
return (
{this.props.disabled ?
photo :
photo
}
{this.props.disabled ?
attach_file :
attach_file
}
{this.props.disabled ?
send :
send }
);
}
};
/* This is just a static page to display when no conversation is selected. */
class LogoView extends React.PureComponent {
render() {
var version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
return (
Tinode Web
Client: {version}
Server: {this.props.serverVersion} ({this.props.serverAddress})
);
}
};
class ImagePreview extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
this.setState({
width: this.container.clientWidth,
height: this.container.clientHeight
});
}
render() {
if (!this.props.content) {
return null;
}
var instance = this;
var dim = fitImageSize(this.props.content.width, this.props.content.height,
this.state.width, this.state.height, false);
var size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
size.maxWidth = '100%';
size.maxHeight = '100%';
var filename = this.props.content.filename;
var maxlength = (this.props.content.width / REM_SIZE) | 0;
if (filename.length > maxlength) {
filename = filename.slice(0, maxlength-2) + '...' + filename.slice(2-maxlength);
}
return (
);
}
};
/* END Conversation panel */
/* The top-level class to hold all fuinctionality together */
class TinodeWeb extends React.Component {
constructor(props) {
super(props);
this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this);
this.handleHashRoute = this.handleHashRoute.bind(this);
this.handleOnline = this.handleOnline.bind(this);
this.handleAppVisibility = this.handleAppVisibility.bind(this);
this.handleReadTimer = this.handleReadTimer.bind(this);
this.handleVisibilityEvent = this.handleVisibilityEvent.bind(this);
this.handleError = this.handleError.bind(this);
this.handleLoginRequest = this.handleLoginRequest.bind(this);
this.handleConnected = this.handleConnected.bind(this);
this.doLogin = this.doLogin.bind(this);
this.handleCredentialsRequest = this.handleCredentialsRequest.bind(this);
this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this);
this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
this.tnMeSubsUpdated = this.tnMeSubsUpdated.bind(this);
this.resetContactList = this.resetContactList.bind(this);
this.tnData = this.tnData.bind(this);
this.tnInitFind = this.tnInitFind.bind(this);
this.tnFndSubsUpdated = this.tnFndSubsUpdated.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleTopicSelected = this.handleTopicSelected.bind(this);
this.handleHideMessagesView = this.handleHideMessagesView.bind(this);
this.handleSendMessage = this.handleSendMessage.bind(this);
this.handleNewAccount = this.handleNewAccount.bind(this);
this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
this.handleUpdateAccountRequest = this.handleUpdateAccountRequest.bind(this);
this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
this.handleSettings = this.handleSettings.bind(this);
this.handleGlobalSettings = this.handleGlobalSettings.bind(this);
this.handleToggleMessageSounds = this.handleToggleMessageSounds.bind(this);
this.initDesktopAlerts = this.initDesktopAlerts.bind(this);
this.togglePushToken = this.togglePushToken.bind(this);
this.requestPushToken = this.requestPushToken.bind(this);
this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
this.handleNewTopic = this.handleNewTopic.bind(this);
this.handleNewTopicRequest = this.handleNewTopicRequest.bind(this);
this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
this.handleChangePermissions = this.handleChangePermissions.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
this.handleLogout = this.handleLogout.bind(this);
this.handleLeaveUnsubRequest = this.handleLeaveUnsubRequest.bind(this);
this.handleDialogCancel = this.handleDialogCancel.bind(this);
this.handleShowContextMenu = this.handleShowContextMenu.bind(this);
this.defaultTopicContextMenu = this.defaultTopicContextMenu.bind(this);
this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
this.handleShowInfoView = this.handleShowInfoView.bind(this);
this.handleHideInfoView = this.handleHideInfoView.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
this.handleResetPassword = this.handleResetPassword.bind(this);
}
getBlankState() {
let settings = localStorage.getObject("settings") || {};
return {
connected: false,
transport: settings.transport || null,
serverAddress: settings.serverAddress || detectServerAddress(),
serverVersion: "no connection",
// "On" is the default, so saving the "off" state.
messageSounds: !settings.messageSoundsOff,
desktopAlerts: settings.desktopAlerts,
desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
(typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
(typeof FIREBASE_INIT != 'undefined'),
firebaseToken: localStorage.getObject("firebase-token"),
errorText: '',
errorLevel: null,
sidePanelSelected: 'login',
sidePanelTitle: null,
sidePanelAvatar: null,
dialogSelected: null,
contextMenuVisible: false,
login: '',
password: '',
myUserId: null,
liveConnection: navigator.onLine,
topicSelected: '',
topicSelectedOnline: false,
topicSelectedAcs: null,
newGroupTopicParams: null,
loginDisabled: false,
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
showInfoPanel: false,
mobilePanel: 'sidepanel',
contextMenuVisible: false,
contextMenuBounds: null,
contextMenuClickAt: null,
contextMenuParams: null,
contextMenuItems: [],
// Chats as shown in the ContactsView
chatList: [],
// Contacts returned by a search query.
searchResults: [],
// Merged results of a search query and p2p chats.
searchableContacts: [],
credMethod: undefined,
credCode: undefined
};
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
window.addEventListener('online', (e) => { this.handleOnline(true); });
window.addEventListener('offline', (e) => { this.handleOnline(false); });
window.addEventListener('hashchange', this.handleHashRoute);
// Window/tab visible or invisible for pausing timers.
document.addEventListener('visibilitychange', this.handleVisibilityEvent);
this.setState({viewportWidth: document.documentElement.clientWidth});
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport);
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
// Initialize desktop alerts.
if (this.state.desktopAlertsEnabled) {
try {
this.fbPush = firebase.initializeApp(FIREBASE_INIT, APP_NAME).messaging();
this.fbPush.usePublicVapidKey(FIREBASE_INIT.messagingVapidKey);
navigator.serviceWorker.register('/service-worker.js').then((reg) => {
this.fbPush.useServiceWorker(reg);
this.initDesktopAlerts();
if (this.state.desktopAlerts) {
if (!this.state.firebaseToken) {
this.togglePushToken(true);
} else {
this.tinode.setDeviceToken(this.state.firebaseToken);
}
}
}).catch((err) => {
// registration failed :(
console.log("Failed to register service worker:", err);
});
} catch (err) {
this.handleError("Failed to initialize push notifications", 'err');
console.log("Failed to initialize push notifications", err);
this.setState({desktopAlertsEnabled: false});
}
}
let token = localStorage.getObject('keep-logged-in') ?
localStorage.getObject('auth-token') : undefined;
let parsedNav = parseUrlHash(window.location.hash);
if (token) {
// When reading from storage, date is returned as string.
token.expires = new Date(token.expires);
this.tinode.setAuthToken(token);
this.tinode.connect().catch((err) => {
// Socket error
this.handleError(err.message, 'err');
});
delete parsedNav.params.info;
delete parsedNav.params.tab;
parsedNav.path[0] = '';
navigateTo(composeUrlHash(parsedNav.path, parsedNav.params));
} else if (!parsedNav.params.token) {
navigateTo('');
}
this.readTimer = null;
this.readTimerCallback = null;
this.handleHashRoute();
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('hashchange', this.handleHashRoute);
document.removeEventListener('visibilitychange', this.handleVisibilityEvent);
}
// Setup transport (usually websocket) and server address. This will terminate connection with the server.
static tnSetup(serverAddress, transport) {
let tinode = new Tinode(APP_NAME, serverAddress, API_KEY, transport, isSecureConnection());
tinode.enableLogging(true, true);
return tinode;
}
handleResize() {
var mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
this.setState({viewportWidth: document.documentElement.clientWidth});
if (this.state.displayMobile != mobile) {
this.setState({displayMobile: mobile});
}
}
// Handle for hashchange event: display appropriate panels.
handleHashRoute() {
var hash = parseUrlHash(window.location.hash);
if (hash.path && hash.path.length > 0) {
// Left-side panel selector.
if (['register','settings','edit','cred','reset','newtpk','contacts',''].includes(hash.path[0])) {
this.setState({sidePanelSelected: hash.path[0]});
} else {
console.log("Unknown sidepanel view", hash.path[0]);
}
// Topic for MessagesView selector.
if (hash.path.length > 1 && hash.path[1] != this.state.topicSelected) {
this.setState({
topicSelected: Tinode.topicType(hash.path[1]) ? hash.path[1] : null
});
}
} else {
// Empty hashpath
this.setState({sidePanelSelected: ''});
}
// Save validation credentials, if available.
if (hash.params.method) {
this.setState({ credMethod: hash.params.method });
}
if (hash.params.code) {
this.setState({ credCode: hash.params.code });
}
// Additional parameters of panels.
this.setState({
showInfoPanel: hash.params.info,
newTopicTabSelected: hash.params.tab
});
}
handleOnline(online) {
var newState = {liveConnection: online};
if (online) {
this.handleError('', null);
} else {
this.handleError("No connection", 'warn');
}
this.setState({liveConnection: online});
}
// Handling read notifications here to be able to pause
// them then the window/tab is not visible.
handleAppVisibility(visible, callback) {
clearTimeout(this.readTimer);
this.readTimerCallback = callback;
if (visible && callback) {
this.readTimer = setTimeout(callback, READ_DELAY);
} else {
this.readTimer = null;
}
}
handleReadTimer(callback) {
this.handleAppVisibility(!document.hidden, callback);
}
handleVisibilityEvent() {
this.handleAppVisibility(!document.hidden, this.readTimerCallback);
}
handleError(err, level) {
this.setState({errorText: err, errorLevel: level});
}
// User clicked Login button in the side panel.
handleLoginRequest(login, password) {
this.setState({loginDisabled: true, login: login, password: password});
this.handleError('', null);
if (this.tinode.isConnected()) {
this.doLogin(login, password, {meth: this.state.credMethod, resp: this.state.credCode});
} else {
this.tinode.connect().catch((err) => {
// Socket error
this.setState({loginDisabled: false});
this.handleError(err.message, 'err');
});
}
}
// Connection succeeded.
handleConnected() {
var params = this.tinode.getServerInfo();
this.setState({
serverVersion: params.ver + ' ' + (params.build ? params.build : 'none') + '; '
});
this.doLogin(this.state.login, this.state.password, {meth: this.state.credMethod, resp: this.state.credCode});
}
doLogin(login, password, cred) {
if (this.tinode.isAuthenticated()) {
// Already logged in. Go to default screen.
navigateTo("");
return;
}
// Sanitize and package credentail.
cred = Tinode.credential(cred);
// Try to login with login/password. If they are not available, try token. If no token, ask for login/password.
let promise = null;
let token = this.tinode.getAuthToken();
if (login && password) {
this.setState({password: null});
promise = this.tinode.loginBasic(login, password, cred);
} else if (token) {
promise = this.tinode.loginToken(token.token, cred);
}
if (promise) {
promise.then((ctrl) => {
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
if (cred) {
this.handleError("Code does not match", 'warn');
}
this.handleCredentialsRequest(ctrl.params);
} else {
this.handleLoginSuccessful();
}
}).catch((err) => {
// Login failed, report error.
this.setState({loginDisabled: false, credMethod: undefined, credCode: undefined});
this.handleError(err.message, 'err');
localStorage.removeItem('auth-token');
navigateTo('');
});
} else {
// No login credentials provided.
// Make sure we are on the login page.
navigateTo('');
this.setState({loginDisabled: false});
}
}
handleCredentialsRequest(params) {
var parsed = parseUrlHash(window.location.hash);
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
navigateTo(composeUrlHash(parsed.path, parsed.params));
}
handleLoginSuccessful() {
this.handleError('', null);
// Refresh authentication token.
if (localStorage.getObject('keep-logged-in')) {
localStorage.setObject('auth-token', this.tinode.getAuthToken());
}
// Logged in fine, subscribe to 'me' attaching callbacks from the contacts view.
var me = this.tinode.getMeTopic();
me.onMetaDesc = this.tnMeMetaDesc;
me.onContactUpdate = this.tnMeContactUpdate;
me.onSubsUpdated = this.tnMeSubsUpdated;
this.setState({
connected: true,
credMethod: undefined,
credCode: undefined,
myUserId: this.tinode.getCurrentUserID()
});
// Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched.
me.subscribe(
me.startMetaQuery().
withLaterSub().
withDesc().
build()
).catch((err) => {
localStorage.removeItem('auth-token');
this.handleError(err.message, 'err');
navigateTo('');
});
navigateTo(setUrlSidePanel(window.location.hash, 'contacts'));
}
handleDisconnect(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(desc) {
if (desc && desc.public) {
this.setState({
sidePanelTitle: desc.public.fn,
sidePanelAvatar: makeImageUrl(desc.public.photo)
});
}
}
// Reactions to updates to the contact list.
tnMeContactUpdate(what, cont) {
if (what == 'on' || what == 'off') {
this.resetContactList();
if (this.state.topicSelected == cont.topic) {
this.setState({topicSelectedOnline: (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.state.topicSelected != cont.topic) {
if (this.state.messageSounds) {
POP_SOUND.play();
}
this.resetContactList();
} else if (document.hidden && this.state.messageSounds) {
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.state.topicSelected == cont.topic) {
this.handleTopicSelected(null);
}
// Redraw without the deleted topic.
this.resetContactList();
} else if (what == 'acs') {
// Permissions changed. If it's for the currently selected topic,
// update the views.
if (this.state.topicSelected == cont.topic) {
this.setState({topicSelectedAcs: cont.acs});
}
} else if (what == 'del') {
// messages deleted (hard or soft) -- update pill counter.
} else {
// TODO(gene): handle other types of notifications:
// * ua -- user agent changes (maybe display a pictogram for mobile/desktop).
// * upd -- topic 'public' updated, issue getMeta().
console.log("Unsupported (yet) presence update:" + what + " in: " + cont.topic);
}
}
tnMeSubsUpdated(unused) {
this.resetContactList();
}
// Merge search results and contact list to create a single flat
// list of kown contacts for GroupManager to use.
static prepareSearchableContacts(chatList, foundContacts) {
let merged = {};
// For chatList topics merge only p2p topics and convert them to the
// same format as foundContacts.
for (const c of chatList) {
if (Tinode.topicType(c.topic) == 'p2p') {
merged[c.topic] = {
user: c.topic,
updated: c.updated,
public: c.public,
private: c.private,
acs: c.acs
};
}
}
// Add all foundCountacts if they have not been added already.
for (const c of foundContacts) {
if (!merged[c.user]) {
merged[c.user] = c;
}
}
return Object.values(merged);
}
resetContactList() {
let newState = {
chatList: []
};
this.tinode.getMeTopic().contacts((c) => {
newState.chatList.push(c);
if (this.state.topicSelected == c.topic) {
newState.topicSelectedOnline = c.online;
newState.topicSelectedAcs = c.acs;
}
});
// Merge search results and chat list.
newState.searchableContacts = TinodeWeb.prepareSearchableContacts(newState.chatList, this.state.searchResults);
this.setState(newState);
}
// Sending "received" notifications
tnData(data) {
let topic = this.tinode.getTopic(data.topic);
if (topic.msgStatus(data) >= Tinode.MESSAGE_STATUS_SENT) {
clearTimeout(this.receivedTimer);
this.receivedTimer = setTimeout(() => {
this.receivedTimer = undefined;
topic.noteRecv(data.seq);
}, RECEIVED_DELAY);
}
}
/* Fnd topic: find contacts by tokens */
tnInitFind() {
let fnd = this.tinode.getFndTopic();
if (fnd.isSubscribed()) {
this.tnFndSubsUpdated();
} else {
fnd.onSubsUpdated = this.tnFndSubsUpdated;
fnd.subscribe(fnd.startMetaQuery().withSub().withTags().build()).catch((err) => {
this.handleError(err.message, 'err');
});
}
}
tnFndSubsUpdated() {
let foundContacts = [];
// Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
this.tinode.getFndTopic().contacts((s) => {
foundContacts.push(s);
});
this.setState({
searchResults: foundContacts,
searchableContacts: TinodeWeb.prepareSearchableContacts(this.state.chatList, foundContacts)
});
}
/** 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(query) {
var fnd = this.tinode.getFndTopic();
fnd.setMeta({desc: {public: query}}).then((ctrl) => {
return fnd.getMeta(fnd.startMetaQuery().withSub().build());
}).catch((err) => {
this.handleError(err.message, 'err');
});
}
// User clicked on a contact in the side panel or deleted a contact.
handleTopicSelected(topicName, unused_index, online, acs) {
if (topicName) {
this.setState({
errorText: '',
errorLevel: null,
mobilePanel: 'topic-view',
showInfoPanel: false
});
// Different contact selected.
if (this.state.topicSelected != topicName) {
this.setState({
topicSelectedOnline: online,
topicSelectedAcs: acs
});
navigateTo(setUrlTopic('', topicName));
}
} else {
// Currently selected contact deleted
this.setState({
errorText: '',
errorLevel: null,
mobilePanel: 'sidepanel',
topicSelectedOnline: false,
topicSelectedAcs: null,
showInfoPanel: false
});
if (this.state.topicSelected) {
navigateTo(setUrlTopic('', null));
}
}
}
// In mobile view user requested to show sidepanel
handleHideMessagesView() {
this.setState({
mobilePanel: 'sidepanel'
});
navigateTo(setUrlTopic(window.location.hash, null));
}
// User is sending a message, either plain text or a drafty object, possibly
// with attachments.
handleSendMessage(msg, promise, uploader) {
let topic = this.tinode.getTopic(this.state.topicSelected);
msg = topic.createMessage(msg, false);
// The uploader is used to show progress.
msg._uploader = uploader;
if (!topic.isSubscribed()) {
if (!promise) {
promise = Promise.resolve();
}
promise = promise.then(() => { return topic.subscribe(); });
}
if (promise) {
promise = promise.catch((err) => {
this.handleError(err.message, 'err');
});
}
topic.publishDraft(msg, promise)
.catch((err) => {
this.handleError(err.message, 'err');
});
}
// User chose a Sign Up menu item.
handleNewAccount() {
navigateTo(setUrlSidePanel(window.location.hash, 'register'));
}
// Actual registration of a new account.
handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
this.tinode.connect(this.state.serverAddress)
.then(() => {
return this.tinode.createAccountBasic(login_, password_,
{public: public_, tags: tags_, cred: Tinode.credential(cred_)});
}).then((ctrl) => {
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
this.handleCredentialsRequest(ctrl.params);
} else {
this.handleLoginSuccessful(this);
}
}).catch((err) => {
this.handleError(err.message, 'err');
});
}
handleUpdateAccountRequest(password, pub, defacs) {
if (pub || defacs) {
this.tinode.getMeTopic().setMeta({desc: {public: pub, defacs: defacs}}).catch((err) => {
this.handleError(err.message, 'err');
});
}
if (password) {
this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
this.handleError(err.message, 'err');
});
}
}
handleUpdateAccountTagsRequest(tags) {
this.tinode.getFndTopic().setMeta({tags: tags})
.catch((err) => {
this.handleError(err.message, 'err');
});
}
// User chose Settings menu item.
handleSettings() {
navigateTo(setUrlSidePanel(window.location.hash, this.state.myUserId ? 'edit' : 'settings'));
}
// User updated global parameters.
handleGlobalSettings(settings) {
let serverAddress = settings.serverAddress || this.state.serverAddress;
let transport = settings.transport || this.state.transport;
if (this.tinode) {
this.tinode.onDisconnect = undefined;
this.tinode.disconnect();
}
this.tinode = TinodeWeb.tnSetup(serverAddress, transport);
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
this.setState({
serverAddress: serverAddress,
transport: transport
});
localStorage.setObject('settings', {
serverAddress: serverAddress,
transport: transport
});
navigateTo(setUrlSidePanel(window.location.hash, ''));
}
// Initialize desktop alerts = push notifications.
initDesktopAlerts() {
// Google could not be bothered to mention that
// onTokenRefresh is never called.
this.fbPush.onTokenRefresh(() => {
this.requestPushToken(true);
});
this.fbPush.onMessage((payload) => {
// No need to do anything about it.
// All the magic happends in the service worker.
});
}
togglePushToken(enabled) {
if (enabled) {
if (!this.state.firebaseToken) {
this.fbPush.requestPermission().then(() => {
this.requestPushToken(true);
}).catch((err) => {
this.handleError(err.message, 'err');
this.setState({desktopAlerts: false, firebaseToken: null});
localStorage.updateObject('settings', {desktopAlerts: false});
console.log("Failed to get permission to notify.", err);
});
} else {
this.setState({desktopAlerts: true});
localStorage.updateObject('settings', {desktopAlerts: true});
}
} else if (this.state.firebaseToken) {
this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
console.log("Unable to delete token.", err);
}).finally(() => {
localStorage.updateObject('settings', {desktopAlerts: false});
localStorage.removeItem('firebase-token');
this.setState({desktopAlerts: false, firebaseToken: null});
});
} else {
this.setState({desktopAlerts: false, firebaseToken: null});
localStorage.updateObject('settings', {desktopAlerts: false});
}
}
requestPushToken(sendToServer) {
this.fbPush.getToken().then((refreshedToken) => {
if (refreshedToken != this.state.firebaseToken) {
this.tinode.setDeviceToken(refreshedToken, sendToServer);
localStorage.setObject('firebase-token', refreshedToken);
}
this.setState({firebaseToken: refreshedToken, desktopAlerts: true});
localStorage.updateObject('settings', {desktopAlerts: true});
}).catch((err) => {
this.handleError(err.message, 'err');
console.log("Failed to retrieve firebase token", err);
});
}
handleToggleMessageSounds(enabled) {
this.setState({messageSounds: enabled});
localStorage.updateObject('settings', {
messageSoundsOff: !enabled
});
}
// User clicked Cancel button in Setting or Sign Up panel.
handleSidepanelCancel() {
var parsed = parseUrlHash(window.location.hash);
parsed.path[0] = this.state.myUserId ? 'contacts' : '';
if (parsed.params) {
delete parsed.params.code;
delete parsed.params.method;
delete parsed.params.tab;
}
navigateTo(composeUrlHash(parsed.path, parsed.params));
this.setState({errorText: '', errorLevel: null});
}
// User clicked a (+) menu item.
handleNewTopic() {
navigateTo(setUrlSidePanel(window.location.hash, 'newtpk'));
}
// Request to start a new topic. New P2P topic requires peer's name.
handleNewTopicRequest(peerName, pub, priv, tags) {
var topicName = peerName || this.tinode.newGroupTopicName();
if (!peerName) {
this.setState(
{newGroupTopicParams: {desc: {public: pub, private: {comment: priv}}, tags: tags}},
() => {this.handleTopicSelected(topicName)}
);
} else {
this.handleTopicSelected(topicName);
}
}
// New topic was creted, here is the new topic name.
handleNewTopicCreated(oldName, newName) {
if (this.state.topicSelected == oldName && oldName != newName) {
// If the current URl contains the old topic name, replace it with new.
// Update the name of the selected topic first so the navigator doen't clear
// the state.
this.setState({topicSelected: newName}, () => { navigateTo(setUrlTopic('', newName)); });
}
}
handleTopicUpdateRequest(topicName, pub, priv, permissions) {
var topic = this.tinode.getTopic(topicName);
if (topic) {
var params = {};
if (pub) {
params.public = pub;
}
if (priv) {
params.private = {comment: priv};
}
if (permissions) {
params.defacs = permissions;
}
topic.setMeta({desc: params}).catch((err) => {
this.handleError(err.message, 'err');
});
}
}
handleChangePermissions(topicName, mode, uid) {
var topic = this.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((err) => {
this.handleError(err.message, 'err');
});
}
}
handleTagsUpdated(topicName, tags) {
var topic = this.tinode.getTopic(topicName);
if (topic) {
var instance = this;
topic.setMeta({tags: tags}).catch((err) => {
this.handleError(err.message, 'err');
});
}
}
handleLogout() {
updateFavicon(0);
localStorage.removeItem('auth-token');
if (this.tinode) {
this.tinode.onDisconnect = undefined;
this.tinode.disconnect();
}
this.setState(this.getBlankState());
this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport);
this.tinode.onConnect = this.handleConnected;
this.tinode.onDisconnect = this.handleDisconnect;
navigateTo('');
}
handleLeaveUnsubRequest(topicName) {
var topic = this.tinode.getTopic(topicName);
if (!topic) {
return;
}
topic.leave(true).then((ctrl) => {
// Hide MessagesView and InfoView panels.
navigateTo(setUrlTopic(window.location.hash, ''));
}).catch((err) => {
this.handleError(err.message, 'err');
});
}
handleDialogCancel() {
this.setState({dialogSelected: null});
}
handleShowContextMenu(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(topicName) {
var muted = false, blocked = false, subscribed = false, deleter = false;
var topic = this.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 ? 'messages_clear' : null,
subscribed && deleter ? 'messages_clear_hard' : null,
subscribed ? (muted ? 'topic_unmute' : 'topic_mute') : null,
subscribed ? (blocked ? 'topic_unblock' : 'topic_block') : null,
'topic_delete'
];
}
handleHideContextMenu() {
this.setState({
contextMenuVisible: false,
contextMenuClickAt: null,
contextMenuParams: null,
contextMenuBounds: null
});
}
handleShowInfoView() {
navigateTo(addUrlParam(window.location.hash, 'info', true));
this.setState({showInfoPanel: true});
}
handleHideInfoView() {
navigateTo(removeUrlParam(window.location.hash, 'info'));
this.setState({showInfoPanel: false});
}
handleMemberUpdateRequest(topicName, added, removed) {
if (!topicName) {
return;
}
var topic = this.tinode.getTopic(topicName);
if (!topic) {
return;
}
if (added && added.length > 0) {
added.map((uid) => {
topic.invite(uid, null).catch((err) => {
this.handleError(err.message, 'err');
});
});
}
if (removed && removed.length > 0) {
removed.map((uid) => {
topic.delSubscription(uid).catch((err) => {
this.handleError(err.message, 'err');
});
});
}
}
handleValidateCredentialsRequest(cred, code) {
this.setState({credMethod: cred, credCode: code});
this.doLogin(null, null, {meth: cred, resp: code});
}
handlePasswordResetRequest(method, value) {
// If already connected, connnect() will return a resolved promise.
this.tinode.connect()
.then(() => {
return this.tinode.requestResetAuthSecret('basic', method, value)
})
.catch((err) => {
// Socket error
this.handleError(err.message, 'err');
});
}
handleResetPassword(scheme, newPassword, token) {
token = base64ReEncode(token);
if (!token) {
this.handleError("Invalid security token", 'err');
} else {
this.tinode.connect()
.then(() => {
return this.tinode.updateAccountBasic(null, null, newPassword, {token: token});
})
.catch((err) => {
// Socket error
this.handleError(err.message, 'err');
});
}
}
render() {
return (
{this.state.contextMenuVisible ?
:
null
}
{this.state.showInfoPanel ?
:
null
}
);
}
};
module.exports = TinodeWeb;