diff --git a/css/base.css b/css/base.css
index afa2fabb..0e433043 100644
--- a/css/base.css
+++ b/css/base.css
@@ -1339,12 +1339,13 @@ ul.tabbar > li {
ul.tabbar > li > a {
position: relative;
padding-top: 0.75rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
display: block;
- min-width: 6.5rem;
text-align: center;
line-height: 2rem;
font-weight: 400;
- font-size: 1.25rem;
+ font-size: 1.3rem;
font-variant: small-caps;
text-decoration: none;
color: rgba(33,150,243,0.6);
diff --git a/index-dev.html b/index-dev.html
index 1c15984c..5d9c632e 100644
--- a/index-dev.html
+++ b/index-dev.html
@@ -28,7 +28,6 @@
-
@@ -36,7 +35,12 @@
-
+
+
+
diff --git a/index.html b/index.html
index b3ef7eb4..9dacd552 100644
--- a/index.html
+++ b/index.html
@@ -23,7 +23,6 @@
-
diff --git a/package.json b/package.json
index a945db96..edfdd450 100644
--- a/package.json
+++ b/package.json
@@ -51,12 +51,13 @@
"react",
"web"
],
- "main": "index.js",
+ "main": "./umd/tinode.prod.js",
"bugs": {
"url": "https://github.com/tinode/example-react-js/issues"
},
"homepage": "https://github.com/tinode/chat",
- "email": "gene@tinode.co",
+ "email": "info@tinode.co",
+ "author": "Tinode Authors ",
"license": "Apache-2.0",
"dependencies": {
"react": "^16.4.2",
diff --git a/src/index.js b/src/index.js
index f3353dd2..859ff0b4 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,7 +4,7 @@
var React = require('react');
var ReactDOM = require('react-dom');
var Tinode = require('tinode-sdk');
-var TinodeWeb = require('../webapp.js')
+var TinodeWeb = require('../webapp.js');
ReactDOM.render(
React.createElement(TinodeWeb, null),
diff --git a/src/webapp.jsx b/src/webapp.jsx
index b2e51fb8..29582254 100644
--- a/src/webapp.jsx
+++ b/src/webapp.jsx
@@ -11,9 +11,6 @@ const DEFAULT_HOST = KNOWN_HOSTS.hosted;
// Sound to play on message received.
const POP_SOUND = new Audio('audio/msg.mp3');
-// Unicode symbol used to clear objects
-const DEL_CHAR = "\u2421";
-
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
const API_KEY = "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K";
@@ -59,9 +56,6 @@ const MAX_IMAGE_DIM = 768;
const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
-// Tinode is defined in 'tinode.js'.
-var Drafty = Tinode.Drafty;
-
// Helper functions for storing values in localStorage.
// By default localStorage can store only strings, not objects or other types.
Storage.prototype.setObject = function(key, value) {
@@ -482,7 +476,7 @@ class ContextMenu extends React.Component {
console.log("Topic not found: ", params.topicName);
return;
}
- topic.delTopic().catch(function(err) {
+ topic.delTopic().catch((err) => {
if (errorHandler) {
errorHandler(err.message, "err");
}
@@ -1394,16 +1388,15 @@ class AvatarUpload extends React.Component {
}
handleFileUpload(e) {
- var instance = this;
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
// Success
- function(base64bits, mime) {
+ (base64bits, mime) => {
var du = makeImageUrl({data: base64bits, type: mime});
- instance.setState({dataUrl: du});
- instance.props.onImageChanged(du);
+ this.setState({dataUrl: du});
+ this.props.onImageChanged(du);
},
// Failure
- function(err) {
+ (err) => {
this.props.onError(err, "err");
});
// Clear the value so the same file can be uploaded again.
@@ -1676,6 +1669,7 @@ class SidepanelView extends React.Component {
myUserId={this.props.myUserId}
connected={this.props.connected}
topicSelected={this.props.topicSelected}
+ chatList={this.props.chatList}
showContextMenu={this.props.showContextMenu}
messageSounds={this.props.messageSounds}
onTopicSelected={this.props.onTopicSelected}
@@ -1685,7 +1679,7 @@ class SidepanelView extends React.Component {
view === 'newtpk' ?
{
- // Force to integers;
- c.seq = ~~c.seq;
- c.read = ~~c.read;
- c.unread = c.seq - c.read;
+ static getDerivedStateFromProps(nextProps, prevState) {
+ let contacts = [];
+ let unreadThreads = 0;
+ nextProps.chatList.map((c) => {
unreadThreads += c.unread > 0 ? 1 : 0;
contacts.push(c);
- if (this.props.topicSelected == c.topic) {
- this.props.onOnlineChange(c.online);
- this.props.onAcsChange(c.acs);
- }
});
- contacts.sort(function(a,b){
+
+ contacts.sort(function(a, b){
return b.touched - a.touched;
});
- updateFavicon(unreadThreads);
- return contacts;
- }
- resetContactList() {
- this.setState({contactList: this.prepareContactList()});
+ updateFavicon(unreadThreads);
+
+ return {contactList: contacts};
}
render() {
@@ -2498,7 +2400,7 @@ class NewTopicView extends React.Component {
this.state = {
tabSelected: "p2p",
searchQuery: props.contactsSearchQuery,
- contactList: props.foundContacts
+ contactList: props.searchResults
};
this.handleTabClick = this.handleTabClick.bind(this);
@@ -2514,7 +2416,7 @@ class NewTopicView extends React.Component {
static getDerivedStateFromProps(nextProps, prevState) {
return {
searchQuery: nextProps.contactsSearchQuery,
- contactList: nextProps.foundContacts
+ contactList: nextProps.searchResults
};
}
@@ -2676,7 +2578,7 @@ class SearchContacts extends React.Component {
componentWillUnmount() {
if (this.state.search) {
this.setState({search: ''});
- this.props.onSearchContacts(DEL_CHAR);
+ this.props.onSearchContacts(Tinode.DEL_CHAR);
}
}
@@ -2691,13 +2593,13 @@ class SearchContacts extends React.Component {
this.setState({edited: true});
this.props.onSearchContacts(query);
} else if (this.props.searchQuery) {
- this.props.onSearchContacts(DEL_CHAR);
+ this.props.onSearchContacts(Tinode.DEL_CHAR);
}
}
handleClear() {
if (this.state.edited || this.state.search) {
- this.props.onSearchContacts(DEL_CHAR);
+ this.props.onSearchContacts(Tinode.DEL_CHAR);
}
this.setState({search: '', edited: false});
}
@@ -3213,7 +3115,7 @@ class InfoView extends React.Component {
members={this.state.contactList}
requiredMember={this.props.myUserId}
myUserId={this.props.myUserId}
- contacts={this.props.foundContacts}
+ contacts={this.props.searchableContacts}
onCancel={this.handleHideAddMembers}
onSubmit={this.handleMemberUpdateRequest} />
:
@@ -4363,6 +4265,9 @@ class TinodeWeb extends React.Component {
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);
@@ -4406,11 +4311,15 @@ class TinodeWeb extends React.Component {
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(),
+ errorText: '',
+ errorLevel: null,
+
sidePanelSelected: 'login',
sidePanelTitle: null,
sidePanelAvatar: null,
@@ -4419,8 +4328,6 @@ class TinodeWeb extends React.Component {
login: '',
password: '',
myUserId: null,
- errorText: '',
- errorLevel: null,
liveConnection: navigator.onLine,
topicSelected: '',
topicSelectedOnline: false,
@@ -4429,14 +4336,20 @@ class TinodeWeb extends React.Component {
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
showInfoPanel: false,
mobilePanel: 'sidepanel',
+
contextMenuVisible: false,
contextMenuBounds: null,
contextMenuClickAt: null,
contextMenuParams: null,
contextMenuItems: [],
- serverVersion: 'no connection',
+
contactsSearchQuery: undefined,
- foundContacts: [],
+ // 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
};
@@ -4664,6 +4577,8 @@ class TinodeWeb extends React.Component {
// Logged in fine, subscribe to 'me' attaching callbacks from the contacts view.
var me = this.state.tinode.getMeTopic();
me.onMetaDesc = this.tnMeMetaDesc;
+ me.onContactUpdate = this.tnMeContactUpdate;
+ me.onSubsUpdated = this.tnMeSubsUpdated;
this.setState({
connected: true,
credMethod: undefined,
@@ -4706,6 +4621,77 @@ class TinodeWeb extends React.Component {
}
}
+ // 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.this.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();
+ }
+
+ resetContactList() {
+ let newState = {
+ chatList: []
+ };
+ this.state.tinode.getMeTopic().contacts((c) => {
+ newState.chatList.push(c);
+ if (this.state.topicSelected == c.topic) {
+ newState.topicSelected = c.online;
+ newState.topicSelectedAcs = c.acs;
+ }
+ });
+ // Merge search results and chat list.
+ let merged = {};
+ for (const c of newState.chatList.concat(this.state.searchResults)) {
+ merged[c.topic] = c;
+ }
+ newState.searchableContacts = Object.values(merged);
+ console.log("Resetting contact list 2", newState);
+ this.setState(newState);
+ }
+
// Sending "received" notifications
tnData(data) {
let topic = this.state.tinode.getTopic(data.topic);
@@ -4720,7 +4706,7 @@ class TinodeWeb extends React.Component {
/* Fnd topic: find contacts by tokens */
tnInitFind() {
- var fnd = this.state.tinode.getFndTopic();
+ let fnd = this.state.tinode.getFndTopic();
if (fnd.isSubscribed()) {
this.setState({contactsSearchQuery: ''});
this.tnFndSubsUpdated();
@@ -4733,12 +4719,15 @@ class TinodeWeb extends React.Component {
}
tnFndSubsUpdated() {
- var contacts = [];
+ let contacts = [];
// Don't attempt to create P2P topics which already exist. Server will reject the duplicates.
this.state.tinode.getFndTopic().contacts((s) => {
contacts.push(s);
});
- this.setState({foundContacts: contacts});
+ this.setState({
+ searchResults: contacts,
+ searchableContacts: mergeContacts(contacts, this.state.chatList)
+ });
}
/** Called when the user enters a contact into the contact search field in the NewAccount panel
@@ -4746,7 +4735,7 @@ class TinodeWeb extends React.Component {
*/
handleSearchContacts(query) {
var fnd = this.state.tinode.getFndTopic();
- this.setState({contactsSearchQuery: query == DEL_CHAR ? '' : query});
+ this.setState({contactsSearchQuery: query == Tinode.DEL_CHAR ? '' : query});
fnd.setMeta({desc: {public: query}}).then((ctrl) => {
return fnd.getMeta(fnd.startMetaQuery().withSub().build());
}).catch((err) => {
@@ -4807,10 +4796,16 @@ class TinodeWeb extends React.Component {
handleSendMessage(msg, promise, uploader) {
let topic = this.state.tinode.getTopic(this.state.topicSelected);
let dft = Drafty.parse(msg);
+ let mimeType, attachments;
if (dft && !Drafty.isPlainText(dft)) {
msg = dft;
+ mimeType = Drafty.getContentType();
+ if (Drafty.hasAttachments(data)) {
+ attachments = [];
+ Drafty.attachments(data, (val) => { attachments.push(val); });
+ }
}
- msg = topic.createMessage(msg);
+ msg = topic.createMessage(msg, false, mimeType, attachments);
msg._uploader = uploader;
if (promise) {
@@ -5124,6 +5119,7 @@ class TinodeWeb extends React.Component {
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}
topicSelected={this.state.topicSelected}
+ chatList={this.state.chatList}
credMethod={this.state.credMethod}
credCode={this.state.credCode}
@@ -5152,7 +5148,7 @@ class TinodeWeb extends React.Component {
onInitFind={this.tnInitFind}
contactsSearchQuery={this.state.contactsSearchQuery}
- foundContacts={this.state.foundContacts}
+ searchResults={this.state.searchResults}
onSearchContacts={this.handleSearchContacts}
showContextMenu={this.handleShowContextMenu} />
@@ -5186,7 +5182,7 @@ class TinodeWeb extends React.Component {
connected={this.state.connected}
displayMobile={this.state.displayMobile}
topic={this.state.topicSelected}
- foundContacts={this.state.foundContacts}
+ searchableContacts={this.state.searchableContacts}
myUserId={this.state.myUserId}
errorText={this.state.errorText}
errorLevel={this.state.errorLevel}