sort components into widgets and views
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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,68 @@
|
||||
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,133 @@
|
||||
import React from 'react';
|
||||
import {Drafty} from 'tinode-sdk'
|
||||
|
||||
import Attachment from './attachment.jsx';
|
||||
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,140 @@
|
||||
import React from 'react';
|
||||
|
||||
import Chip from './chip.jsx';
|
||||
|
||||
/* 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,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
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,80 @@
|
||||
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,193 @@
|
||||
/* Context Menu: popup/dropdown menu */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
|
||||
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,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,165 @@
|
||||
/* GroupMembers: control for managing a list of group members */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import ChipInput from './chip-input.jsx';
|
||||
import ContactList from './contact-list.jsx';
|
||||
|
||||
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,38 @@
|
||||
/* GroupSubs: a list of group subscribers currently online */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
|
||||
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,116 @@
|
||||
/* In-place text editor. Shows text with an icon
|
||||
* which toggles it into an input field */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import VisiblePassword from './visible-password.jsx';
|
||||
|
||||
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,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
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,15 @@
|
||||
// The X menu to be displayed in title bars.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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,29 @@
|
||||
// Toggle [Title text >] -> [Title text v]
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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,82 @@
|
||||
import React from 'react';
|
||||
|
||||
import AvatarUpload from './avatar-upload.jsx';
|
||||
import TagManager from './tag-manager.jsx';
|
||||
|
||||
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,110 @@
|
||||
// PermissionsEditor: Component for editing permissions
|
||||
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import CheckBox from './checkbox.jsx';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// Received/read indicator.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Tinode from 'tinode-sdk';
|
||||
|
||||
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,184 @@
|
||||
// Send message form.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import LetterTile from './letter-tile.jsx';
|
||||
import MenuCancel from './menu-cancel.jsx';
|
||||
import MenuContacts from './menu-contacts.jsx';
|
||||
import MenuStart from './menu-start.jsx';
|
||||
|
||||
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 @@
|
||||
// TagManager: edit topic or user tags.
|
||||
import React from 'react';
|
||||
|
||||
import ChipInput from './chip-input.jsx';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
// The counter of unread messages in the topic.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
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,80 @@
|
||||
// Password with a visiblity toggle.
|
||||
import React from 'react';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user