diff --git a/index.html b/index.html
index b122f711..485eb426 100644
--- a/index.html
+++ b/index.html
@@ -17,7 +17,7 @@
-
diff --git a/package.json b/package.json
index 910b841e..39079722 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"version": "0.15.8-rc1",
"devDependencies": {
"babel-cli": "^6.26.0",
+ "babel-plugin-react-intl": "^3.0.1",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"cssnano": "^4.0.5",
@@ -17,7 +18,7 @@
"build": "npm run vers && npm run build:babel && npm run build:prod && npm run build:dev && npm run build:css",
"build:prod": "browserify ./src/index.js --standalone Webapp | uglifyjs --compress --mangle > ./umd/index.prod.js",
"build:dev": "browserify ./src/index.js --standalone Webapp > ./umd/index.dev.js",
- "build:babel": "babel ./src/webapp.jsx --presets react,env --out-file ./src/webapp.es5.js",
+ "build:babel": "babel ./src/webapp.jsx --out-file ./src/webapp.es5.js",
"build:css": "postcss css/base.css > css/base.min.css",
"vers": "echo \"{\\\"version\\\": \\\"`node -p -e \"require('./package.json').version\"`\\\"}\" > version.json"
},
@@ -69,6 +70,7 @@
"firebase": "^5.5.5",
"react": "^16.5.2",
"react-dom": "^16.5.2",
+ "react-intl": "^2.7.1",
"tinode-sdk": "^0.15.8-rc1"
}
}
diff --git a/src/components/attachment.jsx b/src/components/attachment.jsx
new file mode 100644
index 00000000..83de0a8c
--- /dev/null
+++ b/src/components/attachment.jsx
@@ -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 ?
+ ({bytesToHumanSize(this.props.size)}) :
+ null;
+
+ // Detect if the download URL is relative or absolute.
+ // If the URL is relative use LargeFileHelper to attach authentication
+ // credentials to the request.
+ let url, helperFunc;
+ if (!this.props.uploader && !this.state.downloader &&
+ !(/^(?:(?:[a-z]+:)?\/\/)/i.test(this.props.downloadUrl))) {
+ // Relative URL. Use download helper.
+ url = "javascript:;";
+ helperFunc = (e) => {
+ this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
+ };
+ } else {
+ url = this.props.downloadUrl;
+ helperFunc = null;
+ }
+ return (
+
+
insert_drive_file
+
+
{filename} {size}
+ {this.props.uploader || this.state.downloader ?
+
+ :
+
+ }
+
+
+ );
+ }
+};
diff --git a/src/components/avatar-upload.jsx b/src/components/avatar-upload.jsx
new file mode 100644
index 00000000..470b5059
--- /dev/null
+++ b/src/components/avatar-upload.jsx
@@ -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 (
+
+ {this.state.dataUrl ?
+
:
+ this.props.readOnly && this.props.uid ?
+
+
+
+ :
+
128×128
}
+ {this.props.readOnly ? null :
+
}
+ {this.props.readOnly ? null :
+
+ file_upload
+ }
+
+ );
+ }
+};
diff --git a/src/components/chat-message.jsx b/src/components/chat-message.jsx
new file mode 100644
index 00000000..62902ec5
--- /dev/null
+++ b/src/components/chat-message.jsx
@@ -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( );
+ }, this);
+ content = React.createElement('span', null, Drafty.format(content, formatter, this));
+ } else if (typeof content != 'string') {
+ content = error_outline invalid content
+ }
+
+ return (
+
+ {this.props.userFrom && this.props.response ?
+
+ {fullDisplay ?
+ :
+ null}
+
:
+ null}
+
+
+ {fullDisplay ?
{this.props.userName}
: null}
+
+
+ );
+ }
+};
diff --git a/src/components/checkbox.jsx b/src/components/checkbox.jsx
new file mode 100644
index 00000000..04f48f37
--- /dev/null
+++ b/src/components/checkbox.jsx
@@ -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 ?
+ check_box :
+ check_box_outline_blank
+ ) : (
+ this.props.checked ?
+ check_box :
+ check_box_outline_blank
+ )
+ );
+ }
+}
+/* END CheckBox */
diff --git a/src/components/chip-input.jsx b/src/components/chip-input.jsx
new file mode 100644
index 00000000..9463ef29
--- /dev/null
+++ b/src/components/chip-input.jsx
@@ -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(
+
+ );
+ count++;
+ });
+ var className = "chip-input" + (this.state.focused ? ' focused' : '');
+ return (
+
+ {chips}
+
+
+ );
+ }
+};
diff --git a/src/components/chip.jsx b/src/components/chip.jsx
new file mode 100644
index 00000000..8cb63899
--- /dev/null
+++ b/src/components/chip.jsx
@@ -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 (
+
+ {this.props.noAvatar ?
+
:
+
+
+
+ }
+
{title}
+ {this.props.onCancel && !this.props.required ?
+
×
+ :
}
+
+ );
+ }
+};
diff --git a/src/components/contact-badges.jsx b/src/components/contact-badges.jsx
new file mode 100644
index 00000000..733c4957
--- /dev/null
+++ b/src/components/contact-badges.jsx
@@ -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({b.name} );
+ });
+ }
+ return badges;
+ }
+};
diff --git a/src/components/contact-list.jsx b/src/components/contact-list.jsx
new file mode 100644
index 00000000..1c7ea4de
--- /dev/null
+++ b/src/components/contact-list.jsx
@@ -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(
+
+ );
+ }, this);
+ }
+
+ return (
+
+ {contactNodes.length > 0 ?
+
+ :
+
+ {this.props.emptyListMessage}
+
}
+
+ );
+ }
+};
+/* END ContactList */
diff --git a/src/components/contact.jsx b/src/components/contact.jsx
new file mode 100644
index 00000000..10e68fb4
--- /dev/null
+++ b/src/components/contact.jsx
@@ -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 = unknown ;
+ } else if (title.length > 30) {
+ // FIXME: this is wrong for RTL languages.
+ title = title.substring(0, 28) + '...';
+ }
+ var online = this.props.now ? 'online' : 'offline';
+ var avatar = this.props.avatar ? this.props.avatar : true;
+ var badges = [];
+ if (this.props.badges && this.props.badges.length > 0) {
+ var count = 0;
+ this.props.badges.map(function(b) {
+ var style = 'badge' + (b.color ? ' ' + b.color : '');
+ badges.push({b.name} );
+ count ++;
+ });
+ }
+ if (this.props.showMode && this.props.acs) {
+ badges.push({this.props.acs.getMode()} );
+ }
+
+ return (
+
+
+
+ {this.props.showOnline ? :
+ (this.props.showCheckmark && this.props.selected ?
+ check_circle
+ : null)}
+
+
+
{title}
+ {this.props.unread > 0 ? : null}
+
+ {this.props.comment ?
{this.props.comment}
: null}
+
{badges}
+
+ {this.props.showContextMenu ?
+
+
+ expand_more
+
+ : null}
+
+ );
+ }
+};
diff --git a/src/components/contacts-view.jsx b/src/components/contacts-view.jsx
new file mode 100644
index 00000000..33a37242
--- /dev/null
+++ b/src/components/contacts-view.jsx
@@ -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 (
+ You have no chats ¯\_(ツ)_/¯}
+ topicSelected={this.props.topicSelected}
+ myUserId={this.props.myUserId}
+ showOnline={true}
+ showUnread={true}
+ onTopicSelected={this.props.onTopicSelected}
+ showContextMenu={this.props.showContextMenu} />
+ );
+ }
+};
diff --git a/src/components/context-menu.jsx b/src/components/context-menu.jsx
new file mode 100644
index 00000000..31485d05
--- /dev/null
+++ b/src/components/context-menu.jsx
@@ -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 == '-' ?
+
+ :
+ {item.title}
+ );
+ }
+ count++;
+ });
+
+ // Ensure that menu is inside the app-container.
+ var hSize = 12 * REM_SIZE;
+ var vSize = REM_SIZE * (0.7 + menu.length * 2.5);
+ var left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
+ (this.props.clickAt.x - this.props.bounds.left - hSize) :
+ (this.props.clickAt.x - this.props.bounds.left);
+ var top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
+ (this.props.clickAt.y - this.props.bounds.top - vSize) :
+ (this.props.clickAt.y - this.props.bounds.top);
+
+ var position = {
+ left: left + 'px',
+ top: top + 'px'
+ };
+
+ return (
+
+ );
+ }
+}
diff --git a/src/components/create-account-view.jsx b/src/components/create-account-view.jsx
new file mode 100644
index 00000000..adb1b837
--- /dev/null
+++ b/src/components/create-account-view.jsx
@@ -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 (
+
+ );
+ }
+};
diff --git a/src/components/edit-account-view.jsx b/src/components/edit-account-view.jsx
new file mode 100644
index 00000000..419dd31c
--- /dev/null
+++ b/src/components/edit-account-view.jsx
@@ -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({tag} );
+ });
+ if (tags.length == 0) {
+ tags = No tags defined. Add some. ;
+ }
+ return (
+ {this.state.showPermissionEditorFor ?
+
+ :
+
+
+
+
Your name
+
+
Password
+
+
+
+
+
+
+
+ Address:
+ {this.props.myUserId}
+
+
+ Default access mode:
+
+
+
Auth: {this.state.auth}
+
Anon: {this.state.anon}
+
+
+
+
+ Message sound:
+
+
+
+ Notification
+ alerts{!this.props.desktopAlertsEnabled ? ' (requires HTTPS)' : null}:
+
+
+
+
+
+
+
+ }
+ );
+ }
+};
diff --git a/src/components/error-panel.jsx b/src/components/error-panel.jsx
new file mode 100644
index 00000000..3ff11117
--- /dev/null
+++ b/src/components/error-panel.jsx
@@ -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 (
+
+
{icon}
+ {this.props.text}
+
+
+ );
+ }
+};
diff --git a/src/components/file-progress.jsx b/src/components/file-progress.jsx
new file mode 100644
index 00000000..a2f32b9a
--- /dev/null
+++ b/src/components/file-progress.jsx
@@ -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 (
+
+
+ {this.props.progress < 0.999 ?
+
close cancel
+ :
+
finishing...
+ }
+
+ );
+ }
+}
diff --git a/src/components/group-manager.jsx b/src/components/group-manager.jsx
new file mode 100644
index 00000000..b5f5ddde
--- /dev/null
+++ b/src/components/group-manager.jsx
@@ -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= 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return true;
+ }
+
+ handleSubmit() {
+ var instance = this;
+ var members = [];
+ var added = [];
+ var removed = [];
+
+ var keys = Object.keys(this.state.index);
+ keys.map(function(k) {
+ if (instance.state.index[k].present) {
+ members.push(k);
+ }
+
+ if (instance.state.index[k].delta > 0) {
+ added.push(k);
+ } else if (instance.state.index[k].delta < 0) {
+ removed.push(k);
+ }
+ });
+ this.props.onSubmit(members, added, removed);
+ }
+
+ handleCancel() {
+ this.props.onCancel();
+ }
+
+ render() {
+ return (
+
+
+ Group members
+
+
+
+
+
+ All contacts
+
+
+
+ OK
+ Cancel
+
+
+ );
+ }
+};
diff --git a/src/components/group-subs.jsx b/src/components/group-subs.jsx
new file mode 100644
index 00000000..bc16ddda
--- /dev/null
+++ b/src/components/group-subs.jsx
@@ -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(
+
+
+
+ );
+ });
+ return (
+ {usersOnline}
+ );
+ }
+};
diff --git a/src/components/host-selector.jsx b/src/components/host-selector.jsx
new file mode 100644
index 00000000..29a4d7e0
--- /dev/null
+++ b/src/components/host-selector.jsx
@@ -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(
+
+ );
+ }
+ return (
+
+
+
+ {hostOptions}
+
+
+ );
+ }
+}
diff --git a/src/components/image-preview.jsx b/src/components/image-preview.jsx
new file mode 100644
index 00000000..dd22fa0a
--- /dev/null
+++ b/src/components/image-preview.jsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+ }
+};
diff --git a/src/components/in-place-edit.jsx b/src/components/in-place-edit.jsx
new file mode 100644
index 00000000..bd84136f
--- /dev/null
+++ b/src/components/in-place-edit.jsx
@@ -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' ?
+
+ :
+
+ )
+ :
+
+ {spanText}
+
+ );
+ }
+};
diff --git a/src/components/info-view.jsx b/src/components/info-view.jsx
new file mode 100644
index 00000000..af233670
--- /dev/null
+++ b/src/components/info-view.jsx
@@ -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 (
+
+
+ {this.props.displayMobile ?
+
: null}
+ {this.state.showMemberPanel ?
+
+ :
+ this.state.showPermissionEditorFor ?
+
+ :
+
+
+
+
Name
+
Unknown}
+ readOnly={!this.state.owner}
+ value={this.state.fullName}
+ onFinished={this.handleFullNameUpdate} />
+
Private comment
+
+
+
+
+
+
+
+ Muted:
+
+
+
+ {this.state.moreInfoExpanded ?
+
+
+ Address:
+ {this.state.address}
+
+ {this.state.groupTopic ?
+
+ Your permissions:
+
+ {this.state.access}
+
+
+ :
+
+
Permissions:
+
+
Yours:
+ {this.state.access}
+
+
{this.state.fullName ? this.state.fullName : "Other"}′s:
+
+ {this.state.modeGiven2}
+
+
+
+
+ }
+ {this.state.sharer && (this.state.auth || this.state.anon) ?
+
+
Default access mode:
+
+
Auth: {this.state.admin ?
+
+ {this.state.auth}
+
+ :
+ {this.state.auth}
+ }
+
+
Anon: {this.state.admin ?
+
+ {this.state.anon}
+
+ :
+ {this.state.anon}
+ }
+
+
+
+ :
+ null
+ }
+
+ :
+ null
+ }
+
+
+ {this.state.owner ?
+
+ :
+ null
+ }
+ {this.state.owner ?
: null }
+ {this.state.groupTopic ?
+
+
+ Group members:
+
+
+
+
+ :
+
+ }
+
+ }
+
+ );
+ }
+};
diff --git a/src/components/letter-tile.jsx b/src/components/letter-tile.jsx
new file mode 100644
index 00000000..d8b53439
--- /dev/null
+++ b/src/components/letter-tile.jsx
@@ -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 = ()
+ } else {
+ avatar = (Tinode.topicType(this.props.topic) == 'grp') ?
+ group :
+ person ;
+ }
+ } else if (this.props.avatar) {
+ avatar = ;
+ } else {
+ avatar = null;
+ }
+ return avatar;
+ }
+}
diff --git a/src/components/load-spinner.jsx b/src/components/load-spinner.jsx
new file mode 100644
index 00000000..6d4aab82
--- /dev/null
+++ b/src/components/load-spinner.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+
+export default class LoadSpinner extends React.PureComponent {
+ render() {
+ return (this.props.show ?
+ : null);
+ }
+}
diff --git a/src/components/login-view.jsx b/src/components/login-view.jsx
new file mode 100644
index 00000000..ff13b9b3
--- /dev/null
+++ b/src/components/login-view.jsx
@@ -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 (
+
+ );
+ }
+};
+/* END Login */
diff --git a/src/components/logo-view.jsx b/src/components/logo-view.jsx
new file mode 100644
index 00000000..d284f968
--- /dev/null
+++ b/src/components/logo-view.jsx
@@ -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 (
+
+
+
+
+ Tinode Web
+
+
Client: {version}
+
Server: {this.props.serverVersion} ({this.props.serverAddress})
+
+
+ );
+ }
+};
diff --git a/src/components/menu-cancel.jsx b/src/components/menu-cancel.jsx
new file mode 100644
index 00000000..f2309d70
--- /dev/null
+++ b/src/components/menu-cancel.jsx
@@ -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 (
+ close
+ );
+ }
+}
diff --git a/src/components/menu-contacts.jsx b/src/components/menu-contacts.jsx
new file mode 100644
index 00000000..4f371cf3
--- /dev/null
+++ b/src/components/menu-contacts.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+
+export default class MenuContacts extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+};
diff --git a/src/components/menu-start.jsx b/src/components/menu-start.jsx
new file mode 100644
index 00000000..f5b13833
--- /dev/null
+++ b/src/components/menu-start.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+
+export default class MenuStart extends React.PureComponent {
+ render() {
+ return (
+
+ );
+ }
+};
diff --git a/src/components/messages-view.jsx b/src/components/messages-view.jsx
new file mode 100644
index 00000000..74701b14
--- /dev/null
+++ b/src/components/messages-view.jsx
@@ -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
+ );
+ }
+
+ let lastSeen = null;
+ let cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
+ if (cont && Tinode.topicType(cont.topic) == 'p2p') {
+ if (cont.online) {
+ lastSeen = "online now";
+ } else if (cont.seen) {
+ lastSeen = "Last active: " + shortDateFormat(cont.seen.when);
+ // TODO(gene): also handle user agent in c.seen.ua
+ }
+ }
+ var avatar = this.state.avatar || true;
+ var online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
+
+ component = (
+
+
+ {this.props.displayMobile ?
+
+ arrow_back
+
+ :
+ null}
+
+
+
+
+
+
{this.state.title || Unknown }
+
{lastSeen}
+
+ {groupTopic ?
+
:
+
+ }
+
+
+ {this.props.displayMobile ?
+
+ : null}
+
+
+
+ {this.state.writeOnly ?
+
+
no access to messages
+
+ :
+ null
+ }
+
+
+ {this.state.imagePreview ?
+
: null}
+
+ );
+ } else {
+ component = (
+
+ );
+ }
+ return component;
+ }
+};
diff --git a/src/components/more-button.jsx b/src/components/more-button.jsx
new file mode 100644
index 00000000..0487e270
--- /dev/null
+++ b/src/components/more-button.jsx
@@ -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 ({this.props.title}...
+ {this.state.open ? expand_more :
+ chevron_right }
+ );
+ }
+}
diff --git a/src/components/new-topic-by-id.jsx b/src/components/new-topic-by-id.jsx
new file mode 100644
index 00000000..b5958441
--- /dev/null
+++ b/src/components/new-topic-by-id.jsx
@@ -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 (
+
+ );
+ }
+};
diff --git a/src/components/new-topic-group.jsx b/src/components/new-topic-group.jsx
new file mode 100644
index 00000000..1baf9840
--- /dev/null
+++ b/src/components/new-topic-group.jsx
@@ -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 (
+
+ );
+ }
+};
diff --git a/src/components/new-topic-view.jsx b/src/components/new-topic-view.jsx
new file mode 100644
index 00000000..b0656c52
--- /dev/null
+++ b/src/components/new-topic-view.jsx
@@ -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 (
+
+
+ {this.state.tabSelected === 'grp' ?
+
:
+ this.state.tabSelected === 'byid' ?
+
:
+
+
+
+
}
+
+ );
+ }
+};
diff --git a/src/components/password-reset-view.jsx b/src/components/password-reset-view.jsx
new file mode 100644
index 00000000..748675a5
--- /dev/null
+++ b/src/components/password-reset-view.jsx
@@ -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 (
+
+ );
+ }
+}
diff --git a/src/components/permissions-editor.jsx b/src/components/permissions-editor.jsx
new file mode 100644
index 00000000..4a68d52a
--- /dev/null
+++ b/src/components/permissions-editor.jsx
@@ -0,0 +1,108 @@
+import React from 'react';
+
+/* BEGIN PermissionsEditor: Component for editing permissions */
+//
+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= 0 && mode.indexOf(c) < 0) {
+ // Permission is marked as inactive: hide unchecked permissions, disable checked permissions
+ continue;
+ }
+ items.push(
+
+ {names[c]}
+ {skip.indexOf(c) < 0 ?
+ = 0)} onChange={this.handleChange}/>
+ :
+ = 0)} />
+ } {this.props.compare ?
+ = 0)}/>
+ : null}
+
+ );
+ }
+
+ return (
+
+ {this.props.userTitle ?
+
: null}
+
Permissions
+
+ {this.props.compare ?
+
+ {this.props.modeTitle}
+ {this.props.compareTitle}
+ :
+ null}
+
+ {items}
+
+
+
+ Ok
+ Cancel
+
+
+ );
+ }
+};
+/* END PermissionsEditor */
diff --git a/src/components/received-marker.jsx b/src/components/received-marker.jsx
new file mode 100644
index 00000000..807888d8
--- /dev/null
+++ b/src/components/received-marker.jsx
@@ -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 = (access_time ); // watch face
+ } else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
+ marker = (error_outline ); // icon (!)
+ } else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
+ marker = (done ); // checkmark
+ } else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
+ marker = (done_all ); // double checkmark
+ } else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
+ marker = (done_all ); // blue double checkmark
+ }
+
+ return (
+
+ {timestamp}{'\u00a0'}{marker}
+
+ );
+ }
+};
diff --git a/src/components/search-contacts.jsx b/src/components/search-contacts.jsx
new file mode 100644
index 00000000..3f28d8f6
--- /dev/null
+++ b/src/components/search-contacts.jsx
@@ -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 (
+
+ );
+ }
+};
diff --git a/src/components/send-message.jsx b/src/components/send-message.jsx
new file mode 100644
index 00000000..164c637d
--- /dev/null
+++ b/src/components/send-message.jsx
@@ -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 (
+
+ {this.props.disabled ?
+
photo :
+
+ photo
+ }
+ {this.props.disabled ?
+
attach_file :
+
+ attach_file
+ }
+
+ {this.props.disabled ?
+
send :
+
send }
+
+
+
+ );
+ }
+};
diff --git a/src/components/settings-view.jsx b/src/components/settings-view.jsx
new file mode 100644
index 00000000..8bc07d5e
--- /dev/null
+++ b/src/components/settings-view.jsx
@@ -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(
+
+
+ {name}
+
+ );
+ });
+
+ return (
+
+ );
+ }
+};
diff --git a/src/components/side-navbar.jsx b/src/components/side-navbar.jsx
new file mode 100644
index 00000000..677e9aa3
--- /dev/null
+++ b/src/components/side-navbar.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+
+export default class SideNavbar extends React.PureComponent {
+ render() {
+ return (
+
+
+
+
+
{this.props.title}
+ {this.props.state === 'login' ?
+
:
+ this.props.state === 'contacts' ?
+
:
+ null}
+ {this.props.onCancel ?
+
: null}
+
+ );
+ }
+};
diff --git a/src/components/sidepanel-view.jsx b/src/components/sidepanel-view.jsx
new file mode 100644
index 00000000..5e4b9a0d
--- /dev/null
+++ b/src/components/sidepanel-view.jsx
@@ -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 (
+
+
+
+
+
+ {view === 'login' ?
+
:
+
+ view === 'register' ?
+
:
+
+ view === 'settings' ?
+
:
+
+ view === 'edit' ?
+
:
+
+ view === 'contacts' ?
+
:
+
+ view === 'newtpk' ?
+
:
+
+ view === 'cred' ?
+
:
+
+ view === 'reset' ?
+
:
+ null}
+
+ );
+ }
+};
diff --git a/src/components/tag-manager.jsx b/src/components/tag-manager.jsx
new file mode 100644
index 00000000..75eeec8e
--- /dev/null
+++ b/src/components/tag-manager.jsx
@@ -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({tag} );
+ });
+ if (tags.length == 0) {
+ tags = No tags defined. Add some. ;
+ }
+ }
+ return (
+
+
+ {this.props.title}
+
+ {this.state.activated ?
+
+
+ {this.props.onSubmit || this.props.onCancel ?
+
+ OK
+ Cancel
+
+ : null}
+
+ :
+
+ }
+
+ );
+ }
+};
diff --git a/src/components/tinode-web.jsx b/src/components/tinode-web.jsx
new file mode 100644
index 00000000..5adcbc9a
--- /dev/null
+++ b/src/components/tinode-web.jsx
@@ -0,0 +1,1105 @@
+import React from 'react';
+
+/* The top-level class to hold all functionality together */
+export default class TinodeWeb extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = this.getBlankState();
+
+ this.handleResize = this.handleResize.bind(this);
+ this.handleHashRoute = this.handleHashRoute.bind(this);
+ this.handleOnline = this.handleOnline.bind(this);
+ this.handleAppVisibility = this.handleAppVisibility.bind(this);
+ this.handleReadTimer = this.handleReadTimer.bind(this);
+ this.handleVisibilityEvent = this.handleVisibilityEvent.bind(this);
+ this.handleError = this.handleError.bind(this);
+ this.handleLoginRequest = this.handleLoginRequest.bind(this);
+ this.handleConnected = this.handleConnected.bind(this);
+ this.doLogin = this.doLogin.bind(this);
+ this.handleCredentialsRequest = this.handleCredentialsRequest.bind(this);
+ this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
+ this.handleDisconnect = this.handleDisconnect.bind(this);
+ this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
+ this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
+ this.tnMeSubsUpdated = this.tnMeSubsUpdated.bind(this);
+ this.resetContactList = this.resetContactList.bind(this);
+ this.tnData = this.tnData.bind(this);
+ this.tnInitFind = this.tnInitFind.bind(this);
+ this.tnFndSubsUpdated = this.tnFndSubsUpdated.bind(this);
+ this.handleSearchContacts = this.handleSearchContacts.bind(this);
+ this.handleTopicSelected = this.handleTopicSelected.bind(this);
+ this.handleHideMessagesView = this.handleHideMessagesView.bind(this);
+ this.handleSendMessage = this.handleSendMessage.bind(this);
+ this.handleNewAccount = this.handleNewAccount.bind(this);
+ this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
+ this.handleUpdateAccountRequest = this.handleUpdateAccountRequest.bind(this);
+ this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
+ this.handleSettings = this.handleSettings.bind(this);
+ this.handleGlobalSettings = this.handleGlobalSettings.bind(this);
+ this.handleToggleMessageSounds = this.handleToggleMessageSounds.bind(this);
+ this.initDesktopAlerts = this.initDesktopAlerts.bind(this);
+ this.togglePushToken = this.togglePushToken.bind(this);
+ this.requestPushToken = this.requestPushToken.bind(this);
+ this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
+ this.handleNewTopic = this.handleNewTopic.bind(this);
+ this.handleNewTopicRequest = this.handleNewTopicRequest.bind(this);
+ this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
+ this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
+ this.handleChangePermissions = this.handleChangePermissions.bind(this);
+ this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
+ this.handleLogout = this.handleLogout.bind(this);
+ this.handleLeaveUnsubRequest = this.handleLeaveUnsubRequest.bind(this);
+ this.handleDialogCancel = this.handleDialogCancel.bind(this);
+ this.handleShowContextMenu = this.handleShowContextMenu.bind(this);
+ this.defaultTopicContextMenu = this.defaultTopicContextMenu.bind(this);
+ this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
+ this.handleShowInfoView = this.handleShowInfoView.bind(this);
+ this.handleHideInfoView = this.handleHideInfoView.bind(this);
+ this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
+ this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
+ this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
+ this.handleResetPassword = this.handleResetPassword.bind(this);
+ }
+
+ getBlankState() {
+ let settings = localStorage.getObject("settings") || {};
+
+ return {
+ connected: false,
+ transport: settings.transport || null,
+ serverAddress: settings.serverAddress || detectServerAddress(),
+ serverVersion: "no connection",
+ // "On" is the default, so saving the "off" state.
+ messageSounds: !settings.messageSoundsOff,
+ desktopAlerts: settings.desktopAlerts,
+ desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
+ (typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
+ (typeof FIREBASE_INIT != 'undefined'),
+ firebaseToken: localStorage.getObject("firebase-token"),
+
+ errorText: '',
+ errorLevel: null,
+
+ sidePanelSelected: 'login',
+ sidePanelTitle: null,
+ sidePanelAvatar: null,
+ dialogSelected: null,
+ contextMenuVisible: false,
+ login: '',
+ password: '',
+ myUserId: null,
+ liveConnection: navigator.onLine,
+ topicSelected: '',
+ topicSelectedOnline: false,
+ topicSelectedAcs: null,
+ newGroupTopicParams: null,
+ loginDisabled: false,
+ displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
+ showInfoPanel: false,
+ mobilePanel: 'sidepanel',
+
+ contextMenuVisible: false,
+ contextMenuBounds: null,
+ contextMenuClickAt: null,
+ contextMenuParams: null,
+ contextMenuItems: [],
+
+ // Chats as shown in the ContactsView
+ chatList: [],
+ // Contacts returned by a search query.
+ searchResults: [],
+ // Merged results of a search query and p2p chats.
+ searchableContacts: [],
+ credMethod: undefined,
+ credCode: undefined
+ };
+ }
+
+ componentDidMount() {
+ window.addEventListener('resize', this.handleResize);
+ window.addEventListener('online', (e) => { this.handleOnline(true); });
+ window.addEventListener('offline', (e) => { this.handleOnline(false); });
+ window.addEventListener('hashchange', this.handleHashRoute);
+ // Window/tab visible or invisible for pausing timers.
+ document.addEventListener('visibilitychange', this.handleVisibilityEvent);
+
+ this.setState({viewportWidth: document.documentElement.clientWidth});
+
+ this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport);
+ this.tinode.onConnect = this.handleConnected;
+ this.tinode.onDisconnect = this.handleDisconnect;
+
+ // Initialize desktop alerts.
+ if (this.state.desktopAlertsEnabled) {
+ try {
+ this.fbPush = firebase.initializeApp(FIREBASE_INIT, APP_NAME).messaging();
+ this.fbPush.usePublicVapidKey(FIREBASE_INIT.messagingVapidKey);
+ navigator.serviceWorker.register('/service-worker.js').then((reg) => {
+ this.fbPush.useServiceWorker(reg);
+ this.initDesktopAlerts();
+ if (this.state.desktopAlerts) {
+ if (!this.state.firebaseToken) {
+ this.togglePushToken(true);
+ } else {
+ this.tinode.setDeviceToken(this.state.firebaseToken);
+ }
+ }
+ }).catch((err) => {
+ // registration failed :(
+ console.log("Failed to register service worker:", err);
+ });
+ } catch (err) {
+ this.handleError("Failed to initialize push notifications", 'err');
+ console.log("Failed to initialize push notifications", err);
+ this.setState({desktopAlertsEnabled: false});
+ }
+ }
+
+ let token = localStorage.getObject('keep-logged-in') ?
+ localStorage.getObject('auth-token') : undefined;
+
+ let parsedNav = parseUrlHash(window.location.hash);
+ if (token) {
+ // When reading from storage, date is returned as string.
+ token.expires = new Date(token.expires);
+ this.tinode.setAuthToken(token);
+ this.tinode.connect().catch((err) => {
+ // Socket error
+ this.handleError(err.message, 'err');
+ });
+ delete parsedNav.params.info;
+ delete parsedNav.params.tab;
+ parsedNav.path[0] = '';
+ navigateTo(composeUrlHash(parsedNav.path, parsedNav.params));
+ } else if (!parsedNav.params.token) {
+ navigateTo('');
+ }
+
+ this.readTimer = null;
+ this.readTimerCallback = null;
+
+ this.handleHashRoute();
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.handleResize);
+ window.removeEventListener('hashchange', this.handleHashRoute);
+ document.removeEventListener('visibilitychange', this.handleVisibilityEvent);
+ }
+
+ // Setup transport (usually websocket) and server address. This will terminate connection with the server.
+ static tnSetup(serverAddress, transport) {
+ let tinode = new Tinode(APP_NAME, serverAddress, API_KEY, transport, isSecureConnection());
+ tinode.enableLogging(true, true);
+ return tinode;
+ }
+
+ handleResize() {
+ var mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
+ this.setState({viewportWidth: document.documentElement.clientWidth});
+ if (this.state.displayMobile != mobile) {
+ this.setState({displayMobile: mobile});
+ }
+ }
+
+ // Handle for hashchange event: display appropriate panels.
+ handleHashRoute() {
+ var hash = parseUrlHash(window.location.hash);
+ if (hash.path && hash.path.length > 0) {
+ // Left-side panel selector.
+ if (['register','settings','edit','cred','reset','newtpk','contacts',''].includes(hash.path[0])) {
+ this.setState({sidePanelSelected: hash.path[0]});
+ } else {
+ console.log("Unknown sidepanel view", hash.path[0]);
+ }
+
+ // Topic for MessagesView selector.
+ if (hash.path.length > 1 && hash.path[1] != this.state.topicSelected) {
+ this.setState({
+ topicSelected: Tinode.topicType(hash.path[1]) ? hash.path[1] : null
+ });
+ }
+ } else {
+ // Empty hashpath
+ this.setState({sidePanelSelected: ''});
+ }
+
+ // Save validation credentials, if available.
+ if (hash.params.method) {
+ this.setState({ credMethod: hash.params.method });
+ }
+ if (hash.params.code) {
+ this.setState({ credCode: hash.params.code });
+ }
+
+ // Additional parameters of panels.
+ this.setState({
+ showInfoPanel: hash.params.info,
+ newTopicTabSelected: hash.params.tab
+ });
+ }
+
+ handleOnline(online) {
+ var newState = {liveConnection: online};
+ if (online) {
+ this.handleError('', null);
+ } else {
+ this.handleError("No connection", 'warn');
+ }
+ this.setState({liveConnection: online});
+ }
+
+ // Handling read notifications here to be able to pause
+ // them then the window/tab is not visible.
+ handleAppVisibility(visible, callback) {
+ clearTimeout(this.readTimer);
+ this.readTimerCallback = callback;
+ if (visible && callback) {
+ this.readTimer = setTimeout(callback, READ_DELAY);
+ } else {
+ this.readTimer = null;
+ }
+ }
+
+ handleReadTimer(callback) {
+ this.handleAppVisibility(!document.hidden, callback);
+ }
+
+ handleVisibilityEvent() {
+ this.handleAppVisibility(!document.hidden, this.readTimerCallback);
+ }
+
+ handleError(err, level) {
+ this.setState({errorText: err, errorLevel: level});
+ }
+
+ // User clicked Login button in the side panel.
+ handleLoginRequest(login, password) {
+ this.setState({loginDisabled: true, login: login, password: password});
+ this.handleError('', null);
+
+ if (this.tinode.isConnected()) {
+ this.doLogin(login, password, {meth: this.state.credMethod, resp: this.state.credCode});
+ } else {
+ this.tinode.connect().catch((err) => {
+ // Socket error
+ this.setState({loginDisabled: false});
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ // Connection succeeded.
+ handleConnected() {
+ var params = this.tinode.getServerInfo();
+ this.setState({
+ serverVersion: params.ver + ' ' + (params.build ? params.build : 'none') + '; '
+ });
+ this.doLogin(this.state.login, this.state.password, {meth: this.state.credMethod, resp: this.state.credCode});
+ }
+
+ doLogin(login, password, cred) {
+ if (this.tinode.isAuthenticated()) {
+ // Already logged in. Go to default screen.
+ navigateTo("");
+ return;
+ }
+ // Sanitize and package credentail.
+ cred = Tinode.credential(cred);
+ // Try to login with login/password. If they are not available, try token. If no token, ask for login/password.
+ let promise = null;
+ let token = this.tinode.getAuthToken();
+ if (login && password) {
+ this.setState({password: null});
+ promise = this.tinode.loginBasic(login, password, cred);
+ } else if (token) {
+ promise = this.tinode.loginToken(token.token, cred);
+ }
+
+ if (promise) {
+ promise.then((ctrl) => {
+ if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
+ if (cred) {
+ this.handleError("Code does not match", 'warn');
+ }
+ this.handleCredentialsRequest(ctrl.params);
+ } else {
+ this.handleLoginSuccessful();
+ }
+ }).catch((err) => {
+ // Login failed, report error.
+ this.setState({loginDisabled: false, credMethod: undefined, credCode: undefined});
+ this.handleError(err.message, 'err');
+ localStorage.removeItem('auth-token');
+ navigateTo('');
+ });
+ } else {
+ // No login credentials provided.
+ // Make sure we are on the login page.
+ navigateTo('');
+ this.setState({loginDisabled: false});
+ }
+ }
+
+ handleCredentialsRequest(params) {
+ var parsed = parseUrlHash(window.location.hash);
+ parsed.path[0] = 'cred';
+ parsed.params['method'] = params.cred[0];
+ navigateTo(composeUrlHash(parsed.path, parsed.params));
+ }
+
+ handleLoginSuccessful() {
+ this.handleError('', null);
+
+ // Refresh authentication token.
+ if (localStorage.getObject('keep-logged-in')) {
+ localStorage.setObject('auth-token', this.tinode.getAuthToken());
+ }
+ // Logged in fine, subscribe to 'me' attaching callbacks from the contacts view.
+ var me = this.tinode.getMeTopic();
+ me.onMetaDesc = this.tnMeMetaDesc;
+ me.onContactUpdate = this.tnMeContactUpdate;
+ me.onSubsUpdated = this.tnMeSubsUpdated;
+ this.setState({
+ connected: true,
+ credMethod: undefined,
+ credCode: undefined,
+ myUserId: this.tinode.getCurrentUserID()
+ });
+ // Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched.
+ me.subscribe(
+ me.startMetaQuery().
+ withLaterSub().
+ withDesc().
+ build()
+ ).catch((err) => {
+ localStorage.removeItem('auth-token');
+ this.handleError(err.message, 'err');
+ navigateTo('');
+ });
+ navigateTo(setUrlSidePanel(window.location.hash, 'contacts'));
+ }
+
+ handleDisconnect(err) {
+ this.setState({
+ connected: false,
+ topicSelectedOnline: false,
+ dialogSelected: null,
+ errorText: err && err.message ? err.message : "Disconnected",
+ errorLevel: err && err.message ? 'err' : 'warn',
+ loginDisabled: false,
+ contextMenuVisible: false,
+ serverVersion: "no connection"
+ });
+ }
+
+ tnMeMetaDesc(desc) {
+ if (desc && desc.public) {
+ this.setState({
+ sidePanelTitle: desc.public.fn,
+ sidePanelAvatar: makeImageUrl(desc.public.photo)
+ });
+ }
+ }
+
+ // Reactions to updates to the contact list.
+ tnMeContactUpdate(what, cont) {
+ if (what == 'on' || what == 'off') {
+ this.resetContactList();
+ if (this.state.topicSelected == cont.topic) {
+ this.setState({topicSelectedOnline: (what == 'on')});
+ }
+ } else if (what == 'read') {
+ this.resetContactList();
+ } else if (what == 'msg') {
+ // New message received
+ // Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
+ if (this.state.topicSelected != cont.topic) {
+ if (this.state.messageSounds) {
+ POP_SOUND.play();
+ }
+ this.resetContactList();
+ } else if (document.hidden && this.state.messageSounds) {
+ POP_SOUND.play();
+ }
+ } else if (what == 'recv') {
+ // Explicitly ignoring "recv" -- it causes no visible updates to contact list.
+ } else if (what == 'gone' || what == 'unsub') {
+ // Topic deleted or user unsubscribed. Remove topic from view.
+ // If the currently selected topic is gone, clear the selection.
+ if (this.state.topicSelected == cont.topic) {
+ this.handleTopicSelected(null);
+ }
+ // Redraw without the deleted topic.
+ this.resetContactList();
+ } else if (what == 'acs') {
+ // Permissions changed. If it's for the currently selected topic,
+ // update the views.
+ if (this.state.topicSelected == cont.topic) {
+ this.setState({topicSelectedAcs: cont.acs});
+ }
+ } else if (what == 'del') {
+ // messages deleted (hard or soft) -- update pill counter.
+ } else {
+ // TODO(gene): handle other types of notifications:
+ // * ua -- user agent changes (maybe display a pictogram for mobile/desktop).
+ // * upd -- topic 'public' updated, issue getMeta().
+ console.log("Unsupported (yet) presence update:" + what + " in: " + cont.topic);
+ }
+ }
+
+ tnMeSubsUpdated(unused) {
+ this.resetContactList();
+ }
+
+ // Merge search results and contact list to create a single flat
+ // list of kown contacts for GroupManager to use.
+ static prepareSearchableContacts(chatList, foundContacts) {
+ let merged = {};
+
+ // For chatList topics merge only p2p topics and convert them to the
+ // same format as foundContacts.
+ for (const c of chatList) {
+ if (Tinode.topicType(c.topic) == 'p2p') {
+ merged[c.topic] = {
+ user: c.topic,
+ updated: c.updated,
+ public: c.public,
+ private: c.private,
+ acs: c.acs
+ };
+ }
+ }
+
+ // Add all foundCountacts if they have not been added already.
+ for (const c of foundContacts) {
+ if (!merged[c.user]) {
+ merged[c.user] = c;
+ }
+ }
+
+ return Object.values(merged);
+ }
+
+ resetContactList() {
+ let newState = {
+ chatList: []
+ };
+ this.tinode.getMeTopic().contacts((c) => {
+ newState.chatList.push(c);
+ if (this.state.topicSelected == c.topic) {
+ newState.topicSelectedOnline = c.online;
+ newState.topicSelectedAcs = c.acs;
+ }
+ });
+ // Merge search results and chat list.
+ newState.searchableContacts = TinodeWeb.prepareSearchableContacts(newState.chatList, this.state.searchResults);
+ this.setState(newState);
+ }
+
+ // Sending "received" notifications
+ tnData(data) {
+ let topic = this.tinode.getTopic(data.topic);
+ if (topic.msgStatus(data) >= Tinode.MESSAGE_STATUS_SENT) {
+ clearTimeout(this.receivedTimer);
+ this.receivedTimer = setTimeout(() => {
+ this.receivedTimer = undefined;
+ topic.noteRecv(data.seq);
+ }, RECEIVED_DELAY);
+ }
+ }
+
+ /* Fnd topic: find contacts by tokens */
+ tnInitFind() {
+ let fnd = this.tinode.getFndTopic();
+ if (fnd.isSubscribed()) {
+ this.tnFndSubsUpdated();
+ } else {
+ fnd.onSubsUpdated = this.tnFndSubsUpdated;
+ fnd.subscribe(fnd.startMetaQuery().withSub().withTags().build()).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ tnFndSubsUpdated() {
+ let foundContacts = [];
+ // Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
+ this.tinode.getFndTopic().contacts((s) => {
+ foundContacts.push(s);
+ });
+ this.setState({
+ searchResults: foundContacts,
+ searchableContacts: TinodeWeb.prepareSearchableContacts(this.state.chatList, foundContacts)
+ });
+ }
+
+ /** Called when the user enters a contact into the contact search field in the NewAccount panel
+ @param query {Array} is an array of contacts to search for
+ */
+ handleSearchContacts(query) {
+ var fnd = this.tinode.getFndTopic();
+ fnd.setMeta({desc: {public: query}}).then((ctrl) => {
+ return fnd.getMeta(fnd.startMetaQuery().withSub().build());
+ }).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ // User clicked on a contact in the side panel or deleted a contact.
+ handleTopicSelected(topicName, unused_index, online, acs) {
+ if (topicName) {
+ this.setState({
+ errorText: '',
+ errorLevel: null,
+ mobilePanel: 'topic-view',
+ showInfoPanel: false
+ });
+ // Different contact selected.
+ if (this.state.topicSelected != topicName) {
+ this.setState({
+ topicSelectedOnline: online,
+ topicSelectedAcs: acs
+ });
+ navigateTo(setUrlTopic('', topicName));
+ }
+ } else {
+ // Currently selected contact deleted
+ this.setState({
+ errorText: '',
+ errorLevel: null,
+ mobilePanel: 'sidepanel',
+ topicSelectedOnline: false,
+ topicSelectedAcs: null,
+ showInfoPanel: false
+ });
+ if (this.state.topicSelected) {
+ navigateTo(setUrlTopic('', null));
+ }
+ }
+ }
+
+ // In mobile view user requested to show sidepanel
+ handleHideMessagesView() {
+ this.setState({
+ mobilePanel: 'sidepanel'
+ });
+ navigateTo(setUrlTopic(window.location.hash, null));
+ }
+
+ // User is sending a message, either plain text or a drafty object, possibly
+ // with attachments.
+ handleSendMessage(msg, promise, uploader) {
+ let topic = this.tinode.getTopic(this.state.topicSelected);
+
+ msg = topic.createMessage(msg, false);
+ // The uploader is used to show progress.
+ msg._uploader = uploader;
+
+ if (!topic.isSubscribed()) {
+ if (!promise) {
+ promise = Promise.resolve();
+ }
+ promise = promise.then(() => { return topic.subscribe(); });
+ }
+
+ if (promise) {
+ promise = promise.catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ topic.publishDraft(msg, promise)
+ .catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ // User chose a Sign Up menu item.
+ handleNewAccount() {
+ navigateTo(setUrlSidePanel(window.location.hash, 'register'));
+ }
+
+ // Actual registration of a new account.
+ handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
+ this.tinode.connect(this.state.serverAddress)
+ .then(() => {
+ return this.tinode.createAccountBasic(login_, password_,
+ {public: public_, tags: tags_, cred: Tinode.credential(cred_)});
+ }).then((ctrl) => {
+ if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
+ this.handleCredentialsRequest(ctrl.params);
+ } else {
+ this.handleLoginSuccessful(this);
+ }
+ }).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ handleUpdateAccountRequest(password, pub, defacs) {
+ if (pub || defacs) {
+ this.tinode.getMeTopic().setMeta({desc: {public: pub, defacs: defacs}}).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+ if (password) {
+ this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ handleUpdateAccountTagsRequest(tags) {
+ this.tinode.getFndTopic().setMeta({tags: tags})
+ .catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ // User chose Settings menu item.
+ handleSettings() {
+ navigateTo(setUrlSidePanel(window.location.hash, this.state.myUserId ? 'edit' : 'settings'));
+ }
+
+ // User updated global parameters.
+ handleGlobalSettings(settings) {
+ let serverAddress = settings.serverAddress || this.state.serverAddress;
+ let transport = settings.transport || this.state.transport;
+ if (this.tinode) {
+ this.tinode.onDisconnect = undefined;
+ this.tinode.disconnect();
+ }
+ this.tinode = TinodeWeb.tnSetup(serverAddress, transport);
+ this.tinode.onConnect = this.handleConnected;
+ this.tinode.onDisconnect = this.handleDisconnect;
+
+ this.setState({
+ serverAddress: serverAddress,
+ transport: transport
+ });
+ localStorage.setObject('settings', {
+ serverAddress: serverAddress,
+ transport: transport
+ });
+
+ navigateTo(setUrlSidePanel(window.location.hash, ''));
+ }
+
+ // Initialize desktop alerts = push notifications.
+ initDesktopAlerts() {
+ // Google could not be bothered to mention that
+ // onTokenRefresh is never called.
+ this.fbPush.onTokenRefresh(() => {
+ this.requestPushToken(true);
+ });
+
+ this.fbPush.onMessage((payload) => {
+ // No need to do anything about it.
+ // All the magic happends in the service worker.
+ });
+ }
+
+ togglePushToken(enabled) {
+ if (enabled) {
+ if (!this.state.firebaseToken) {
+ this.fbPush.requestPermission().then(() => {
+ this.requestPushToken(true);
+ }).catch((err) => {
+ this.handleError(err.message, 'err');
+ this.setState({desktopAlerts: false, firebaseToken: null});
+ localStorage.updateObject('settings', {desktopAlerts: false});
+ console.log("Failed to get permission to notify.", err);
+ });
+ } else {
+ this.setState({desktopAlerts: true});
+ localStorage.updateObject('settings', {desktopAlerts: true});
+ }
+ } else if (this.state.firebaseToken) {
+ this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
+ console.log("Unable to delete token.", err);
+ }).finally(() => {
+ localStorage.updateObject('settings', {desktopAlerts: false});
+ localStorage.removeItem('firebase-token');
+ this.setState({desktopAlerts: false, firebaseToken: null});
+ });
+ } else {
+ this.setState({desktopAlerts: false, firebaseToken: null});
+ localStorage.updateObject('settings', {desktopAlerts: false});
+ }
+ }
+
+ requestPushToken(sendToServer) {
+ this.fbPush.getToken().then((refreshedToken) => {
+ if (refreshedToken != this.state.firebaseToken) {
+ this.tinode.setDeviceToken(refreshedToken, sendToServer);
+ localStorage.setObject('firebase-token', refreshedToken);
+ }
+ this.setState({firebaseToken: refreshedToken, desktopAlerts: true});
+ localStorage.updateObject('settings', {desktopAlerts: true});
+ }).catch((err) => {
+ this.handleError(err.message, 'err');
+ console.log("Failed to retrieve firebase token", err);
+ });
+ }
+
+ handleToggleMessageSounds(enabled) {
+ this.setState({messageSounds: enabled});
+ localStorage.updateObject('settings', {
+ messageSoundsOff: !enabled
+ });
+ }
+
+ // User clicked Cancel button in Setting or Sign Up panel.
+ handleSidepanelCancel() {
+ var parsed = parseUrlHash(window.location.hash);
+ parsed.path[0] = this.state.myUserId ? 'contacts' : '';
+ if (parsed.params) {
+ delete parsed.params.code;
+ delete parsed.params.method;
+ delete parsed.params.tab;
+ }
+ navigateTo(composeUrlHash(parsed.path, parsed.params));
+ this.setState({errorText: '', errorLevel: null});
+ }
+
+ // User clicked a (+) menu item.
+ handleNewTopic() {
+ navigateTo(setUrlSidePanel(window.location.hash, 'newtpk'));
+ }
+
+ // Request to start a new topic. New P2P topic requires peer's name.
+ handleNewTopicRequest(peerName, pub, priv, tags) {
+ var topicName = peerName || this.tinode.newGroupTopicName();
+ if (!peerName) {
+ this.setState(
+ {newGroupTopicParams: {desc: {public: pub, private: {comment: priv}}, tags: tags}},
+ () => {this.handleTopicSelected(topicName)}
+ );
+ } else {
+ this.handleTopicSelected(topicName);
+ }
+ }
+
+ // New topic was creted, here is the new topic name.
+ handleNewTopicCreated(oldName, newName) {
+ if (this.state.topicSelected == oldName && oldName != newName) {
+ // If the current URl contains the old topic name, replace it with new.
+ // Update the name of the selected topic first so the navigator doen't clear
+ // the state.
+ this.setState({topicSelected: newName}, () => { navigateTo(setUrlTopic('', newName)); });
+ }
+ }
+
+ handleTopicUpdateRequest(topicName, pub, priv, permissions) {
+ var topic = this.tinode.getTopic(topicName);
+ if (topic) {
+ var params = {};
+ if (pub) {
+ params.public = pub;
+ }
+ if (priv) {
+ params.private = {comment: priv};
+ }
+ if (permissions) {
+ params.defacs = permissions;
+ }
+ topic.setMeta({desc: params}).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ handleChangePermissions(topicName, mode, uid) {
+ var topic = this.tinode.getTopic(topicName);
+ if (topic) {
+ var am = topic.getAccessMode();
+ if (uid) {
+ am.updateGiven(mode);
+ mode = am.getGiven();
+ } else {
+ am.updateWant(mode);
+ mode = am.getWant();
+ }
+ topic.setMeta({sub: {user: uid, mode: mode}}).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ handleTagsUpdated(topicName, tags) {
+ var topic = this.tinode.getTopic(topicName);
+ if (topic) {
+ var instance = this;
+ topic.setMeta({tags: tags}).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ handleLogout() {
+ updateFavicon(0);
+ localStorage.removeItem('auth-token');
+ if (this.tinode) {
+ this.tinode.onDisconnect = undefined;
+ this.tinode.disconnect();
+ }
+ this.setState(this.getBlankState());
+ this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport);
+ this.tinode.onConnect = this.handleConnected;
+ this.tinode.onDisconnect = this.handleDisconnect;
+ navigateTo('');
+ }
+
+ handleLeaveUnsubRequest(topicName) {
+ var topic = this.tinode.getTopic(topicName);
+ if (!topic) {
+ return;
+ }
+
+ topic.leave(true).then((ctrl) => {
+ // Hide MessagesView and InfoView panels.
+ navigateTo(setUrlTopic(window.location.hash, ''));
+ }).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ handleDialogCancel() {
+ this.setState({dialogSelected: null});
+ }
+
+ handleShowContextMenu(params, menuItems) {
+ this.setState({
+ contextMenuVisible: true,
+ contextMenuClickAt: {x: params.x, y: params.y},
+ contextMenuParams: params,
+ contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
+ contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect()
+ });
+ }
+
+ defaultTopicContextMenu(topicName) {
+ var muted = false, blocked = false, subscribed = false, deleter = false;
+ var topic = this.tinode.getTopic(topicName);
+ if (topic) {
+ if (topic.isSubscribed()) {
+ subscribed = true;
+ var acs = topic.getAccessMode();
+ muted = acs && acs.isMuted();
+ blocked = acs && !acs.isJoiner();
+ deleter = acs && acs.isDeleter();
+ }
+ }
+
+ return [
+ subscribed ? {title: "Info", handler: this.handleShowInfoView} : null,
+ subscribed ? 'messages_clear' : null,
+ subscribed && deleter ? 'messages_clear_hard' : null,
+ subscribed ? (muted ? 'topic_unmute' : 'topic_mute') : null,
+ subscribed ? (blocked ? 'topic_unblock' : 'topic_block') : null,
+ 'topic_delete'
+ ];
+ }
+
+ handleHideContextMenu() {
+ this.setState({
+ contextMenuVisible: false,
+ contextMenuClickAt: null,
+ contextMenuParams: null,
+ contextMenuBounds: null
+ });
+ }
+
+ handleShowInfoView() {
+ navigateTo(addUrlParam(window.location.hash, 'info', true));
+ this.setState({showInfoPanel: true});
+ }
+
+ handleHideInfoView() {
+ navigateTo(removeUrlParam(window.location.hash, 'info'));
+ this.setState({showInfoPanel: false});
+ }
+
+ handleMemberUpdateRequest(topicName, added, removed) {
+ if (!topicName) {
+ return;
+ }
+
+ var topic = this.tinode.getTopic(topicName);
+ if (!topic) {
+ return;
+ }
+
+ if (added && added.length > 0) {
+ added.map((uid) => {
+ topic.invite(uid, null).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ });
+ }
+
+ if (removed && removed.length > 0) {
+ removed.map((uid) => {
+ topic.delSubscription(uid).catch((err) => {
+ this.handleError(err.message, 'err');
+ });
+ });
+ }
+ }
+
+ handleValidateCredentialsRequest(cred, code) {
+ this.setState({credMethod: cred, credCode: code});
+ this.doLogin(null, null, {meth: cred, resp: code});
+ }
+
+ handlePasswordResetRequest(method, value) {
+ // If already connected, connnect() will return a resolved promise.
+ this.tinode.connect()
+ .then(() => {
+ return this.tinode.requestResetAuthSecret('basic', method, value)
+ })
+ .catch((err) => {
+ // Socket error
+ this.handleError(err.message, 'err');
+ });
+ }
+
+ handleResetPassword(scheme, newPassword, token) {
+ token = base64ReEncode(token);
+ if (!token) {
+ this.handleError("Invalid security token", 'err');
+ } else {
+ this.tinode.connect()
+ .then(() => {
+ return this.tinode.updateAccountBasic(null, null, newPassword, {token: token});
+ })
+ .catch((err) => {
+ // Socket error
+ this.handleError(err.message, 'err');
+ });
+ }
+ }
+
+ render() {
+ return (
+
+ {this.state.contextMenuVisible ?
+
+ :
+ null
+ }
+
+
+
+
+
+ {this.state.showInfoPanel ?
+
+ :
+ null
+ }
+
+ );
+ }
+};
diff --git a/src/components/unread-badge.jsx b/src/components/unread-badge.jsx
new file mode 100644
index 00000000..e7f5d767
--- /dev/null
+++ b/src/components/unread-badge.jsx
@@ -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 = {count} ;
+ }
+ return showUnreadBadge;
+ }
+};
diff --git a/src/components/validation-view.jsx b/src/components/validation-view.jsx
new file mode 100644
index 00000000..c223dd94
--- /dev/null
+++ b/src/components/validation-view.jsx
@@ -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 (
+
+
+
+ Enter confirmation code sent to you by {method}:
+
+
+
+
+
+
+ Confirm
+
+
+ );
+ }
+};
diff --git a/src/components/visible-password.jsx b/src/components/visible-password.jsx
new file mode 100644
index 00000000..f5289612
--- /dev/null
+++ b/src/components/visible-password.jsx
@@ -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 (
+
+
+
+
+ {this.state.visible ? 'visibility' : 'visibility_off'}
+
+
+
+ );
+ }
+}
diff --git a/src/webapp.jsx b/src/webapp.jsx
index d0f1096e..8efde42c 100644
--- a/src/webapp.jsx
+++ b/src/webapp.jsx
@@ -15,6 +15,10 @@ if (typeof require == 'function') {
var firebase = require('firebase/app');
require('firebase/messaging');
}
+ if (typeof ReactIntl == 'undefined') {
+ var ReactIntl = require('react-intl');
+ var {IntlProvider, FormattedMessage} = ReactIntl;
+ }
var package_version = require('../version.json').version;
}
@@ -487,5160 +491,3 @@ function arrayEqual(a, b) {
}
return true;
}
-
-/* BEGIN Context Menu: popup/dropdown menu */
-
-class ContextMenu extends React.Component {
- constructor(props) {
- super(props);
-
- this.handlePageClick = this.handlePageClick.bind(this);
- this.handleEscapeKey = this.handleEscapeKey.bind(this);
- this.handleClick = this.handleClick.bind(this);
-
- // Preconfigured menu items.
- this.MenuItems = {
- 'topic_info': {title: "Info", handler: null},
-
- 'messages_clear': {title: "Clear messages", handler: (params, errorHandler) => {
- this.deleteMessages(true, false, params, errorHandler);
- }},
- 'messages_clear_hard': {title: "Clear for All", handler: (params, errorHandler) => {
- this.deleteMessages(true, true, params, errorHandler);
- }},
- 'message_delete': {title: "Delete", handler: (params, errorHandler) => {
- this.deleteMessages(false, false, params, errorHandler);
- }},
- 'message_delete_hard': {title: "Delete for All", handler: (params, errorHandler) => {
- this.deleteMessages(false, true, params, errorHandler);
- }},
- 'topic_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')},
- 'topic_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')},
- 'topic_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')},
- 'topic_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')},
- 'topic_delete': {title: "Delete", handler: (params, errorHandler) => {
- let topic = this.props.tinode.getTopic(params.topicName);
- if (!topic) {
- console.log("Topic not found: ", params.topicName);
- return;
- }
- topic.delTopic().catch((err) => {
- if (errorHandler) {
- errorHandler(err.message, 'err');
- }
- });
- }},
-
- 'permissions': {title: "Edit permissions", handler: null},
- 'member_delete': {title: "Remove", handler: (params, errorHandler) => {
- let topic = this.props.tinode.getTopic(params.topicName);
- if (!topic || !params.user) {
- console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
- return;
- }
- topic.delSubscription(params.user).catch((err) => {
- if (errorHandler) {
- errorHandler(err.message, 'err');
- }
- });
- }},
- 'member_mute': {title: "Mute", handler: this.topicPermissionSetter.bind(this, '-P')},
- 'member_unmute': {title: "Unmute", handler: this.topicPermissionSetter.bind(this, '+P')},
- 'member_block': {title: "Block", handler: this.topicPermissionSetter.bind(this, '-J')},
- 'member_unblock': {title: "Unblock", handler: this.topicPermissionSetter.bind(this, '+J')},
- };
- }
-
- componentDidMount() {
- document.addEventListener('mousedown', this.handlePageClick, false);
- document.addEventListener('keyup', this.handleEscapeKey, false);
- }
-
- componentWillUnmount() {
- document.removeEventListener('mousedown', this.handlePageClick, false);
- document.removeEventListener('keyup', this.handleEscapeKey, false);
- }
-
- handlePageClick(e) {
- if (ReactDOM.findDOMNode(this).contains(e.target)) {
- return;
- }
- e.preventDefault();
- e.stopPropagation();
- this.props.hide();
- }
-
- handleEscapeKey(e) {
- if (e.keyCode === 27) {
- this.props.hide();
- }
- }
-
- handleClick(e) {
- e.preventDefault();
- e.stopPropagation();
- this.props.hide();
- let item = this.props.items[e.currentTarget.dataset.id];
- if (typeof item == 'string') {
- item = this.MenuItems[item];
- }
- item.handler(this.props.params, this.props.onError);
- }
-
- // Menu Actions
-
- deleteMessages(all, hard, params, errorHandler) {
- var topic = this.props.tinode.getTopic(params.topicName);
- if (!topic) {
- console.log("Topic not found: ", params.topicName);
- return;
- }
- // We don't know if the message is still pending (e.g. attachment is being uploaded),
- // so try cancelling first. No harm if we can't cancel.
- if (topic.cancelSend(params.seq)) {
- return Promise.resolve();
- }
- // Can't cancel. Delete instead.
- var promise = all ?
- topic.delMessagesAll(hard) :
- topic.delMessagesList([params.seq], hard);
- promise.catch((err) => {
- if (errorHandler) {
- errorHandler(err.message, 'err');
- }
- });
- }
-
- // Function is used by context menu to set permissions.
- topicPermissionSetter(mode, params, errorHandler) {
- var topic = this.props.tinode.getTopic(params.topicName);
- if (!topic) {
- console.log("Topic not found", params.topicName);
- return;
- }
-
- var am, user;
- if (params.user) {
- user = topic.subscriber(params.user);
- if (!user) {
- console.log("Subscriber not found", params.topicName + "[" + params.user + "]");
- return;
- }
- am = user.acs.updateGiven(mode).getGiven();
- } else {
- am = topic.getAccessMode().updateWant(mode).getWant();
- }
-
- topic.setMeta({sub: {user: params.user, mode: am}}).catch((err) => {
- if (errorHandler) {
- errorHandler(err.message, 'err');
- }
- });
- }
-
- render() {
- let count = 0;
- let menu = [];
- this.props.items.map((item) => {
- if (typeof item == 'string') {
- item = this.MenuItems[item];
- }
- if (item && item.title) {
- menu.push(
- item.title == '-' ?
-
- :
- {item.title}
- );
- }
- count++;
- });
-
- // Ensure that menu is inside the app-container.
- var hSize = 12 * REM_SIZE;
- var vSize = REM_SIZE * (0.7 + menu.length * 2.5);
- var left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
- (this.props.clickAt.x - this.props.bounds.left - hSize) :
- (this.props.clickAt.x - this.props.bounds.left);
- var top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
- (this.props.clickAt.y - this.props.bounds.top - vSize) :
- (this.props.clickAt.y - this.props.bounds.top);
-
- var position = {
- left: left + 'px',
- top: top + 'px'
- };
-
- return (
-
- );
- }
-}
-/* END Popup/dropdown menu */
-
-/* The X menu to be displayed in title bars */
-class MenuCancel extends React.PureComponent {
- constructor(props) {
- super(props);
- }
-
- render() {
- return (
- close
- );
- }
-}
-
-class LoadSpinner extends React.PureComponent {
- render() {
- return (this.props.show ?
- : null);
- }
-}
-
-// Toggle [Title text >] -> [Title text v]
-class MoreButton extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- open: props.open
- };
- this.handleToggle = this.handleToggle.bind(this);
- }
-
- handleToggle() {
- let open = !this.state.open;
- this.setState({open: open});
- if (this.props.onToggle) {
- this.props.onToggle(open);
- }
- }
-
- render() {
- return ({this.props.title}...
- {this.state.open ? expand_more :
- chevron_right }
- );
- }
-}
-
-/* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */
-class LetterTile extends React.PureComponent {
- render() {
- let avatar;
- if (this.props.avatar === true) {
- if (this.props.topic && this.props.title && this.props.title.trim()) {
- let letter = this.props.title.trim().charAt(0);
- let color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16);
- avatar = ()
- } else {
- avatar = (Tinode.topicType(this.props.topic) == 'grp') ?
- group :
- person ;
- }
- } else if (this.props.avatar) {
- avatar = ;
- } else {
- avatar = null;
- }
- return avatar;
- }
-}
-/* END Lettertile */
-
-/* BEGIN In-place text editor. Shows text with an icon
- * which toggles it into an input field */
-class InPlaceEdit extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- active: props.active,
- initialValue: props.value || '',
- value: props.value || ''
- };
-
- this.handeTextChange = this.handeTextChange.bind(this);
- this.handleKeyDown = this.handleKeyDown.bind(this);
- this.handleStartEditing = this.handleStartEditing.bind(this);
- this.handleEditingFinished = this.handleEditingFinished.bind(this);
- this.handlePasswordFinished = this.handlePasswordFinished.bind(this);
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- // If text has changed while in read mode, update text and discard changes.
- // Ignore update if in edit mode.
- if (prevState.initialValue != nextProps.value && !prevState.active) {
- return {
- initialValue: nextProps.value || '',
- value: nextProps.value || ''
- };
- }
- return null;
- }
-
- handeTextChange(e) {
- this.setState({value: e.target.value});
- }
-
- handleKeyDown(e) {
- if (e.keyCode === 27) {
- // Escape pressed
- this.setState({value: this.props.value, active: false});
- } else if (e.keyCode === 13) {
- // Enter pressed
- this.handleEditingFinished();
- }
- }
-
- handleStartEditing() {
- if (!this.props.readOnly) {
- ReactDOM.findDOMNode(this).focus();
- this.setState({active: true});
- }
- }
-
- handleEditingFinished() {
- this.setState({active: false});
- let value = this.state.value.trim();
- if ((value || this.props.value) && (value !== this.props.value)) {
- this.props.onFinished(value);
- }
- }
-
- handlePasswordFinished(value) {
- this.setState({active: false});
- if (value && (value !== this.props.value)) {
- this.props.onFinished(value);
- }
- }
-
- render() {
- if (this.state.active) {
- var fieldType = this.props.type || 'text';
- } else {
- var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
- var spanClass = 'in-place-edit' +
- (this.props.readOnly ? ' disabled' : '');
- if (!spanText) {
- spanText = this.props.placeholder;
- spanClass += ' placeholder';
- }
- if (spanText.length > 20) {
- // FIXME: this is wrong for RTL languages.
- spanText = spanText.substring(0, 19) + '...';
- }
- }
- return (
- this.state.active ?
- (fieldType == 'password' ?
-
- :
-
- )
- :
-
- {spanText}
-
- );
- }
-};
-
-class VisiblePassword extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- value: this.props.value,
- visible: false
- };
-
- this.handleVisibility = this.handleVisibility.bind(this);
- this.handeTextChange = this.handeTextChange.bind(this);
- this.handleKeyDown = this.handleKeyDown.bind(this);
- this.handleEditingFinished = this.handleEditingFinished.bind(this);
- }
-
- handeTextChange(e) {
- this.setState({value: e.target.value});
- if (this.props.onChange) {
- this.props.onChange(e);
- }
- }
-
- handleVisibility(e) {
- e.preventDefault();
- this.setState({visible: !this.state.visible});
- }
-
- handleKeyDown(e) {
- if (e.keyCode == 27) {
- // Escape pressed
- this.setState({value: this.props.value, visible: false});
- if (this.props.onFinished) {
- this.props.onFinished();
- }
- } else if (e.keyCode == 13) {
- // Enter pressed
- this.handleEditingFinished();
- }
- }
-
- handleEditingFinished(e) {
- if (e) {
- let currentTarget = e.currentTarget;
- setTimeout(() => {
- if (!currentTarget.contains(document.activeElement)) {
- if (this.props.onFinished) {
- this.props.onFinished(this.state.value);
- }
- }
- }, 0);
- } else if (this.props.onFinished) {
- this.props.onFinished(this.state.value.trim());
- }
- }
-
- render() {
- return (
-
-
-
-
- {this.state.visible ? 'visibility' : 'visibility_off'}
-
-
-
- );
- }
-}
-
-/* END InPlaceEdit */
-
-/* BEGIN Combobox for selecting host name */
-class HostSelector extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- hostName: props.serverAddress,
- changed: false
- };
-
- this.handleHostNameChange = this.handleHostNameChange.bind(this);
- this.handleEditingFinished = this.handleEditingFinished.bind(this);
- }
-
- handleHostNameChange(e) {
- this.setState({hostName: e.target.value, changed: true});
- }
-
- handleEditingFinished() {
- if (this.state.changed) {
- this.setState({changed: false});
- this.props.onServerAddressChange(this.state.hostName.trim());
- }
- }
-
- render() {
- var hostOptions = [];
- for (var key in KNOWN_HOSTS) {
- var item = KNOWN_HOSTS[key];
- hostOptions.push(
-
- );
- }
- return (
-
-
-
- {hostOptions}
-
-
- );
- }
-}
-/* END Combobox for selecting host name */
-
-/* BEGIN CheckBox: styled checkbox */
-class CheckBox extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.handleChange = this.handleChange.bind(this);
- }
-
- handleChange() {
- this.props.onChange(this.props.name, !this.props.checked);
- }
-
- render() {
- return (
- this.props.onChange ? (
- this.props.checked ?
- check_box :
- check_box_outline_blank
- ) : (
- this.props.checked ?
- check_box :
- check_box_outline_blank
- )
- );
- }
-}
-/* END CheckBox */
-
-/* BEGIN PermissionsEditor: Component for editing permissions */
-//
-class PermissionsEditor extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- mode: (props.mode || '').replace('N', '')
- };
-
- this.handleChange = this.handleChange.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleCancel = this.handleCancel.bind(this);
- }
-
- handleChange(val) {
- let mode = this.state.mode;
- let idx = mode.indexOf(val);
- if (idx == -1) {
- mode += val;
- } else {
- mode = mode.replace(val, '');
- }
- this.setState({mode: mode});
- }
-
- handleSubmit() {
- // Normalize string, otherwise cannot check if mode has changed.
- var mode = (this.state.mode || 'N').split('').sort().join('');
- var before = (this.props.mode || 'N').split('').sort().join('')
- if (mode !== before) {
- this.props.onSubmit(mode);
- } else {
- this.props.onCancel();
- }
- }
-
- handleCancel() {
- this.props.onCancel();
- }
-
- render() {
- const all = 'JRWPASDO';
- const names = {
- 'J': 'Join (J)',
- 'R': 'Read (R)',
- 'W': 'Write (W)',
- 'P': 'Get notified (P)',
- 'A': 'Approve (A)',
- 'S': 'Share (S)',
- 'D': 'Delete (D)',
- 'O': 'Owner (O)'
- };
-
- let skip = this.props.skip || '';
- let mode = this.state.mode;
- let compare = (this.props.compare || '').replace('N', '');
- let items = [];
- for (let i=0; i= 0 && mode.indexOf(c) < 0) {
- // Permission is marked as inactive: hide unchecked permissions, disable checked permissions
- continue;
- }
- items.push(
-
- {names[c]}
- {skip.indexOf(c) < 0 ?
- = 0)} onChange={this.handleChange}/>
- :
- = 0)} />
- } {this.props.compare ?
- = 0)}/>
- : null}
-
- );
- }
-
- return (
-
- {this.props.userTitle ?
-
: null}
-
Permissions
-
- {this.props.compare ?
-
- {this.props.modeTitle}
- {this.props.compareTitle}
- :
- null}
-
- {items}
-
-
-
- Ok
- Cancel
-
-
- );
- }
-};
-/* END PermissionsEditor */
-
-/* BEGIN ChipInput: group membership widget */
-class ChipInput extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- placeholder: props.chips ? '' : props.prompt,
- sortedChips: ChipInput.sortChips(props.chips, props.required),
- chipIndex: ChipInput.indexChips(props.chips),
- input: '',
- focused: false
- };
-
- this.handleTextInput = this.handleTextInput.bind(this);
- this.removeChipAt = this.removeChipAt.bind(this);
- this.handleChipCancel = this.handleChipCancel.bind(this);
- this.handleFocusGained = this.handleFocusGained.bind(this);
- this.handleFocusLost = this.handleFocusLost.bind(this);
- this.handleKeyDown = this.handleKeyDown.bind(this);
- }
-
- UNSAFE_componentWillReceiveProps(nextProps) {
- this.setState({
- sortedChips: ChipInput.sortChips(nextProps.chips, nextProps.required),
- chipIndex: ChipInput.indexChips(nextProps.chips)
- });
- if (nextProps.chips.length > this.props.chips.length) {
- // Chip added: clear input.
- this.setState({input: ''});
- }
- }
-
- // Map chip index to user name
- static indexChips(chips) {
- var index = {};
- var count = 0;
- chips.map(function(item) {
- index[item.user] = count;
- count ++;
- });
- return index;
- }
-
- // Have non-removable chips appear before all other chips.
- static sortChips(chips, keep) {
- var required = [];
- var normal = [];
- chips.map(function(item) {
- if (item.user === keep) {
- required.push(item);
- } else {
- normal.push(item);
- }
- });
- return required.concat(normal);
- }
-
- handleTextInput(e) {
- this.setState({input: e.target.value});
- if (this.props.filterFunc) {
- this.props.filterFunc(e.target.value);
- }
- }
-
- removeChipAt(idx) {
- var removed = this.state.sortedChips[idx];
- this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
- }
-
- handleChipCancel(item, idx) {
- this.removeChipAt(idx);
- }
-
- handleFocusGained() {
- this.setState({focused: true});
- }
-
- handleFocusLost() {
- this.setState({focused: false});
- if (this.props.onFocusLost) {
- this.props.onFocusLost(this.state.input);
- }
- }
-
- handleKeyDown(e) {
- if (e.key === 'Backspace') {
- if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
- var at = this.state.sortedChips.length - 1;
- if (this.state.sortedChips[at].user !== this.props.required) {
- this.removeChipAt(at);
- }
- }
- } else if (e.key === 'Enter') {
- if (this.props.onEnter) {
- this.props.onEnter(this.state.input);
- }
- } else if (e.key === 'Escape') {
- if (this.props.onCancel) {
- this.props.onCancel();
- }
- }
- }
-
- render() {
- var chips = [];
- var count = 0;
- this.state.sortedChips.map((item) => {
- chips.push(
-
- );
- count++;
- });
- var className = "chip-input" + (this.state.focused ? ' focused' : '');
- return (
-
- {chips}
-
-
- );
- }
-};
-
-class Chip extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.handleCancel = this.handleCancel.bind(this);
- }
-
- handleCancel() {
- this.props.onCancel(this.props.topic, this.props.index);
- }
-
- render() {
- var title = this.props.title || this.props.topic;
- return (
-
- {this.props.noAvatar ?
-
:
-
-
-
- }
-
{title}
- {this.props.onCancel && !this.props.required ?
-
×
- :
}
-
- );
- }
-};
-/* END ChipInput */
-
-/* BEGIN GroupSubs: a list of group subscribers currently online */
-class GroupSubs extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- onlineSubs: props.subscribers || []
- };
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- return {
- onlineSubs: nextProps.subscribers ? nextProps.subscribers : []
- };
- }
-
- render() {
- var usersOnline = [];
- this.state.onlineSubs.map((sub) => {
- usersOnline.push(
-
-
-
- );
- });
- return (
- {usersOnline}
- );
- }
-};
-/* END GroupSubs */
-
-/* BEGIN Login: a login form */
-class LoginView extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- login: props.login,
- password: '',
- hostName: props.serverAddress,
- saveToken: localStorage.getObject('keep-logged-in')
- };
- this.handleLoginChange = this.handleLoginChange.bind(this);
- this.handlePasswordChange = this.handlePasswordChange.bind(this);
- this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- }
-
- handleLoginChange(e) {
- this.setState({login: e.target.value});
- }
-
- handlePasswordChange(e) {
- this.setState({password: e.target.value});
- }
-
- handleToggleSaveToken() {
- localStorage.setObject('keep-logged-in', !this.state.saveToken);
- this.setState({saveToken: !this.state.saveToken});
- }
-
- handleSubmit(e) {
- e.preventDefault();
- this.props.onLogin(this.state.login.trim(), this.state.password.trim());
- }
-
- render() {
- var submitClasses = 'blue';
- if (this.props.disabled) {
- submitClasses += ' disabled';
- }
-
- return (
-
- );
- }
-};
-/* END Login */
-
-/* BEGIN Account registration */
-class CreateAccountView extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- login: '',
- password: '',
- email: '',
- fn: '', // full/formatted name
- imageDataUrl: null,
- errorCleared: false,
- saveToken: localStorage.getObject('keep-logged-in')
- };
-
- this.handleLoginChange = this.handleLoginChange.bind(this);
- this.handlePasswordChange = this.handlePasswordChange.bind(this);
- this.handleEmailChange = this.handleEmailChange.bind(this);
- this.handleFnChange = this.handleFnChange.bind(this);
- this.handleImageChanged = this.handleImageChanged.bind(this);
- this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- }
-
- handleLoginChange(e) {
- this.setState({login: e.target.value});
- }
-
- handlePasswordChange(password) {
- this.setState({password: password});
- }
-
- handleEmailChange(e) {
- this.setState({email: e.target.value})
- }
-
- handleFnChange(e) {
- this.setState({fn: e.target.value});
- }
-
- handleImageChanged(img) {
- this.setState({imageDataUrl: img});
- }
-
- handleToggleSaveToken() {
- localStorage.setObject('keep-logged-in', !this.state.saveToken);
- this.setState({saveToken: !this.state.saveToken});
- }
-
- handleSubmit(e) {
- e.preventDefault();
- // TODO: check email for validity
- this.setState({errorCleared: false});
- this.props.onCreateAccount(
- this.state.login.trim(),
- this.state.password.trim(),
- vcard(this.state.fn, this.state.imageDataUrl),
- {'meth': 'email', 'val': this.state.email});
- }
-
- render() {
- var submitClasses = 'blue';
- if (this.props.disabled) {
- submitClasses += ' disabled';
- }
- return (
-
- );
- }
-};
-
-class AvatarUpload extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- dataUrl: props.avatar
- };
-
- this.handleFileUpload = this.handleFileUpload.bind(this);
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- if (prevState.dataUrl != nextProps.avatar) {
- return {
- dataUrl: nextProps.avatar
- };
- }
- return null;
- }
-
- handleFileUpload(e) {
- imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
- // Success
- (base64bits, mime) => {
- var du = makeImageUrl({data: base64bits, type: mime});
- this.setState({dataUrl: du});
- this.props.onImageChanged(du);
- },
- // Failure
- (err) => {
- this.props.onError(err, 'err');
- });
- // Clear the value so the same file can be uploaded again.
- e.target.value = '';
- }
-
- render() {
- // Randomize id value in case more than one AvatarUpload is shown
- // at the same time.
- var randId = "file-input-avatar-" + (Math.random() + '').substr(2);
- return (
-
- {this.state.dataUrl ?
-
:
- this.props.readOnly && this.props.uid ?
-
-
-
- :
-
128×128
}
- {this.props.readOnly ? null :
-
}
- {this.props.readOnly ? null :
-
- file_upload
- }
-
- );
- }
-};
-/* END Account registration */
-
-
-/* BEGIN PasswordResetView: a password reset form */
-class PasswordResetView extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- email: '',
- password: ''
- };
-
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleEmailChange = this.handleEmailChange.bind(this);
- this.handlePasswordChange = this.handlePasswordChange.bind(this);
- }
-
- componentDidMount() {
- let parsed = parseUrlHash(window.location.hash);
- this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
- }
-
- handleSubmit(e) {
- e.preventDefault();
- if (this.state.token) {
- this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
- } else {
- this.props.onRequest("email", this.state.email.trim());
- }
- }
-
- handleEmailChange(e) {
- this.setState({email: e.target.value});
- }
-
- handlePasswordChange(e) {
- this.setState({password: e.target.value});
- }
-
- render() {
- let reset = (this.state.token && this.state.scheme);
- return (
-
- );
- }
-}
-/* END PasswordResetView */
-
-
-/* BEGIN Tinode config panel */
-class SettingsView extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- transport: props.transport || 'def',
- serverAddress: props.serverAddress,
- };
-
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleTransportSelected = this.handleTransportSelected.bind(this);
- this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
- }
-
- handleSubmit(e) {
- e.preventDefault();
- this.props.onUpdate({
- transport: this.state.transport,
- serverAddress: this.state.serverAddress
- });
- }
-
- handleTransportSelected(e) {
- this.setState({transport: e.currentTarget.value});
- }
-
- handleServerAddressChange(name) {
- this.setState({serverAddress: name});
- this.props.onServerAddressChange(name);
- }
-
- render() {
- var names = {def: "default", ws: "websocket", lp: "long polling"};
- var transportOptions = [];
- var instance = this;
- ['def', 'ws', 'lp'].map(function(item){
- var id = 'transport-' + item;
- var name = names[item];
- transportOptions.push(
-
-
- {name}
-
- );
- });
- return (
-
- );
- }
-};
-/* END Tinode config panel */
-
-/* BEGIN Manage side panel - handle Login, Account Registration, Contacts, NewTopic views */
-class SideNavbar extends React.PureComponent {
- render() {
- return (
-
-
-
-
-
{this.props.title}
- {this.props.state === 'login' ?
-
:
- this.props.state === 'contacts' ?
-
:
- null}
- {this.props.onCancel ?
-
: null}
-
- );
- }
-};
-
-class MenuStart extends React.PureComponent {
- render() {
- return (
-
- );
- }
-};
-
-class MenuContacts extends React.PureComponent {
- render() {
- return (
-
- );
- }
-};
-
-class SidepanelView extends React.Component {
- constructor(props) {
- super(props);
-
- this.handleLoginRequested = this.handleLoginRequested.bind(this);
- }
-
- handleLoginRequested(login, password) {
- this.props.onLoginRequest(login, password);
- }
-
- render() {
- var title = null;
- var avatar = false;
- var onCancel = undefined;
- var view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
- switch (view) {
- case 'login':
- title = "Sign In";
- break;
- case 'register':
- title = "Create Account";
- onCancel = this.props.onCancel;
- break;
- case 'settings':
- title = "Settings";
- onCancel = this.props.onCancel;
- break;
- case 'edit':
- title = "Edit Account";
- onCancel = this.props.onCancel;
- break;
- case 'contacts':
- title = this.props.title;
- avatar = this.props.avatar ? this.props.avatar : true;
- break;
- case 'newtpk':
- title = "Start New Chat";
- onCancel = this.props.onCancel;
- break;
- case 'cred':
- title = "Confirm Credentials";
- onCancel = this.props.onCancel;
- break;
- case 'reset':
- title = "Reset Password";
- onCancel = this.props.onCancel;
- break;
- default:;
- };
- return (
-
-
-
-
-
- {view === 'login' ?
-
:
-
- view === 'register' ?
-
:
-
- view === 'settings' ?
-
:
-
- view === 'edit' ?
-
:
-
- view === 'contacts' ?
-
:
-
- view === 'newtpk' ?
-
:
-
- view === 'cred' ?
-
:
-
- view === 'reset' ?
-
:
- null}
-
- );
- }
-};
-
-class ErrorPanel extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- show: false,
- };
-
- this.hide = this.hide.bind(this);
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- return {
- show: !(!nextProps.level)
- };
- }
-
- hide() {
- this.setState({show: false});
- if (this.props.onClearError) {
- this.props.onClearError();
- }
- }
-
- render() {
- var icon = this.props.level == 'err' ? 'error': 'warning';
- return (
-
-
{icon}
- {this.props.text}
-
-
- );
- }
-};
-
-/* END Side panel */
-
-/* EditAccount parameters */
-class EditAccountView extends React.Component {
- constructor(props) {
- super(props);
-
- let me = this.props.tinode.getMeTopic();
- let defacs = me.getDefaultAccess();
- let fnd = this.props.tinode.getFndTopic();
- this.state = {
- fullName: me.public ? me.public.fn : undefined,
- avatar: makeImageUrl(me.public ? me.public.photo : null),
- auth: defacs ? defacs.auth : null,
- anon: defacs ? defacs.anon : null,
- tags: fnd.tags(),
- showPermissionEditorFor: undefined,
- previousOnTags: fnd.onTagsUpdated
- };
-
- this.tnNewTags = this.tnNewTags.bind(this);
- this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
- this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
- this.handleImageChanged = this.handleImageChanged.bind(this);
- this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
- this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
- this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
- this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
- this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
- }
-
- componentDidMount() {
- let fnd = this.props.tinode.getFndTopic();
- fnd.onTagsUpdated = this.tnNewTags;
- if (!fnd.isSubscribed()) {
- fnd.subscribe(fnd.startMetaQuery().withLaterDesc().withTags().build()).catch((err) => {
- this.props.onError(err.message, 'err');
- });
- }
- }
-
- componentWillUnmount() {
- var fnd = this.props.tinode.getFndTopic();
- fnd.onTagsUpdated = this.state.previousOnTags;
- }
-
- tnNewTags(tags) {
- this.setState({tags: tags});
- }
-
- handleFullNameUpdate(fn) {
- this.setState({fullName: fn});
- this.props.onUpdateAccount(undefined, vcard(fn, this.state.avatar));
- }
-
- handlePasswordUpdate(pwd) {
- this.setState({password: pwd});
- this.props.onUpdateAccount(pwd);
- }
-
- handleImageChanged(img) {
- this.setState({avatar: img});
- this.props.onUpdateAccount(undefined, vcard(this.state.fullName, img));
- }
-
- handleCheckboxClick(what, checked) {
- if (what == 'sound') {
- this.props.onToggleMessageSounds(checked);
- } else if (what == 'alert') {
- this.props.onTogglePushNotifications(checked);
- }
- }
-
- handleLaunchPermissionsEditor(which) {
- this.setState({
- showPermissionEditorFor: which,
- editedPermissions: this.state[which]
- });
- }
-
- handleHidePermissionsEditor() {
- this.setState({showPermissionEditorFor: undefined});
- }
-
- handlePermissionsChanged(perm) {
- let defacs = {};
- defacs[this.state.showPermissionEditorFor] = perm;
- this.props.onUpdateAccount(undefined, undefined, defacs);
-
- let newState = {showPermissionEditorFor: undefined};
- newState[this.state.showPermissionEditorFor] = perm;
- this.setState(newState);
- }
-
- handleTagsUpdated(tags) {
- // Check if tags have actually changed.
- if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
- return;
- }
- this.props.onUpdateTags(tags);
- }
-
- render() {
- var tags = [];
- this.state.tags.map(function(tag) {
- tags.push({tag} );
- });
- if (tags.length == 0) {
- tags = No tags defined. Add some. ;
- }
- return (
- {this.state.showPermissionEditorFor ?
-
- :
-
-
-
-
Your name
-
-
Password
-
-
-
-
-
-
-
- Address:
- {this.props.myUserId}
-
-
- Default access mode:
-
-
-
Auth: {this.state.auth}
-
Anon: {this.state.anon}
-
-
-
-
- Message sound:
-
-
-
- Notification
- alerts{!this.props.desktopAlertsEnabled ? ' (requires HTTPS)' : null}:
-
-
-
-
-
-
-
- }
- );
- }
-};
-/* END EditAccount */
-
-/* BEGIN TagManager: edit topic or user tags */
-class TagManager extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- tags: this.props.tags,
- tagInput: '',
- activated: this.props.activated,
- noTagsMessage: "Add some tags"
- };
-
- this.handleShowTagManager = this.handleShowTagManager.bind(this);
- this.handleTagInput = this.handleTagInput.bind(this);
- this.handleAddTag = this.handleAddTag.bind(this);
- this.handleRemoveTag = this.handleRemoveTag.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleCancel = this.handleCancel.bind(this);
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
- return {tags: nextProps.tags};
- }
- return null;
- }
-
- handleShowTagManager() {
- this.setState({activated: true});
- }
-
- handleTagInput(text) {
- this.setState({tagInput: text});
- // Check if user entered ',', ' ' or ';'
- if (text.length > 0) {
- var last = text[text.length-1];
- if (last == ',' || last == ' ' || last == ';') {
- var tag = text.substr(0, text.length-1).trim();
- if (tag.length >= MIN_TAG_LENGTH) {
- this.handleAddTag(tag);
- }
- }
- }
- }
-
- handleAddTag(tag) {
- tag = tag.trim();
- if (tag.length > 0) {
- let tags = this.state.tags.slice(0);
- tags.push(tag);
- this.setState({tags: tags, tagInput: ''});
- if (this.props.onTagsChanged) {
- this.props.onTagsChanged(tags);
- }
- }
- }
-
- handleRemoveTag(tag, index) {
- var tags = this.state.tags.slice(0);
- tags.splice(index, 1);
- this.setState({tags: tags});
- if (this.props.onTagsChanged) {
- this.props.onTagsChanged(tags);
- }
- }
-
- handleSubmit() {
- let tags = this.state.tags.slice(0);
- let inp = this.state.tagInput.trim();
- if (inp.length > 0) {
- tags.push(inp);
- if (this.props.onTagsChanged) {
- this.props.onTagsChanged(tags);
- }
- }
- this.props.onSubmit(tags);
- this.setState({activated: false, tagInput: '', tags: this.props.tags});
- }
-
- handleCancel() {
- this.setState({activated: false, tagInput: '', tags: this.props.tags});
- if (this.props.onCancel) {
- this.props.onCancel();
- }
- }
-
- render() {
- var tags = [];
- if (this.state.activated) {
- this.state.tags.map(function(tag) {
- tags.push({user: tag});
- });
- } else {
- this.state.tags.map(function(tag) {
- tags.push({tag} );
- });
- if (tags.length == 0) {
- tags = No tags defined. Add some. ;
- }
- }
- return (
-
-
- {this.props.title}
-
- {this.state.activated ?
-
-
- {this.props.onSubmit || this.props.onCancel ?
-
- OK
- Cancel
-
- : null}
-
- :
-
- }
-
- );
- }
-};
-/* END TagManager */
-
-/* BEGIN Contact list (list of topics) */
-/* A single topic */;
-class Contact extends React.Component {
- constructor(props) {
- super(props);
-
- this.handleClick = this.handleClick.bind(this);
- this.handleContextClick = this.handleContextClick.bind(this);
- }
-
- handleClick(e) {
- e.preventDefault();
- e.stopPropagation();
- if (this.props.onSelected) {
- this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs);
- }
- }
-
- handleContextClick(e) {
- e.preventDefault();
- e.stopPropagation();
- this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
- }
-
- render() {
- var title = this.props.title;
- if (!title) {
- title = unknown ;
- } else if (title.length > 30) {
- // FIXME: this is wrong for RTL languages.
- title = title.substring(0, 28) + '...';
- }
- var online = this.props.now ? 'online' : 'offline';
- var avatar = this.props.avatar ? this.props.avatar : true;
- var badges = [];
- if (this.props.badges && this.props.badges.length > 0) {
- var count = 0;
- this.props.badges.map(function(b) {
- var style = 'badge' + (b.color ? ' ' + b.color : '');
- badges.push({b.name} );
- count ++;
- });
- }
- if (this.props.showMode && this.props.acs) {
- badges.push({this.props.acs.getMode()} );
- }
-
- return (
-
-
-
- {this.props.showOnline ? :
- (this.props.showCheckmark && this.props.selected ?
- check_circle
- : null)}
-
-
-
{title}
- {this.props.unread > 0 ? : null}
-
- {this.props.comment ?
{this.props.comment}
: null}
-
{badges}
-
- {this.props.showContextMenu ?
-
-
- expand_more
-
- : null}
-
- );
- }
-};
-
-/* The counter of unread messages in the topic */
-class UnreadBadge extends React.PureComponent {
- render() {
- var showUnreadBadge = null;
- if (this.props.count > 0) {
- var count = this.props.count > 9 ? "9+" : this.props.count;
- showUnreadBadge = {count} ;
- }
- return showUnreadBadge;
- }
-};
-
-/* Contact's labels: [you], [muted], [blocked], etc */
-// FIXME: this class is unused.
-class ContactBadges_UNUSED_REMOVE extends React.PureComponent {
- render() {
- let badges = null;
- if (this.props.badges && this.props.badges.length > 0) {
- badges = [];
- this.props.badges.map(function(b) {
- var style = 'badge' + (b.color ? ' ' + b.color : '');
- // Badge names are expected to be unique, so using the name as the key.
- badges.push({b.name} );
- });
- }
- return badges;
- }
-};
-
-/* ContactsView holds all contacts-related stuff */
-class ContactsView extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = ContactsView.getDerivedStateFromProps(props, {});
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- let contacts = [];
- let unreadThreads = 0;
- nextProps.chatList.map((c) => {
- unreadThreads += c.unread > 0 ? 1 : 0;
- contacts.push(c);
- });
- contacts.sort(function(a, b){
- return (b.touched || 0) - (a.touched || 0);
- });
-
- updateFavicon(unreadThreads);
-
- return {contactList: contacts};
- }
-
- render() {
- return (
- You have no chats ¯\_(ツ)_/¯}
- topicSelected={this.props.topicSelected}
- myUserId={this.props.myUserId}
- showOnline={true}
- showUnread={true}
- onTopicSelected={this.props.onTopicSelected}
- showContextMenu={this.props.showContextMenu} />
- );
- }
-};
-/* END Contact list */
-
-/* BEGIN ContactList: component for showing a list of contacts,
- * such as a list of group members in a group chat */
-class ContactList extends React.Component {
- render() {
- var contactNodes = [];
- var showCheckmark = Array.isArray(this.props.topicSelected);
- if (this.props.contacts && this.props.contacts.length > 0) {
- this.props.contacts.map((c) => {
- var key = c.topic ? c.topic : c.user;
- // If filter function is provided, filter out the items
- // which don't satisfy the condition.
- if (this.props.filterFunc && this.props.filter) {
- var content = [key];
- if (c.private && c.private.comment) {
- content.push(('' + c.private.comment).toLowerCase());
- }
- if (c.public && c.public.fn) {
- content.push(('' + c.public.fn).toLowerCase());
- }
- if (!this.props.filterFunc(this.props.filter, content)) {
- return;
- }
- }
-
- var selected = showCheckmark ?
- (this.props.topicSelected.indexOf(key) > -1) :
- (this.props.topicSelected === key);
- var badges = [];
- if (this.props.showMode) {
- if (key === this.props.myUserId) {
- badges.push({name: "you", color: 'green'});
- }
- if (c.acs && c.acs.isOwner()) {
- badges.push({name: "owner"});
- }
- }
- var comment = Array.isArray(c.private) ?
- c.private.join(', ') : (c.private ? c.private.comment : null);
-
- contactNodes.push(
-
- );
- }, this);
- }
-
- return (
-
- {contactNodes.length > 0 ?
-
- :
-
- {this.props.emptyListMessage}
-
}
-
- );
- }
-};
-/* END ContactList */
-
-/* BEGIN GroupMembers: control for managing a list of group members */
-class GroupManager extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- members: props.members,
- index: GroupManager.indexMembers(props.members),
- contactFilter: '',
- noContactsMessage: "You have no contacts :-(",
- selectedContacts: GroupManager.selectedContacts(props.members)
- };
-
- this.handleContactSelected = this.handleContactSelected.bind(this);
- this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
- this.handleContactFilter = this.handleContactFilter.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleCancel = this.handleCancel.bind(this);
- }
-
- static indexMembers(members) {
- var index = {};
- members.map(function(m) {
- index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
- });
- return index;
- }
-
- static selectedContacts(members) {
- var sel = [];
- members.map(function(m) {
- sel.push(m.user);
- });
- return sel;
- }
-
- handleContactSelected(userId, index) {
- var status = this.state.index[userId];
- if (status) {
- if (status.present) {
- // Prevent duplicate members
- return;
- }
- status.delta += 1;
- status.present = true;
- } else {
- status = {delta: 1, present: true};
- }
-
- var m = this.state.members.slice();
- m.push(this.props.contacts[index]);
-
- var sel = GroupManager.selectedContacts(m);
-
- var i = this.state.index;
- i[userId] = status;
-
- this.setState({members: m, index: i, selectedContacts: sel});
- }
-
- handleMemberRemoved(userId, index) {
- var status = this.state.index[userId];
- if (!status || !status.present) {
- return;
- }
- status.present = false;
- status.delta -= 1;
-
- var m = this.state.members.slice();
- m.splice(index, 1);
-
- var sel = GroupManager.selectedContacts(m);
-
- var i = this.state.index;
- i[userId] = status;
-
- this.setState({members: m, index: i, selectedContacts: sel});
- }
-
- handleContactFilter(val) {
- var msg = !val ?
- "You have no contacts :-(" :
- "No contacts match '" + val + "'";
-
- this.setState({contactFilter: val, noContactsMessage: msg});
- }
-
- static doContactFiltering(filter, values) {
- if (filter) {
- for (var i=0; i= 0) {
- return true;
- }
- }
- return false;
- }
- return true;
- }
-
- handleSubmit() {
- var instance = this;
- var members = [];
- var added = [];
- var removed = [];
-
- var keys = Object.keys(this.state.index);
- keys.map(function(k) {
- if (instance.state.index[k].present) {
- members.push(k);
- }
-
- if (instance.state.index[k].delta > 0) {
- added.push(k);
- } else if (instance.state.index[k].delta < 0) {
- removed.push(k);
- }
- });
- this.props.onSubmit(members, added, removed);
- }
-
- handleCancel() {
- this.props.onCancel();
- }
-
- render() {
- return (
-
-
- Group members
-
-
-
-
-
- All contacts
-
-
-
- OK
- Cancel
-
-
- );
- }
-};
-/* END GroupManager */
-
-/* BEGIN Create new topic and invite users or send an invite */
-class NewTopicView extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- tabSelected: 'p2p',
- contactList: props.searchResults
- };
-
- this.handleTabClick = this.handleTabClick.bind(this);
- this.handleContactSelected = this.handleContactSelected.bind(this);
- this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
- this.handleGroupByID = this.handleGroupByID.bind(this);
- }
-
- componentDidMount() {
- this.props.onInitFind();
- }
-
- static getDerivedStateFromProps(nextProps, prevState) {
- return {
- contactList: nextProps.searchResults
- };
- }
-
- handleTabClick(e) {
- e.preventDefault();
- navigateTo(addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
- this.setState({tabSelected: e.currentTarget.dataset.id});
- }
-
- handleContactSelected(sel) {
- if (this.state.tabSelected === 'p2p') {
- navigateTo(removeUrlParam(window.location.hash, 'tab'));
- this.props.onCreateTopic(sel, undefined);
- }
- }
-
- handleNewGroupSubmit(name, dataUrl, priv, tags) {
- navigateTo(removeUrlParam(window.location.hash, 'tab'));
- this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
- }
-
- handleGroupByID(topicName) {
- navigateTo(removeUrlParam(window.location.hash, 'tab'));
- this.props.onCreateTopic(topicName);
- }
-
- render() {
- return (
-
-
- {this.state.tabSelected === 'grp' ?
-
:
- this.state.tabSelected === 'byid' ?
-
:
-
-
-
-
}
-
- );
- }
-};
-
-class NewTopicGroup extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- fn: '', // full/formatted name
- private: '',
- imageDataUrl: null,
- tags: []
- };
-
- this.handleFnChange = this.handleFnChange.bind(this);
- this.handlePrivateChange = this.handlePrivateChange.bind(this);
- this.handleImageChanged = this.handleImageChanged.bind(this);
- this.handleTagsChanged = this.handleTagsChanged.bind(this);
- this.handleTagsChanged = this.handleTagsChanged.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- }
-
- handleFnChange(e) {
- this.setState({fn: e.target.value});
- }
-
- handlePrivateChange(e) {
- this.setState({private: e.target.value});
- }
-
- handleImageChanged(img) {
- this.setState({imageDataUrl: img});
- }
-
- handleTagsChanged(tags) {
- this.setState({tags: tags});
- }
-
- handleSubmit(e) {
- e.preventDefault();
- if (this.state.fn && this.state.fn.trim()) {
- this.props.onSubmit(this.state.fn.trim(),
- this.state.imageDataUrl, this.state.private.trim(),
- this.state.tags);
- }
- }
-
- render() {
- var submitClasses = 'blue';
- if (this.props.disabled) {
- submitClasses += ' disabled';
- }
- return (
-
- );
- }
-};
-
-class SearchContacts extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- edited: false,
- search: ''
- };
-
- this.handleSearchChange = this.handleSearchChange.bind(this);
- this.handleSearch = this.handleSearch.bind(this);
- this.handleClear = this.handleClear.bind(this);
- this.handleKeyDown = this.handleKeyDown.bind(this);
- }
-
- componentWillUnmount() {
- if (this.state.edited) {
- this.setState({search: '', edited: false});
- this.props.onSearchContacts(Tinode.DEL_CHAR);
- }
- }
-
- handleSearchChange(e) {
- this.setState({search: e.target.value});
- }
-
- handleSearch(e) {
- e.preventDefault();
- var query = this.state.search.trim();
- this.setState({edited: (query.length > 0)});
- this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
- }
-
- handleClear() {
- if (this.state.edited) {
- this.props.onSearchContacts(Tinode.DEL_CHAR);
- }
- this.setState({search: '', edited: false});
- }
-
- handleKeyDown(e) {
- if (e.key === 'Enter') {
- this.handleSearch(e);
- } else if (e.key === 'Escape') {
- this.handleClear();
- }
- }
-
- render() {
- return (
-
- );
- }
-};
-
-class NewTopicById extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- groupId: '',
- };
-
- this.handleChange = this.handleChange.bind(this);
- this.handleKeyPress = this.handleKeyPress.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- }
-
- handleChange(e) {
- this.setState({groupId: e.target.value});
- }
-
- handleKeyPress(e) {
- if (e.key === 'Enter') {
- this.handleSubmit(e);
- }
- }
-
- handleSubmit(e) {
- e.preventDefault();
- if (this.state.groupId) {
- var name = this.state.groupId.trim();
- if (name.length > 3 && (name.substr(0, 3) == 'usr' || name.substr(0, 3) == 'grp')) {
- this.props.onSubmit(name);
- } else {
- this.props.onError("Invalid ID", 'err');
- }
- }
- }
-
- render() {
- return (
-
- );
- }
-};
-/* END Create new topic and invite users or send an invite */
-
-/* BEGIN ValidationView: panel for confirming credentials, like email or phone */
-class ValidationView extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- code: props.credCode || ''
- };
-
- this.handleChange = this.handleChange.bind(this);
- this.handleKeyPress = this.handleKeyPress.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- }
-
- handleChange(e) {
- this.setState({code: e.target.value});
- }
-
- handleKeyPress(e) {
- if (e.key === 'Enter') {
- this.handleSubmit(e);
- }
- }
-
- handleSubmit(e) {
- e.preventDefault();
- if (this.state.code && this.state.code.trim()) {
- this.props.onSubmit(this.props.credMethod, this.state.code.trim());
- }
- }
-
- render() {
- var methods = {'email': 'email', 'tel': 'phone'};
- var method = methods[this.props.credMethod] || this.props.credMethod;
- return (
-
-
-
- Enter confirmation code sent to you by {method}:
-
-
-
-
-
-
- Confirm
-
-
- );
- }
-};
-/* END ValidationView */
-
-/* BEGIN InfoView: panel with topic/user info */
-class InfoView extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- topic: null,
- owner: false,
- admin: false,
- sharer: false,
- muted: false,
- address: null,
- groupTopic: undefined,
- fullName: undefined,
- avatar: null,
- private: null,
- selectedContact: null,
- access: null,
- modeGiven: null,
- modeWant: null,
- modeGiven2: null, // P2P topic, the other user mode given
- modeWant2: null, // P2P topic, the other user mode want
- auth: null,
- anon: null,
- contactList: [],
- tags: [],
- showMemberPanel: false,
- showPermissionEditorFor: undefined,
- moreInfoExpanded: false,
- previousMetaDesc: undefined,
- previousSubsUpdated: undefined,
- previousTagsUpdated: undefined
- };
-
- this.resetSubs = this.resetSubs.bind(this);
- this.resetDesc = this.resetDesc.bind(this);
- this.onMetaDesc = this.onMetaDesc.bind(this);
- this.onSubsUpdated = this.onSubsUpdated.bind(this);
- this.onTagsUpdated = this.onTagsUpdated.bind(this);
- this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
- this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
- this.handleImageChanged = this.handleImageChanged.bind(this);
- this.handleMuted = this.handleMuted.bind(this);
- this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
- this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
- this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
- this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
- this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
- this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
- this.handleLeave = this.handleLeave.bind(this);
- this.handleMemberSelected = this.handleMemberSelected.bind(this);
- this.handleMoreInfo = this.handleMoreInfo.bind(this);
- this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
- this.handleContextMenu = this.handleContextMenu.bind(this);
- }
-
- // No need to separately handle component mount.
- UNSAFE_componentWillReceiveProps(props) {
- var topic = this.props.tinode.getTopic(props.topic);
- if (!topic) {
- return;
- }
-
- if (this.onMetaDesc != topic.onMetaDesc) {
- this.previousMetaDesc = topic.onMetaDesc;
- topic.onMetaDesc = this.onMetaDesc;
-
- this.previousSubsUpdated = topic.onSubsUpdated;
- topic.onSubsUpdated = this.onSubsUpdated;
-
- if (topic.getType() == 'grp') {
- this.previousTagsUpdated = topic.onTagsUpdated;
- topic.onTagsUpdated = this.onTagsUpdated;
- } else {
- this.previousTagsUpdated = undefined;
- }
- }
-
- if (this.state.topic != props.topic) {
- this.setState({topic: props.topic});
- this.resetDesc(topic, props);
- this.resetSubs(topic, props);
- }
- }
-
- componentWillUnmount() {
- var topic = this.props.tinode.getTopic(this.props.topic);
- if (!topic) {
- return;
- }
- this.setState({topic: null});
- topic.onMetaDesc = this.previousMetaDesc;
- topic.onSubsUpdated = this.previousSubsUpdated;
- topic.onTagsUpdated = this.previousTagsUpdated;
- }
-
- resetSubs(topic, props) {
- var newState = {contactList: []};
- if (topic.getType() == 'p2p') {
- // Fetch the other party in the p2p conversation.
- // Topic may not be ready yet, so check if user is found.
- var user2 = topic.subscriber(props.topic);
- if (user2) {
- newState.modeGiven2 = user2.acs.getGiven();
- newState.modeWant2 = user2.acs.getWant();
- }
- } else {
- topic.subscribers(function(sub) {
- newState.contactList.push(sub);
- }, this);
- }
-
- this.setState(newState);
- }
-
- resetDesc(topic, props) {
- var defacs = topic.getDefaultAccess() || {};
- var acs = topic.getAccessMode();
- this.setState({
- owner: acs && acs.isOwner(),
- admin: acs && acs.isAdmin(),
- sharer: acs && (acs.isAdmin() || acs.isSharer()),
- muted: acs && acs.isMuted(),
-
- fullName: topic.public ? topic.public.fn : undefined,
- avatar: makeImageUrl(topic.public ? topic.public.photo : null),
- private: topic.private ? topic.private.comment : null,
- address: topic.name,
- groupTopic: (topic.getType() == 'grp'),
- showMemberPanel: false,
- access: acs ? acs.getMode() : undefined,
- modeGiven: acs ? acs.getGiven() : undefined,
- modeWant: acs ? acs.getWant() : undefined,
- auth: defacs.auth,
- anon: defacs.anon
- });
-
- if (topic.getType() == 'grp' && acs && acs.isOwner()) {
- // Requesting tags: owner is editing the topic.
- topic.getMeta(topic.startMetaQuery().withTags().build());
- }
- }
-
- onMetaDesc(desc) {
- var topic = this.props.tinode.getTopic(this.props.topic);
- if (!topic) {
- return;
- }
- this.resetDesc(topic, this.props);
-
- if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
- this.previousMetaDesc(desc);
- }
- }
-
- onSubsUpdated() {
- var topic = this.props.tinode.getTopic(this.props.topic);
- if (!topic) {
- return;
- }
- this.resetSubs(topic, this.props);
-
- if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
- this.previousSubsUpdated();
- }
- }
-
- onTagsUpdated(tags) {
- this.setState({tags: tags});
-
- if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
- this.previousTagsUpdated();
- }
- }
-
- handleFullNameUpdate(fn) {
- if (this.state.fullName !== fn) {
- this.setState({fullName: fn});
- this.props.onTopicDescUpdate(this.props.topic,
- vcard(fn, this.state.avatar),
- null);
- }
- }
-
- handlePrivateUpdate(priv) {
- if (this.state.priv !== priv) {
- this.setState({private: priv});
- this.props.onTopicDescUpdate(this.props.topic,
- null,
- priv);
- }
- }
-
- handleImageChanged(img) {
- this.setState({avatar: img});
- this.props.onTopicDescUpdate(this.props.topic,
- vcard(this.state.fullName, img),
- null);
- }
-
- handleMuted(ignored, checked) {
- this.setState({muted: checked});
- this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
- }
-
- handlePermissionsChanged(perm) {
- switch (this.state.showPermissionEditorFor) {
- case 'auth':
- this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
- break;
- case 'anon':
- this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
- break;
- case 'mode':
- case 'want':
- this.props.onChangePermissions(this.props.topic, perm);
- break;
- case 'given':
- this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
- break;
- case 'user':
- this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
- break;
- }
-
- this.setState({showPermissionEditorFor: undefined});
- }
-
- handleLaunchPermissionsEditor(which, uid) {
- let toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar;
- switch (which) {
- case 'mode':
- toEdit = this.state.access;
- break;
- case 'want':
- toEdit = this.state.modeWant;
- toCompare = this.state.modeGiven;
- toSkip = this.state.groupTopic ? 'O' : 'ASDO';
- titleEdit = "Requested";
- titleCompare = "Granted";
- break;
- case 'given':
- toEdit = this.state.modeGiven2;
- toCompare = this.state.modeWant2;
- toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
- titleEdit = "Granted";
- titleCompare = "Requested";
- break;
- case 'auth':
- toEdit = this.state.auth;
- toSkip = 'O';
- break;
- case 'anon':
- toEdit = this.state.anon;
- toSkip = 'O';
- break;
- case 'user': {
- let topic = this.props.tinode.getTopic(this.props.topic);
- if (!topic) {
- return;
- }
- var user = topic.subscriber(uid);
- if (!user || !user.acs) {
- return;
- }
- toEdit = user.acs.getGiven();
- toCompare = user.acs.getWant();
- toSkip = this.state.owner ? '' : 'O';
- titleEdit = "Granted";
- titleCompare = "Requested";
- if (user.public) {
- userTitle = user.public.fn;
- userAvatar = user.public.photo;
- }
- break;
- }
- default:
- console.log("Unknown permission editing mode '" + which + "'");
- break;
- }
- this.setState({
- showPermissionEditorFor: which,
- userPermissionsEdited: uid,
- userPermissionsTitle: userTitle,
- userPermissionsAvatar: userAvatar,
- editedPermissions: toEdit,
- immutablePermissions: toCompare,
- editedPermissionsTitle: titleEdit,
- immutablePermissionsTitle: titleCompare,
- editedPermissionsSkipped: toSkip,
- });
- }
-
- handleHidePermissionsEditor() {
- this.setState({showPermissionEditorFor: undefined});
- }
-
- handleShowAddMembers() {
- this.props.onInitFind();
- this.setState({showMemberPanel: true});
- }
-
- handleHideAddMembers() {
- this.setState({showMemberPanel: false});
- }
-
- handleMemberUpdateRequest(members, added, removed) {
- this.props.onMemberUpdateRequest(this.props.topic, added, removed);
- this.setState({showMemberPanel: false});
- }
-
- handleLeave() {
- this.props.onLeaveTopic(this.props.topic);
- }
-
- handleMemberSelected(uid) {
- this.setState({selectedContact: uid});
- }
-
- handleMoreInfo(open) {
- this.setState({moreInfoExpanded: open});
- }
-
- handleTagsUpdated(tags) {
- if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
- this.props.onTopicTagsUpdate(this.props.topic, tags);
- }
- }
-
- handleContextMenu(params) {
- var instance = this;
- var topic = this.props.tinode.getTopic(this.props.topic);
- if (!topic) {
- return;
- }
- var user = topic.subscriber(params.topicName);
- if (!user || !user.acs) {
- return;
- }
-
- var menuItems = [
- {title: "Edit permissions", handler: function() {
- instance.handleLaunchPermissionsEditor('user', params.topicName);
- }},
- 'member_delete',
- user.acs.isMuted() ? 'member_unmute' : 'member_mute',
- user.acs.isJoiner() ? 'member_block' : 'member_unblock'
- ];
- this.props.showContextMenu({
- topicName: this.props.topic,
- x: params.x,
- y: params.y,
- user: params.topicName}, menuItems);
- }
-
- render() {
- return (
-
-
- {this.props.displayMobile ?
-
: null}
- {this.state.showMemberPanel ?
-
- :
- this.state.showPermissionEditorFor ?
-
- :
-
-
-
-
Name
-
Unknown}
- readOnly={!this.state.owner}
- value={this.state.fullName}
- onFinished={this.handleFullNameUpdate} />
-
Private comment
-
-
-
-
-
-
-
- Muted:
-
-
-
- {this.state.moreInfoExpanded ?
-
-
- Address:
- {this.state.address}
-
- {this.state.groupTopic ?
-
- Your permissions:
-
- {this.state.access}
-
-
- :
-
-
Permissions:
-
-
Yours:
- {this.state.access}
-
-
{this.state.fullName ? this.state.fullName : "Other"}′s:
-
- {this.state.modeGiven2}
-
-
-
-
- }
- {this.state.sharer && (this.state.auth || this.state.anon) ?
-
-
Default access mode:
-
-
Auth: {this.state.admin ?
-
- {this.state.auth}
-
- :
- {this.state.auth}
- }
-
-
Anon: {this.state.admin ?
-
- {this.state.anon}
-
- :
- {this.state.anon}
- }
-
-
-
- :
- null
- }
-
- :
- null
- }
-
-
- {this.state.owner ?
-
- :
- null
- }
- {this.state.owner ?
: null }
- {this.state.groupTopic ?
-
-
- Group members:
-
-
-
-
- :
-
- }
-
- }
-
- );
- }
-};
-/* END InfoView */
-
-/* BEGIN Conversation panel */
-class ChatMessage extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- progress: 0
- };
-
- if (props.uploader) {
- props.uploader.onProgress = this.handleProgress.bind(this);
- }
-
- this.handlePreviewImage = this.handlePreviewImage.bind(this);
- this.handleContextClick = this.handleContextClick.bind(this);
- this.handleCancelUpload = this.handleCancelUpload.bind(this);
- }
-
- handlePreviewImage(e) {
- e.preventDefault();
- this.props.onImagePreview({
- url: e.target.src,
- filename: e.target.title,
- width: e.target.dataset.width,
- height: e.target.dataset.height,
- size: e.target.dataset.size,
- type: e.target.dataset.mime
- });
- }
-
- handleContextClick(e) {
- e.preventDefault();
- e.stopPropagation();
- this.props.showContextMenu({ seq: this.props.seq, y: e.pageY, x: e.pageX });
- }
-
- handleProgress(ratio) {
- this.setState({progress: ratio});
- }
-
- handleCancelUpload() {
- this.props.uploader.cancel();
- }
-
- render() {
- var elementKey = 0;
-
- var formatter = function(style, data, values) {
- elementKey += 1;
- var el = Drafty.tagName(style);
- if (el) {
- var attr = Drafty.attrValue(style, data) || {};
- attr.key = elementKey;
- if (style == 'IM') {
- // Additional processing for images
- var dim = fitImageSize(data.width, data.height,
- Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false);
- attr.className = 'inline-image';
- attr.style = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } : null;
- attr.onClick = this.handlePreviewImage;
- }
- return React.createElement(el, attr, values);
- } else {
- return values;
- }
- };
-
- var sideClass = this.props.sequence + ' ' + (this.props.response ? 'left' : 'right');
- var bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ?
- 'bubble tip' : 'bubble';
- var avatar = this.props.userAvatar || true;
- var fullDisplay = (this.props.userFrom && this.props.response &&
- (this.props.sequence == 'single' || this.props.sequence == 'last'));
-
- var content = this.props.content;
- var attachments = [];
- if (this.props.mimeType == Drafty.getContentType()) {
- Drafty.attachments(content, function(att, i) {
- attachments.push( );
- }, this);
- content = React.createElement('span', null, Drafty.format(content, formatter, this));
- } else if (typeof content != 'string') {
- content = error_outline invalid content
- }
-
- return (
-
- {this.props.userFrom && this.props.response ?
-
- {fullDisplay ?
- :
- null}
-
:
- null}
-
-
- {fullDisplay ?
{this.props.userName}
: null}
-
-
- );
- }
-};
-
-/* Received/read indicator */
-class ReceivedMarker extends React.PureComponent {
- render() {
- let timestamp;
- if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
- timestamp = "sending ...";
- } else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
- timestamp = "failed";
- } else {
- timestamp = shortDateFormat(this.props.timestamp);
- }
-
- let marker = null;
- if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
- marker = (access_time ); // watch face
- } else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
- marker = (error_outline ); // icon (!)
- } else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
- marker = (done ); // checkmark
- } else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
- marker = (done_all ); // double checkmark
- } else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
- marker = (done_all ); // blue double checkmark
- }
-
- return (
-
- {timestamp}{'\u00a0'}{marker}
-
- );
- }
-};
-
-/* File uload/download progress indicator with a cancel inside */
-class FileProgress extends React.PureComponent {
- render() {
- return (
-
-
- {this.props.progress < 0.999 ?
-
close cancel
- :
-
finishing...
- }
-
- );
- }
-}
-
-class Attachment extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- downloader: null,
- progress: 0
- };
-
- this.downloadFile = this.downloadFile.bind(this);
- this.handleCancel = this.handleCancel.bind(this);
- }
-
- downloadFile(url, filename, mimetype) {
- var downloader = this.props.tinode.getLargeFileHelper();
- this.setState({downloader: downloader});
- downloader.download(url, filename, mimetype, (loaded) => {
- this.setState({progress: loaded / this.props.size});
- }).then(() => {
- this.setState({downloader: null, progress: 0});
- }).catch((err) => {
- if (err) {
- this.props.onError("Error downloading file: " + err.message, 'err');
- }
- this.setState({downloader: null, progress: 0});
- });
- }
-
- handleCancel() {
- if (this.props.uploader) {
- this.props.onCancelUpload();
- } else if (this.state.downloader) {
- this.state.downloader.cancel();
- }
- }
-
- render() {
- let filename = this.props.filename || 'file_attachment';
- if (filename.length > 36) {
- filename = filename.substr(0, 16) + '...' + filename.substr(-16);
- }
- let size = this.props.size > 0 ?
- ({bytesToHumanSize(this.props.size)}) :
- null;
-
- // Detect if the download URL is relative or absolute.
- // If the URL is relative use LargeFileHelper to attach authentication
- // credentials to the request.
- let url, helperFunc;
- if (!this.props.uploader && !this.state.downloader &&
- !(/^(?:(?:[a-z]+:)?\/\/)/i.test(this.props.downloadUrl))) {
- // Relative URL. Use download helper.
- url = "javascript:;";
- helperFunc = (e) => {
- this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
- };
- } else {
- url = this.props.downloadUrl;
- helperFunc = null;
- }
- return (
-
-
insert_drive_file
-
-
{filename} {size}
- {this.props.uploader || this.state.downloader ?
-
- :
-
- }
-
-
- );
- }
-};
-
-const NOT_FOUND_TOPIC_TITLE = "Not found";
-
-class MessagesView extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- messages: [],
- onlineSubs: [],
- topic: '',
- title: '',
- avatar: null,
- scrollPosition: 0,
- readOnly: props.acs ? !props.acs.isWriter() : true,
- writeOnly: props.acs ? !props.acs.isReader() : true,
- typingIndicator: false,
- imagePreview: null
- };
-
- this.propsChange = this.propsChange.bind(this);
- this.leave = this.leave.bind(this);
- this.handleScrollReference = this.handleScrollReference.bind(this);
- this.fetchMoreMessages = this.fetchMoreMessages.bind(this);
- this.handleDescChange = this.handleDescChange.bind(this);
- this.handleSubsUpdated = this.handleSubsUpdated.bind(this);
- this.handleNewMessage = this.handleNewMessage.bind(this);
- this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
- this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
- this.handleImagePreview = this.handleImagePreview.bind(this);
- this.handleCloseImagePreview = this.handleCloseImagePreview.bind(this);
- this.handleContextClick = this.handleContextClick.bind(this);
- this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
- this.handleBackNavigation = this.handleBackNavigation.bind(this);
- }
-
- // Scroll last message into view on component update e.g. on message received.
- componentDidUpdate(prevProps, prevState) {
- if (this.messagesScroller &&
- (prevState.title != this.state.title ||
- prevState.messages.length != this.state.messages.length)) {
- this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
- }
- }
-
- componentDidMount() {
- this.propsChange(this.props);
- if (this.messagesScroller) {
- this.messagesScroller.addEventListener('scroll', this.fetchMoreMessages);
- }
- }
-
- componentWillUnmount() {
- this.leave(this.state.topic);
- if (this.messagesScroller) {
- this.messagesScroller.removeEventListener('scroll', this.fetchMoreMessages);
- }
- }
-
- UNSAFE_componentWillReceiveProps(nextProps) {
- this.propsChange(nextProps);
- }
-
- propsChange(props) {
- if (!props || !props.topic) {
- let oldTopic = this.state.topic;
- this.setState({messages: [], onlineSubs: [], topic: null}, this.leave(oldTopic));
- return;
- }
-
- if (!props.connected) {
- // connection lost, clear online subs
- this.setState({onlineSubs: []});
- return;
- }
-
- let topic = this.props.tinode.getTopic(props.topic);
- if (!topic) {
- return;
- }
-
- let newGroupTopic = Tinode.isNewGroupTopicName(props.topic);
- let tryToResubscribe = !this.props.connected && props.connected;
-
- if (props.topic != this.state.topic) {
- let msgs = [];
- let subs = [];
-
- // Bind the new topic to component.
- topic.onData = this.handleNewMessage;
- topic.onAllMessagesReceived = this.handleAllMessagesReceived;
- topic.onInfo = this.handleInfoReceipt;
- topic.onMetaDesc = this.handleDescChange;
- topic.onSubsUpdated = this.handleSubsUpdated;
- topic.onPres = this.handleSubsUpdated;
- // Unbind the previous topic from this component.
- this.leave(this.state.topic);
-
- this.handleDescChange(topic);
- topic.subscribers((sub) => {
- if (sub.online && sub.user != this.props.myUserId) {
- subs.push(sub);
- }
- });
-
- topic.messages(function(msg) {
- if (!msg.deleted) {
- msgs = msgs.concat(msg);
- }
- });
-
- this.setState({
- messages: msgs,
- onlineSubs: subs,
- topic: props.topic,
- imagePreview: null,
- scrollPosition: 0
- });
- tryToResubscribe = true;
-
- // The user switched to the new topic before the timer for
- // the previous topic has triggered, kill it.
- this.props.readTimerHandler(null);
- }
-
- this.setState({
- readOnly: props.acs ? !props.acs.isWriter() : true,
- writeOnly: props.acs ? !props.acs.isReader() : true
- });
-
- if (!topic.isSubscribed() && tryToResubscribe) {
- // Don't request the tags. They are useless unless the user
- // is the owner and is editing the topic.
- let getQuery = topic.startMetaQuery()
- .withLaterDesc()
- .withLaterSub()
- .withLaterData(MESSAGES_PAGE)
- .withLaterDel();
- let setQuery = newGroupTopic ? props.newGroupTopicParams : undefined;
- // Show "loading" spinner.
- this.setState({ fetchingMessages: true });
- topic.subscribe(getQuery.build(), setQuery)
- .then((ctrl) => {
- this.setState({topic: ctrl.topic});
- this.props.onNewTopicCreated(props.topic, ctrl.topic);
- // If there are unsent messages, try sending them now.
- topic.queuedMessages((pub) => {
- if (!pub._sending && topic.isSubscribed()) {
- topic.publishMessage(pub);
- }
- });
- })
- .catch((err) => {
- this.props.onError(err.message, 'err');
- this.setState({
- title: NOT_FOUND_TOPIC_TITLE,
- avatar: null,
- readOnly: true,
- writeOnly: true,
- fetchingMessages: false,
- messages: [],
- onlineSubs: [],
- typingIndicator: false,
- imagePreview: null
- });
- });
- }
- }
-
- leave(oldTopicName) {
- if (!oldTopicName) {
- return;
- }
- let oldTopic = this.props.tinode.getTopic(oldTopicName);
- if (oldTopic && oldTopic.isSubscribed()) {
- oldTopic.leave(false)
- .catch(() => { /* do nothing here */ })
- .finally(() => {
- // We don't care if the request succeeded or failed.
- // The topic is dead regardless.
- this.setState({fetchingMessages: false});
- oldTopic.onData = undefined;
- oldTopic.onAllMessagesReceived = undefined;
- oldTopic.onInfo = undefined;
- oldTopic.onMetaDesc = undefined;
- oldTopic.onSubsUpdated = undefined;
- oldTopic.onPres = undefined;
- });
- }
- }
-
- handleScrollReference(node) {
- if (node) {
- node.addEventListener('scroll', this.fetchMoreMessages);
- this.messagesScroller = node;
- }
- }
-
- // Get older messages
- fetchMoreMessages(event) {
- if (event.target.scrollTop <= 0) {
- let newState = {scrollPosition: event.target.scrollHeight - event.target.scrollTop};
- this.setState((prevState, props) => {
- if (!prevState.fetchingMessages) {
- var topic = this.props.tinode.getTopic(this.state.topic);
- if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
- newState.fetchingMessages = true;
- topic.getMessagesPage(MESSAGES_PAGE).catch((err) => {
- this.setState({fetchingMessages: false});
- this.props.onError(err.message, 'err');
- });
- }
- }
- return newState;
- });
- }
- }
-
- handleDescChange(desc) {
- if (desc.public) {
- this.setState({
- title: desc.public.fn,
- avatar: makeImageUrl(desc.public.photo)
- });
- } else {
- this.setState({
- title: '',
- avatar: null
- });
- }
- if (desc.acs) {
- this.setState({
- readOnly: !desc.acs.isWriter(),
- writeOnly: !desc.acs.isReader()
- });
- }
- }
-
- handleSubsUpdated() {
- if (this.state.topic) {
- var subs = [];
- var topic = this.props.tinode.getTopic(this.state.topic);
- topic.subscribers((sub) => {
- if (sub.online && sub.user != this.props.myUserId) {
- subs.push(sub);
- }
- });
- this.setState({onlineSubs: subs});
- }
- }
-
- handleNewMessage(msg) {
- // Regenerate messages list
- var topic = this.props.tinode.getTopic(this.state.topic);
- var newState = {messages: []};
- topic.messages(function(m) {
- if (!m.deleted) {
- newState.messages = newState.messages.concat(m);
- }
- });
-
- // msg could be null if one or more messages were deleted.
- if (msg && !msg.deleted) {
- // If the message is added to the end of the message list,
- // scroll to the bottom.
- if (topic.isNewMessage(msg.seq)) {
- newState.scrollPosition = 0;
- }
-
- // Aknowledge all messages, including own messages.
- let status = topic.msgStatus(msg);
- if (status >= Tinode.MESSAGE_STATUS_SENT) {
- this.props.readTimerHandler(() => {
- topic.noteRead(msg.seq);
- });
- }
- this.props.onData(msg);
- }
-
- this.setState(newState);
- }
-
- handleAllMessagesReceived(count) {
- this.setState({fetchingMessages: false});
- }
-
- handleInfoReceipt(info) {
- switch (info.what) {
- case 'kp': {
- clearTimeout(this.keyPressTimer);
- var instance = this;
- this.keyPressTimer = setTimeout(function() {
- instance.setState({typingIndicator: false});
- }, KEYPRESS_DELAY + 1000);
- if (!this.state.typingIndicator) {
- this.setState({typingIndicator: true});
- }
- break;
- }
- case 'read':
- case 'recv':
- // Redraw due to changed recv/read status.
- this.forceUpdate();
- break;
- default:
- console.log("Other change in topic: ", info.what);
- }
- }
-
- handleImagePreview(content) {
- this.setState({ imagePreview: content });
- }
-
- handleCloseImagePreview() {
- this.setState({ imagePreview: null });
- }
-
- handleContextClick(e) {
- e.preventDefault();
- e.stopPropagation();
- this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
- }
-
- handleShowContextMenuMessage(params) {
- params.topicName = this.state.topic;
- var menuItems = ['message_delete'];
- var topic = this.props.tinode.getTopic(params.topicName);
- if (topic) {
- var acs = topic.getAccessMode();
- if (acs && acs.isDeleter()) {
- menuItems.push('message_delete_hard');
- }
- }
- this.props.showContextMenu(params, menuItems);
- }
-
- handleBackNavigation() {
- this.props.onHideMessagesView();
- }
-
- render() {
- var component = null;
- if (this.state.topic) {
- var messageNodes = [];
- var topic = this.props.tinode.getTopic(this.state.topic);
- var groupTopic = topic.getType() == 'grp';
- var previousFrom = null;
- for (var i=0; i
- );
- }
-
- let lastSeen = null;
- let cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
- if (cont && Tinode.topicType(cont.topic) == 'p2p') {
- if (cont.online) {
- lastSeen = "online now";
- } else if (cont.seen) {
- lastSeen = "Last active: " + shortDateFormat(cont.seen.when);
- // TODO(gene): also handle user agent in c.seen.ua
- }
- }
- var avatar = this.state.avatar || true;
- var online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
-
- component = (
-
-
- {this.props.displayMobile ?
-
- arrow_back
-
- :
- null}
-
-
-
-
-
-
{this.state.title || Unknown }
-
{lastSeen}
-
- {groupTopic ?
-
:
-
- }
-
-
- {this.props.displayMobile ?
-
- : null}
-
-
-
- {this.state.writeOnly ?
-
-
no access to messages
-
- :
- null
- }
-
-
- {this.state.imagePreview ?
-
: null}
-
- );
- } else {
- component = (
-
- );
- }
- return component;
- }
-};
-
-/* Send message form */
-class SendMessage extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {
- message: '',
- // Make initial keypress time as if it happened 5001 milliseconds in the past.
- keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
- };
-
- this.handlePasteEvent = this.handlePasteEvent.bind(this);
- this.handleAttachImage = this.handleAttachImage.bind(this);
- this.handleAttachFile = this.handleAttachFile.bind(this);
- this.handleSend = this.handleSend.bind(this);
- this.handleKeyPress = this.handleKeyPress.bind(this);
- this.handleMessageTyping = this.handleMessageTyping.bind(this);
- }
-
- componentDidMount() {
- this.pasteFile.addEventListener('paste', this.handlePasteEvent, false);
- }
-
- componentWillUnmount() {
- this.pasteFile.removeEventListener('paste', this.handlePasteEvent, false)
- }
-
- handlePasteEvent(e) {
- if (this.props.disabled) {
- return;
- }
- // FIXME: handle large files too.
- if (filePasted(e,
- (bits, mime, width, height, fname) => {
- this.props.sendMessage(Drafty.insertImage(null,
- 0, mime, bits, width, height, fname));
- },
- (mime, bits, fname) => {
- this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
- },
- this.props.onError)) {
-
- // If a file was pasted, don't paste base64 data into input field.
- e.preventDefault();
- }
- }
-
- handleAttachImage(e) {
- if (e.target.files && e.target.files.length > 0) {
- let file = e.target.files[0];
- // Check if the uploaded file is indeed an image and if it isn't too large.
- if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
- // Convert image for size or format.
- imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
- // Success
- (bits, mime, width, height, fname) => {
- this.props.sendMessage(Drafty.insertImage(null,
- 0, mime, bits, width, height, fname));
- },
- // Failure
- (err) => {
- this.props.onError(err, 'err');
- });
- } else {
- // Image can be uploaded as is. No conversion is needed.
- imageFileToBase64(file,
- // Success
- (bits, mime, width, height, fname) => {
- this.props.sendMessage(Drafty.insertImage(null,
- 0, mime, bits, width, height, fname));
- },
- // Failure
- (err) => {
- this.props.onError(err, 'err');
- }
- );
- }
- }
- // Clear the value so the same file can be uploaded again.
- e.target.value = '';
- }
-
- handleAttachFile(e) {
- if (e.target.files && e.target.files.length > 0) {
- var file = e.target.files[0];
- if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
- // Too large.
- this.props.onError("The file size " + bytesToHumanSize(file.size) +
- " exceeds the " + bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE) + " limit.", 'err');
- } else if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
- // Too large to send inband - uploading out of band and sending as a link.
- let uploader = this.props.tinode.getLargeFileHelper();
- if (!uploader) {
- this.props.onError("Cannot initiate file upload.");
- return;
- }
- // Format data and initiate upload.
- let uploadCompletionPromise = uploader.upload(file);
- let msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
- // Pass data and the uploader to the TinodeWeb.
- this.props.sendMessage(msg, uploadCompletionPromise, uploader);
- } else {
- // Small enough to send inband.
- fileToBase64(file,
- (mime, bits, fname) => {
- this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
- },
- this.props.onError
- );
- }
- }
- // Clear the value so the same file can be uploaded again.
- e.target.value = '';
- }
-
- handleSend() {
- var message = this.state.message.trim();
- if (message) {
- this.props.sendMessage(this.state.message.trim());
- this.setState({message: ''});
- }
- }
-
- /* Send on Enter key */
- handleKeyPress(e) {
- // Remove this if you don't want Enter to trigger send
- if (e.key === 'Enter') {
- // Have Shift-Enter insert a line break instead
- if (!e.shiftKey) {
- e.preventDefault();
- e.stopPropagation();
-
- this.handleSend();
- }
- }
- }
-
- handleMessageTyping(e) {
- var newState = {message: e.target.value};
- var now = new Date().getTime();
- if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
- var topic = this.props.tinode.getTopic(this.props.topic);
- if (topic.isSubscribed()) {
- topic.noteKeyPress();
- }
- newState.keypressTimestamp = now;
- }
- this.setState(newState);
- }
-
- render() {
- var prompt = this.props.disabled ? "Messaging disabled" : "New message";
- var instance = this;
- return (
-
- {this.props.disabled ?
-
photo :
-
- photo
- }
- {this.props.disabled ?
-
attach_file :
-
- attach_file
- }
-
- {this.props.disabled ?
-
send :
-
send }
-
-
-
- );
- }
-};
-
-/* This is just a static page to display when no conversation is selected. */
-class LogoView extends React.PureComponent {
- render() {
- var version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
- return (
-
-
-
-
- Tinode Web
-
-
Client: {version}
-
Server: {this.props.serverVersion} ({this.props.serverAddress})
-
-
- );
- }
-};
-
-class ImagePreview extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.state = {};
- }
-
- componentDidMount() {
- this.setState({
- width: this.container.clientWidth,
- height: this.container.clientHeight
- });
- }
-
- render() {
- if (!this.props.content) {
- return null;
- }
- var instance = this;
- var dim = fitImageSize(this.props.content.width, this.props.content.height,
- this.state.width, this.state.height, false);
- var size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
- ((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
- size.maxWidth = '100%';
- size.maxHeight = '100%';
-
- var filename = this.props.content.filename;
- var maxlength = (this.props.content.width / REM_SIZE) | 0;
- if (filename.length > maxlength) {
- filename = filename.slice(0, maxlength-2) + '...' + filename.slice(2-maxlength);
- }
- return (
-
-
-
-
-
-
-
- );
- }
-};
-/* END Conversation panel */
-
-/* The top-level class to hold all functionality together */
-class TinodeWeb extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = this.getBlankState();
-
- this.handleResize = this.handleResize.bind(this);
- this.handleHashRoute = this.handleHashRoute.bind(this);
- this.handleOnline = this.handleOnline.bind(this);
- this.handleAppVisibility = this.handleAppVisibility.bind(this);
- this.handleReadTimer = this.handleReadTimer.bind(this);
- this.handleVisibilityEvent = this.handleVisibilityEvent.bind(this);
- this.handleError = this.handleError.bind(this);
- this.handleLoginRequest = this.handleLoginRequest.bind(this);
- this.handleConnected = this.handleConnected.bind(this);
- this.doLogin = this.doLogin.bind(this);
- this.handleCredentialsRequest = this.handleCredentialsRequest.bind(this);
- this.handleLoginSuccessful = this.handleLoginSuccessful.bind(this);
- this.handleDisconnect = this.handleDisconnect.bind(this);
- this.tnMeMetaDesc = this.tnMeMetaDesc.bind(this);
- this.tnMeContactUpdate = this.tnMeContactUpdate.bind(this);
- this.tnMeSubsUpdated = this.tnMeSubsUpdated.bind(this);
- this.resetContactList = this.resetContactList.bind(this);
- this.tnData = this.tnData.bind(this);
- this.tnInitFind = this.tnInitFind.bind(this);
- this.tnFndSubsUpdated = this.tnFndSubsUpdated.bind(this);
- this.handleSearchContacts = this.handleSearchContacts.bind(this);
- this.handleTopicSelected = this.handleTopicSelected.bind(this);
- this.handleHideMessagesView = this.handleHideMessagesView.bind(this);
- this.handleSendMessage = this.handleSendMessage.bind(this);
- this.handleNewAccount = this.handleNewAccount.bind(this);
- this.handleNewAccountRequest = this.handleNewAccountRequest.bind(this);
- this.handleUpdateAccountRequest = this.handleUpdateAccountRequest.bind(this);
- this.handleUpdateAccountTagsRequest = this.handleUpdateAccountTagsRequest.bind(this);
- this.handleSettings = this.handleSettings.bind(this);
- this.handleGlobalSettings = this.handleGlobalSettings.bind(this);
- this.handleToggleMessageSounds = this.handleToggleMessageSounds.bind(this);
- this.initDesktopAlerts = this.initDesktopAlerts.bind(this);
- this.togglePushToken = this.togglePushToken.bind(this);
- this.requestPushToken = this.requestPushToken.bind(this);
- this.handleSidepanelCancel = this.handleSidepanelCancel.bind(this);
- this.handleNewTopic = this.handleNewTopic.bind(this);
- this.handleNewTopicRequest = this.handleNewTopicRequest.bind(this);
- this.handleNewTopicCreated = this.handleNewTopicCreated.bind(this);
- this.handleTopicUpdateRequest = this.handleTopicUpdateRequest.bind(this);
- this.handleChangePermissions = this.handleChangePermissions.bind(this);
- this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
- this.handleLogout = this.handleLogout.bind(this);
- this.handleLeaveUnsubRequest = this.handleLeaveUnsubRequest.bind(this);
- this.handleDialogCancel = this.handleDialogCancel.bind(this);
- this.handleShowContextMenu = this.handleShowContextMenu.bind(this);
- this.defaultTopicContextMenu = this.defaultTopicContextMenu.bind(this);
- this.handleHideContextMenu = this.handleHideContextMenu.bind(this);
- this.handleShowInfoView = this.handleShowInfoView.bind(this);
- this.handleHideInfoView = this.handleHideInfoView.bind(this);
- this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
- this.handleValidateCredentialsRequest = this.handleValidateCredentialsRequest.bind(this);
- this.handlePasswordResetRequest = this.handlePasswordResetRequest.bind(this);
- this.handleResetPassword = this.handleResetPassword.bind(this);
- }
-
- getBlankState() {
- let settings = localStorage.getObject("settings") || {};
-
- return {
- connected: false,
- transport: settings.transport || null,
- serverAddress: settings.serverAddress || detectServerAddress(),
- serverVersion: "no connection",
- // "On" is the default, so saving the "off" state.
- messageSounds: !settings.messageSoundsOff,
- desktopAlerts: settings.desktopAlerts,
- desktopAlertsEnabled: (isSecureConnection() || isLocalHost()) &&
- (typeof firebase != 'undefined') && (typeof navigator != 'undefined') &&
- (typeof FIREBASE_INIT != 'undefined'),
- firebaseToken: localStorage.getObject("firebase-token"),
-
- errorText: '',
- errorLevel: null,
-
- sidePanelSelected: 'login',
- sidePanelTitle: null,
- sidePanelAvatar: null,
- dialogSelected: null,
- contextMenuVisible: false,
- login: '',
- password: '',
- myUserId: null,
- liveConnection: navigator.onLine,
- topicSelected: '',
- topicSelectedOnline: false,
- topicSelectedAcs: null,
- newGroupTopicParams: null,
- loginDisabled: false,
- displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
- showInfoPanel: false,
- mobilePanel: 'sidepanel',
-
- contextMenuVisible: false,
- contextMenuBounds: null,
- contextMenuClickAt: null,
- contextMenuParams: null,
- contextMenuItems: [],
-
- // Chats as shown in the ContactsView
- chatList: [],
- // Contacts returned by a search query.
- searchResults: [],
- // Merged results of a search query and p2p chats.
- searchableContacts: [],
- credMethod: undefined,
- credCode: undefined
- };
- }
-
- componentDidMount() {
- window.addEventListener('resize', this.handleResize);
- window.addEventListener('online', (e) => { this.handleOnline(true); });
- window.addEventListener('offline', (e) => { this.handleOnline(false); });
- window.addEventListener('hashchange', this.handleHashRoute);
- // Window/tab visible or invisible for pausing timers.
- document.addEventListener('visibilitychange', this.handleVisibilityEvent);
-
- this.setState({viewportWidth: document.documentElement.clientWidth});
-
- this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport);
- this.tinode.onConnect = this.handleConnected;
- this.tinode.onDisconnect = this.handleDisconnect;
-
- // Initialize desktop alerts.
- if (this.state.desktopAlertsEnabled) {
- try {
- this.fbPush = firebase.initializeApp(FIREBASE_INIT, APP_NAME).messaging();
- this.fbPush.usePublicVapidKey(FIREBASE_INIT.messagingVapidKey);
- navigator.serviceWorker.register('/service-worker.js').then((reg) => {
- this.fbPush.useServiceWorker(reg);
- this.initDesktopAlerts();
- if (this.state.desktopAlerts) {
- if (!this.state.firebaseToken) {
- this.togglePushToken(true);
- } else {
- this.tinode.setDeviceToken(this.state.firebaseToken);
- }
- }
- }).catch((err) => {
- // registration failed :(
- console.log("Failed to register service worker:", err);
- });
- } catch (err) {
- this.handleError("Failed to initialize push notifications", 'err');
- console.log("Failed to initialize push notifications", err);
- this.setState({desktopAlertsEnabled: false});
- }
- }
-
- let token = localStorage.getObject('keep-logged-in') ?
- localStorage.getObject('auth-token') : undefined;
-
- let parsedNav = parseUrlHash(window.location.hash);
- if (token) {
- // When reading from storage, date is returned as string.
- token.expires = new Date(token.expires);
- this.tinode.setAuthToken(token);
- this.tinode.connect().catch((err) => {
- // Socket error
- this.handleError(err.message, 'err');
- });
- delete parsedNav.params.info;
- delete parsedNav.params.tab;
- parsedNav.path[0] = '';
- navigateTo(composeUrlHash(parsedNav.path, parsedNav.params));
- } else if (!parsedNav.params.token) {
- navigateTo('');
- }
-
- this.readTimer = null;
- this.readTimerCallback = null;
-
- this.handleHashRoute();
- }
-
- componentWillUnmount() {
- window.removeEventListener('resize', this.handleResize);
- window.removeEventListener('hashchange', this.handleHashRoute);
- document.removeEventListener('visibilitychange', this.handleVisibilityEvent);
- }
-
- // Setup transport (usually websocket) and server address. This will terminate connection with the server.
- static tnSetup(serverAddress, transport) {
- let tinode = new Tinode(APP_NAME, serverAddress, API_KEY, transport, isSecureConnection());
- tinode.enableLogging(true, true);
- return tinode;
- }
-
- handleResize() {
- var mobile = document.documentElement.clientWidth <= MEDIA_BREAKPOINT;
- this.setState({viewportWidth: document.documentElement.clientWidth});
- if (this.state.displayMobile != mobile) {
- this.setState({displayMobile: mobile});
- }
- }
-
- // Handle for hashchange event: display appropriate panels.
- handleHashRoute() {
- var hash = parseUrlHash(window.location.hash);
- if (hash.path && hash.path.length > 0) {
- // Left-side panel selector.
- if (['register','settings','edit','cred','reset','newtpk','contacts',''].includes(hash.path[0])) {
- this.setState({sidePanelSelected: hash.path[0]});
- } else {
- console.log("Unknown sidepanel view", hash.path[0]);
- }
-
- // Topic for MessagesView selector.
- if (hash.path.length > 1 && hash.path[1] != this.state.topicSelected) {
- this.setState({
- topicSelected: Tinode.topicType(hash.path[1]) ? hash.path[1] : null
- });
- }
- } else {
- // Empty hashpath
- this.setState({sidePanelSelected: ''});
- }
-
- // Save validation credentials, if available.
- if (hash.params.method) {
- this.setState({ credMethod: hash.params.method });
- }
- if (hash.params.code) {
- this.setState({ credCode: hash.params.code });
- }
-
- // Additional parameters of panels.
- this.setState({
- showInfoPanel: hash.params.info,
- newTopicTabSelected: hash.params.tab
- });
- }
-
- handleOnline(online) {
- var newState = {liveConnection: online};
- if (online) {
- this.handleError('', null);
- } else {
- this.handleError("No connection", 'warn');
- }
- this.setState({liveConnection: online});
- }
-
- // Handling read notifications here to be able to pause
- // them then the window/tab is not visible.
- handleAppVisibility(visible, callback) {
- clearTimeout(this.readTimer);
- this.readTimerCallback = callback;
- if (visible && callback) {
- this.readTimer = setTimeout(callback, READ_DELAY);
- } else {
- this.readTimer = null;
- }
- }
-
- handleReadTimer(callback) {
- this.handleAppVisibility(!document.hidden, callback);
- }
-
- handleVisibilityEvent() {
- this.handleAppVisibility(!document.hidden, this.readTimerCallback);
- }
-
- handleError(err, level) {
- this.setState({errorText: err, errorLevel: level});
- }
-
- // User clicked Login button in the side panel.
- handleLoginRequest(login, password) {
- this.setState({loginDisabled: true, login: login, password: password});
- this.handleError('', null);
-
- if (this.tinode.isConnected()) {
- this.doLogin(login, password, {meth: this.state.credMethod, resp: this.state.credCode});
- } else {
- this.tinode.connect().catch((err) => {
- // Socket error
- this.setState({loginDisabled: false});
- this.handleError(err.message, 'err');
- });
- }
- }
-
- // Connection succeeded.
- handleConnected() {
- var params = this.tinode.getServerInfo();
- this.setState({
- serverVersion: params.ver + ' ' + (params.build ? params.build : 'none') + '; '
- });
- this.doLogin(this.state.login, this.state.password, {meth: this.state.credMethod, resp: this.state.credCode});
- }
-
- doLogin(login, password, cred) {
- if (this.tinode.isAuthenticated()) {
- // Already logged in. Go to default screen.
- navigateTo("");
- return;
- }
- // Sanitize and package credentail.
- cred = Tinode.credential(cred);
- // Try to login with login/password. If they are not available, try token. If no token, ask for login/password.
- let promise = null;
- let token = this.tinode.getAuthToken();
- if (login && password) {
- this.setState({password: null});
- promise = this.tinode.loginBasic(login, password, cred);
- } else if (token) {
- promise = this.tinode.loginToken(token.token, cred);
- }
-
- if (promise) {
- promise.then((ctrl) => {
- if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
- if (cred) {
- this.handleError("Code does not match", 'warn');
- }
- this.handleCredentialsRequest(ctrl.params);
- } else {
- this.handleLoginSuccessful();
- }
- }).catch((err) => {
- // Login failed, report error.
- this.setState({loginDisabled: false, credMethod: undefined, credCode: undefined});
- this.handleError(err.message, 'err');
- localStorage.removeItem('auth-token');
- navigateTo('');
- });
- } else {
- // No login credentials provided.
- // Make sure we are on the login page.
- navigateTo('');
- this.setState({loginDisabled: false});
- }
- }
-
- handleCredentialsRequest(params) {
- var parsed = parseUrlHash(window.location.hash);
- parsed.path[0] = 'cred';
- parsed.params['method'] = params.cred[0];
- navigateTo(composeUrlHash(parsed.path, parsed.params));
- }
-
- handleLoginSuccessful() {
- this.handleError('', null);
-
- // Refresh authentication token.
- if (localStorage.getObject('keep-logged-in')) {
- localStorage.setObject('auth-token', this.tinode.getAuthToken());
- }
- // Logged in fine, subscribe to 'me' attaching callbacks from the contacts view.
- var me = this.tinode.getMeTopic();
- me.onMetaDesc = this.tnMeMetaDesc;
- me.onContactUpdate = this.tnMeContactUpdate;
- me.onSubsUpdated = this.tnMeSubsUpdated;
- this.setState({
- connected: true,
- credMethod: undefined,
- credCode: undefined,
- myUserId: this.tinode.getCurrentUserID()
- });
- // Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched.
- me.subscribe(
- me.startMetaQuery().
- withLaterSub().
- withDesc().
- build()
- ).catch((err) => {
- localStorage.removeItem('auth-token');
- this.handleError(err.message, 'err');
- navigateTo('');
- });
- navigateTo(setUrlSidePanel(window.location.hash, 'contacts'));
- }
-
- handleDisconnect(err) {
- this.setState({
- connected: false,
- topicSelectedOnline: false,
- dialogSelected: null,
- errorText: err && err.message ? err.message : "Disconnected",
- errorLevel: err && err.message ? 'err' : 'warn',
- loginDisabled: false,
- contextMenuVisible: false,
- serverVersion: "no connection"
- });
- }
-
- tnMeMetaDesc(desc) {
- if (desc && desc.public) {
- this.setState({
- sidePanelTitle: desc.public.fn,
- sidePanelAvatar: makeImageUrl(desc.public.photo)
- });
- }
- }
-
- // Reactions to updates to the contact list.
- tnMeContactUpdate(what, cont) {
- if (what == 'on' || what == 'off') {
- this.resetContactList();
- if (this.state.topicSelected == cont.topic) {
- this.setState({topicSelectedOnline: (what == 'on')});
- }
- } else if (what == 'read') {
- this.resetContactList();
- } else if (what == 'msg') {
- // New message received
- // Skip update if the topic is currently open, otherwise the badge will annoyingly flash.
- if (this.state.topicSelected != cont.topic) {
- if (this.state.messageSounds) {
- POP_SOUND.play();
- }
- this.resetContactList();
- } else if (document.hidden && this.state.messageSounds) {
- POP_SOUND.play();
- }
- } else if (what == 'recv') {
- // Explicitly ignoring "recv" -- it causes no visible updates to contact list.
- } else if (what == 'gone' || what == 'unsub') {
- // Topic deleted or user unsubscribed. Remove topic from view.
- // If the currently selected topic is gone, clear the selection.
- if (this.state.topicSelected == cont.topic) {
- this.handleTopicSelected(null);
- }
- // Redraw without the deleted topic.
- this.resetContactList();
- } else if (what == 'acs') {
- // Permissions changed. If it's for the currently selected topic,
- // update the views.
- if (this.state.topicSelected == cont.topic) {
- this.setState({topicSelectedAcs: cont.acs});
- }
- } else if (what == 'del') {
- // messages deleted (hard or soft) -- update pill counter.
- } else {
- // TODO(gene): handle other types of notifications:
- // * ua -- user agent changes (maybe display a pictogram for mobile/desktop).
- // * upd -- topic 'public' updated, issue getMeta().
- console.log("Unsupported (yet) presence update:" + what + " in: " + cont.topic);
- }
- }
-
- tnMeSubsUpdated(unused) {
- this.resetContactList();
- }
-
- // Merge search results and contact list to create a single flat
- // list of kown contacts for GroupManager to use.
- static prepareSearchableContacts(chatList, foundContacts) {
- let merged = {};
-
- // For chatList topics merge only p2p topics and convert them to the
- // same format as foundContacts.
- for (const c of chatList) {
- if (Tinode.topicType(c.topic) == 'p2p') {
- merged[c.topic] = {
- user: c.topic,
- updated: c.updated,
- public: c.public,
- private: c.private,
- acs: c.acs
- };
- }
- }
-
- // Add all foundCountacts if they have not been added already.
- for (const c of foundContacts) {
- if (!merged[c.user]) {
- merged[c.user] = c;
- }
- }
-
- return Object.values(merged);
- }
-
- resetContactList() {
- let newState = {
- chatList: []
- };
- this.tinode.getMeTopic().contacts((c) => {
- newState.chatList.push(c);
- if (this.state.topicSelected == c.topic) {
- newState.topicSelectedOnline = c.online;
- newState.topicSelectedAcs = c.acs;
- }
- });
- // Merge search results and chat list.
- newState.searchableContacts = TinodeWeb.prepareSearchableContacts(newState.chatList, this.state.searchResults);
- this.setState(newState);
- }
-
- // Sending "received" notifications
- tnData(data) {
- let topic = this.tinode.getTopic(data.topic);
- if (topic.msgStatus(data) >= Tinode.MESSAGE_STATUS_SENT) {
- clearTimeout(this.receivedTimer);
- this.receivedTimer = setTimeout(() => {
- this.receivedTimer = undefined;
- topic.noteRecv(data.seq);
- }, RECEIVED_DELAY);
- }
- }
-
- /* Fnd topic: find contacts by tokens */
- tnInitFind() {
- let fnd = this.tinode.getFndTopic();
- if (fnd.isSubscribed()) {
- this.tnFndSubsUpdated();
- } else {
- fnd.onSubsUpdated = this.tnFndSubsUpdated;
- fnd.subscribe(fnd.startMetaQuery().withSub().withTags().build()).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
- }
-
- tnFndSubsUpdated() {
- let foundContacts = [];
- // Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
- this.tinode.getFndTopic().contacts((s) => {
- foundContacts.push(s);
- });
- this.setState({
- searchResults: foundContacts,
- searchableContacts: TinodeWeb.prepareSearchableContacts(this.state.chatList, foundContacts)
- });
- }
-
- /** Called when the user enters a contact into the contact search field in the NewAccount panel
- @param query {Array} is an array of contacts to search for
- */
- handleSearchContacts(query) {
- var fnd = this.tinode.getFndTopic();
- fnd.setMeta({desc: {public: query}}).then((ctrl) => {
- return fnd.getMeta(fnd.startMetaQuery().withSub().build());
- }).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
-
- // User clicked on a contact in the side panel or deleted a contact.
- handleTopicSelected(topicName, unused_index, online, acs) {
- if (topicName) {
- this.setState({
- errorText: '',
- errorLevel: null,
- mobilePanel: 'topic-view',
- showInfoPanel: false
- });
- // Different contact selected.
- if (this.state.topicSelected != topicName) {
- this.setState({
- topicSelectedOnline: online,
- topicSelectedAcs: acs
- });
- navigateTo(setUrlTopic('', topicName));
- }
- } else {
- // Currently selected contact deleted
- this.setState({
- errorText: '',
- errorLevel: null,
- mobilePanel: 'sidepanel',
- topicSelectedOnline: false,
- topicSelectedAcs: null,
- showInfoPanel: false
- });
- if (this.state.topicSelected) {
- navigateTo(setUrlTopic('', null));
- }
- }
- }
-
- // In mobile view user requested to show sidepanel
- handleHideMessagesView() {
- this.setState({
- mobilePanel: 'sidepanel'
- });
- navigateTo(setUrlTopic(window.location.hash, null));
- }
-
- // User is sending a message, either plain text or a drafty object, possibly
- // with attachments.
- handleSendMessage(msg, promise, uploader) {
- let topic = this.tinode.getTopic(this.state.topicSelected);
-
- msg = topic.createMessage(msg, false);
- // The uploader is used to show progress.
- msg._uploader = uploader;
-
- if (!topic.isSubscribed()) {
- if (!promise) {
- promise = Promise.resolve();
- }
- promise = promise.then(() => { return topic.subscribe(); });
- }
-
- if (promise) {
- promise = promise.catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
-
- topic.publishDraft(msg, promise)
- .catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
-
- // User chose a Sign Up menu item.
- handleNewAccount() {
- navigateTo(setUrlSidePanel(window.location.hash, 'register'));
- }
-
- // Actual registration of a new account.
- handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
- this.tinode.connect(this.state.serverAddress)
- .then(() => {
- return this.tinode.createAccountBasic(login_, password_,
- {public: public_, tags: tags_, cred: Tinode.credential(cred_)});
- }).then((ctrl) => {
- if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
- this.handleCredentialsRequest(ctrl.params);
- } else {
- this.handleLoginSuccessful(this);
- }
- }).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
-
- handleUpdateAccountRequest(password, pub, defacs) {
- if (pub || defacs) {
- this.tinode.getMeTopic().setMeta({desc: {public: pub, defacs: defacs}}).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
- if (password) {
- this.tinode.updateAccountBasic(null, this.tinode.getCurrentLogin(), password).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
- }
-
- handleUpdateAccountTagsRequest(tags) {
- this.tinode.getFndTopic().setMeta({tags: tags})
- .catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
-
- // User chose Settings menu item.
- handleSettings() {
- navigateTo(setUrlSidePanel(window.location.hash, this.state.myUserId ? 'edit' : 'settings'));
- }
-
- // User updated global parameters.
- handleGlobalSettings(settings) {
- let serverAddress = settings.serverAddress || this.state.serverAddress;
- let transport = settings.transport || this.state.transport;
- if (this.tinode) {
- this.tinode.onDisconnect = undefined;
- this.tinode.disconnect();
- }
- this.tinode = TinodeWeb.tnSetup(serverAddress, transport);
- this.tinode.onConnect = this.handleConnected;
- this.tinode.onDisconnect = this.handleDisconnect;
-
- this.setState({
- serverAddress: serverAddress,
- transport: transport
- });
- localStorage.setObject('settings', {
- serverAddress: serverAddress,
- transport: transport
- });
-
- navigateTo(setUrlSidePanel(window.location.hash, ''));
- }
-
- // Initialize desktop alerts = push notifications.
- initDesktopAlerts() {
- // Google could not be bothered to mention that
- // onTokenRefresh is never called.
- this.fbPush.onTokenRefresh(() => {
- this.requestPushToken(true);
- });
-
- this.fbPush.onMessage((payload) => {
- // No need to do anything about it.
- // All the magic happends in the service worker.
- });
- }
-
- togglePushToken(enabled) {
- if (enabled) {
- if (!this.state.firebaseToken) {
- this.fbPush.requestPermission().then(() => {
- this.requestPushToken(true);
- }).catch((err) => {
- this.handleError(err.message, 'err');
- this.setState({desktopAlerts: false, firebaseToken: null});
- localStorage.updateObject('settings', {desktopAlerts: false});
- console.log("Failed to get permission to notify.", err);
- });
- } else {
- this.setState({desktopAlerts: true});
- localStorage.updateObject('settings', {desktopAlerts: true});
- }
- } else if (this.state.firebaseToken) {
- this.fbPush.deleteToken(this.state.firebaseToken).catch((err) => {
- console.log("Unable to delete token.", err);
- }).finally(() => {
- localStorage.updateObject('settings', {desktopAlerts: false});
- localStorage.removeItem('firebase-token');
- this.setState({desktopAlerts: false, firebaseToken: null});
- });
- } else {
- this.setState({desktopAlerts: false, firebaseToken: null});
- localStorage.updateObject('settings', {desktopAlerts: false});
- }
- }
-
- requestPushToken(sendToServer) {
- this.fbPush.getToken().then((refreshedToken) => {
- if (refreshedToken != this.state.firebaseToken) {
- this.tinode.setDeviceToken(refreshedToken, sendToServer);
- localStorage.setObject('firebase-token', refreshedToken);
- }
- this.setState({firebaseToken: refreshedToken, desktopAlerts: true});
- localStorage.updateObject('settings', {desktopAlerts: true});
- }).catch((err) => {
- this.handleError(err.message, 'err');
- console.log("Failed to retrieve firebase token", err);
- });
- }
-
- handleToggleMessageSounds(enabled) {
- this.setState({messageSounds: enabled});
- localStorage.updateObject('settings', {
- messageSoundsOff: !enabled
- });
- }
-
- // User clicked Cancel button in Setting or Sign Up panel.
- handleSidepanelCancel() {
- var parsed = parseUrlHash(window.location.hash);
- parsed.path[0] = this.state.myUserId ? 'contacts' : '';
- if (parsed.params) {
- delete parsed.params.code;
- delete parsed.params.method;
- delete parsed.params.tab;
- }
- navigateTo(composeUrlHash(parsed.path, parsed.params));
- this.setState({errorText: '', errorLevel: null});
- }
-
- // User clicked a (+) menu item.
- handleNewTopic() {
- navigateTo(setUrlSidePanel(window.location.hash, 'newtpk'));
- }
-
- // Request to start a new topic. New P2P topic requires peer's name.
- handleNewTopicRequest(peerName, pub, priv, tags) {
- var topicName = peerName || this.tinode.newGroupTopicName();
- if (!peerName) {
- this.setState(
- {newGroupTopicParams: {desc: {public: pub, private: {comment: priv}}, tags: tags}},
- () => {this.handleTopicSelected(topicName)}
- );
- } else {
- this.handleTopicSelected(topicName);
- }
- }
-
- // New topic was creted, here is the new topic name.
- handleNewTopicCreated(oldName, newName) {
- if (this.state.topicSelected == oldName && oldName != newName) {
- // If the current URl contains the old topic name, replace it with new.
- // Update the name of the selected topic first so the navigator doen't clear
- // the state.
- this.setState({topicSelected: newName}, () => { navigateTo(setUrlTopic('', newName)); });
- }
- }
-
- handleTopicUpdateRequest(topicName, pub, priv, permissions) {
- var topic = this.tinode.getTopic(topicName);
- if (topic) {
- var params = {};
- if (pub) {
- params.public = pub;
- }
- if (priv) {
- params.private = {comment: priv};
- }
- if (permissions) {
- params.defacs = permissions;
- }
- topic.setMeta({desc: params}).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
- }
-
- handleChangePermissions(topicName, mode, uid) {
- var topic = this.tinode.getTopic(topicName);
- if (topic) {
- var am = topic.getAccessMode();
- if (uid) {
- am.updateGiven(mode);
- mode = am.getGiven();
- } else {
- am.updateWant(mode);
- mode = am.getWant();
- }
- topic.setMeta({sub: {user: uid, mode: mode}}).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
- }
-
- handleTagsUpdated(topicName, tags) {
- var topic = this.tinode.getTopic(topicName);
- if (topic) {
- var instance = this;
- topic.setMeta({tags: tags}).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
- }
-
- handleLogout() {
- updateFavicon(0);
- localStorage.removeItem('auth-token');
- if (this.tinode) {
- this.tinode.onDisconnect = undefined;
- this.tinode.disconnect();
- }
- this.setState(this.getBlankState());
- this.tinode = TinodeWeb.tnSetup(this.state.serverAddress, this.state.transport);
- this.tinode.onConnect = this.handleConnected;
- this.tinode.onDisconnect = this.handleDisconnect;
- navigateTo('');
- }
-
- handleLeaveUnsubRequest(topicName) {
- var topic = this.tinode.getTopic(topicName);
- if (!topic) {
- return;
- }
-
- topic.leave(true).then((ctrl) => {
- // Hide MessagesView and InfoView panels.
- navigateTo(setUrlTopic(window.location.hash, ''));
- }).catch((err) => {
- this.handleError(err.message, 'err');
- });
- }
-
- handleDialogCancel() {
- this.setState({dialogSelected: null});
- }
-
- handleShowContextMenu(params, menuItems) {
- this.setState({
- contextMenuVisible: true,
- contextMenuClickAt: {x: params.x, y: params.y},
- contextMenuParams: params,
- contextMenuItems: menuItems || this.defaultTopicContextMenu(params.topicName),
- contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect()
- });
- }
-
- defaultTopicContextMenu(topicName) {
- var muted = false, blocked = false, subscribed = false, deleter = false;
- var topic = this.tinode.getTopic(topicName);
- if (topic) {
- if (topic.isSubscribed()) {
- subscribed = true;
- var acs = topic.getAccessMode();
- muted = acs && acs.isMuted();
- blocked = acs && !acs.isJoiner();
- deleter = acs && acs.isDeleter();
- }
- }
-
- return [
- subscribed ? {title: "Info", handler: this.handleShowInfoView} : null,
- subscribed ? 'messages_clear' : null,
- subscribed && deleter ? 'messages_clear_hard' : null,
- subscribed ? (muted ? 'topic_unmute' : 'topic_mute') : null,
- subscribed ? (blocked ? 'topic_unblock' : 'topic_block') : null,
- 'topic_delete'
- ];
- }
-
- handleHideContextMenu() {
- this.setState({
- contextMenuVisible: false,
- contextMenuClickAt: null,
- contextMenuParams: null,
- contextMenuBounds: null
- });
- }
-
- handleShowInfoView() {
- navigateTo(addUrlParam(window.location.hash, 'info', true));
- this.setState({showInfoPanel: true});
- }
-
- handleHideInfoView() {
- navigateTo(removeUrlParam(window.location.hash, 'info'));
- this.setState({showInfoPanel: false});
- }
-
- handleMemberUpdateRequest(topicName, added, removed) {
- if (!topicName) {
- return;
- }
-
- var topic = this.tinode.getTopic(topicName);
- if (!topic) {
- return;
- }
-
- if (added && added.length > 0) {
- added.map((uid) => {
- topic.invite(uid, null).catch((err) => {
- this.handleError(err.message, 'err');
- });
- });
- }
-
- if (removed && removed.length > 0) {
- removed.map((uid) => {
- topic.delSubscription(uid).catch((err) => {
- this.handleError(err.message, 'err');
- });
- });
- }
- }
-
- handleValidateCredentialsRequest(cred, code) {
- this.setState({credMethod: cred, credCode: code});
- this.doLogin(null, null, {meth: cred, resp: code});
- }
-
- handlePasswordResetRequest(method, value) {
- // If already connected, connnect() will return a resolved promise.
- this.tinode.connect()
- .then(() => {
- return this.tinode.requestResetAuthSecret('basic', method, value)
- })
- .catch((err) => {
- // Socket error
- this.handleError(err.message, 'err');
- });
- }
-
- handleResetPassword(scheme, newPassword, token) {
- token = base64ReEncode(token);
- if (!token) {
- this.handleError("Invalid security token", 'err');
- } else {
- this.tinode.connect()
- .then(() => {
- return this.tinode.updateAccountBasic(null, null, newPassword, {token: token});
- })
- .catch((err) => {
- // Socket error
- this.handleError(err.message, 'err');
- });
- }
- }
-
- render() {
- return (
-
- {this.state.contextMenuVisible ?
-
- :
- null
- }
-
-
-
-
-
- {this.state.showInfoPanel ?
-
- :
- null
- }
-
- );
- }
-};
-
-if (typeof module != 'undefined') {
- module.exports = TinodeWeb;
-}