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="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
||||||
<link rel="canonical" href="https://api.tinode.co/" />
|
<link rel="canonical" href="https://api.tinode.co/" />
|
||||||
<!-- Google's Roboto font -->
|
<!-- 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" />
|
rel="stylesheet" type="text/css" />
|
||||||
<!-- Google's material design icons -->
|
<!-- Google's material design icons -->
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@
|
|||||||
"version": "0.15.8-rc1",
|
"version": "0.15.8-rc1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-cli": "^6.26.0",
|
"babel-cli": "^6.26.0",
|
||||||
|
"babel-plugin-react-intl": "^3.0.1",
|
||||||
"babel-preset-env": "^1.7.0",
|
"babel-preset-env": "^1.7.0",
|
||||||
"babel-preset-react": "^6.24.1",
|
"babel-preset-react": "^6.24.1",
|
||||||
"cssnano": "^4.0.5",
|
"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": "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: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: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",
|
"build:css": "postcss css/base.css > css/base.min.css",
|
||||||
"vers": "echo \"{\\\"version\\\": \\\"`node -p -e \"require('./package.json').version\"`\\\"}\" > version.json"
|
"vers": "echo \"{\\\"version\\\": \\\"`node -p -e \"require('./package.json').version\"`\\\"}\" > version.json"
|
||||||
},
|
},
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"firebase": "^5.5.5",
|
"firebase": "^5.5.5",
|
||||||
"react": "^16.5.2",
|
"react": "^16.5.2",
|
||||||
"react-dom": "^16.5.2",
|
"react-dom": "^16.5.2",
|
||||||
|
"react-intl": "^2.7.1",
|
||||||
"tinode-sdk": "^0.15.8-rc1"
|
"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