split the code into components
This commit is contained in:
+1
-1
@@ -17,7 +17,7 @@
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
||||
<link rel="canonical" href="https://api.tinode.co/" />
|
||||
<!-- Google's Roboto font -->
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700&subset=latin,cyrillic"
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700"
|
||||
rel="stylesheet" type="text/css" />
|
||||
<!-- Google's material design icons -->
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
|
||||
+3
-1
@@ -4,6 +4,7 @@
|
||||
"version": "0.15.8-rc1",
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-plugin-react-intl": "^3.0.1",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"cssnano": "^4.0.5",
|
||||
@@ -17,7 +18,7 @@
|
||||
"build": "npm run vers && npm run build:babel && npm run build:prod && npm run build:dev && npm run build:css",
|
||||
"build:prod": "browserify ./src/index.js --standalone Webapp | uglifyjs --compress --mangle > ./umd/index.prod.js",
|
||||
"build:dev": "browserify ./src/index.js --standalone Webapp > ./umd/index.dev.js",
|
||||
"build:babel": "babel ./src/webapp.jsx --presets react,env --out-file ./src/webapp.es5.js",
|
||||
"build:babel": "babel ./src/webapp.jsx --out-file ./src/webapp.es5.js",
|
||||
"build:css": "postcss css/base.css > css/base.min.css",
|
||||
"vers": "echo \"{\\\"version\\\": \\\"`node -p -e \"require('./package.json').version\"`\\\"}\" > version.json"
|
||||
},
|
||||
@@ -69,6 +70,7 @@
|
||||
"firebase": "^5.5.5",
|
||||
"react": "^16.5.2",
|
||||
"react-dom": "^16.5.2",
|
||||
"react-intl": "^2.7.1",
|
||||
"tinode-sdk": "^0.15.8-rc1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import FileProgress from './file-progress.jsx';
|
||||
|
||||
export default 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 ?
|
||||
<span className="small gray">({bytesToHumanSize(this.props.size)})</span> :
|
||||
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 (
|
||||
<div className="attachment">
|
||||
<div><i className="material-icons big gray">insert_drive_file</i></div>
|
||||
<div className="flex-column">
|
||||
<div>{filename} {size}</div>
|
||||
{this.props.uploader || this.state.downloader ?
|
||||
<FileProgress progress={this.props.uploader ? this.props.progress : this.state.progress}
|
||||
onCancel={this.handleCancel} />
|
||||
:
|
||||
<div><a href={url} download={this.props.filename} onClick={helperFunc} >
|
||||
<i className="material-icons">file_download</i> save
|
||||
</a></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
export default 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 (
|
||||
<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={randId} className="inputfile hidden"
|
||||
accept="image/*" onChange={this.handleFileUpload} />}
|
||||
{this.props.readOnly ? null :
|
||||
<label htmlFor={randId} className="round">
|
||||
<i className="material-icons">file_upload</i>
|
||||
</label>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import {Drafty} from 'tinode-sdk'
|
||||
|
||||
import LetterTile from './letter-tile.jsx'
|
||||
import ReceivedMarker from './received-marker.jsx'
|
||||
|
||||
/* Single message, sent or received. */
|
||||
export default 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(<Attachment
|
||||
tinode={this.props.tinode}
|
||||
downloadUrl={Drafty.getDownloadUrl(att)}
|
||||
filename={att.name} uploader={Drafty.isUploading(att)}
|
||||
mimetype={att.mime} size={Drafty.getEntitySize(att)}
|
||||
progress={this.state.progress}
|
||||
onCancelUpload={this.handleCancelUpload}
|
||||
onError={this.props.onError}
|
||||
key={i} />);
|
||||
}, this);
|
||||
content = React.createElement('span', null, Drafty.format(content, formatter, this));
|
||||
} else if (typeof content != 'string') {
|
||||
content = <span><i className="material-icons">error_outline</i> <i>invalid content</i></span>
|
||||
}
|
||||
|
||||
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}>
|
||||
<div className="message-content">
|
||||
{content}
|
||||
{attachments}
|
||||
<ReceivedMarker
|
||||
timestamp={this.props.timestamp}
|
||||
received={this.props.received} />
|
||||
</div>
|
||||
<span className="menuTrigger">
|
||||
<a href="javascript:;" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{fullDisplay ? <div className="author">{this.props.userName}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
/* BEGIN CheckBox: styled checkbox */
|
||||
export default 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 */
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
|
||||
/* BEGIN ChipInput: group membership widget */
|
||||
export default 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(
|
||||
<Chip
|
||||
onCancel={this.handleChipCancel}
|
||||
avatar={makeImageUrl(item.public ? item.public.photo : null)}
|
||||
title={item.public ? item.public.fn : undefined}
|
||||
noAvatar={this.props.avatarDisabled}
|
||||
topic={item.user}
|
||||
required={item.user === this.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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 (
|
||||
<div className="chip">
|
||||
{this.props.noAvatar ?
|
||||
<span className="spacer" /> :
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={this.props.avatar || true}
|
||||
topic={this.props.topic}
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
}
|
||||
<span>{title}</span>
|
||||
{this.props.onCancel && !this.props.required ?
|
||||
<a href="javascript:;" onClick={this.handleCancel} >×</a>
|
||||
: <span className="spacer" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Contact's labels: [you], [muted], [blocked], etc */
|
||||
// FIXME: this class is unused.
|
||||
export default 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(<span className={style} key={b.name}>{b.name}</span>);
|
||||
});
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import Contact from './contact.jsx';
|
||||
|
||||
/* ContactList: component for showing a list of contacts,
|
||||
* such as a list of group members in a group chat */
|
||||
export default 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(
|
||||
<Contact
|
||||
title={c.public ? c.public.fn : null}
|
||||
avatar={makeImageUrl(c.public ? c.public.photo : null)}
|
||||
comment={comment}
|
||||
unread={this.props.showUnread ? c.unread : 0}
|
||||
now={c.online && this.props.connected}
|
||||
acs={c.acs}
|
||||
showMode={this.props.showMode}
|
||||
badges={badges}
|
||||
showCheckmark={showCheckmark}
|
||||
selected={selected}
|
||||
showOnline={this.props.showOnline}
|
||||
onSelected={this.props.onTopicSelected}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
item={key}
|
||||
index={contactNodes.length}
|
||||
key={key} />
|
||||
);
|
||||
}, this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={this.props.noScroll ? null : "scrollable-panel"}>
|
||||
{contactNodes.length > 0 ?
|
||||
<ul className="contact-box">
|
||||
{contactNodes}
|
||||
</ul>
|
||||
:
|
||||
<div className="center-medium-text">
|
||||
{this.props.emptyListMessage}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
/* END ContactList */
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import UnreadBadge from './unread-badge.jsx';
|
||||
|
||||
/* A single topic or user */
|
||||
|
||||
export default 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 = <i>unknown</i>;
|
||||
} 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(<span className={style} key={count}>{b.name}</span>);
|
||||
count ++;
|
||||
});
|
||||
}
|
||||
if (this.props.showMode && this.props.acs) {
|
||||
badges.push(<span className="badge" key="mode">{this.props.acs.getMode()}</span>);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={!this.props.showCheckmark && this.props.selected ? "selected" : null}
|
||||
onClick={this.handleClick}>
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={avatar}
|
||||
title={this.props.title}
|
||||
topic={this.props.item} />
|
||||
{this.props.showOnline ? <span className={online} /> :
|
||||
(this.props.showCheckmark && this.props.selected ?
|
||||
<i className="checkmark material-icons">check_circle</i>
|
||||
: null)}
|
||||
</div>
|
||||
<div className="text-box">
|
||||
<div><span className="contact-title">{title}</span>
|
||||
{this.props.unread > 0 ? <UnreadBadge count={this.props.unread} /> : null}
|
||||
</div>
|
||||
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
|
||||
<span>{badges}</span>
|
||||
</div>
|
||||
{this.props.showContextMenu ?
|
||||
<span className="menuTrigger">
|
||||
<a href="javascript:;" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">expand_more</i>
|
||||
</a>
|
||||
</span> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
/* ContactsView holds all contacts-related stuff */
|
||||
|
||||
export default 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 (
|
||||
<ContactList
|
||||
connected={this.props.connected}
|
||||
contacts={this.state.contactList}
|
||||
emptyListMessage={<span>You have no chats<br />¯\_(ツ)_/¯</span>}
|
||||
topicSelected={this.props.topicSelected}
|
||||
myUserId={this.props.myUserId}
|
||||
showOnline={true}
|
||||
showUnread={true}
|
||||
onTopicSelected={this.props.onTopicSelected}
|
||||
showContextMenu={this.props.showContextMenu} />
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Context Menu: popup/dropdown menu */
|
||||
|
||||
export default 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 == '-' ?
|
||||
<li className="separator" key={count} />
|
||||
:
|
||||
<li onClick={this.handleClick} data-id={count} key={count}>{item.title}</li>
|
||||
);
|
||||
}
|
||||
count++;
|
||||
});
|
||||
|
||||
// Ensure that menu is inside the app-container.
|
||||
var hSize = 12 * REM_SIZE;
|
||||
var vSize = REM_SIZE * (0.7 + menu.length * 2.5);
|
||||
var left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
|
||||
(this.props.clickAt.x - this.props.bounds.left - hSize) :
|
||||
(this.props.clickAt.x - this.props.bounds.left);
|
||||
var top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
|
||||
(this.props.clickAt.y - this.props.bounds.top - vSize) :
|
||||
(this.props.clickAt.y - this.props.bounds.top);
|
||||
|
||||
var position = {
|
||||
left: left + 'px',
|
||||
top: top + 'px'
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="menu" style={position}>
|
||||
{menu}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Account registration */
|
||||
|
||||
export default 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 (
|
||||
<form className="panel-form-column" onSubmit={this.handleSubmit}>
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<input type="text" placeholder="Login" autoComplete="user-name"
|
||||
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
|
||||
<VisiblePassword placeholder="Password" autoComplete="new-password"
|
||||
value={this.state.password} onFinished={this.handlePasswordChange}
|
||||
required={true} />
|
||||
</div>
|
||||
<AvatarUpload
|
||||
onImageChanged={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<input type="text" placeholder="Full name, e.g. John Doe" autoComplete="name"
|
||||
value={this.state.fn} onChange={this.handleFnChange} required/>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<input type="email" placeholder="Email, e.g john.doe@example.com"
|
||||
autoComplete="email" value={this.state.email} onChange={this.handleEmailChange} required/>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
||||
onChange={this.handleToggleSaveToken} />
|
||||
<label htmlFor="save-token"> Stay logged in</label>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className={submitClasses} type="submit">Sign up</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
|
||||
import AvatarUpload from './avatar-upload.jsx';
|
||||
import CheckBox from './checkbox.jsx';
|
||||
import PermissionsEditor from './permissions-editor.jsx';
|
||||
import TagManager from './tag-manager.jsx';
|
||||
|
||||
/* Edit account parameters */
|
||||
|
||||
export default 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(<span className="badge" key={tags.length}>{tag}</span>);
|
||||
});
|
||||
if (tags.length == 0) {
|
||||
tags = <i>No tags defined. Add some.</i>;
|
||||
}
|
||||
return (
|
||||
<React.Fragment>{this.state.showPermissionEditorFor ?
|
||||
<PermissionsEditor
|
||||
mode={this.state.editedPermissions}
|
||||
skip="O"
|
||||
onSubmit={this.handlePermissionsChanged}
|
||||
onCancel={this.handleHidePermissionsEditor} />
|
||||
:
|
||||
<div id="edit-account" className="scrollable-panel">
|
||||
<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"
|
||||
value={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.props.myUserId}
|
||||
title={this.state.fullName}
|
||||
onImageChanged={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label>Address:</label>
|
||||
<tt>{this.props.myUserId}</tt>
|
||||
</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>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-row">
|
||||
<label htmlFor="message-sound">Message sound:</label>
|
||||
<CheckBox name="sound" id="message-sound"
|
||||
checked={this.props.messageSounds}
|
||||
onChange={this.handleCheckboxClick} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label htmlFor="desktop-alerts">Notification
|
||||
alerts{!this.props.desktopAlertsEnabled ? ' (requires HTTPS)' : null}:</label>
|
||||
<CheckBox name="alert" id="desktop-alerts"
|
||||
checked={this.props.desktopAlerts}
|
||||
onChange={this.props.desktopAlertsEnabled ? this.handleCheckboxClick : null} />
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<TagManager
|
||||
title="Tags (user discovery)"
|
||||
activated={false}
|
||||
tags={this.state.tags}
|
||||
onSubmit={this.handleTagsUpdated} />
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="javascript:;" className="red flat-button" onClick={this.props.onLogout}>
|
||||
<i className="material-icons">exit_to_app</i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}</React.Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
import MenuCancel from './menu-cancel.jsx';
|
||||
|
||||
export default 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 (
|
||||
<div className={this.state.show ?
|
||||
(this.props.level == 'err' ? 'alert-box error' : 'alert-box warning') :
|
||||
"alert-box"}>
|
||||
<div className="icon"><i className="material-icons">{icon}</i></div>
|
||||
{this.props.text}
|
||||
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
/* File uload/download progress indicator with a cancel inside */
|
||||
|
||||
export default class FileProgress extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="uploader">
|
||||
<div><span style={{width: (this.props.progress * 100) + "%"}}></span></div>
|
||||
{this.props.progress < 0.999 ?
|
||||
<a href="javascript:;" onClick={this.props.onCancel}><i className="material-icons">close</i> cancel</a>
|
||||
:
|
||||
<span>finishing...</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
|
||||
/* GroupMembers: control for managing a list of group members */
|
||||
export default 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<values.length; i++) {
|
||||
if (values[i].indexOf(filter) >= 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 (
|
||||
<div id="group-manager">
|
||||
<div className="panel-form-row">
|
||||
<label className="small">Group members</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<ChipInput
|
||||
chips={this.state.members}
|
||||
required={this.props.requiredMember}
|
||||
prompt="add members"
|
||||
filterFunc={this.handleContactFilter}
|
||||
onChipRemoved={this.handleMemberRemoved} />
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<label className="small">All contacts</label>
|
||||
</div>
|
||||
<ContactList
|
||||
contacts={this.props.contacts}
|
||||
myUserId={this.props.myUserId}
|
||||
topicSelected={this.state.selectedContacts}
|
||||
filter={this.state.contactFilter}
|
||||
filterFunc={GroupManager.doContactFiltering}
|
||||
emptyListMessage={this.state.noContactsMessage}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
<div id="group-manager-buttons" className="panel-form-row">
|
||||
<button className="blue" onClick={this.handleSubmit}>OK</button>
|
||||
<button className="white" onClick={this.handleCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
/* GroupSubs: a list of group subscribers currently online */
|
||||
export default 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(
|
||||
<div className="avatar-box" key={sub.user}>
|
||||
<LetterTile
|
||||
topic={sub.user}
|
||||
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
|
||||
title={sub.public ? sub.public.fn : null} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div id="topic-users">{usersOnline}</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Combobox for selecting host name */
|
||||
|
||||
export default 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(
|
||||
<option key={item} value={item} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="panel-form-row">
|
||||
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
|
||||
className="quoted" value={this.state.hostName} onChange={this.handleHostNameChange}
|
||||
onBlur={this.handleEditingFinished} required />
|
||||
<datalist id="known-hosts">
|
||||
{hostOptions}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 (
|
||||
<div id="image-preview" onClick={this.props.onClose}>
|
||||
<div id="image-preview-caption-panel">
|
||||
<a href={this.props.content.url} download={this.props.content.filename}>
|
||||
<i className="material-icons">file_download</i> download
|
||||
</a>
|
||||
<a href="javascript:;" onClick={this.props.onClose}><i className="material-icons gray">close</i></a>
|
||||
</div>
|
||||
<div id="image-preview-container" ref={function(ref) {instance.container = ref;}}>
|
||||
<img src={this.props.content.url} style={size} />
|
||||
</div>
|
||||
<div id="image-preview-footer">
|
||||
<div><div><b>File name</b>:</div><div><span title={this.props.content.filename}>{filename}</span></div></div>
|
||||
<div><div><b>Content type</b>:</div><div>{this.props.content.type}</div></div>
|
||||
<div>
|
||||
<div><b>Size</b>:</div>
|
||||
<div>{this.props.content.width} × {this.props.content.height} px; {bytesToHumanSize(this.props.content.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
|
||||
/* BEGIN In-place text editor. Shows text with an icon
|
||||
* which toggles it into an input field */
|
||||
export default 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' ?
|
||||
<VisiblePassword
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
autoFocus={true}
|
||||
onFinished={this.handlePasswordFinished}/>
|
||||
:
|
||||
<input type={fieldType}
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
autoFocus
|
||||
onChange={this.handeTextChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onBlur={this.handleEditingFinished} />
|
||||
)
|
||||
:
|
||||
<span className={spanClass} onClick={this.handleStartEditing}>
|
||||
<span className="content">{spanText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,544 @@
|
||||
import React from 'react';
|
||||
|
||||
/* InfoView: panel with topic/user info */
|
||||
export default 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 (
|
||||
<div id="info-view">
|
||||
<div className="caption-panel" id="info-caption-panel">
|
||||
<div className="panel-title" id="info-title">Info</div>
|
||||
<div>
|
||||
<MenuCancel onCancel={this.props.onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} /> : null}
|
||||
{this.state.showMemberPanel ?
|
||||
<GroupManager
|
||||
members={this.state.contactList}
|
||||
requiredMember={this.props.myUserId}
|
||||
myUserId={this.props.myUserId}
|
||||
contacts={this.props.searchableContacts}
|
||||
onCancel={this.handleHideAddMembers}
|
||||
onSubmit={this.handleMemberUpdateRequest} />
|
||||
:
|
||||
this.state.showPermissionEditorFor ?
|
||||
<PermissionsEditor
|
||||
mode={this.state.editedPermissions}
|
||||
compare={this.state.immutablePermissions}
|
||||
skip={this.state.editedPermissionsSkipped}
|
||||
modeTitle={this.state.editedPermissionsTitle}
|
||||
compareTitle={this.state.immutablePermissionsTitle}
|
||||
userTitle={this.state.userPermissionsTitle}
|
||||
item={this.state.userPermissionsEdited}
|
||||
userAvatar={this.state.userPermissionsAvatar}
|
||||
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={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
|
||||
readOnly={!this.state.owner}
|
||||
value={this.state.fullName}
|
||||
onFinished={this.handleFullNameUpdate} /></div>
|
||||
<div><label className="small">Private comment</label></div>
|
||||
<div><InPlaceEdit
|
||||
placeholder="Visible to you only"
|
||||
value={this.state.private}
|
||||
onFinished={this.handlePrivateUpdate} /></div>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
avatar={this.state.avatar}
|
||||
readOnly={!this.state.owner}
|
||||
uid={this.props.topic}
|
||||
title={this.state.fullName}
|
||||
onImageChanged={this.handleImageChanged}
|
||||
onError={this.props.onError} />
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label>Muted:</label>
|
||||
<CheckBox name="P" checked={this.state.muted}
|
||||
onChange={this.handleMuted} />
|
||||
</div>
|
||||
<MoreButton
|
||||
title="More"
|
||||
open={this.state.moreInfoExpanded}
|
||||
onToggle={this.handleMoreInfo} />
|
||||
{this.state.moreInfoExpanded ?
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label>Address:</label>
|
||||
<tt>{this.state.address}</tt>
|
||||
</div>
|
||||
{this.state.groupTopic ?
|
||||
<div className="panel-form-row">
|
||||
<label>Your permissions:</label>
|
||||
<tt className="clickable"
|
||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
||||
{this.state.access}
|
||||
</tt>
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<div><label className="small">Permissions:</label></div>
|
||||
<div className="quoted">
|
||||
<div>Yours: <tt className="clickable"
|
||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
|
||||
{this.state.access}
|
||||
</tt></div>
|
||||
<div>{this.state.fullName ? this.state.fullName : "Other"}′s:
|
||||
<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
|
||||
{this.state.modeGiven2}
|
||||
</tt>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{this.state.sharer && (this.state.auth || this.state.anon) ?
|
||||
<div>
|
||||
<div><label className="small">Default access mode:</label></div>
|
||||
<div className="quoted">
|
||||
<div>Auth: {this.state.admin ?
|
||||
<tt className="clickable"
|
||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
|
||||
{this.state.auth}
|
||||
</tt>
|
||||
:
|
||||
<tt>{this.state.auth}</tt>
|
||||
}
|
||||
</div>
|
||||
<div>Anon: {this.state.admin ?
|
||||
<tt className="clickable"
|
||||
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
|
||||
{this.state.anon}
|
||||
</tt>
|
||||
:
|
||||
<tt>{this.state.anon}</tt>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className="hr" />
|
||||
{this.state.owner ?
|
||||
<TagManager
|
||||
title="Tags"
|
||||
tags={this.state.tags}
|
||||
activated={false}
|
||||
onSubmit={this.handleTagsUpdated} />
|
||||
:
|
||||
null
|
||||
}
|
||||
{this.state.owner ? <div className="hr" /> : null }
|
||||
{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="javascript:;" className="flat-button" onClick={this.handleShowAddMembers}>
|
||||
<i className="material-icons">person_add</i> Add members
|
||||
</a>
|
||||
: null}
|
||||
{!this.state.owner ?
|
||||
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
|
||||
<i className="material-icons">exit_to_app</i> Leave
|
||||
</a>
|
||||
: null}
|
||||
</div>
|
||||
<ContactList
|
||||
contacts={this.state.contactList}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage="No members"
|
||||
topicSelected={this.state.selectedContact}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showMode={true}
|
||||
noScroll={true}
|
||||
onTopicSelected={this.handleMemberSelected}
|
||||
showContextMenu={this.state.admin ? this.handleContextMenu : false}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<div className="panel-form-row">
|
||||
<a href="javascript:;" className="red flat-button" onClick={this.handleLeave}>
|
||||
<i className="material-icons">exit_to_app</i> Leave
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 = (<div className={color}><div>{letter}</div></div>)
|
||||
} else {
|
||||
avatar = (Tinode.topicType(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class LoadSpinner extends React.PureComponent {
|
||||
render() {
|
||||
return (this.props.show ?
|
||||
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
/* BEGIN Login: a login form */
|
||||
export default 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 (
|
||||
<form id="login-form" onSubmit={this.handleSubmit}>
|
||||
<FormattedMessage id="login.login_prompt" defaultMessage="Login">
|
||||
{
|
||||
(login_prompt) => <input type="text" id="inputLogin"
|
||||
placeholder={login_prompt}
|
||||
autoComplete="username"
|
||||
value={this.state.login}
|
||||
onChange={this.handleLoginChange}
|
||||
required autoFocus />
|
||||
}
|
||||
</FormattedMessage>
|
||||
<FormattedMessage id="login.password_prompt" defaultMessage="Password">
|
||||
{
|
||||
(password_prompt) => <VisiblePassword type="password" id="inputPassword"
|
||||
placeholder={password_prompt}
|
||||
autoComplete="current-password"
|
||||
value={this.state.password}
|
||||
onChange={this.handlePasswordChange}
|
||||
required={true} />
|
||||
}
|
||||
</FormattedMessage>
|
||||
<div className="panel-form-row">
|
||||
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
||||
onChange={this.handleToggleSaveToken} />
|
||||
<label htmlFor="save-token">
|
||||
<FormattedMessage id="login.stay_logged_in" defaultMessage="Stay logged in" />
|
||||
</label>
|
||||
<a href="#reset">
|
||||
<FormattedMessage id="login.forgot_password_link" defaultMessage="Forgot password?" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className={submitClasses} type="submit">
|
||||
<FormattedMessage id="login.sign_in_button" defaultMessage="Sign in" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
/* END Login */
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
/* This is just a static page to display when no conversation is selected. */
|
||||
export default class LogoView extends React.PureComponent {
|
||||
render() {
|
||||
var version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
|
||||
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 Web</h2>
|
||||
</a>
|
||||
<p>Client: {version}</p>
|
||||
<p>Server: {this.props.serverVersion} ({this.props.serverAddress})</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
/* The X menu to be displayed in title bars */
|
||||
export default class MenuCancel extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<a href="javascript:;" onClick={this.props.onCancel}><i className="material-icons">close</i></a>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuContacts extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<a href="javascript:;" onClick={this.props.onNewTopic}><i className="material-icons">chat</i></a>
|
||||
|
||||
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class MenuStart extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<a href="javascript:;" onClick={this.props.onSignUp}><i className="material-icons">person_add</i></a>
|
||||
|
||||
<a href="javascript:;" onClick={this.props.onSettings}><i className="material-icons">settings</i></a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,486 @@
|
||||
import React from 'react';
|
||||
|
||||
const NOT_FOUND_TOPIC_TITLE = "Not found";
|
||||
|
||||
export default 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<this.state.messages.length; i++) {
|
||||
var msg = this.state.messages[i];
|
||||
var nextFrom = null;
|
||||
|
||||
if (i + 1 < this.state.messages.length) {
|
||||
nextFrom = this.state.messages[i+1].from
|
||||
}
|
||||
|
||||
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 deliveryStatus = topic.msgStatus(msg);
|
||||
|
||||
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(
|
||||
<ChatMessage
|
||||
tinode={this.props.tinode}
|
||||
content={msg.content}
|
||||
mimeType={msg.head ? msg.head.mime : null}
|
||||
timestamp={msg.ts} response={isReply} seq={msg.seq}
|
||||
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
|
||||
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
|
||||
viewportWidth={this.props.viewportWidth}
|
||||
showContextMenu={this.handleShowContextMenuMessage}
|
||||
onImagePreview={this.handleImagePreview}
|
||||
onError={this.props.onError}
|
||||
key={msg.seq} />
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
|
||||
<div id="topic-caption-panel" className="caption-panel">
|
||||
{this.props.displayMobile ?
|
||||
<a href="javascript:;" id="hide-message-view" onClick={this.handleBackNavigation}>
|
||||
<i className="material-icons">arrow_back</i>
|
||||
</a>
|
||||
:
|
||||
null}
|
||||
<div className="avatar-box">
|
||||
<LetterTile
|
||||
avatar={avatar}
|
||||
topic={this.state.topic}
|
||||
title={this.state.title} />
|
||||
<span className={online} />
|
||||
</div>
|
||||
<div id="topic-title-group">
|
||||
<div id="topic-title" className="panel-title">{this.state.title || <i>Unknown</i>}</div>
|
||||
<div id="topic-last-seen">{lastSeen}</div>
|
||||
</div>
|
||||
{groupTopic ?
|
||||
<GroupSubs
|
||||
subscribers={this.state.onlineSubs} /> :
|
||||
<div id="topic-users" />
|
||||
}
|
||||
<div>
|
||||
<a href="javascript:;" onClick={this.handleContextClick}>
|
||||
<i className="material-icons">more_vert</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.displayMobile ?
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} />
|
||||
: null}
|
||||
<LoadSpinner show={this.state.fetchingMessages} />
|
||||
<div id="messages-container">
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
{this.state.writeOnly ?
|
||||
<div id="write-only-background">
|
||||
<div id="write-only-note">no access to messages</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<SendMessage
|
||||
tinode={this.props.tinode}
|
||||
topic={this.props.topic}
|
||||
disabled={this.state.readOnly}
|
||||
sendMessage={this.props.sendMessage}
|
||||
onError={this.props.onError} />
|
||||
{this.state.imagePreview ?
|
||||
<ImagePreview content={this.state.imagePreview}
|
||||
onClose={this.handleCloseImagePreview} /> : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
component = (
|
||||
<LogoView hideSelf={this.props.hideSelf}
|
||||
serverVersion={this.props.serverVersion}
|
||||
serverAddress={this.props.serverAddress} />
|
||||
);
|
||||
}
|
||||
return component;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
// Toggle [Title text >] -> [Title text v]
|
||||
export default 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 (<label className="small" onClick={this.handleToggle}>{this.props.title}...
|
||||
{this.state.open ? <i className="material-icons">expand_more</i> :
|
||||
<i className="material-icons">chevron_right</i>}
|
||||
</label>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 (
|
||||
<div className="panel-form">
|
||||
<div className="panel-form-row">
|
||||
<input type="text" placeholder="Group or User ID"
|
||||
value={this.state.groupId} onChange={this.handleChange}
|
||||
onKeyPress={this.handleKeyPress} required />
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className="blue" onClick={this.handleSubmit}>Subscribe</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 (
|
||||
<form className="panel-form" onSubmit={this.handleSubmit}>
|
||||
<div className="panel-form-row">
|
||||
<div className="panel-form-column">
|
||||
<label className="small" htmlFor="new-topic-fn">Group name</label>
|
||||
<input type="text" id="new-topic-fn" placeholder="Freeform name of the group"
|
||||
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
|
||||
<br />
|
||||
<label className="small" htmlFor="new-topic-priv">Private comment</label>
|
||||
<input type="text" id="new-topic-priv" placeholder="Visible to you only"
|
||||
value={this.state.private} onChange={this.handlePrivateChange} />
|
||||
</div>
|
||||
<AvatarUpload
|
||||
onError={this.props.onError}
|
||||
onImageChanged={this.handleImageChanged} />
|
||||
</div>
|
||||
<TagManager
|
||||
tags={this.state.tags}
|
||||
activated={true}
|
||||
onTagsChanged={this.handleTagsChanged}
|
||||
title="Optional tags (search and discovery)" />
|
||||
<div className="dialog-buttons">
|
||||
<button className={submitClasses}>Create</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Create new topic and invite users or send an invite */
|
||||
|
||||
export default 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 (
|
||||
<div className="flex-column">
|
||||
<ul className="tabbar">
|
||||
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
|
||||
<a href="javascript:;" data-id="p2p" onClick={this.handleTabClick}>find</a>
|
||||
</li>
|
||||
<li className={this.state.tabSelected === "grp" ? "active" : null}>
|
||||
<a href="javascript:;" data-id="grp" onClick={this.handleTabClick}>new group</a>
|
||||
</li>
|
||||
<li className={this.state.tabSelected === "byid" ? "active" : null}>
|
||||
<a href="javascript:;" data-id="byid" onClick={this.handleTabClick}>by id</a>
|
||||
</li>
|
||||
</ul>
|
||||
{this.state.tabSelected === 'grp' ?
|
||||
<NewTopicGroup onSubmit={this.handleNewGroupSubmit} /> :
|
||||
this.state.tabSelected === 'byid' ?
|
||||
<NewTopicById
|
||||
onSubmit={this.handleGroupByID}
|
||||
onError={this.props.onError} /> :
|
||||
<div className="flex-column">
|
||||
<SearchContacts type="p2p"
|
||||
onSearchContacts={this.props.onSearchContacts} />
|
||||
<ContactList
|
||||
contacts={this.state.contactList}
|
||||
myUserId={this.props.myUserId}
|
||||
emptyListMessage="Use search to find contacts"
|
||||
topicSelected={this.state.selectedContact}
|
||||
showOnline={false}
|
||||
showUnread={false}
|
||||
showContextMenu={false}
|
||||
onTopicSelected={this.handleContactSelected} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/* A password reset form */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import VisiblePassword from './visible-password.jsx';
|
||||
|
||||
export default 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 (
|
||||
<form id="password-reset-form" onSubmit={this.handleSubmit}>
|
||||
{reset ?
|
||||
<VisiblePassword
|
||||
placeholder="Enter new password"
|
||||
autoComplete="new-password"
|
||||
value={this.state.password}
|
||||
required={true} autoFocus={true}
|
||||
onChange={this.handlePasswordChange} />
|
||||
:
|
||||
<React.Fragment>
|
||||
<label htmlFor="inputEmail">Send a password reset email:</label>
|
||||
<input type="email" id="inputEmail"
|
||||
placeholder="Your registration email"
|
||||
autoComplete="email"
|
||||
value={this.state.email}
|
||||
onChange={this.handleEmailChange}
|
||||
required autoFocus />
|
||||
</React.Fragment>
|
||||
}
|
||||
<div className="dialog-buttons">
|
||||
<button className="blue" type="submit">{reset ? "Reset" : "Send request"}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
|
||||
/* BEGIN PermissionsEditor: Component for editing permissions */
|
||||
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
|
||||
export default 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<all.length; i++) {
|
||||
let c = all.charAt(i);
|
||||
if (skip.indexOf(c) >= 0 && mode.indexOf(c) < 0) {
|
||||
// Permission is marked as inactive: hide unchecked permissions, disable checked permissions
|
||||
continue;
|
||||
}
|
||||
items.push(
|
||||
<tr key={c}>
|
||||
<td>{names[c]}</td>
|
||||
<td className="checkbox">{skip.indexOf(c) < 0 ?
|
||||
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} onChange={this.handleChange}/>
|
||||
:
|
||||
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} />
|
||||
}</td>{this.props.compare ? <td className="checkbox">
|
||||
<CheckBox name={c} checked={(compare.indexOf(c) >= 0)}/>
|
||||
</td> : null}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-form-column">
|
||||
{this.props.userTitle ?
|
||||
<ul className="contact-box"><Contact
|
||||
item={this.props.item}
|
||||
title={this.props.userTitle}
|
||||
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
|
||||
<label className="small">Permissions</label>
|
||||
<table className="permission-editor">
|
||||
{this.props.compare ?
|
||||
<thead><tr>
|
||||
<th></th><th>{this.props.modeTitle}</th>
|
||||
<th>{this.props.compareTitle}</th>
|
||||
</tr></thead> :
|
||||
null}
|
||||
<tbody>
|
||||
{items}
|
||||
</tbody></table>
|
||||
<br />
|
||||
<div className="dialog-buttons">
|
||||
<button className="blue" onClick={this.handleSubmit}>Ok</button>
|
||||
<button className="white" onClick={this.handleCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
/* END PermissionsEditor */
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Received/read indicator */
|
||||
export default 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 = (<i className="material-icons small">access_time</i>); // watch face
|
||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||
marker = (<i className="material-icons small red">error_outline</i>); // icon (!)
|
||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
|
||||
marker = (<i className="material-icons small">done</i>); // checkmark
|
||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
|
||||
marker = (<i className="material-icons small">done_all</i>); // double checkmark
|
||||
} else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
|
||||
marker = (<i className="material-icons small blue">done_all</i>); // blue double checkmark
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="timestamp">
|
||||
{timestamp}{'\u00a0'}{marker}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 (
|
||||
<div className="panel-form">
|
||||
<div className="panel-form-row">
|
||||
<i className="material-icons search">search</i>
|
||||
<input className="search" type="text"
|
||||
placeholder="List like email:alice@example.com, tel:17025550003..."
|
||||
value={this.state.search} onChange={this.handleSearchChange}
|
||||
onKeyDown={this.handleKeyDown} required autoFocus />
|
||||
<a href="javascript:;" onClick={this.handleClear}>
|
||||
<i className="material-icons">close</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Send message form */
|
||||
export default 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 (
|
||||
<div id="send-message-panel">
|
||||
{this.props.disabled ?
|
||||
<i className="material-icons disabled">photo</i> :
|
||||
<a href="javascript:;" onClick={function(e) {instance.attachImage.click();}} title="Add image">
|
||||
<i className="material-icons secondary">photo</i>
|
||||
</a>}
|
||||
{this.props.disabled ?
|
||||
<i className="material-icons disabled">attach_file</i> :
|
||||
<a href="javascript:;" onClick={function(e) {instance.attachFile.click();}} title="Attach file">
|
||||
<i className="material-icons secondary">attach_file</i>
|
||||
</a>}
|
||||
<textarea id="sendMessage" placeholder={prompt}
|
||||
disabled={this.props.disabled} value={this.state.message}
|
||||
onChange={this.handleMessageTyping} onKeyPress={this.handleKeyPress}
|
||||
ref={function(ref) {instance.pasteFile = ref;}}
|
||||
autoFocus />
|
||||
{this.props.disabled ?
|
||||
<i className="material-icons disabled">send</i> :
|
||||
<a href="javascript:;" onClick={this.handleSend} title="Send"><i className="material-icons">send</i></a>}
|
||||
<input type="file" ref={function(ref) {instance.attachFile = ref;}}
|
||||
onChange={this.handleAttachFile} style={{display: 'none'}} />
|
||||
<input type="file" ref={function(ref) {instance.attachImage = ref;}} accept="image/*"
|
||||
onChange={this.handleAttachImage} style={{display: 'none'}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
/* Tinode config panel */
|
||||
export default 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(
|
||||
<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" className="panel-form" onSubmit={this.handleSubmit}>
|
||||
<div className="panel-form-row">
|
||||
<label className="small">Server to use:</label>
|
||||
</div>
|
||||
<HostSelector serverAddress={this.state.serverAddress}
|
||||
onServerAddressChange={this.handleServerAddressChange} />
|
||||
<div className="panel-form-row">
|
||||
<label className="small">Wire transport:</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<ul className="quoted">
|
||||
{transportOptions}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button type="submit" className="blue">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
|
||||
export default class SideNavbar extends React.PureComponent {
|
||||
render() {
|
||||
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' ?
|
||||
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
|
||||
this.props.state === 'contacts' ?
|
||||
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
|
||||
null}
|
||||
{this.props.onCancel ?
|
||||
<MenuCancel onCancel={this.props.onCancel} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
|
||||
export default 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 (
|
||||
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
|
||||
<SideNavbar
|
||||
state={view}
|
||||
title={title}
|
||||
avatar={avatar}
|
||||
myUserId={this.props.myUserId}
|
||||
onSignUp={this.props.onSignUp}
|
||||
onSettings={this.props.onSettings}
|
||||
onNewTopic={this.props.onNewTopic}
|
||||
onCancel={onCancel} />
|
||||
|
||||
<ErrorPanel
|
||||
level={this.props.errorLevel}
|
||||
text={this.props.errorText}
|
||||
onClearError={this.props.onError} />
|
||||
|
||||
{view === 'login' ?
|
||||
<LoginView
|
||||
login={this.props.login}
|
||||
disabled={this.props.loginDisabled}
|
||||
onLogin={this.handleLoginRequested} /> :
|
||||
|
||||
view === 'register' ?
|
||||
<CreateAccountView
|
||||
onCreateAccount={this.props.onCreateAccount}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} /> :
|
||||
|
||||
view === 'settings' ?
|
||||
<SettingsView
|
||||
transport={this.props.transport}
|
||||
serverAddress={this.props.serverAddress}
|
||||
onServerAddressChange={this.props.onGlobalSettings}
|
||||
onCancel={this.props.onCancel}
|
||||
onUpdate={this.props.onGlobalSettings} /> :
|
||||
|
||||
view === 'edit' ?
|
||||
<EditAccountView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
messageSounds={this.props.messageSounds}
|
||||
desktopAlerts={this.props.desktopAlerts}
|
||||
desktopAlertsEnabled={this.props.desktopAlertsEnabled}
|
||||
onUpdateAccount={this.props.onUpdateAccount}
|
||||
onUpdateTags={this.props.onUpdateAccountTags}
|
||||
onTogglePushNotifications={this.props.onTogglePushNotifications}
|
||||
onToggleMessageSounds={this.props.onToggleMessageSounds}
|
||||
onLogout={this.props.onLogout}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} /> :
|
||||
|
||||
view === 'contacts' ?
|
||||
<ContactsView
|
||||
tinode={this.props.tinode}
|
||||
myUserId={this.props.myUserId}
|
||||
connected={this.props.connected}
|
||||
topicSelected={this.props.topicSelected}
|
||||
chatList={this.props.chatList}
|
||||
showContextMenu={this.props.showContextMenu}
|
||||
onTopicSelected={this.props.onTopicSelected} /> :
|
||||
|
||||
view === 'newtpk' ?
|
||||
<NewTopicView
|
||||
searchResults={this.props.searchResults}
|
||||
onInitFind={this.props.onInitFind}
|
||||
onSearchContacts={this.props.onSearchContacts}
|
||||
onCreateTopic={this.props.onCreateTopic}
|
||||
onError={this.props.onError} /> :
|
||||
|
||||
view === 'cred' ?
|
||||
<ValidationView
|
||||
credCode={this.props.credCode}
|
||||
credMethod={this.props.credMethod}
|
||||
onSubmit={this.props.onValidateCredentials}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} /> :
|
||||
|
||||
view === 'reset' ?
|
||||
<PasswordResetView
|
||||
onRequest={this.props.onPasswordResetRequest}
|
||||
onReset={this.props.onResetPassword} /> :
|
||||
null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
|
||||
|
||||
/* TagManager: edit topic or user tags */
|
||||
export default 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(<span className="badge" key={tags.length}>{tag}</span>);
|
||||
});
|
||||
if (tags.length == 0) {
|
||||
tags = <i>No tags defined. Add some.</i>;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="panel-form-column">
|
||||
<div className="panel-form-row">
|
||||
<label className="small">{this.props.title}</label>
|
||||
</div>
|
||||
{this.state.activated ?
|
||||
<div>
|
||||
<ChipInput
|
||||
chips={tags}
|
||||
avatarDisabled={true}
|
||||
prompt={this.state.noTagsMessage}
|
||||
onEnter={this.handleAddTag}
|
||||
onFocusLost={this.handleAddTag}
|
||||
onCancel={this.handleCancel}
|
||||
onChipRemoved={this.handleRemoveTag}
|
||||
filterFunc={this.handleTagInput} />
|
||||
{this.props.onSubmit || this.props.onCancel ?
|
||||
<div id="tag-manager-buttons" className="panel-form-row">
|
||||
<button className="blue" onClick={this.handleSubmit}>OK</button>
|
||||
<button className="white" onClick={this.handleCancel}>Cancel</button>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
:
|
||||
<div>
|
||||
<a href="javascript:;" className="flat-button" onClick={this.handleShowTagManager}>
|
||||
<i className="material-icons">edit</i> Manage tags
|
||||
</a>
|
||||
<span>{tags}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
/* The counter of unread messages in the topic */
|
||||
export default 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 = <span className="unread">{count}</span>;
|
||||
}
|
||||
return showUnreadBadge;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
/* ValidationView: panel for confirming credentials, like email or phone */
|
||||
export default 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 (
|
||||
<div className="panel-form">
|
||||
<div className="panel-form-row">
|
||||
<label className="small" htmlFor="enter-confirmation-code">
|
||||
Enter confirmation code sent to you by {method}:
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<input type="text" id="enter-confirmation-code" placeholder="Numbers only"
|
||||
value={this.state.code} onChange={this.handleChange}
|
||||
onKeyPress={this.handleKeyPress} required />
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className="blue" onClick={this.handleSubmit}>Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
// Password with a visiblity toggle
|
||||
export default 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 (
|
||||
<div tabIndex="-1" className="group-focus"
|
||||
onBlur={this.handleEditingFinished}>
|
||||
<input className="with-visibility"
|
||||
type={this.state.visible ? "text" : "password"}
|
||||
value={this.state.value}
|
||||
placeholder={this.props.placeholder}
|
||||
required={this.props.required ? 'required' : ''}
|
||||
autoFocus={this.props.autoFocus ? 'autoFocus' : ''}
|
||||
autoComplete={this.props.autoComplete}
|
||||
onChange={this.handeTextChange}
|
||||
onKeyDown={this.handleKeyDown} />
|
||||
<span onClick={this.handleVisibility}>
|
||||
<i className="material-icons clickable light-gray">
|
||||
{this.state.visible ? 'visibility' : 'visibility_off'}
|
||||
</i>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-5157
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user