2943 lines
100 KiB
HTML
2943 lines
100 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Tinode example: chat web application in react.js</title>
|
|
<link rel="shortcut icon" href="img/logo32x32.png" />
|
|
<!-- Browser styling for Chrome, FF, Opera -->
|
|
<meta name="theme-color" content="#3949AB">
|
|
<!-- Google's Roboto font -->
|
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700&subset=latin,cyrillic"
|
|
rel="stylesheet" type="text/css" />
|
|
<!-- Google's material design icons -->
|
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
|
<!-- Styles for local elements -->
|
|
<link rel="stylesheet" href="css/base.css" />
|
|
<!-- ReactJs scripts -->
|
|
<script src="https://unpkg.com/react@15.4/dist/react.js"></script>
|
|
<script src="https://unpkg.com/react-dom@15.4/dist/react-dom.js"></script>
|
|
<script src="https://unpkg.com/babel-standalone@6.24.0/babel.min.js"></script>
|
|
<script src="tinode.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="mountPoint"></div>
|
|
<script type="text/babel">
|
|
// Name of this application, used in the User-Agent
|
|
var APP_NAME = "TinodeWeb/0.13";
|
|
|
|
var KNOWN_HOSTS = {hosted: "api.tinode.co", local: "localhost:6060"};
|
|
// Default host name and port to connect to.
|
|
var DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
|
// var DEFAULT_HOST = KNOWN_HOSTS.local;
|
|
|
|
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
|
|
var API_KEY = "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K";
|
|
|
|
// Minimum time between two keypress notifications, milliseconds.
|
|
var KEYPRESS_DELAY = 5*1000;
|
|
|
|
// Delay before sending a {note} for reciving a message, milliseconds.
|
|
var RECEIVED_DELAY = 500;
|
|
|
|
// Delay before sending a read notification, milliseconds.
|
|
var READ_DELAY = 1000;
|
|
|
|
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
|
|
// in @meadia (max-size: 640px) in base.css
|
|
var MEDIA_BREAKPOINT = 640;
|
|
// Size of rem unit in pixels. Default 1rem = 10pt = 13px.
|
|
var REM_SIZE = 13;
|
|
|
|
// Short representation of time in the past.
|
|
function shortDateFormat(then) {
|
|
var locale = window.navigator.userLanguage || window.navigator.language;
|
|
var now = new Date();
|
|
if (then.getFullYear() == now.getFullYear()) {
|
|
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
|
|
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
|
|
} else {
|
|
return then.toLocaleDateString(locale,
|
|
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
|
|
}
|
|
}
|
|
return then.toLocaleDateString(locale,
|
|
{hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
|
|
}
|
|
|
|
// Make a data URL from public.photo
|
|
function makeImageUrl(photo) {
|
|
//console.log("makeImageUrl");
|
|
//console.log(photo);
|
|
return (photo && photo.type && photo.data) ?
|
|
'data:image/' + photo.type + ';base64,' + photo.data : null;
|
|
}
|
|
|
|
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
|
|
// as Java's String#hash().
|
|
function stringHash(value) {
|
|
var hash = 0;
|
|
value = "" + value;
|
|
for (var i = 0; i < value.length; i++) {
|
|
hash = ((hash<<5)-hash) + value.charCodeAt(i);
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
// Detect server address from the URL
|
|
function detectServerAddress() {
|
|
var host = DEFAULT_HOST;
|
|
if (window.location.protocol === 'file:' || window.location.hostname === 'localhost') {
|
|
host = KNOWN_HOSTS.local;
|
|
} else if (window.location.hostname) {
|
|
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
|
}
|
|
return host;
|
|
}
|
|
|
|
// Create VCard which represents topic 'public' info
|
|
function vcard(fn, imageDataUrl) {
|
|
var card = null;
|
|
|
|
if ((fn && fn.trim()) || imageDataUrl) {
|
|
card = {};
|
|
if (fn) {
|
|
card.fn = fn.trim();
|
|
}
|
|
if (imageDataUrl) {
|
|
var dataStart = imageDataUrl.indexOf(",");
|
|
card.photo = {
|
|
data: imageDataUrl.substring(dataStart+1),
|
|
type: "jpg"
|
|
};
|
|
}
|
|
}
|
|
return card;
|
|
}
|
|
|
|
// Construct a user discovery tag
|
|
function to_tags(tagsObj) {
|
|
if(tagsObj && Object.keys(tagsObj).length > 0) {
|
|
var tags = [];
|
|
for(var key in tagsObj) {
|
|
tags.push(key + ":" + tagsObj[key]);
|
|
}
|
|
return tags;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/* BEGIN Popup/dropdown menu */
|
|
var ContextMenu = React.createClass({
|
|
componentWillUnmount: function() {
|
|
this.toggle(false);
|
|
},
|
|
componentWillReceiveProps: function(nextProps) {
|
|
this.toggle(nextProps.visible);
|
|
},
|
|
toggle: function(visible) {
|
|
if (visible) {
|
|
document.addEventListener('mousedown', this.handlePageClick, false);
|
|
document.addEventListener('keyup', this.handleEscapeKey, false);
|
|
} else {
|
|
document.removeEventListener('mousedown', this.handlePageClick, false);
|
|
document.removeEventListener('keyup', this.handleEscapeKey, false);
|
|
}
|
|
},
|
|
handlePageClick: function(e) {
|
|
if (ReactDOM.findDOMNode(this).contains(e.target)) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.props.hide();
|
|
},
|
|
handleEscapeKey: function(e) {
|
|
if (e.keyCode === 27) {
|
|
this.props.hide();
|
|
}
|
|
},
|
|
handleClick: function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.props.hide();
|
|
this.props.onAction(this.props.topic, this.props.items[e.currentTarget.dataset.id]);
|
|
},
|
|
|
|
render: function() {
|
|
if (!this.props.visible) {
|
|
return null;
|
|
}
|
|
|
|
// Ensure that menu is inside the app-container.
|
|
var hSize = 12 * REM_SIZE;
|
|
var vSize = REM_SIZE * (0.7 + this.props.items.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"
|
|
};
|
|
|
|
var count = 0;
|
|
var instance = this;
|
|
var menu = [];
|
|
this.props.items.map(function(item) {
|
|
menu.push(
|
|
item === "-" ?
|
|
<li className="separator" key={count} />
|
|
:
|
|
<li onClick={instance.handleClick} data-id={count} key={count}>{item}</li>
|
|
);
|
|
count++;
|
|
});
|
|
return (
|
|
<ul className="menu" style={position}>
|
|
{menu}
|
|
</ul>
|
|
);
|
|
}
|
|
});
|
|
/* END Popup/dropdown menu */
|
|
|
|
/* The X menu to be displayed in title bars */
|
|
var MenuCancel = React.createClass({
|
|
render: function() {
|
|
return (
|
|
<a href="#" onClick={this.props.onCancel}><i className="material-icons">close</i></a>
|
|
);
|
|
}
|
|
});
|
|
|
|
/* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */
|
|
class LetterTile extends React.PureComponent {
|
|
render() {
|
|
var avatar;
|
|
if (this.props.avatar === true) {
|
|
if (this.props.topic && this.props.title && this.props.title.trim()) {
|
|
var letter = this.props.title.trim().charAt(0);
|
|
var color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16);
|
|
avatar = (<div className={color}><div>{letter}</div></div>)
|
|
} else {
|
|
avatar = (Tinode.getTopicType(this.props.topic) === "grp") ?
|
|
<i className="material-icons">group</i> :
|
|
<i className="material-icons">person</i>;
|
|
}
|
|
} else if (this.props.avatar) {
|
|
avatar = <img className="avatar" alt="avatar" src={this.props.avatar} />;
|
|
} else {
|
|
avatar = null;
|
|
}
|
|
return avatar;
|
|
}
|
|
}
|
|
|
|
|
|
/* END Lettertile */
|
|
|
|
/* BEGIN In-place text editor. Shows text with an icon
|
|
* which toggles it into an input field */
|
|
var InPlaceEdit = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
active: this.props.state,
|
|
text: this.props.text || "",
|
|
changed: false
|
|
};
|
|
},
|
|
|
|
handeTextChange: function(e) {
|
|
this.setState({text: e.target.value});
|
|
},
|
|
|
|
handleKeyDown: function(e) {
|
|
if (e.keyCode === 27) {
|
|
// Escape pressed
|
|
this.setState({text: this.props.text, active: false});
|
|
} else if (e.keyCode === 13) {
|
|
// Enter pressed
|
|
this.handleEditingFinished();
|
|
}
|
|
},
|
|
|
|
handleStartEditing: function() {
|
|
ReactDOM.findDOMNode(this).focus();
|
|
this.setState({active: !this.props.readOnly});
|
|
},
|
|
|
|
handleEditingFinished: function() {
|
|
this.setState({active: false});
|
|
var text = this.state.text.trim();
|
|
if ((text || this.props.text) && (text !== this.props.text)) {
|
|
this.props.onFinished(text);
|
|
}
|
|
},
|
|
|
|
render: function() {
|
|
var spanText = this.props.type === "password" ?
|
|
"••••••••" : this.state.text;
|
|
var spanClass = "in-place-edit" +
|
|
(this.props.readOnly ? " disabled" : "");
|
|
if (!spanText) {
|
|
spanText = this.props.placeholder;
|
|
spanClass += " placeholder";
|
|
}
|
|
if (spanText.length > 20) {
|
|
spanText = spanText.substring(0, 19) + "...";
|
|
}
|
|
return (
|
|
this.state.active ?
|
|
<input type={this.props.type || "text"}
|
|
value={this.state.text}
|
|
placeholder={this.props.placeholder}
|
|
onChange={this.handeTextChange}
|
|
onKeyDown={this.handleKeyDown}
|
|
onBlur={this.handleEditingFinished}
|
|
autoFocus />
|
|
:
|
|
<span className={spanClass} onClick={this.handleStartEditing}>
|
|
<span className="content">{spanText}</span>
|
|
</span>
|
|
);
|
|
}
|
|
});
|
|
/* END InPlaceEdit */
|
|
|
|
/* BEGIN Combobox for selecting host name */
|
|
class HostSelector extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
hostName: this.props.serverAddress,
|
|
changed: false
|
|
};
|
|
this.handleHostNameChange = this.handleHostNameChange.bind(this);
|
|
this.handleEditingFinished = this.handleEditingFinished.bind(this);
|
|
}
|
|
|
|
handleHostNameChange(e) {
|
|
this.setState({hostName: e.target.value, changed: true});
|
|
}
|
|
|
|
handleEditingFinished() {
|
|
if (this.state.changed) {
|
|
this.setState({changed: false});
|
|
this.props.onServerAddressChange(this.state.hostName.trim());
|
|
}
|
|
}
|
|
|
|
render() {
|
|
var hostOptions = [];
|
|
for (var key in KNOWN_HOSTS) {
|
|
var item = KNOWN_HOSTS[key];
|
|
hostOptions.push(
|
|
<option key={item} value={item} />
|
|
);
|
|
}
|
|
return (
|
|
<div>
|
|
<label htmlFor="host-name">Server address:</label>
|
|
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
|
|
value={this.state.hostName} onChange={this.handleHostNameChange}
|
|
onBlur={this.handleEditingFinished} required />
|
|
<datalist id="known-hosts">
|
|
{hostOptions}
|
|
</datalist>
|
|
</div>);
|
|
}
|
|
}
|
|
/* END Combobox for selecting host name */
|
|
|
|
/* BEGIN CheckBox: styled checkbox */
|
|
class CheckBox extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.handleChange = this.handleChange.bind(this);
|
|
}
|
|
|
|
handleChange() {
|
|
this.props.onChange(this.props.name, !this.props.checked);
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
this.props.onChange ? (
|
|
this.props.checked ?
|
|
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
|
|
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i>
|
|
) : (
|
|
this.props.checked ?
|
|
<i className="material-icons">check_box</i> :
|
|
<i className="material-icons">check_box_outline_blank</i>
|
|
)
|
|
);
|
|
}
|
|
}
|
|
/* END CheckBox */
|
|
|
|
/* BEGIN PermissionsEditor: Component for editing permissions */
|
|
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
|
|
var PermissionsEditor = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
mode: this.props.mode
|
|
};
|
|
},
|
|
|
|
handleChange: function(val) {
|
|
var mode = this.state.mode;
|
|
var idx = mode.indexOf(val);
|
|
if (idx == -1) {
|
|
mode += val;
|
|
} else {
|
|
mode = mode.replace(val, "");
|
|
}
|
|
this.setState({mode: mode});
|
|
},
|
|
|
|
handleSubmit: function() {
|
|
var mode = this.state.mode || "N";
|
|
if (mode !== this.props.mode) {
|
|
this.props.onSubmit(mode);
|
|
} else {
|
|
this.props.onCancel();
|
|
}
|
|
},
|
|
|
|
handleCancel: function() {
|
|
this.props.onCancel();
|
|
},
|
|
|
|
render: function() {
|
|
var all = 'JRWPASDO';
|
|
var names = {
|
|
'J': 'Join (J)',
|
|
'R': 'Read (R)',
|
|
'W': 'Write (W)',
|
|
'P': 'Get notified (P)',
|
|
'A': 'Approve (A)',
|
|
'S': 'Share (S)',
|
|
'D': 'Delete (D)',
|
|
'O': 'Owner (O)'
|
|
};
|
|
|
|
var skip = this.props.skip || "";
|
|
var mode = (this.state.mode || "").replace("N", "");
|
|
var compare = (this.props.compare || "").replace("N", "");
|
|
var items = [];
|
|
for (var i=0; i<all.length; i++) {
|
|
var c = all.charAt(i);
|
|
if (skip.indexOf(c) >= 0) {
|
|
continue;
|
|
}
|
|
items.push(
|
|
<tr key={c}>
|
|
<td>{names[c]}</td>
|
|
<td className="checkbox">
|
|
<CheckBox name={c} checked={(mode.indexOf(c) > -1)} onChange={this.handleChange}/>
|
|
</td>{this.props.compare ? <td className="checkbox">
|
|
<CheckBox name={c} checked={(compare.indexOf(c) > -1)}/>
|
|
</td> : null}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="panel-form-column">
|
|
<label className="small">Permissions</label>
|
|
<table className="permission-editor">
|
|
{this.props.compare ?
|
|
<thead><tr>
|
|
<th></th><th>{this.props.modeTitle}</th>
|
|
<th>{this.props.compareTitle}</th>
|
|
</tr></thead> :
|
|
null}
|
|
<tbody>
|
|
{items}
|
|
</tbody></table>
|
|
<br />
|
|
<div className="dialog-buttons">
|
|
<button className="blue" onClick={this.handleSubmit}>Ok</button>
|
|
<button className="white" onClick={this.handleCancel}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END PermissionsEditor */
|
|
|
|
/* BEGIN ChipInput: group membership widget */
|
|
var ChipInput = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
placeholder: this.props.chips ? "" : this.props.prompt,
|
|
sortedChips: this.sortChips(this.props.chips, this.props.required),
|
|
chipIndex: this.indexChips(this.props.chips),
|
|
input: '',
|
|
focused: false
|
|
};
|
|
},
|
|
|
|
componentWillReceiveProps: function(newProps) {
|
|
this.setState({
|
|
sortedChips: this.sortChips(newProps.chips, newProps.required),
|
|
chipIndex: this.indexChips(newProps.chips)
|
|
});
|
|
},
|
|
|
|
// Map chip index to user name
|
|
indexChips: function(chips) {
|
|
var index = {};
|
|
var count = 0;
|
|
chips.map(function(item) {
|
|
index[item.user] = count;
|
|
count ++;
|
|
});
|
|
return index;
|
|
},
|
|
|
|
// Have non-removable chips appear before all other chips.
|
|
sortChips: function(chips, keep) {
|
|
var required = [];
|
|
var normal = [];
|
|
chips.map(function(item) {
|
|
if (item.user === keep) {
|
|
required.push(item);
|
|
} else {
|
|
normal.push(item);
|
|
}
|
|
});
|
|
return required.concat(normal);
|
|
},
|
|
|
|
handleTextInput: function(e) {
|
|
this.setState({input: e.target.value});
|
|
if (this.props.filterFunc) {
|
|
this.props.filterFunc(e.target.value);
|
|
}
|
|
},
|
|
|
|
removeChipAt: function(idx) {
|
|
var removed = this.state.sortedChips[idx];
|
|
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
|
|
},
|
|
|
|
handleChipCancel: function(item, idx) {
|
|
this.removeChipAt(idx);
|
|
},
|
|
|
|
handleFocusGained: function() {
|
|
this.setState({focused: true});
|
|
},
|
|
|
|
handleFocusLost: function() {
|
|
this.setState({focused: false});
|
|
},
|
|
|
|
handleKeyDown: function(e) {
|
|
if (e.key === 'Backspace') {
|
|
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
|
|
var at = this.state.sortedChips.length - 1;
|
|
if (this.state.sortedChips[at].user !== this.props.required) {
|
|
this.removeChipAt(at);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
render: function() {
|
|
var chips = [];
|
|
|
|
var instance = this;
|
|
var count = 0;
|
|
this.state.sortedChips.map(function(item) {
|
|
chips.push(
|
|
<ChipInput.Chip
|
|
onCancel={instance.handleChipCancel}
|
|
avatar={makeImageUrl(item.public ? item.public.photo : null)}
|
|
title={item.public ? item.public.fn : undefined}
|
|
topic={item.user}
|
|
required={item.user === instance.props.required}
|
|
index={count}
|
|
key={item.user} />
|
|
);
|
|
count++;
|
|
});
|
|
var className = "chip-input" + (this.state.focused ? " focused" : "");
|
|
return (
|
|
<div className={className}>
|
|
{chips}
|
|
<input type="text"
|
|
placeholder={this.state.placeholder}
|
|
onChange={this.handleTextInput}
|
|
onFocus={this.handleFocusGained}
|
|
onBlur={this.handleFocusLost}
|
|
onKeyDown={this.handleKeyDown}
|
|
value={this.state.input}
|
|
autoFocus />
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
ChipInput.Chip = React.createClass({
|
|
handleCancel: function() {
|
|
this.props.onCancel(this.props.topic, this.props.index);
|
|
},
|
|
|
|
render: function() {
|
|
return (
|
|
<div className="chip">
|
|
<div className="avatar-box">
|
|
<LetterTile
|
|
avatar={this.props.avatar || true}
|
|
topic={this.props.topic}
|
|
title={this.props.title} />
|
|
</div>
|
|
<span>{this.props.title}</span>
|
|
{this.props.onCancel && !this.props.required ?
|
|
<a href="#" onClick={this.handleCancel} >×</a>
|
|
: <span className="spacer" />}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END ChipInput */
|
|
|
|
/* BEGIN Login: a login form */
|
|
var LoginView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
login: this.props.login,
|
|
password: '',
|
|
hostName: this.props.serverAddress,
|
|
errorCleared: false
|
|
};
|
|
},
|
|
clearError: function() {
|
|
if (!this.state.errorCleared) {
|
|
this.props.onClearError();
|
|
this.setState({errorCleared: true});
|
|
}
|
|
},
|
|
handleLoginChange: function(e) {
|
|
this.clearError();
|
|
this.setState({login: e.target.value});
|
|
},
|
|
handlePasswordChange: function(e) {
|
|
this.clearError();
|
|
this.setState({password: e.target.value});
|
|
},
|
|
handleServerAddressChange: function(name) {
|
|
this.clearError();
|
|
this.setState({hostName: name});
|
|
this.props.onServerAddressChange(name);
|
|
},
|
|
handleSubmit: function(e) {
|
|
e.preventDefault();
|
|
this.setState({errorCleared: false});
|
|
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
|
|
},
|
|
|
|
render: function() {
|
|
var submitClasses = "blue";
|
|
if (this.props.disabled) {
|
|
submitClasses += " disabled";
|
|
}
|
|
return (
|
|
<div>
|
|
<form id="login-form" onSubmit={this.handleSubmit}>
|
|
<input type="text" id="inputLogin"
|
|
placeholder="Login (alice, bob, carol, dave, frank)"
|
|
value={this.state.login}
|
|
onChange={this.handleLoginChange}
|
|
required autoFocus />
|
|
<input type="password" id="inputPassword"
|
|
placeholder="Password (alice123, bob123, ...)"
|
|
value={this.state.password}
|
|
onChange={this.handlePasswordChange}
|
|
required />
|
|
<HostSelector serverAddress={this.props.serverAddress}
|
|
onServerAddressChange={this.handleServerAddressChange} />
|
|
<div className="dialog-buttons">
|
|
<button className={submitClasses} type="submit">Sign in</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END Login */
|
|
|
|
/* BEGIN Account registration */
|
|
var CreateAccountView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
login: '',
|
|
password: '',
|
|
password2: '',
|
|
email: '',
|
|
fn: '', // full/formatted name
|
|
imageDataUrl: null,
|
|
errorCleared: false
|
|
};
|
|
},
|
|
handleLoginChange: function(e) {
|
|
this.setState({login: e.target.value});
|
|
},
|
|
handlePasswordChange: function(e) {
|
|
this.setState({password: e.target.value});
|
|
},
|
|
handlePassword2Change: function(e) {
|
|
this.setState({password2: e.target.value});
|
|
},
|
|
handleEmailChange: function(e) {
|
|
this.setState({email: e.target.value})
|
|
},
|
|
handleFnChange: function(e) {
|
|
this.setState({fn: e.target.value});
|
|
},
|
|
handleImageChanged: function(img) {
|
|
this.setState({imageDataUrl: img});
|
|
},
|
|
handleSubmit: function(e) {
|
|
e.preventDefault();
|
|
if (this.state.password.trim() != this.state.password2.trim()) {
|
|
// FIXME(gene): report an error here.
|
|
} else {
|
|
this.setState({errorCleared: false});
|
|
this.props.onCreateAccount(
|
|
this.state.login.trim(),
|
|
this.state.password.trim(),
|
|
vcard(this.state.fn, this.state.imageDataUrl),
|
|
null,
|
|
to_tags({"email": this.state.email}));
|
|
}
|
|
},
|
|
render: function() {
|
|
var submitClasses = "blue";
|
|
if (this.props.disabled) {
|
|
submitClasses += " disabled";
|
|
}
|
|
return (
|
|
<div className="panel-form">
|
|
<div className="panel-form-row">
|
|
<div className="panel-form-column">
|
|
<input type="text" placeholder="Login"
|
|
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
|
|
<input type="password" placeholder="Password"
|
|
value={this.state.password} onChange={this.handlePasswordChange} required />
|
|
<input type="password" placeholder="Repeat password"
|
|
value={this.state.password2} onChange={this.handlePassword2Change} required />
|
|
</div>
|
|
<AvatarUpload onImageChanged={this.handleImageChanged} />
|
|
</div>
|
|
<div className="panel-form-row">
|
|
<input type="text" placeholder="Email, e.g john.doe@example.com"
|
|
value={this.state.email} onChange={this.handleEmailChange} required/>
|
|
</div>
|
|
<div className="panel-form-row">
|
|
<input type="text" placeholder="Full name, e.g. John Doe"
|
|
value={this.state.fn} onChange={this.handleFnChange} required/>
|
|
</div>
|
|
<div className="dialog-buttons">
|
|
<button className={submitClasses} onClick={this.handleSubmit}>Sign up</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
var AvatarUpload = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
dataUrl: this.props.avatar
|
|
};
|
|
},
|
|
handleFileUpload(e) {
|
|
var instance = this;
|
|
var img = new Image();
|
|
img.crossOrigin = 'Anonymous';
|
|
img.onload = function() {
|
|
var canvas = document.createElement('CANVAS');
|
|
canvas.width = canvas.height = 128;
|
|
var ctx = canvas.getContext('2d');
|
|
var sx = 0, sy = 0, size;
|
|
if (this.height > this.width) {
|
|
size = this.width;
|
|
sy = (this.height - size) / 2;
|
|
} else {
|
|
size = this.height;
|
|
sx = (this.width - size) / 2;
|
|
}
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.drawImage(this, sx, sy, size, size, 0, 0, canvas.width, canvas.height);
|
|
var bits = canvas.toDataURL("image/jpg");
|
|
canvas = null;
|
|
|
|
instance.setState({dataUrl: bits});
|
|
instance.props.onImageChanged(bits);
|
|
};
|
|
img.src = URL.createObjectURL(e.target.files[0]);
|
|
},
|
|
render: function() {
|
|
return (
|
|
<div className="avatar-upload">
|
|
{this.state.dataUrl ?
|
|
<img src={this.state.dataUrl} className="preview" /> :
|
|
this.props.readOnly && this.props.uid ?
|
|
<div className="avatar-box">
|
|
<LetterTile avatar={true} topic={this.props.uid} title={this.props.title} />
|
|
</div>
|
|
:
|
|
<div className="blank">128×128</div>}
|
|
{this.props.readOnly ? null :
|
|
<input type="file" id="file-input-avatar" className="inputfile hidden"
|
|
accept="image/*" onChange={this.handleFileUpload} />}
|
|
{this.props.readOnly ? null :
|
|
<label htmlFor="file-input-avatar" className="round">
|
|
<i className="material-icons">file_upload</i>
|
|
</label>}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END Account registration */
|
|
|
|
/* BEGIN Tinode config panel */
|
|
var SettingsView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
transport: "def"
|
|
};
|
|
},
|
|
handleSubmit: function(e) {
|
|
e.preventDefault();
|
|
this.props.onUpdate(null, this.state.transport);
|
|
},
|
|
handleTransportSelected: function(e) {
|
|
this.setState({transport: e.currentTarget.value});
|
|
},
|
|
render: function() {
|
|
var names = {def: "default", ws: "websocket", lp: "long polling"};
|
|
var transportOptions = [];
|
|
var instance = this;
|
|
["def", "ws", "lp"].map(function(item){
|
|
var id = "transport-" + item;
|
|
var name = names[item];
|
|
transportOptions.push(
|
|
<li key={item}>
|
|
<input type="radio" id={id} name="transport-select" value={item}
|
|
checked={instance.state.transport === item}
|
|
onChange={instance.handleTransportSelected} />
|
|
<label htmlFor={id}>{name}</label>
|
|
</li>
|
|
);
|
|
});
|
|
return (
|
|
<form id="settings-form" onSubmit={this.handleSubmit}>
|
|
<label className="small">Wire transport:</label>
|
|
<ul className="quoted">
|
|
{transportOptions}
|
|
</ul>
|
|
<div className="dialog-buttons">
|
|
<button type="submit" className="blue">Update</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
});
|
|
/* END Tinode config panel */
|
|
|
|
/* BEGIN Manage side panel - handle Login, Account Registration, Contacts, NewTopic views */
|
|
var SideNavbar = React.createClass({
|
|
render: function() {
|
|
return (
|
|
<div id="side-caption-panel" className="caption-panel">
|
|
<div id="self-avatar" className="avatar-box"><LetterTile
|
|
avatar={this.props.avatar} topic={this.props.myUserId} title={this.props.title} /></div>
|
|
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
|
|
{this.props.state === 'login' ?
|
|
<SideNavbar.MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
|
this.props.state === 'settings' ?
|
|
<MenuCancel onCancel={this.props.onCancel} /> :
|
|
this.props.state === 'edit-account' ?
|
|
<MenuCancel onCancel={this.props.onCancel} /> :
|
|
this.props.state === 'register' ?
|
|
<MenuCancel onCancel={this.props.onCancel} /> :
|
|
this.props.state === 'contacts' ?
|
|
<SideNavbar.MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
|
this.props.state === 'new-topic' ?
|
|
<MenuCancel onCancel={this.props.onCancel} /> :
|
|
null}
|
|
</div>
|
|
);
|
|
}
|
|
})
|
|
|
|
SideNavbar.MenuStart = React.createClass({
|
|
render: function() {
|
|
return (
|
|
<div>
|
|
<a href="#" onClick={this.props.onSignUp}><i className="material-icons">person_add</i></a>
|
|
|
|
<a href="#" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
SideNavbar.MenuContacts = React.createClass({
|
|
render: function() {
|
|
return (
|
|
<div>
|
|
<a href="#" onClick={this.props.onNewTopic}><i className="material-icons">chat</i></a>
|
|
|
|
<a href="#" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
|
|
</div>
|
|
);
|
|
}
|
|
})
|
|
|
|
var SidepanelView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
showErrorText: true
|
|
};
|
|
},
|
|
handleClearErrorText: function() {
|
|
this.setState({showErrorText: false});
|
|
},
|
|
handleLoginRequested: function(login, password) {
|
|
this.props.onLoginRequest(login, password);
|
|
this.setState({showErrorText: true});
|
|
},
|
|
render: function() {
|
|
var title = null;
|
|
var avatar = false;
|
|
switch (this.props.state) {
|
|
case 'login':
|
|
title = "Sign In"; break;
|
|
case 'register':
|
|
title = "Create Account"; break;
|
|
case 'settings':
|
|
title = "Settings"; break;
|
|
case 'edit-account':
|
|
title = "Edit Account"; break;
|
|
case 'contacts':
|
|
title = this.props.title;
|
|
avatar = this.props.avatar ? this.props.avatar : true;
|
|
break;
|
|
case 'new-topic':
|
|
title = "Start New Chat";
|
|
break;
|
|
default:;
|
|
};
|
|
return (
|
|
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
<SideNavbar state={this.props.state}
|
|
title={title} avatar={avatar}
|
|
myUserId={this.props.myUserId}
|
|
onSignUp={this.props.onSignUp}
|
|
onSettings={this.props.onSettings}
|
|
onNewTopic={this.props.onNewTopic}
|
|
onCancel={this.props.onCancel} />
|
|
|
|
<SidepanelView.ErrorPanel
|
|
showError={this.state.showErrorText}
|
|
errorText={this.props.errorText} />
|
|
|
|
{this.props.state === 'login' ?
|
|
<LoginView login={this.props.login}
|
|
disabled={this.props.loginDisabled}
|
|
serverAddress={this.props.serverAddress}
|
|
onLogin={this.handleLoginRequested}
|
|
onServerAddressChange={this.props.onConnectionSettings}
|
|
onClearError={this.handleClearErrorText} /> :
|
|
|
|
this.props.state === 'register' ?
|
|
<CreateAccountView
|
|
onCreateAccount={this.props.onCreateAccount}
|
|
onCancel={this.props.onCancel} /> :
|
|
|
|
this.props.state === 'settings' ?
|
|
<SettingsView onCancel={this.props.onCancel}
|
|
onUpdate={this.props.onConnectionSettings}
|
|
/> :
|
|
|
|
this.props.state === 'edit-account' ?
|
|
<EditAccountView
|
|
login={this.props.login}
|
|
onSubmit={this.props.onUpdateAccount}
|
|
onCancel={this.props.onCancel} /> :
|
|
|
|
this.props.state === 'contacts' ?
|
|
<ContactsView
|
|
data={this.props.subscriptions}
|
|
topicSelected={this.props.topicSelected}
|
|
showContextMenu={this.props.showContextMenu}
|
|
onTopicSelected={this.props.onTopicSelected} /> :
|
|
|
|
this.props.state === 'new-topic' ?
|
|
<NewTopicView
|
|
contactsSearchQuery={this.props.contactsSearchQuery}
|
|
foundContacts={this.props.foundContacts}
|
|
onInitFind={this.props.onInitFind}
|
|
onSearchContacts={this.props.onSearchContacts}
|
|
onCreateTopic={this.props.onCreateTopic} /> :
|
|
|
|
null}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
SidepanelView.ErrorPanel = React.createClass({
|
|
render: function() {
|
|
var errorClass = null;
|
|
var errorText = ' ';
|
|
if (this.props.errorText && this.props.showError) {
|
|
errorClass = "error-alert";
|
|
errorText = this.props.errorText;
|
|
}
|
|
return (
|
|
<div id="alert-box" className={errorClass}>{errorText}</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
/* END Side panel */
|
|
|
|
/* EditAccount parameters */
|
|
var EditAccountView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
address: null,
|
|
fullName: undefined,
|
|
avatar: null,
|
|
login: undefined,
|
|
password: undefined
|
|
};
|
|
},
|
|
componentWillMount: function() {
|
|
var me = Tinode.getMeTopic();
|
|
me.onMetaDesc = this.onMetaDesc;
|
|
var defacs = me.getDefaultAccess();
|
|
this.setState({
|
|
fullName: me.public ? me.public.fn : undefined,
|
|
avatar: makeImageUrl(me.public ? me.public.photo : null),
|
|
address: Tinode.getCurrentUserID(),
|
|
auth: defacs.auth,
|
|
anon: defacs.anon
|
|
});
|
|
},
|
|
componentWillUnmount: function() {
|
|
var me = Tinode.getMeTopic();
|
|
me.onMetaDesc = undefined;
|
|
},
|
|
|
|
onMetaDesc: function(desc) {
|
|
// Update form with changed values
|
|
},
|
|
|
|
handleFullNameUpdate: function(fn) {
|
|
this.setState({fullName: fn});
|
|
this.props.onSubmit(null, vcard(fn, this.state.avatar));
|
|
},
|
|
handlePasswordUpdate: function(pwd) {
|
|
this.setState({password: pwd});
|
|
this.props.onSubmit(pwd, null);
|
|
},
|
|
handleImageChanged: function(img) {
|
|
this.setState({avatar: img});
|
|
this.props.onSubmit(null, vcard(this.state.fullName, img));
|
|
},
|
|
|
|
render: function() {
|
|
return (
|
|
<div id="edit-account" className="panel-form">
|
|
<div className="panel-form-row">
|
|
<div className="panel-form-column">
|
|
<div><label className="small">Your name</label></div>
|
|
<div><InPlaceEdit
|
|
placeholder="Full name, e.g. John Doe"
|
|
text={this.state.fullName}
|
|
onFinished={this.handleFullNameUpdate} /></div>
|
|
<div><label className="small">Password</label></div>
|
|
<div><InPlaceEdit
|
|
placeholder="Unchanged"
|
|
type="password"
|
|
onFinished={this.handlePasswordUpdate} /></div>
|
|
</div>
|
|
<AvatarUpload
|
|
avatar={this.state.avatar}
|
|
uid={this.state.address}
|
|
title={this.state.fullName}
|
|
onImageChanged={this.handleImageChanged} />
|
|
</div>
|
|
<div className="hr" />
|
|
<div className="panel-form-column">
|
|
<div className="panel-form-row">
|
|
<label>Login:</label>
|
|
<tt>{this.props.login}</tt>
|
|
</div>
|
|
<div className="panel-form-row">
|
|
<label>Address:</label>
|
|
<tt>{this.state.address}</tt>
|
|
</div>
|
|
<div>
|
|
<label className="small">Default access mode:</label>
|
|
</div>
|
|
<div className="quoted">
|
|
<div>Auth: <tt>{this.state.auth}</tt></div>
|
|
<div>Anon: <tt>{this.state.anon}</tt></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END EditAccount */
|
|
|
|
/* BEGIN Contact list (list of topics) */
|
|
/* A single topic */;
|
|
var Contact = React.createClass({
|
|
handleClick: function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.props.onSelected(this.props.topic, this.props.index);
|
|
},
|
|
|
|
handleContextClick: function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.props.showContextMenu(this.props.topic, e.pageY, e.pageX);
|
|
},
|
|
|
|
render: function() {
|
|
var title = this.props.title;
|
|
if (!title) {
|
|
title = <i>unknown</i>;
|
|
} else if (title.length > 30) {
|
|
title = title.substring(0, 28) + "...";
|
|
}
|
|
var online = this.props.now ? "online" : "offline";
|
|
var avatar = this.props.avatar ? this.props.avatar : true;
|
|
return (
|
|
<li className={!this.props.showCheckmark && this.props.selected ? "selected" : null}
|
|
onClick={this.handleClick}>
|
|
<div className="avatar-box">
|
|
<LetterTile
|
|
avatar={avatar} title={this.props.title} topic={this.props.topic} />
|
|
|
|
{this.props.showOnline ? <span className={online} /> :
|
|
(this.props.showCheckmark && this.props.selected ?
|
|
<i className="checkmark material-icons">check_circle</i>
|
|
: null)}
|
|
</div>
|
|
<div className="text-box">
|
|
<div><span className="contact-title">{title}</span>
|
|
{this.props.unread > 0 ? <Contact.Unread count={this.props.unread} /> : null}</div>
|
|
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
|
|
</div>
|
|
{this.props.showContextMenu ?
|
|
<span className="menuTrigger">
|
|
<a href="#" onClick={this.handleContextClick}>
|
|
<i className="material-icons">expand_more</i>
|
|
</a>
|
|
</span> : null}
|
|
</li>
|
|
);
|
|
}
|
|
});
|
|
|
|
/*
|
|
<ContextMenu
|
|
items={this.props.MenuItems}
|
|
topic={this.props.topic}
|
|
onAction={this.handleMenuAction}
|
|
visible={this.state.menuShownFor ? true : false} />
|
|
*/
|
|
|
|
/* The counter of unread messages in the topic */
|
|
Contact.Unread = React.createClass({
|
|
render: function() {
|
|
var showUnreadBadge = null;
|
|
if (this.props.count > 0) {
|
|
var count = this.props.count > 9 ? "9+" : this.props.count;
|
|
showUnreadBadge = <span className="badge">{count}</span>;
|
|
}
|
|
return showUnreadBadge;
|
|
}
|
|
});
|
|
|
|
/* ContactsView holds all contacts-related stuff */
|
|
var ContactsView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
contactList: this.prepareContactList()
|
|
};
|
|
},
|
|
componentWillMount: function() {
|
|
var me = Tinode.getMeTopic();
|
|
me.onContactUpdate = this.tnMeContactUpdate;
|
|
me.onData = this.tnInvite;
|
|
me.onSubsUpdated = this.tnMeSubsUpdated;
|
|
},
|
|
componentWillUnmount: function() {
|
|
var me = Tinode.getMeTopic();
|
|
me.onContactUpdate = undefined;
|
|
me.onData = undefined;
|
|
me.onSubsUpdated = undefined;
|
|
},
|
|
// Reactions to updates to the contact list.
|
|
tnMeContactUpdate: function(what, cont) {
|
|
if (what === "on" || what === "off" || what === "read") {
|
|
this.resetContactList();
|
|
} else if (what === "msg") {
|
|
// New message received
|
|
|
|
// Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
|
|
if (this.props.topicSelected !== cont.topic) {
|
|
this.resetContactList();
|
|
}
|
|
} else if (what === "recv") {
|
|
// Explicitly ignoring "recv" -- it causes no visible updates to contact list.
|
|
} else if (what === "gone" || what === "unsub") {
|
|
// Topic deleted or user unsubscribed. Remove topic from view.
|
|
|
|
// If the currently selected topic is gone, clear the selection.
|
|
if (this.props.topicSelected === cont.topic) {
|
|
this.props.onTopicSelected(null);
|
|
}
|
|
// Redraw without the deleted topic.
|
|
this.resetContactList();
|
|
} else {
|
|
// TODO(gene): handle other types of notifications:
|
|
// * ua -- user agent changes (may display a pictogram for mobile/desktop)
|
|
// * upd -- topic 'public' updated (fetch new public and refresh)
|
|
// * del -- messages deleted (hard or soft) -- reset pill counter
|
|
console.log("changed (not updating for it): " + what + " in: " + cont.topic);
|
|
}
|
|
},
|
|
|
|
tnInvite: function(invite) {
|
|
// TODO(gene): implement invite handling
|
|
console.log("Got invite:");
|
|
console.log(invite);
|
|
},
|
|
|
|
tnMeSubsUpdated: function(names) {
|
|
this.resetContactList();
|
|
},
|
|
|
|
prepareContactList: function() {
|
|
var contacts = [];
|
|
Tinode.getMeTopic().contacts(function(c) {
|
|
c.unread = c.seq;
|
|
if (c.read > 0) {
|
|
c.unread -= c.read;
|
|
}
|
|
contacts.push(c);
|
|
}, this);
|
|
|
|
return contacts;
|
|
},
|
|
|
|
resetContactList: function() {
|
|
this.setState({contactList: this.prepareContactList()});
|
|
},
|
|
|
|
render: function() {
|
|
return (
|
|
<ContactList
|
|
contacts={this.state.contactList}
|
|
emptyListMessage={<span>You have no chats<br />¯\_(ツ)_/¯</span>}
|
|
topicSelected={this.props.topicSelected}
|
|
showOnline={true}
|
|
onTopicSelected={this.props.onTopicSelected}
|
|
showContextMenu={this.props.showContextMenu} />
|
|
);
|
|
}
|
|
});
|
|
/* END Contact list */
|
|
|
|
/* BEGIN ContactList: component for showing a list of contacts,
|
|
* such as a list of group members in a group chat */
|
|
var ContactList = React.createClass({
|
|
render: function() {
|
|
var contactNodes = [];
|
|
var instance = this;
|
|
var showCheckmark = Array.isArray(this.props.topicSelected);
|
|
|
|
if (this.props.contacts && this.props.contacts.length > 0) {
|
|
this.props.contacts.map(function(c) {
|
|
var key = c.topic ? c.topic : c.user;
|
|
|
|
// If filter function is provided, filter out the items
|
|
// which don't satisfy the condition.
|
|
if (instance.props.filterFunc && instance.props.filter) {
|
|
var content = [key];
|
|
if (c.private) {
|
|
content.push(("" + c.private).toLowerCase());
|
|
}
|
|
if (c.public && c.public.fn) {
|
|
content.push(("" + c.public.fn).toLowerCase());
|
|
}
|
|
if (!instance.props.filterFunc(instance.props.filter, content)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
var selected = showCheckmark ?
|
|
(this.props.topicSelected.indexOf(key) > -1) :
|
|
(this.props.topicSelected === key);
|
|
|
|
contactNodes.push(
|
|
<Contact
|
|
title={c.public ? c.public.fn : null}
|
|
avatar={makeImageUrl(c.public ? c.public.photo : null)}
|
|
comment={c.private}
|
|
unread={c.unread} now={c.online}
|
|
showCheckmark={showCheckmark}
|
|
selected={selected}
|
|
showOnline={this.props.showOnline}
|
|
onSelected={this.props.onTopicSelected}
|
|
showContextMenu={this.props.showContextMenu}
|
|
topic={key}
|
|
index={contactNodes.length}
|
|
key={key} />
|
|
);
|
|
}, this);
|
|
}
|
|
|
|
return (
|
|
<div className={this.props.noScroll ? null : "scrollable-panel"}>
|
|
{contactNodes.length > 0 ?
|
|
<ul className="contact-box">
|
|
{contactNodes}
|
|
</ul>
|
|
:
|
|
<div className="center-medium-text">
|
|
{this.props.emptyListMessage}
|
|
</div>}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END ContactList */
|
|
|
|
/* BEGIN GroupMembers: controlc for managing a list of group members */
|
|
var GroupManager = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
members: this.props.members,
|
|
contacts: this.props.contacts,
|
|
index: this.indexMembers(this.props.members),
|
|
contactFilter: '',
|
|
noContactsMessage: 'You have no contacts :-(',
|
|
selectedContacts: this.selectedContacts(this.props.members)
|
|
};
|
|
},
|
|
|
|
indexMembers: function(members) {
|
|
var index = {};
|
|
members.map(function(m) {
|
|
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
|
|
});
|
|
return index;
|
|
},
|
|
|
|
selectedContacts: function(members) {
|
|
var sel = [];
|
|
members.map(function(m) {
|
|
sel.push(m.user);
|
|
});
|
|
return sel;
|
|
},
|
|
|
|
handleContactSelected: function(userId, index) {
|
|
var status = this.state.index[userId];
|
|
if (status) {
|
|
if (status.present) {
|
|
// Prevent duplicate members
|
|
return;
|
|
}
|
|
status.delta += 1;
|
|
status.present = true;
|
|
} else {
|
|
status = {delta: 1, present: true};
|
|
}
|
|
|
|
var m = this.state.members.slice();
|
|
m.push(this.props.contacts[index]);
|
|
|
|
var sel = this.selectedContacts(m);
|
|
|
|
var i = this.state.index;
|
|
i[userId] = status;
|
|
|
|
this.setState({members: m, index: i, selectedContacts: sel});
|
|
},
|
|
|
|
handleMemberRemoved: function(userId, index) {
|
|
var status = this.state.index[userId];
|
|
if (!status || !status.present) {
|
|
return;
|
|
}
|
|
status.present = false;
|
|
status.delta -= 1;
|
|
|
|
var m = this.state.members.slice();
|
|
m.splice(index, 1);
|
|
|
|
var sel = this.selectedContacts(m);
|
|
|
|
var i = this.state.index;
|
|
i[userId] = status;
|
|
|
|
this.setState({members: m, index: i, selectedContacts: sel});
|
|
},
|
|
|
|
handleContactFilter: function(val) {
|
|
var msg = !val ?
|
|
"You have no contacts :-(" :
|
|
"No contacts match '" + val + "'";
|
|
|
|
this.setState({contactFilter: val, noContactsMessage: msg});
|
|
},
|
|
|
|
doContactFiltering: function(filter, values) {
|
|
if (filter) {
|
|
for (var i=0; i<values.length; i++) {
|
|
if (values[i].indexOf(filter) >= 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
handleSubmit: function() {
|
|
var instance = this;
|
|
var members = [];
|
|
var added = [];
|
|
var removed = [];
|
|
|
|
var keys = Object.keys(this.state.index);
|
|
keys.map(function(k) {
|
|
if (instance.state.index[k].present) {
|
|
members.push(k);
|
|
}
|
|
|
|
if (instance.state.index[k].delta > 0) {
|
|
added.push(k);
|
|
} else if (instance.state.index[k].delta < 0) {
|
|
removed.push(k);
|
|
}
|
|
});
|
|
this.props.onSubmit(members, added, removed);
|
|
},
|
|
|
|
handleCancel: function() {
|
|
this.props.onCancel();
|
|
},
|
|
|
|
render: function() {
|
|
return (
|
|
<div id="group-manager">
|
|
<div className="panel-form-row">
|
|
<label className="small">Group members</label>
|
|
</div>
|
|
<ChipInput
|
|
chips={this.state.members}
|
|
required={this.props.requiredMember}
|
|
prompt="add members"
|
|
filterFunc={this.handleContactFilter}
|
|
onChipRemoved={this.handleMemberRemoved} />
|
|
<div className="panel-form-row">
|
|
<label className="small">All contacts</label>
|
|
</div>
|
|
<ContactList
|
|
contacts={this.props.contacts}
|
|
topicSelected={this.state.selectedContacts}
|
|
filter={this.state.contactFilter}
|
|
filterFunc={this.doContactFiltering}
|
|
emptyListMessage={this.state.noContactsMessage}
|
|
onTopicSelected={this.handleContactSelected} />
|
|
<div id="group-manager-buttons" className="panel-form-row">
|
|
<button className="blue" onClick={this.handleSubmit}>OK</button>
|
|
<button className="white" onClick={this.handleCancel}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END GroupManager */
|
|
|
|
/* BEGIN Create new topic and invite users or send an invite */
|
|
var NewTopicView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
tabSelected: "p2p",
|
|
searchQuery: this.props.contactsSearchQuery,
|
|
contactList: this.props.foundContacts,
|
|
contactSelected: null
|
|
};
|
|
},
|
|
componentWillMount: function() {
|
|
this.props.onInitFind();
|
|
},
|
|
componentWillReceiveProps: function(nextProps) {
|
|
this.setState({
|
|
searchQuery: nextProps.contactsSearchQuery,
|
|
contactList: nextProps.foundContacts
|
|
});
|
|
},
|
|
handleTabClick: function(e) {
|
|
e.preventDefault();
|
|
this.setState({tabSelected: e.currentTarget.dataset.id, contactSelected: null});
|
|
},
|
|
handleContactSelected: function(sel) {
|
|
if (this.state.tabSelected === "p2p") {
|
|
this.props.onCreateTopic(sel, undefined);
|
|
} else {
|
|
// TODO(gene): implement handling of group invites
|
|
console.log("handle group invite " + sel);
|
|
}
|
|
},
|
|
handleGroupSubmit: function(name, dataUrl) {
|
|
this.props.onCreateTopic(undefined, vcard(name, dataUrl));
|
|
},
|
|
render: function() {
|
|
return (
|
|
<div className="flex-column">
|
|
<ul className="tabbar">
|
|
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
|
|
<a href="#" data-id="p2p" onClick={this.handleTabClick}>1:1</a>
|
|
</li>
|
|
<li className={this.state.tabSelected === "grp" ? "active" : null}>
|
|
<a href="#" data-id="grp" onClick={this.handleTabClick}>group</a>
|
|
</li>
|
|
</ul>
|
|
{this.state.tabSelected === "grp" ?
|
|
<NewTopicView.Group onSubmit={this.handleGroupSubmit} /> :
|
|
<div className="flex-column">
|
|
<NewTopicView.SearchContacts type="p2p"
|
|
searchQuery={this.state.searchQuery}
|
|
onSearchContacts={this.props.onSearchContacts} />
|
|
<ContactList
|
|
contacts={this.state.contactList}
|
|
emptyListMessage="Use search to find contacts"
|
|
topicSelected={this.state.selectedContact}
|
|
showOnline={false}
|
|
showContextMenu={false}
|
|
onTopicSelected={this.handleContactSelected} />
|
|
</div>}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
NewTopicView.Group = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
fn: '', // full/formatted name
|
|
private: '',
|
|
imageDataUrl: null
|
|
};
|
|
},
|
|
handleFnChange: function(e) {
|
|
this.setState({fn: e.target.value});
|
|
},
|
|
handlePrivateChange: function(e) {
|
|
this.setState({private: e.target.value});
|
|
},
|
|
handleImageChanged: function(img) {
|
|
this.setState({imageDataUrl: img});
|
|
},
|
|
handleSubmit: function(e) {
|
|
e.preventDefault();
|
|
if (this.state.fn && this.state.fn.trim()) {
|
|
this.props.onSubmit(this.state.fn.trim(),
|
|
this.state.imageDataUrl, this.state.private.trim());
|
|
}
|
|
},
|
|
render: function() {
|
|
var submitClasses = "blue";
|
|
if (this.props.disabled) {
|
|
submitClasses += " disabled";
|
|
}
|
|
return (
|
|
<div className="panel-form">
|
|
<div className="panel-form-row">
|
|
<div className="panel-form-column">
|
|
<label className="small" htmlFor="new-topic-fn">Group name</label>
|
|
<input type="text" id="new-topic-fn" placeholder="Freeform name of the group"
|
|
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
|
|
<br />
|
|
<label className="small" htmlFor="new-topic-priv">Private comment</label>
|
|
<input type="text" id="new-topic-priv" placeholder="Visible to you only"
|
|
value={this.state.private} onChange={this.handlePrivateChange} />
|
|
</div>
|
|
<AvatarUpload onImageChanged={this.handleImageChanged} />
|
|
</div>
|
|
<div className="dialog-buttons">
|
|
<button className={submitClasses} onClick={this.handleSubmit}>Create</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
NewTopicView.SearchContacts = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
search: this.props.searchQuery
|
|
};
|
|
},
|
|
componentWillReceiveProps: function(nextProps) {
|
|
this.setState({search: nextProps.searchQuery});
|
|
},
|
|
handleSearchChange: function(e) {
|
|
this.setState({search: e.target.value});
|
|
},
|
|
handleSearch: function(e) {
|
|
e.preventDefault();
|
|
// Split query string by either space or comma, filter out empty strings.
|
|
var query = this.state.search.split(/[,\s]/).filter(Boolean);
|
|
if (query.length > 0) {
|
|
this.props.onSearchContacts(query);
|
|
}
|
|
},
|
|
handleKeyPress: function(e) {
|
|
if (e.key === 'Enter') {
|
|
this.handleSearch(e);
|
|
}
|
|
},
|
|
render: function() {
|
|
return (
|
|
<div className="panel-form">
|
|
<div className="panel-form-row">
|
|
<input type="text" placeholder="List like email:alice@example.com, tel:17025550003..."
|
|
value={this.state.search} onChange={this.handleSearchChange}
|
|
onKeyPress={this.handleKeyPress} required />
|
|
<a href="#" onClick={this.handleSearch}>
|
|
<i className="material-icons">search</i>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END Create new topic and invite users or send an invite */
|
|
|
|
/* BEGIN InfoView: panel with topic/user info */
|
|
var InfoView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
owner: false,
|
|
address: null,
|
|
groupTopic: undefined,
|
|
fullName: undefined,
|
|
avatar: null,
|
|
private: null,
|
|
selectedContact: null,
|
|
muted: undefined,
|
|
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: [],
|
|
showMemberPanel: this.props.showMemberPanel,
|
|
showPermissionEditorFor: undefined
|
|
};
|
|
},
|
|
|
|
componentWillMount: function() {
|
|
var topic = Tinode.getTopic(this.props.topic);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
|
|
topic.onMetaDesc = this.onMetaDesc;
|
|
topic.onSubsUpdated = this.onSubsUpdated;
|
|
this.resetDesc(topic);
|
|
this.resetSubs(topic);
|
|
|
|
if (topic.getType() === "grp") {
|
|
this.props.onInitFind();
|
|
}
|
|
},
|
|
|
|
componentWillUnmount: function() {
|
|
var topic = Tinode.getTopic(this.props.topic);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
topic.onMetaDesc = undefined;
|
|
topic.onSubsUpdated = undefined;
|
|
},
|
|
|
|
resetSubs: function(topic) {
|
|
var contacts = [];
|
|
if (topic.getType() === "p2p") {
|
|
// Fetch the other party in the p2p conversation.
|
|
var user2 = topic.subscriber(this.props.topic);
|
|
this.setState({
|
|
modeGiven2: user2.acs.getGiven(),
|
|
modeWant2: user2.acs.getWant(),
|
|
});
|
|
console.log("Processed resetSub");
|
|
console.log(user2);
|
|
} else {
|
|
topic.subscribers(function(sub){
|
|
contacts.push(sub);
|
|
}, this);
|
|
this.setState({
|
|
contactList: contacts
|
|
});
|
|
}
|
|
},
|
|
|
|
resetDesc: function(topic) {
|
|
var defacs = topic.getDefaultAccess() || {};
|
|
var acs = topic.getAccessMode();
|
|
this.setState({
|
|
owner: acs && acs.isOwner(),
|
|
sharer: acs && (acs.isAdmin() || acs.isSharer()),
|
|
muted: acs && acs.isMuted(),
|
|
|
|
fullName: topic.public ? topic.public.fn : undefined,
|
|
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
|
|
private: topic.private,
|
|
address: topic.name,
|
|
groupTopic: (topic.getType() === "grp"),
|
|
access: acs.getMode(),
|
|
modeGiven: acs.getGiven(),
|
|
modeWant: acs.getWant(),
|
|
auth: defacs.auth,
|
|
anon: defacs.anon
|
|
});
|
|
},
|
|
|
|
onMetaDesc: function(desc) {
|
|
var topic = Tinode.getTopic(this.props.topic);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
this.resetDesc(topic);
|
|
},
|
|
|
|
onSubsUpdated: function() {
|
|
console.log("onSubsUpdated");
|
|
var topic = Tinode.getTopic(this.props.topic);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
this.resetSubs(topic);
|
|
},
|
|
|
|
handleFullNameUpdate: function(fn) {
|
|
if (this.state.fullName !== fn) {
|
|
this.setState({fullName: fn});
|
|
this.props.onTopicDescUpdate(this.props.topic,
|
|
vcard(fn, this.state.avatar),
|
|
null);
|
|
}
|
|
},
|
|
|
|
handlePrivateUpdate: function(priv) {
|
|
if (this.state.priv !== priv) {
|
|
this.setState({private: priv});
|
|
this.props.onTopicDescUpdate(this.props.topic,
|
|
null,
|
|
priv);
|
|
}
|
|
},
|
|
|
|
handleImageChanged: function(img) {
|
|
this.setState({avatar: img});
|
|
this.props.onTopicDescUpdate(this.props.topic,
|
|
vcard(this.state.fullName, img),
|
|
null);
|
|
},
|
|
|
|
handleMuted: function(ignored, checked) {
|
|
this.setState({muted: checked});
|
|
this.props.onChangePermissions(this.props.topic, checked ? "-P" : "+P");
|
|
},
|
|
|
|
handlePermissionsChanged: function(perm) {
|
|
switch (this.state.showPermissionEditorFor) {
|
|
case 'auth':
|
|
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
|
|
break;
|
|
case 'anon':
|
|
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
|
|
break;
|
|
case 'mode':
|
|
case 'want':
|
|
this.props.onChangePermissions(this.props.topic, perm);
|
|
break;
|
|
case 'given':
|
|
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
|
|
break;
|
|
}
|
|
|
|
this.setState({showPermissionEditorFor: undefined});
|
|
},
|
|
|
|
handleLaunchPermissionsEditor: function(which) {
|
|
var toEdit, toCompare, toSkip, titleEdit, titleCompare;
|
|
switch (which) {
|
|
case 'mode':
|
|
toEdit = this.state.access;
|
|
break;
|
|
case 'want':
|
|
toEdit = this.state.modeWant;
|
|
toCompare = this.state.modeGiven;
|
|
toSkip ='O';
|
|
titleEdit = 'Requested';
|
|
titleCompare = 'Granted';
|
|
break;
|
|
case 'given':
|
|
toEdit = this.state.modeGiven2;
|
|
toCompare = this.state.modeWant2;
|
|
titleEdit = 'Granted';
|
|
titleCompare = 'Requested';
|
|
break;
|
|
case 'auth':
|
|
toEdit = this.state.auth;
|
|
toSkip = 'O';
|
|
break;
|
|
case 'anon':
|
|
toEdit = this.state.auth;
|
|
toSkip = 'O';
|
|
break;
|
|
default:
|
|
console.log("Unknown permission editing mode '" + which + "'");
|
|
break;
|
|
}
|
|
this.setState({
|
|
showPermissionEditorFor: which,
|
|
editedPermissions: toEdit,
|
|
immutablePermissions: toCompare,
|
|
editedPermissionsTitle: titleEdit,
|
|
immutablePermissionsTitle: titleCompare,
|
|
editedPermissionsSkipped: toSkip
|
|
});
|
|
},
|
|
|
|
handleHidePermissionsEditor: function() {
|
|
this.setState({showPermissionEditorFor: undefined});
|
|
},
|
|
|
|
handleShowAddMembers: function() {
|
|
// this.props.onAddMember(this.props.topic);
|
|
this.setState({showMemberPanel: true});
|
|
},
|
|
|
|
handleHideAddMembers: function() {
|
|
// this.props.onAddMember(this.props.topic);
|
|
this.setState({showMemberPanel: false});
|
|
},
|
|
|
|
handleMemberUpdateRequest: function(members, added, removed) {
|
|
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
|
|
this.setState({showMemberPanel: false});
|
|
},
|
|
|
|
handleLeave: function() {
|
|
this.props.onLeaveTopic(this.props.topic);
|
|
},
|
|
|
|
handleMemberSelected: function(uid) {
|
|
this.setState({selectedContact: uid});
|
|
},
|
|
|
|
handleContextMenu: function(item) {
|
|
console.log("context menu: " + item);
|
|
},
|
|
|
|
render: function() {
|
|
return (
|
|
<div id="info-view">
|
|
<div className="caption-panel" id="info-caption-panel">
|
|
<div className="panel-title" id="info-title">Info</div>
|
|
<div>
|
|
<MenuCancel onCancel={this.props.onCancel} />
|
|
</div>
|
|
</div>
|
|
{this.state.showMemberPanel ?
|
|
<GroupManager
|
|
members={this.state.contactList}
|
|
requiredMember={this.props.myUserId}
|
|
contacts={this.props.foundContacts}
|
|
onCancel={this.handleHideAddMembers}
|
|
onSubmit={this.handleMemberUpdateRequest} />
|
|
:
|
|
this.state.showPermissionEditorFor ?
|
|
<PermissionsEditor
|
|
mode={this.state.editedPermissions}
|
|
compare={this.state.immutablePermissions}
|
|
skip={this.state.editedPermissionsSkipped}
|
|
modeTitle={this.state.editedPermissionsTitle}
|
|
compareTitle={this.state.immutablePermissionsTitle}
|
|
skip={this.state.editedPermissionsSkipped}
|
|
onSubmit={this.handlePermissionsChanged}
|
|
onCancel={this.handleHidePermissionsEditor}
|
|
/>
|
|
:
|
|
<div id="info-view-content" className="scrollable-panel">
|
|
<div className="panel-form-row">
|
|
<div className="panel-form-column">
|
|
<div><label className="small">Name</label></div>
|
|
<div><InPlaceEdit
|
|
placeholder="Group name"
|
|
readOnly={!this.state.owner}
|
|
text={this.state.fullName}
|
|
onFinished={this.handleFullNameUpdate} /></div>
|
|
<div><label className="small">Private comment</label></div>
|
|
<div><InPlaceEdit
|
|
placeholder="Visible to you only"
|
|
text={this.state.private}
|
|
onFinished={this.handlePrivateUpdate} /></div>
|
|
</div>
|
|
<AvatarUpload
|
|
avatar={this.state.avatar}
|
|
readOnly={!this.state.owner}
|
|
uid={this.props.topic}
|
|
title={this.state.fullName}
|
|
onImageChanged={this.handleImageChanged} />
|
|
</div>
|
|
<div className="hr" />
|
|
<div className="panel-form-column">
|
|
<div className="panel-form-row">
|
|
<label>Address:</label>
|
|
<tt>{this.state.address}</tt>
|
|
</div>
|
|
<div className="panel-form-row">
|
|
<label>Muted:</label>
|
|
<CheckBox name="P" checked={this.state.muted}
|
|
onChange={this.handleMuted} />
|
|
</div>
|
|
{this.state.groupTopic ?
|
|
<div className="panel-form-row">
|
|
<label>Your permissions:</label>
|
|
<tt className="clickable"
|
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
|
{this.state.access}
|
|
</tt>
|
|
</div>
|
|
:
|
|
<div>
|
|
<div><label className="small">Permissions:</label></div>
|
|
<div className="quoted">
|
|
<div>Yours: <tt className="clickable"
|
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
|
{this.state.access}
|
|
</tt></div>
|
|
<div>{this.state.fullName}′s: <tt className="clickable"
|
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
|
|
{this.state.modeGiven}
|
|
</tt></div>
|
|
</div>
|
|
</div>
|
|
}
|
|
{this.state.auth || this.state.anon ?
|
|
<div>
|
|
<div><label className="small">Default access mode:</label></div>
|
|
<div className="quoted">
|
|
<div>Auth: <tt className="clickable"
|
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
|
|
{this.state.auth}
|
|
</tt></div>
|
|
<div>Anon: <tt className="clickable"
|
|
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
|
|
{this.state.anon}
|
|
</tt></div>
|
|
</div>
|
|
</div>
|
|
:
|
|
null
|
|
}
|
|
</div>
|
|
<div className="hr" />
|
|
{this.state.groupTopic ?
|
|
<div className="panel-form-column">
|
|
<div className="panel-form-row">
|
|
<label className="small">Group members:</label>
|
|
</div>
|
|
<div className="panel-form-row">
|
|
{this.state.sharer ?
|
|
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
|
|
<i className="material-icons">person_add</i> Add members
|
|
</a>
|
|
: null}
|
|
{!this.state.owner ?
|
|
<a href="#" className="red flat-button" onClick={this.handleLeave}>
|
|
<i className="material-icons">exit_to_app</i> Leave
|
|
</a>
|
|
: null}
|
|
</div>
|
|
<ContactList
|
|
contacts={this.state.contactList}
|
|
emptyListMessage="No members"
|
|
topicSelected={this.state.selectedContact}
|
|
showOnline={false}
|
|
noScroll={true}
|
|
onTopicSelected={this.handleMemberSelected}
|
|
showContextMenu={this.handleContextMenu}
|
|
/>
|
|
</div>
|
|
:
|
|
<div className="panel-form-row">
|
|
<a href="#" className="red flat-button" onClick={this.handleLeave}>
|
|
<i className="material-icons">exit_to_app</i> Leave
|
|
</a>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END InfoView */
|
|
|
|
/* BEGIN Conversation panel */
|
|
var Received = {
|
|
UNDEF: -2,
|
|
SENDING: -1,
|
|
SERVER: 0,
|
|
CLIENT: 1,
|
|
READ: 2
|
|
};
|
|
|
|
var Message = React.createClass({
|
|
render: function() {
|
|
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"));
|
|
return (
|
|
<li className={sideClass}>
|
|
{this.props.userFrom && this.props.response ?
|
|
<div className="avatar-box">
|
|
{fullDisplay ?
|
|
<LetterTile topic={this.props.userFrom}
|
|
title={this.props.userName} avatar={avatar} /> :
|
|
null}
|
|
</div> :
|
|
null}
|
|
<div>
|
|
<div className={bubbleClass}>
|
|
<p>{this.props.content}
|
|
<Message.ReceivedMarker response={this.props.response}
|
|
timestamp={this.props.timestamp} received={this.props.received} />
|
|
</p>
|
|
</div>
|
|
{fullDisplay ? <div className="author">{this.props.userName}</div> : null}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
});
|
|
|
|
/* Received/read indicator */
|
|
Message.ReceivedMarker = React.createClass({
|
|
render: function() {
|
|
var timestamp = (this.props.received === Received.SENDING) ?
|
|
"sending ..." :
|
|
shortDateFormat(this.props.timestamp);
|
|
var marker = null;
|
|
|
|
if (this.props.received === Received.SENDING) {
|
|
marker = (<i className="material-icons small">access_time</i>); // watch face
|
|
} else if (this.props.received === Received.SERVER) {
|
|
marker = (<i className="material-icons small">done</i>); // checkmark
|
|
} else if (this.props.received === Received.CLIENT) {
|
|
marker = (<i className="material-icons small">done_all</i>); // double checkmark
|
|
} else if (this.props.received === Received.READ) {
|
|
marker = (<i className="material-icons small blue">done_all</i>); // open eye
|
|
}
|
|
|
|
return (
|
|
<span className="timestamp">
|
|
{timestamp}{'\u00a0'}{marker}
|
|
</span>
|
|
);
|
|
}
|
|
});
|
|
|
|
var MessagesView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
messages: [],
|
|
topic: '',
|
|
title: '',
|
|
avatar: null
|
|
};
|
|
},
|
|
// Scroll last message into view on component update e.g. on message received.
|
|
componentDidUpdate: function() {
|
|
var node = this.refs.messagesPanel;
|
|
if (node) {
|
|
node.scrollTop = node.scrollHeight;
|
|
}
|
|
},
|
|
componentWillMount: function() {
|
|
this.propsChange(this.props);
|
|
},
|
|
componentWillReceiveProps: function(nextProps) {
|
|
this.propsChange(nextProps);
|
|
},
|
|
propsChange: function(props) {
|
|
if (!props.topic) {
|
|
this.setState({messages: [], topic: null});
|
|
} else if (props.topic != this.state.topic) {
|
|
var msgs = [];
|
|
var topic = Tinode.getTopic(props.topic);
|
|
// Bind the new topic to component.
|
|
topic.onData = this.handleNewMessage;
|
|
topic.onInfo = this.handleInfoReceipt;
|
|
topic.onMetaDesc = this.handleDescChange;
|
|
topic.onSubsUpdated = this.handleSubsUpdated;
|
|
// Unbind the previous topic from this component.
|
|
if (this.state.topic) {
|
|
var oldTopic = Tinode.getTopic(this.state.topic);
|
|
if (oldTopic.isSubscribed()) {
|
|
oldTopic.leave(false);
|
|
}
|
|
oldTopic.onData = undefined;
|
|
oldTopic.onInfo = undefined;
|
|
oldTopic.onMetaDesc = undefined;
|
|
oldTopic.onSubsUpdated = undefined;
|
|
}
|
|
|
|
if (topic.isSubscribed()) {
|
|
this.handleDescChange(topic);
|
|
topic.messages(function(msg) {
|
|
msgs = msgs.concat(msg);
|
|
});
|
|
} else {
|
|
topic.subscribe({get: {desc: {}, sub: {}, data: {}}});
|
|
}
|
|
|
|
// The user switched to the new topic before the timer for
|
|
// the previous topic has triggered, kill it.
|
|
clearTimeout(this.readTimer);
|
|
|
|
this.setState({messages: msgs, topic: props.topic});
|
|
}
|
|
},
|
|
handleDescChange: function(desc) {
|
|
if (desc.public) {
|
|
this.setState({
|
|
title: desc.public.fn,
|
|
avatar: makeImageUrl(desc.public.photo)
|
|
});
|
|
}
|
|
},
|
|
handleSubsUpdated: function(uids) {
|
|
// console.log("topic subscriptions updated");
|
|
},
|
|
handleNewMessage: function(msg) {
|
|
clearTimeout(this.readTimer);
|
|
if (msg) {
|
|
this.setState({messages: this.state.messages.concat(msg)});
|
|
// Aknowledge all messages, including own messges.
|
|
this.readTimer = setTimeout(function() {
|
|
Tinode.getTopic(msg.topic).noteRead(msg.seq);
|
|
}, READ_DELAY);
|
|
this.props.onData(msg);
|
|
} else {
|
|
// msg could be null if one or more messages were deleted.
|
|
// Just regenerate the message list;
|
|
var _messages = [];
|
|
Tinode.getTopic(this.state.topic).messages(function(m) {
|
|
_messages.concat(m);
|
|
});
|
|
this.setState({messages: _messages});
|
|
}
|
|
},
|
|
handleInfoReceipt: function(info) {
|
|
switch (info.what) {
|
|
case "kp":
|
|
console.log("show typing notification");
|
|
break;
|
|
case "read":
|
|
case "recv":
|
|
// Redraw due to changed recv/read status.
|
|
this.forceUpdate();
|
|
break;
|
|
default:
|
|
console.log("other change in topic: " + info.what);
|
|
}
|
|
},
|
|
handleContextClick: function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.props.showContextMenu(this.state.topic, e.pageY, e.pageX);
|
|
},
|
|
|
|
render: function() {
|
|
var component = null;
|
|
if (this.state.topic) {
|
|
var messageNodes = [];
|
|
var topic = Tinode.getTopic(this.state.topic);
|
|
var groupTopic = topic.getType() === "grp";
|
|
var previousFrom = null;
|
|
for (var i=0; i<this.state.messages.length; i++) {
|
|
var msg = this.state.messages[i];
|
|
var nextFrom = (i + 1 < this.state.messages.length) ? this.state.messages[i+1].from : null;
|
|
var sequence = "single";
|
|
if (msg.from === previousFrom) {
|
|
if (msg.from === nextFrom) {
|
|
sequence = "middle";
|
|
} else {
|
|
sequence = "last";
|
|
}
|
|
} else if (msg.from === nextFrom) {
|
|
sequence = "first";
|
|
}
|
|
previousFrom = msg.from;
|
|
|
|
var isReply = !(msg.from === this.props.myUserId);
|
|
var msgReceived = Received.UNDEF;
|
|
if (!isReply) {
|
|
if (topic.msgReadCount(msg.seq) > 0) {
|
|
msgReceived = Received.READ;
|
|
} else if (topic.msgRecvCount(msg.seq) > 0) {
|
|
msgReceived = Received.CLIENT;
|
|
} else {
|
|
msgReceived = Received.SERVER;
|
|
}
|
|
}
|
|
var userName, userAvatar, userFrom, chatBoxClass;
|
|
if (groupTopic) {
|
|
var user = topic.userDesc(msg.from);
|
|
if (user && user.public) {
|
|
userName = user.public.fn;
|
|
userAvatar = makeImageUrl(user.public.photo);
|
|
}
|
|
userFrom = msg.from;
|
|
chatBoxClass="chat-box group";
|
|
} else {
|
|
chatBoxClass="chat-box";
|
|
}
|
|
messageNodes.push(
|
|
<Message content={msg.content} timestamp={msg.ts} response={isReply}
|
|
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
|
sequence={sequence} received={msgReceived} key={msg.seq} />
|
|
);
|
|
}
|
|
|
|
var lastSeen = null;
|
|
var cont = Tinode.getMeTopic().getContact(this.state.topic);
|
|
if (cont && Tinode.getTopicType(cont.topic) === "p2p") {
|
|
if (cont.online) {
|
|
lastSeen = "online now";
|
|
} else if (cont.seen) {
|
|
lastSeen = "Last seen: " + shortDateFormat(cont.seen.when);
|
|
// TODO(gene): also handle user agent in c.seen.ua
|
|
}
|
|
}
|
|
var avatar = this.state.avatar || true;
|
|
component = (
|
|
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
<div id="topic-caption-panel" className="caption-panel">
|
|
{this.props.displayMobile ?
|
|
<a href="#" id="hide-message-view" onClick={this.props.onHideMessagesView}>
|
|
<i className="material-icons big">arrow_back</i>
|
|
</a>
|
|
:
|
|
null}
|
|
<div className="avatar-box">
|
|
<LetterTile avatar={avatar}
|
|
topic={this.state.topic} title={this.state.title} />
|
|
<span className="online" />
|
|
</div>
|
|
<div id="topic-title-group">
|
|
<div id="topic-title" className="panel-title">{this.state.title}</div>
|
|
<div id="topic-last-seen">{lastSeen}</div>
|
|
</div>
|
|
<div id="topic-users"></div>
|
|
<div>
|
|
<a href="#" onClick={this.handleContextClick}>
|
|
<i className="material-icons big">more_vert</i>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div id="messages-panel" ref="messagesPanel">
|
|
<ul className={chatBoxClass}>
|
|
{messageNodes}
|
|
</ul>
|
|
</div>
|
|
<MessagesView.Send topic={this.props.topic} sendMessage={this.props.sendMessage} />
|
|
</div>
|
|
);
|
|
} else {
|
|
component = (
|
|
<MessagesView.Dummy hideSelf={this.props.hideSelf}
|
|
serverVersion={this.props.serverVersion}
|
|
serverAddress={this.props.serverAddress} />
|
|
);
|
|
}
|
|
return component;
|
|
}
|
|
});
|
|
|
|
/* Send message form */
|
|
MessagesView.Send = React.createClass({
|
|
getInitialState:function() {
|
|
return {
|
|
message: '',
|
|
// Make initial keypress time as if it happened 5001 milliseconds in the past.
|
|
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
|
|
};
|
|
},
|
|
handleSend: function() {
|
|
var message = this.state.message.trim();
|
|
if (message) {
|
|
this.props.sendMessage(this.state.message.trim());
|
|
this.setState({message: ''});
|
|
}
|
|
},
|
|
/* Send on Enter key */
|
|
handleKeyPress: function(e) {
|
|
/* Remove this if you don't want Enter to trigger send */
|
|
if (e.key === 'Enter') {
|
|
this.handleSend();
|
|
}
|
|
},
|
|
handleMessageTyping: function(e) {
|
|
var newState = {message: e.target.value};
|
|
var now = new Date().getTime();
|
|
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
|
|
var topic = Tinode.getTopic(this.props.topic);
|
|
if (topic.isSubscribed()) {
|
|
topic.noteKeyPress();
|
|
}
|
|
newState.keypressTimestamp = now;
|
|
}
|
|
this.setState(newState);
|
|
},
|
|
render: function() {
|
|
return (
|
|
<div id="send-message-panel">
|
|
<input id="sendMessage" type="text" placeholder="New message"
|
|
value={this.state.message} onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress} />
|
|
<a href="#" onClick={this.handleSend}>
|
|
<i className="material-icons">send</i>
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
|
|
/* This is just a static page to display when no conversation is selected. */
|
|
MessagesView.Dummy = React.createClass({
|
|
render: function() {
|
|
var version = Tinode.getVersion() + " build " + document.lastModified;
|
|
return (
|
|
<div id="dummy-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
|
<div>
|
|
<a href="https://github.com/tinode/chat/">
|
|
<img id="logo" alt="logo" src="img/logo.svg" />
|
|
<h2>Tinode Demo Chat</h2>
|
|
</a>
|
|
<p>Client: {version}</p>
|
|
<p>Server: {this.props.serverVersion} ({this.props.serverAddress})</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* END Conversation panel */
|
|
|
|
/* This is a div which covers the entire AppView and shows selected modal dialog box */
|
|
var ModalDialogWrapper = React.createClass({
|
|
render: function() {
|
|
var dialog = null;
|
|
switch (this.props.dialog) {
|
|
case "new-topic":
|
|
dialog = <ModalDialogWrapper.NewTopic
|
|
onOk={this.props.onOk}
|
|
onCancel={this.props.onCancel} />;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
if (dialog) {
|
|
dialog = <div className="dialog-overlay">{dialog}</div>;
|
|
}
|
|
return dialog;
|
|
}
|
|
});
|
|
|
|
ModalDialogWrapper.NewTopic = React.createClass({
|
|
render: function() {
|
|
return (
|
|
<div className="modal-dialog">
|
|
<div className="title-panel">
|
|
New topic
|
|
</div>
|
|
<div className="body-panel">Not implemented</div>
|
|
<div className="dialog-buttons">
|
|
<a href="#" onClick={this.props.onOk}>Yes</a>
|
|
<a href="#" onClick={this.props.onCancel}>OK</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
});
|
|
/* End of modal dialog wrapper */
|
|
|
|
/* The top-level class to hold all fuinctionality together */
|
|
var AppView = React.createClass({
|
|
getInitialState: function() {
|
|
return {
|
|
transport: null,
|
|
serverAddress: detectServerAddress(),
|
|
sidePanelSelected: 'login',
|
|
sidePanelTitle: null,
|
|
sidePanelAvatar: null,
|
|
dialogSelected: null,
|
|
contextMenuVisible: false,
|
|
login: '',
|
|
password: '',
|
|
myUserId: null,
|
|
errorText: '',
|
|
topicSelected: '',
|
|
loginDisabled: false,
|
|
subscriptions: {},
|
|
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
|
|
showInfoPanel: false,
|
|
infoPanelShowMembers: false,
|
|
mobilePanel: 'sidepanel',
|
|
contextMenuVisible: false,
|
|
contextMenuBounds: null,
|
|
contextMenuClickAt: null,
|
|
contextMenuTopic: '',
|
|
contextMenuItems: [],
|
|
serverVersion: 'no connection',
|
|
contactsSearchQuery: '',
|
|
foundContacts: []
|
|
};
|
|
},
|
|
componentDidMount: function() {
|
|
window.addEventListener('resize', this.handleResize);
|
|
Tinode.enableLogging(true, true);
|
|
Tinode.onConnect = this.handleConnected;
|
|
Tinode.onDisconnect = this.handleDisconnect;
|
|
this.tnSetup(this.state.serverAddress, this.state.transport);
|
|
},
|
|
// Setup transport (usually websocket) and server address. This will terminate connection with the server.
|
|
tnSetup: function(serverAddress, transport) {
|
|
Tinode.setup(APP_NAME, serverAddress, API_KEY, transport);
|
|
},
|
|
componentWillUnmount: function(){
|
|
window.removeEventListener('resize', this.handleResize);
|
|
},
|
|
handleResize: function(){
|
|
var mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
|
|
if (this.state.displayMobile != mobile) {
|
|
this.setState({displayMobile: mobile});
|
|
}
|
|
},
|
|
// User clicked Login button in the side panel.
|
|
handleLoginRequest: function(login, password) {
|
|
var instance = this;
|
|
this.setState({loginDisabled: true, errorText: '', login: login, password: password});
|
|
if (Tinode.isConnected()) {
|
|
this.doLogin(login, password);
|
|
} else {
|
|
Tinode.connect().catch(function(err) {
|
|
// Socket error
|
|
instance.setState({loginDisabled: false, errorText: err.message});
|
|
});
|
|
}
|
|
},
|
|
// Connection succeeded.
|
|
handleConnected: function() {
|
|
var params = Tinode.getServerInfo();
|
|
this.setState({serverVersion: params.ver + " (" + (params.build ? params.build : "none") + ")"});
|
|
this.doLogin(this.state.login, this.state.password);
|
|
},
|
|
doLogin: function(login, password) {
|
|
var instance = this;
|
|
|
|
// Try to login with token. If token is not available, try password. If no password, ask for it.
|
|
var promise = null;
|
|
var token = Tinode.getLoginToken();
|
|
if (token) {
|
|
promise = Tinode.loginToken(token.token);
|
|
} else if (password) {
|
|
this.setState({password: null});
|
|
promise = Tinode.loginBasic(login, password);
|
|
} else {
|
|
this.setState({loginDisabled: false});
|
|
}
|
|
|
|
if (promise) {
|
|
promise.then(function() {
|
|
instance.handleLoginSuccessful(instance);
|
|
})
|
|
.catch(function(err) {
|
|
// Login failed, report error
|
|
instance.setState({loginDisabled: false, errorText: err.message});
|
|
});
|
|
}
|
|
},
|
|
|
|
handleLoginSuccessful: function(instance) {
|
|
instance.setState({errorText: ''});
|
|
|
|
// Logged in fine, subscribe to 'me' attaching callbacks from the contacts view.
|
|
var me = Tinode.getMeTopic();
|
|
me.onMetaDesc = instance.tnMeMetaDesc;
|
|
instance.setState({sidePanelSelected: "contacts", myUserId: Tinode.getCurrentUserID()});
|
|
// Subscribe, fetch topic desc, the list of subscriptions, and messages (invites).
|
|
me.subscribe({get: {desc: {}, sub: {}, data: {}}});
|
|
},
|
|
|
|
handleDisconnect: function(err) {
|
|
this.setState({
|
|
sidePanelSelected: 'login',
|
|
sidePanelTitle: null,
|
|
sidePanelAvatar: null,
|
|
dialogSelected: null,
|
|
myUserId: null,
|
|
errorText: err && err.message ? err.message : 'disconnected',
|
|
topicSelected: '',
|
|
loginDisabled: false,
|
|
subscriptions: {},
|
|
showInfoPanel: false,
|
|
infoPanelShowMembers: false,
|
|
mobilePanel: 'sidepanel',
|
|
contextMenuVisible: false,
|
|
contextMenuBounds: null,
|
|
contextMenuClickAt: null,
|
|
contextMenuTopic: '',
|
|
contextMenuItems: [],
|
|
serverVersion: 'no connection'
|
|
});
|
|
},
|
|
tnMeMetaDesc: function(desc) {
|
|
this.setState({
|
|
sidePanelTitle: desc.public.fn,
|
|
sidePanelAvatar: makeImageUrl(desc.public.photo)
|
|
});
|
|
},
|
|
// Sending "received" notifications
|
|
tnData: function(data) {
|
|
clearTimeout(this.receivedTimer);
|
|
this.receivedTimer = setTimeout(function() {
|
|
this.receivedTimer = undefined;
|
|
Tinode.getTopic(data.topic).noteRecv(data.seq);
|
|
}, RECEIVED_DELAY);
|
|
},
|
|
|
|
/* Fnd topic: find contacts by tokens */
|
|
tnInitFind: function() {
|
|
var fnd = Tinode.getFndTopic();
|
|
fnd.onSubsUpdated = this.tnFndSubsUpdated;
|
|
fnd.onMetaDesc = this.tnFndMetaDesc;
|
|
if (fnd.isSubscribed()) {
|
|
this.setState({contactsSearchQuery: fnd.private ? fnd.private.toString() : ''});
|
|
this.tnFndSubsUpdated();
|
|
} else {
|
|
fnd.subscribe({get: {sub: {}, desc: {}}});
|
|
}
|
|
},
|
|
|
|
tnFndMetaDesc: function(desc) {
|
|
if (desc.private) {
|
|
this.setState({contactsSearchQuery: desc.private.toString()});
|
|
}
|
|
},
|
|
|
|
tnFndSubsUpdated: function() {
|
|
var contacts = [];
|
|
// Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
|
|
Tinode.getFndTopic().subscribers(function(s) {
|
|
contacts.push(s);
|
|
});
|
|
this.setState({foundContacts: contacts});
|
|
},
|
|
/** Called when the user enters a contact into the contact search field in the NewAccount panel
|
|
@param query {Array} is an array of contacts to search for
|
|
*/
|
|
handleSearchContacts: function(query) {
|
|
var find = Tinode.getFndTopic();
|
|
find.setMeta({desc: {private: query}}).then(function(ctrl) {
|
|
find.getMeta({sub: {}, desc: {}});
|
|
});
|
|
},
|
|
|
|
// User clicked on a contact in the side panel or deleted a contact.
|
|
handleTopicSelected: function(topicName) {
|
|
if (topicName) {
|
|
// Contact selected
|
|
this.setState({
|
|
topicSelected: topicName,
|
|
mobilePanel: 'topic-view',
|
|
showInfoPanel: false
|
|
});
|
|
} else {
|
|
// Currently selected contact deleted
|
|
this.setState({
|
|
topicSelected: null,
|
|
mobilePanel: 'sidepanel',
|
|
showInfoPanel: false
|
|
});
|
|
}
|
|
},
|
|
// In mobile view user requested to show sidepanel
|
|
handleHideMessagesView: function() {
|
|
this.setState({mobilePanel: 'sidepanel'});
|
|
},
|
|
// User clicked on a message send button.
|
|
handleSendMessage: function(msg) {
|
|
var topic = Tinode.getTopic(this.state.topicSelected);
|
|
topic.publish(msg);
|
|
},
|
|
// User chose a Sign Up menu item.
|
|
handleNewAccount: function() {
|
|
this.setState({sidePanelSelected: 'register'});
|
|
},
|
|
// Actual registration of a new account.
|
|
handleNewAccountRequest: function(login_, password_, public_, private_, tags_) {
|
|
var instance = this;
|
|
tags_ = tags_ || []; // init tags_ value just in case undefined
|
|
Tinode.connect(this.state.serverAddress)
|
|
.then(function() {
|
|
return Tinode.createAccountBasic(login_, password_,
|
|
{public: public_, private: private_, tags: tags_});
|
|
})
|
|
.then(function() {
|
|
instance.handleLoginSuccessful(instance);
|
|
})
|
|
.catch(function(err) {
|
|
instance.setState({errorText: err.message});
|
|
});
|
|
},
|
|
|
|
handleUpdateAccountRequest: function(password, pub, priv) {
|
|
if (pub || priv) {
|
|
Tinode.getMeTopic().setMeta({desc: {public: pub, private: priv}});
|
|
}
|
|
if (password) {
|
|
Tinode.updateAccountBasic(null, Tinode.getCurrentLogin(), password);
|
|
}
|
|
},
|
|
// User chose Settings menu item.
|
|
handleSettings: function() {
|
|
this.setState({sidePanelSelected: this.state.myUserId ? 'edit-account' : 'settings'});
|
|
},
|
|
// User updated connection parameters - transport and server address
|
|
handleConnectionSettings: function(serverAddress, transport) {
|
|
transport = transport || this.state.transport;
|
|
serverAddress = serverAddress || this.state.serverAddress;
|
|
this.setState({serverAddress: serverAddress, transport: transport, sidePanelSelected: 'login'});
|
|
this.tnSetup(serverAddress, transport);
|
|
},
|
|
// User clicked Cancel button in Setting or Sign Up panel.
|
|
handleSidepanelCancel: function() {
|
|
if (this.state.myUserId) {
|
|
this.setState({sidePanelSelected: 'contacts'});
|
|
} else {
|
|
this.setState({sidePanelSelected: 'login'});
|
|
}
|
|
},
|
|
// User clicked a (+) menu item.
|
|
handleNewTopic: function() {
|
|
this.setState({sidePanelSelected: 'new-topic'});
|
|
},
|
|
// Create of a new topic. New P2P topic requires peer's name.
|
|
handleNewTopicRequest: function(peerName, pub) {
|
|
var instance = this;
|
|
|
|
var topic = peerName ? Tinode.newTopicWith(peerName) : Tinode.newTopic();
|
|
var params = {get: {desc: {}, data: {}, sub: {}}};
|
|
if (!peerName) {
|
|
params.set = {desc: {public: pub}};
|
|
}
|
|
|
|
topic.subscribe(params).then(function() {
|
|
instance.setState({
|
|
errorText: '',
|
|
topicSelected: topic.name,
|
|
sidePanelSelected: 'contacts',
|
|
mobilePanel: 'topic-view',
|
|
showInfoPanel: false,
|
|
infoPanelShowMembers: peerName ? false : true
|
|
});
|
|
}).catch(function(err) {
|
|
instance.setState({errorText: err.message});
|
|
});
|
|
},
|
|
|
|
handleTopicUpdateRequest: function(topicName, pub, priv, permissions) {
|
|
var topic = Tinode.getTopic(topicName);
|
|
if (topic) {
|
|
var params = {};
|
|
if (pub) {
|
|
params.public = pub;
|
|
}
|
|
if (priv) {
|
|
params.private = priv;
|
|
}
|
|
if (permissions) {
|
|
params.defacs = permissions;
|
|
}
|
|
topic.setMeta({desc: params});
|
|
}
|
|
},
|
|
|
|
handleChangePermissions: function(topicName, mode, uid) {
|
|
var topic = Tinode.getTopic(topicName);
|
|
if (topic) {
|
|
var am = topic.getAccessMode();
|
|
if (uid) {
|
|
am.updateGiven(mode);
|
|
mode = am.getGiven();
|
|
} else {
|
|
am.updateWant(mode);
|
|
mode = am.getWant();
|
|
}
|
|
topic.setMeta({sub: {user: uid, mode: mode}});
|
|
}
|
|
},
|
|
|
|
handleLeaveUnsubRequest: function(topicName) {
|
|
var topic = Tinode.getTopic(topicName);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
var instance = this;
|
|
topic.leave(true).then(function(ctrl) {
|
|
// Hide MessageView and InfoView panels.
|
|
instance.setState({
|
|
topicSelected: '',
|
|
showInfoPanel: false,
|
|
infoPanelShowMembers: false
|
|
});
|
|
});
|
|
},
|
|
|
|
handleDialogCancel: function() {
|
|
this.setState({dialogSelected: null});
|
|
},
|
|
handleShowContextMenu: function(topicName, y, x) {
|
|
var muted = false, blocked = false;
|
|
var topic = Tinode.getTopic(topicName);
|
|
if (topic) {
|
|
var acs = topic.getAccessMode();
|
|
muted = acs && acs.isMuted();
|
|
blocked = acs && !acs.isJoiner();
|
|
}
|
|
this.setState({
|
|
contextMenuVisible: true,
|
|
contextMenuClickAt: {x: x, y: y},
|
|
contextMenuTopic: topicName,
|
|
contextMenuItems: [
|
|
"Info",
|
|
"Clear messages",
|
|
muted ? "Unmute" : "Mute",
|
|
blocked ? "Unblock" : "Block",
|
|
"Delete"
|
|
],
|
|
contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect()
|
|
});
|
|
},
|
|
handleHideContextMenu: function() {
|
|
this.setState({
|
|
contextMenuVisible: false,
|
|
contextMenuClickAt: null,
|
|
contextMenuTopic: '',
|
|
contextMenuBounds: null
|
|
});
|
|
},
|
|
// Menu click.
|
|
handleContextMenuAction: function(topicName, action) {
|
|
if (!topicName || !action) {
|
|
return;
|
|
}
|
|
|
|
var topic = Tinode.getTopic(topicName);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
|
|
var metaParam = function(want) {
|
|
var am = topic.getAccessMode();
|
|
am.updateWant(want);
|
|
topic.setMeta({sub: {mode: am.getWant()}});
|
|
}
|
|
|
|
switch (action) {
|
|
case "Info":
|
|
this.handleShowInfoView();
|
|
break;
|
|
case "Clear messages":
|
|
topic.delMessagesAll(false).then(function(){
|
|
console.log("refresh message list");
|
|
});
|
|
break;
|
|
case "Mute":
|
|
metaParam('-P');
|
|
break;
|
|
case "Unmute":
|
|
metaParam('+P');
|
|
break;
|
|
case "Block":
|
|
metaParam('-J');
|
|
break;
|
|
case "Unblock":
|
|
metaParam('+J');
|
|
break;
|
|
case "Delete":
|
|
topic.delTopic();
|
|
break;
|
|
default:
|
|
console.log("Unknown menu action '" + action + "'");
|
|
break;
|
|
}
|
|
},
|
|
handleShowInfoView: function() {
|
|
this.setState({showInfoPanel: true, infoPanelShowMembers: false});
|
|
},
|
|
handleHideInfoView: function() {
|
|
this.setState({showInfoPanel: false, infoPanelShowMembers: false});
|
|
},
|
|
handleMemberUpdateRequest: function(topicName, added, removed) {
|
|
if (!topicName) {
|
|
return;
|
|
}
|
|
|
|
var topic = Tinode.getTopic(topicName);
|
|
if (!topic) {
|
|
return;
|
|
}
|
|
|
|
if (added && added.length > 0) {
|
|
added.map(function(uid) {
|
|
topic.invite(uid, null, "Join my group!");
|
|
});
|
|
}
|
|
|
|
if (removed && removed.length > 0) {
|
|
removed.map(function(uid) {
|
|
topic.delSubscription(uid);
|
|
});
|
|
}
|
|
},
|
|
|
|
render: function() {
|
|
return (
|
|
<div id="app-container">
|
|
<ModalDialogWrapper
|
|
dialog={this.state.dialogSelected}
|
|
onOk={this.handleDialogCancel}
|
|
onCancel={this.handleDialogCancel} />
|
|
|
|
<ContextMenu
|
|
bounds={this.state.contextMenuBounds}
|
|
clickAt={this.state.contextMenuClickAt}
|
|
visible={this.state.contextMenuVisible}
|
|
topic={this.state.contextMenuTopic}
|
|
items={this.state.contextMenuItems}
|
|
hide={this.handleHideContextMenu}
|
|
onAction={this.handleContextMenuAction} />
|
|
|
|
<SidepanelView
|
|
displayMobile={this.state.displayMobile}
|
|
hideSelf={(this.state.displayMobile && this.state.mobilePanel !== 'sidepanel')}
|
|
state={this.state.sidePanelSelected}
|
|
title={this.state.sidePanelTitle}
|
|
avatar={this.state.sidePanelAvatar}
|
|
subscriptions={this.state.subscriptions}
|
|
login={this.state.login}
|
|
myUserId={this.state.myUserId}
|
|
loginDisabled={this.state.loginDisabled}
|
|
errorText={this.state.errorText}
|
|
topicSelected={this.state.topicSelected}
|
|
|
|
serverAddress={this.state.serverAddress}
|
|
onConnectionSettings={this.handleConnectionSettings}
|
|
|
|
onSignUp={this.handleNewAccount}
|
|
onSettings={this.handleSettings}
|
|
onLoginRequest={this.handleLoginRequest}
|
|
onCreateAccount={this.handleNewAccountRequest}
|
|
onUpdateAccount={this.handleUpdateAccountRequest}
|
|
onTopicSelected={this.handleTopicSelected}
|
|
onCreateTopic={this.handleNewTopicRequest}
|
|
onNewTopic={this.handleNewTopic}
|
|
onCancel={this.handleSidepanelCancel}
|
|
|
|
onInitFind={this.tnInitFind}
|
|
contactsSearchQuery={this.state.contactsSearchQuery}
|
|
foundContacts={this.state.foundContacts}
|
|
onSearchContacts={this.handleSearchContacts}
|
|
|
|
showContextMenu={this.handleShowContextMenu} />
|
|
|
|
<MessagesView
|
|
onHideMessagesView={this.handleHideMessagesView}
|
|
displayMobile={this.state.displayMobile}
|
|
hideSelf={(this.state.displayMobile &&
|
|
(this.state.mobilePanel !== 'topic-view' || this.state.showInfoPanel))}
|
|
topic={this.state.topicSelected}
|
|
myUserId={this.state.myUserId}
|
|
serverVersion={this.state.serverVersion}
|
|
serverAddress={this.state.serverAddress}
|
|
onData={this.tnData}
|
|
showContextMenu={this.handleShowContextMenu}
|
|
sendMessage={this.handleSendMessage} />
|
|
|
|
{this.state.showInfoPanel ?
|
|
<InfoView
|
|
topic={this.state.topicSelected}
|
|
foundContacts={this.state.foundContacts}
|
|
myUserId={this.state.myUserId}
|
|
showMemberPanel={this.state.infoPanelShowMembers}
|
|
onTopicDescUpdate={this.handleTopicUpdateRequest}
|
|
onCancel={this.handleHideInfoView}
|
|
onChangePermissions={this.handleChangePermissions}
|
|
onMemberUpdateRequest={this.handleMemberUpdateRequest}
|
|
onLeaveTopic={this.handleLeaveUnsubRequest}
|
|
onAddMember={this.handleManageGroupMembers}
|
|
onInitFind={this.tnInitFind}
|
|
/>
|
|
:
|
|
null
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
})
|
|
|
|
ReactDOM.render(
|
|
<AppView />,
|
|
document.getElementById('mountPoint')
|
|
);
|
|
</script>
|
|
</body>
|
|
</html>
|