diff --git a/README.md b/README.md index fcd6d109..ef2df14e 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,19 @@ Try it (possibly older version) live at http://api.tinode.co/x/example-react-js/ License: [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0) -Photos from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/). +Sample avatar photos from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/). Background pattern from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution. ## Missing functionality -* Cannot manage group members. -* Editing chat parameters is incomplete and buggy. * Cannot send images or other media. * No typing indicators. -* No indicators of chat status (muted, blocked, etc.). +* No indication of chat status (muted, blocked, etc.). * Long polling as a wire transport does not work. * Browser push notifications are missing. * No encryption. +* Editing chat permissions is incomplete. ## Responsive design diff --git a/css/base.css b/css/base.css index cdcfde3f..a8229b6a 100644 --- a/css/base.css +++ b/css/base.css @@ -37,17 +37,41 @@ a:focus { outline-offset: -2px; } +a.red:hover, +a.red:focus { + color: #F44336; +} + +a.flat-button { + display: flex; + align-items: center; + margin: 0.15rem 0.35rem; + height: 2em; + line-height: 2em; + font-weight: normal; + text-align: center; + vertical-align: middle; + touch-action: manipulation; + white-space: nowrap; + text-transform: uppercase; +} +a.flat-button:hover, +a.flat-button:focus { + text-decoration: none; +} + form { font-size: 1.1rem; padding: 0.5rem 0.75rem; - line-height: 1.75; + line-height: 1.5; } -form label { +label { font-size: 1.1rem; + margin-right: auto; } -form label.small { +label.small { color: #1e88e5; font-size: 0.95rem; } @@ -159,7 +183,7 @@ textarea { height: 2.5rem; border-bottom: 1px solid #ccc; box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); - transition: all 0.2s ease-in-out; + transition: all 0.1s ease-in-out; width: 100%; } textarea:focus, @@ -225,6 +249,26 @@ ul.menu > li.separator:hover { } /* END popup/dropdown menu */ +/* Generic text colors */ +.blue { + color: #2196f3; /* blue 500 */ +} + +.red { + color: #D32F2F; /* red 700 */ +} + +.hr { + display: block; + width: 100%; + height: 0.6rem; + background-color: #f0f0f0; + box-shadow: inset 0 0.25rem 0.25rem -0.25rem rgba(0,0,0,0.15), + inset 0 -0.25rem 0.25rem -0.25rem rgba(0,0,0,0.15); +} + +/* END generic text colors */ + /* END of basic elements */ #mountPoint { @@ -251,7 +295,7 @@ ul.menu > li.separator:hover { position: relative; display: flex; flex-direction: column; - flex-grow: 0.3; + flex-grow: 0; flex-shrink: 0.3; width: 24rem; min-width: 18rem; @@ -268,7 +312,7 @@ ul.menu > li.separator:hover { #info-view { display: flex; flex-direction: column; - flex-grow: 0.3; + flex-grow: 0; flex-shrink: 0.3; width: 24rem; min-width: 18rem; @@ -374,6 +418,7 @@ ul.menu > li.separator:hover { } .avatar-box { + position: relative; flex-grow: 0; flex-shrink: 0; } @@ -446,21 +491,27 @@ ul.menu > li.separator:hover { overflow: hidden; } -.contacts-panel { +.scrollable-panel { overflow-y: auto; - margin-top: 1rem;: + margin-top: 0.25rem; margin-bottom: auto; height: 100%; } .contact-box { list-style: none; + overflow-x: hidden; } .contact-box > li { display: flex; padding: 0.35rem 0.25rem 0.5rem 0.35rem; min-height: 3.5rem; + flex-grow: 0; + flex-shrink: 0; + border-bottom: 1px solid #eee; + border-collapse: collapse; + overflow-x: hidden; } .contact-box > li:hover { background-color: #eceff1; /* blue-gray 50 highlight */ @@ -469,8 +520,19 @@ ul.menu > li.separator:hover { background-color: #cfd8dc; /* blue-gray 100 highlight */ } +.contact-box .text-box { + overflow-x: hidden; +} + .contact-box .contact-title { font-size: 1.05rem; + text-overflow: ellipsis; + overflow: hidden +} + +.contact-box .contact-comment { + text-overflow: ellipsis; + overflow: hidden } /* The down arrow head - trigger for context menu in contacts list */ @@ -521,7 +583,7 @@ ul.menu > li.separator:hover { border-radius: 50%; position: absolute; right: 0; - bottom: 0; + bottom: 0.1rem; } .contact-box .online { background-color: #4caf50; @@ -529,6 +591,22 @@ ul.menu > li.separator:hover { .contact-box .offline { background-color: #C0C0C0; } + +.contact-box .checkmark.material-icons { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + font-size: 1.5rem; + line-height: 1.5rem; + vertical-align: middle; + color: #0097A7; + position: absolute; + right: 0.05rem; + bottom: 0.05rem; + background-color: white; + overflow: visible; + border: 0.0px solid transparent; +} /* END of avatar box */ #add-topic { @@ -543,20 +621,35 @@ ul.menu > li.separator:hover { flex-direction: column; } -.panel-form-one { +.panel-form-row { display: flex; justify-content: flex-start; align-items: center; + padding: 0.5rem 0.75rem; + line-height: 1.5; + flex-grow: 0; + flex-shrink: 0; } -.panel-form-two { +.panel-form-column { display: flex; flex-direction: column; - flex-grow: 1; + flex-grow: 0; + flex-shrink: 0; + padding: 0.5rem 0.75rem; + line-height: 1.5; +} +/* Make sure padding is only applied at + the top level panel */ +.panel-form-row > .panel-form-column { + padding: 0!important; + flex-grow: 1; +} +.panel-form-column > .panel-form-row { + padding: 0!important; } - -/* Styling of avatar upload area in the New Account panel */ +/* Styling of avatar upload control */ .avatar-upload { display: flex; flex-direction: column; @@ -564,8 +657,16 @@ ul.menu > li.separator:hover { width: 7rem; height: 9rem; margin-left: 0.5rem; + font-size: 4.5rem; padding: 0; } + +.avatar-upload .avatar-box { + position: relative; + width: 7rem; + height: 7rem; +} + /* Clearfix */ .avatar-upload:after { content: "."; @@ -649,6 +750,7 @@ ul.menu > li.separator:hover { #topic-caption-panel .avatar-box { + position: relative; width: 3rem; min-width: 3rem; height: 3rem; @@ -680,10 +782,11 @@ ul.menu > li.separator:hover { display: flex; align-items: center; flex: none; - padding: 0.5rem; + padding: 0.4rem; background-color: #eceff1; box-shadow: 0 -1px rgba(0,0,0,0.1); /* Faint shadow above panel */ z-index: 2; + height: 4rem; } /* A static panel shown when no topic is selected */ @@ -886,6 +989,24 @@ ul.menu > li.separator:hover { margin-right: auto; } +#group-manager { + position: relative; + display: flex; + flex-direction: column; + height: 100%; +} + +#group-manager-buttons { + display: flex; + background-color: #eceff1; + box-shadow: 0 -1px rgba(0,0,0,0.1); /* Faint shadow above panel */ + z-index: 2; + justify-content: space-around; + padding: 0.4rem; + height: 4rem; + border-left: 1px solid #ccc; +} + /* END of InfoView */ /* Modal dialog */ @@ -967,7 +1088,7 @@ ul.tabbar > li > a { font-size: 1.1rem; color: rgba(33,150,243,0.6); overflow: hidden; - box-shadow: inset 0 -2px 2.5px -1px rgba(0,0,0,0.5); + box-shadow: inset 0 -2px 2.5px -1px rgba(0,0,0,0.25); } ul.tabbar li.active > a { @@ -1007,25 +1128,19 @@ ul.tabbar li.active > a:after { background-color: #0097A7; color: white; border-radius: 50%; - padding: 0.25rem; - min-width: 1.4rem; + width: 1.35rem; + height: 1.35rem; margin-left: 0.5rem; - line-height: 1; + line-height: 1.35rem; text-align: center; vertical-align: middle; } -/* Color of buttons */ -.blue { - color: #2196f3; /* blue 500 */ -} - /* Letter tile base style, responsive square box */ .lettertile { width: 100%; padding-bottom: 100%; border-radius: 50%; - position: relative; } /* Div with letter tile content */ @@ -1044,6 +1159,10 @@ ul.tabbar li.active > a:after { color: rgba(250, 250, 250, 0.8); /* default text color #FAFAFA, gray 50 */ } +.lettertile:hover { + color: white; +} + /* Colors of letter tiles */ .lettertile.color0 { background-color: #ef9a9a; @@ -1073,7 +1192,7 @@ ul.tabbar li.active > a:after { background-color: #c5e1a5; } .lettertile.color9 { - background-color: #fff59d; + background-color: #f0e57d; } .lettertile.color10 { background-color: #f48fb1; @@ -1154,10 +1273,104 @@ ul.tabbar li.active > a:after { } #self-avatar.material-icons, .avatar-box .material-icons { - font-size: 3rem; + font-size: 2.4rem; } .avatar-box .material-icons { color: rgba(250, 250, 250, 0.8); /* #fafafa, gray 50 */ background-color: #c0c0c0; border-radius: 50%; } + +.chip-input { + display: flex; + flex-wrap: wrap; + flex-grow: 1; + cursor: text; + font-size: 1.1rem; + font-weight: normal; + background: transparent; + border-bottom: 1px solid #ccc; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); + transition: all 0.1s ease-in-out; + width: 100%; + max-height: 10rem; + min-height: 4rem; + overflow-y: scroll; + margin: 0.25rem 0rem 0.25rem 0rem; + padding: 0 0 0.5rem 0.5rem; + outline: none; + color: #666; + align-items: center; + justify-content: flex-start; + text-align: center; +} + +.chip-input.focused { + border-bottom: 1px solid #2196f3; + box-shadow: 0 1px 0 rgba(33, 150, 243, 0.25); +} + +.chip-input > input[type="text"] { + display: inline-block; + width: auto; + border: none; + line-height: 1.8; + box-shadow: none; + min-width: 4rem; + flex: 1 0 auto; + height: 1.8rem; + margin: 0.125rem; +} + +.chip { + display: flex; + flex: 0 0 auto; + align-items: center; + height: 1.8rem; + line-height: 1.8; + border-radius: 0.9rem; + margin: 0.125rem; + background-color: #eee; + white-space: nowrap; + font-size: 90%; +} + +.chip > a { + display: inline-block; + color: #ccc; + background-color: #aaa; + border-radius: 50%; + margin: 0.3rem; + width: 1.175rem; + height: 1.175rem; + line-height: 1.15; + text-decoration: none; +} + +.chip > a:focus, +.chip > a:hover { + text-decoration: none; + color: #ddd; + background-color: #bbb; +} + +.chip .avatar-box { + position: relative; + width: 1.8rem; + min-width: 1.8rem; + height: 1.8rem; + min-height: 1.8rem; + font-size: 1rem; + margin-right: 0.2rem; +} + +.chip .avatar-box .material-icons { + font-size: 1.8rem; +} + +.chip .spacer { + display: inline-block; + margin: 0.3rem; + width: 0.25rem; + height: 1.175rem; +} diff --git a/index.html b/index.html index c5cc4165..a78beea0 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ - @@ -210,7 +210,7 @@ if (this.props.avatar === true) { if (this.props.topic && this.props.title && this.props.title.trim()) { var letter = this.props.title.trim().charAt(0); - var color = "lettertile color" + (Math.abs(stringHash(this.props.topic)) % 16); + var color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16); avatar = (
{letter}
) } else { avatar = (Tinode.getTopicType(this.props.topic) === "grp") ? @@ -244,9 +244,13 @@ this.setState({text: e.target.value}); }, - handleKeyPress: function(e) { + handleKeyDown: function(e) { if (e.keyCode === 27) { + // Escape pressed this.setState({text: this.props.text, active: false}); + } else if (e.keyCode === 13) { + // Enter pressed + this.handleEditingFinished(); } }, @@ -257,8 +261,9 @@ handleEditingFinished: function() { this.setState({active: false}); - if (this.state.text !== this.props.text) { - this.props.onFinished(this.state.text); + var text = this.state.text.trim(); + if ((text || this.props.text) && (text !== this.props.text)) { + this.props.onFinished(text); } }, @@ -271,8 +276,8 @@ spanText = this.props.placeholder; spanClass += " placeholder"; } - if (spanText.length > 16) { - spanText = spanText.substring(0, 16) + "..."; + if (spanText.length > 20) { + spanText = spanText.substring(0, 19) + "..."; } return ( this.state.active ? @@ -280,7 +285,7 @@ value={this.state.text} placeholder={this.props.placeholder} onChange={this.handeTextChange} - onKeyPress={this.handleKeyPress} + onKeyDown={this.handleKeyDown} onBlur={this.handleEditingFinished} autoFocus /> : @@ -290,7 +295,6 @@ ); } }); - /* END InPlaceEdit */ /* BEGIN Combobox for selecting host name */ @@ -338,6 +342,144 @@ } /* END Combobox for selecting host name */ + /* BEGIN ChipInput: group membership widget */ + var ChipInput = React.createClass({ + getInitialState: function() { + return { + placeholder: this.props.chips ? "" : this.props.prompt, + sortedChips: this.sortChips(this.props.chips, this.props.required), + chipIndex: this.indexChips(this.props.chips), + input: '', + focused: false + }; + }, + + componentWillReceiveProps: function(newProps) { + this.setState({ + sortedChips: this.sortChips(newProps.chips, newProps.required), + chipIndex: this.indexChips(newProps.chips) + }); + }, + + // Map chip index to user name + indexChips: function(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. + sortChips: function(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: function(e) { + this.setState({input: e.target.value}); + if (this.props.filterFunc) { + this.props.filterFunc(e.target.value); + } + }, + + removeChipAt: function(idx) { + var removed = this.state.sortedChips[idx]; + this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]); + }, + + handleChipCancel: function(item, idx) { + this.removeChipAt(idx); + }, + + handleFocusGained: function() { + this.setState({focused: true}); + }, + + handleFocusLost: function() { + this.setState({focused: false}); + }, + + handleKeyDown: function(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); + } + } + } + }, + + render: function() { + var chips = []; + + var instance = this; + var count = 0; + this.state.sortedChips.map(function(item) { + chips.push( + + ); + count++; + }); + var className = "chip-input" + (this.state.focused ? " focused" : ""); + return ( +
+ {chips} + +
+ ); + } + }); + + ChipInput.Chip = React.createClass({ + handleCancel: function() { + this.props.onCancel(this.props.topic, this.props.index); + }, + + render: function() { + return ( +
+
+ +
+ {this.props.title} + {this.props.onCancel && !this.props.required ? + × + : } +
+ ); + } + }); + /* END ChipInput */ + /* BEGIN Login: a login form */ var LoginView = React.createClass({ getInitialState: function() { @@ -447,9 +589,9 @@ submitClasses += " disabled"; } return ( -
-
-
+
+
+
- -
- +
+
- +
+ +
+
); } }); @@ -506,7 +650,12 @@
{this.state.dataUrl ? : -
128×128
} + this.props.readOnly && this.props.uid ? +
+ +
+ : +
128×128
} {this.props.readOnly ? null : } @@ -696,7 +845,9 @@ this.props.state === 'new-topic' ? : @@ -726,8 +877,6 @@ var EditAccountView = React.createClass({ getInitialState: function() { return { - updatedDesc: false, - updatedPasswd: false, address: null, fullName: undefined, avatar: null, @@ -743,7 +892,6 @@ fullName: me.public ? me.public.fn : undefined, avatar: makeImageUrl(me.public ? me.public.photo : null), address: Tinode.getCurrentUserID(), - login: this.props.login, auth: defacs.auth, anon: defacs.anon }); @@ -758,29 +906,23 @@ }, handleFullNameUpdate: function(fn) { - this.setState({updatedDesc: true, fullName: fn}); + this.setState({fullName: fn}); + this.props.onSubmit(null, vcard(fn, this.state.avatar)); }, handlePasswordUpdate: function(pwd) { - this.setState({updatedPasswd: true, password: pwd}); + this.setState({password: pwd}); + this.props.onSubmit(pwd, null); }, handleImageChanged: function(img) { - this.setState({updatedDesc: true, avatar: img}); - }, - - handleSubmit: function(e) { - e.preventDefault(); - if (this.state.updatedDesc || this.state.updatedPasswd) { - this.props.onSubmit( - this.state.updatedPasswd ? this.state.password : null, - this.state.updatedDesc ? vcard(this.state.fullName, this.state.avatar) : null); - } + this.setState({avatar: img}); + this.props.onSubmit(null, vcard(this.state.fullName, img)); }, render: function() { return ( -
-
-
+
+
+
-
-
Login: {this.state.login}
-
Address: {this.state.address}
- -
+
+
+
+ + {this.props.login} +
+
+ + {this.state.address} +
+
+ +
Auth: {this.state.auth}
Anon: {this.state.anon}
-
- -
- +
); } }); @@ -821,7 +970,7 @@ handleClick: function(e) { e.preventDefault(); e.stopPropagation(); - this.props.onSelected(this.props.topic); + this.props.onSelected(this.props.topic, this.props.index); }, handleContextClick: function(e) { @@ -837,20 +986,24 @@ } else if (title.length > 30) { title = title.substring(0, 28) + "..."; } - var selected = (this.props.selected === this.props.topic) ? "selected" : null; var online = this.props.now ? "online" : "offline"; var avatar = this.props.avatar ? this.props.avatar : true; 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} + {this.props.comment ?
    {this.props.comment}
    : null}
    {this.props.showContextMenu ? @@ -885,6 +1038,11 @@ /* ContactsView holds all contacts-related stuff */ var ContactsView = React.createClass({ + getInitialState: function() { + return { + contactList: this.prepareContactList() + }; + }, componentWillMount: function() { var me = Tinode.getMeTopic(); me.onContactUpdate = this.tnMeContactUpdate; @@ -900,13 +1058,13 @@ // Reactions to updates to the contact list. tnMeContactUpdate: function(what, cont) { if (what === "on" || what === "off" || what === "read") { - this.forceUpdate(); + 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.props.topicSelected !== cont.topic) { - this.forceUpdate(); + this.resetContactList(); } } else if (what === "recv") { // Explicitly ignoring "recv" -- it causes no visible updates to contact list. @@ -918,7 +1076,7 @@ this.props.onTopicSelected(null); } // Redraw without the deleted topic. - this.forceUpdate(); + this.resetContactList(); } else { // TODO(gene): handle other types of notifications: // * ua -- user agent changes (may display a pictogram for mobile/desktop) @@ -927,83 +1085,280 @@ console.log("changed (not updating for it): " + what + " in: " + cont.topic); } }, + tnInvite: function(invite) { // TODO(gene): implement invite handling console.log("Got invite:"); console.log(invite); }, + tnMeSubsUpdated: function(names) { - this.forceUpdate(); + this.resetContactList(); }, - render: function() { - var contactNodes = []; + + prepareContactList: function() { + var contacts = []; Tinode.getMeTopic().contacts(function(c) { - var unread = c.seq; + c.unread = c.seq; if (c.read > 0) { - unread -= c.read; + c.unread -= c.read; } - contactNodes.push( - - ); + contacts.push(c); }, this); + return contacts; + }, + + resetContactList: function() { + this.setState({contactList: this.prepareContactList()}); + }, + + render: function() { return ( -
    + You have no chats
    ¯\_(ツ)_/¯} + topicSelected={this.props.topicSelected} + showOnline={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 */ + var ContactList = React.createClass({ + render: function() { + var contactNodes = []; + var instance = this; + var showCheckmark = Array.isArray(this.props.topicSelected); + + if (this.props.contacts && this.props.contacts.length > 0) { + this.props.contacts.map(function(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 (instance.props.filterFunc && instance.props.filter) { + var content = [key]; + if (c.private) { + content.push(("" + c.private).toLowerCase()); + } + if (c.public && c.public.fn) { + content.push(("" + c.public.fn).toLowerCase()); + } + if (!instance.props.filterFunc(instance.props.filter, content)) { + return; + } + } + + var selected = showCheckmark ? + (this.props.topicSelected.indexOf(key) > -1) : + (this.props.topicSelected === key); + + contactNodes.push( + + ); + }, this); + } + + return ( +
    {contactNodes.length > 0 ?
      {contactNodes}
    : -

    - You have no chats
    ¯\_(ツ)_/¯ -

    } +
    + {this.props.emptyListMessage} +
    }
    ); } }); - /* END Contact list */ + /* END ContactList */ + + /* BEGIN GroupMembers: controlc for managing a list of group members */ + var GroupManager = React.createClass({ + getInitialState: function() { + return { + members: this.props.members, + contacts: this.props.contacts, + index: this.indexMembers(this.props.members), + contactFilter: '', + noContactsMessage: 'You have no contacts :-(', + selectedContacts: this.selectedContacts(this.props.members) + }; + }, + + indexMembers: function(members) { + var index = {}; + members.map(function(m) { + index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed + }); + return index; + }, + + selectedContacts: function(members) { + var sel = []; + members.map(function(m) { + sel.push(m.user); + }); + return sel; + }, + + handleContactSelected: function(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 = this.selectedContacts(m); + + var i = this.state.index; + i[userId] = status; + + this.setState({members: m, index: i, selectedContacts: sel}); + }, + + handleMemberRemoved: function(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 = this.selectedContacts(m); + + var i = this.state.index; + i[userId] = status; + + this.setState({members: m, index: i, selectedContacts: sel}); + }, + + handleContactFilter: function(val) { + var msg = !val ? + "You have no contacts :-(" : + "No contacts match '" + val + "'"; + + this.setState({contactFilter: val, noContactsMessage: msg}); + }, + + doContactFiltering: function(filter, values) { + if (filter) { + for (var i=0; i= 0) { + return true; + } + } + return false; + } + return true; + }, + + handleSubmit: function() { + 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: function() { + this.props.onCancel(); + }, + + render: function() { + return ( +
    +
    + +
    + +
    + +
    + +
    + + +
    +
    + ); + } + }); + /* END GroupManager */ /* BEGIN Create new topic and invite users or send an invite */ var NewTopicView = React.createClass({ getInitialState: function() { return { tabSelected: "p2p", - searchQuery: '', + searchQuery: this.props.contactsSearchQuery, + contactList: this.props.foundContacts, contactSelected: null }; }, componentWillMount: function() { - var fnd = Tinode.getFndTopic(); - fnd.onSubsUpdated = this.fndSubsUpdated; - fnd.onMetaDesc = this.fndMetaDesc; - if (fnd.isSubscribed()) { - this.setState({searchQuery: fnd.private ? fnd.private.toString() : ''}); - } else { - fnd.subscribe({get: {sub: {}, desc: {}}}); - } + this.props.onInitFind(); }, - componentWillUnmount: function() { - var fnd = Tinode.getFndTopic(); - fnd.onSubsUpdated = undefined; - fnd.onMetaDesc = undefined; - }, - fndMetaDesc: function(desc) { - if (desc.private) { - this.setState({searchQuery: desc.private.toString()}); - } - }, - fndSubsUpdated: function() { - //console.log(keys); - this.forceUpdate(); + componentWillReceiveProps: function(nextProps) { + this.setState({ + searchQuery: nextProps.contactsSearchQuery, + contactList: nextProps.foundContacts + }); }, handleTabClick: function(e) { e.preventDefault(); @@ -1037,7 +1392,13 @@ - +
    }
  • ); @@ -1048,19 +1409,24 @@ getInitialState: function() { return { fn: '', // full/formatted name + private: '', imageDataUrl: null }; }, handleFnChange: function(e) { this.setState({fn: e.target.value}); }, + handlePrivateChange: function(e) { + this.setState({private: e.target.value}); + }, handleImageChanged: function(img) { this.setState({imageDataUrl: img}); }, handleSubmit: function(e) { e.preventDefault(); if (this.state.fn && this.state.fn.trim()) { - this.props.onSubmit(this.state.fn.trim(), this.state.imageDataUrl); + this.props.onSubmit(this.state.fn.trim(), + this.state.imageDataUrl, this.state.private.trim()); } }, render: function() { @@ -1069,18 +1435,23 @@ submitClasses += " disabled"; } return ( -
    -
    -
    - +
    +
    + + -
    - -
    +
    + +
    - +
    + +
    +
    ); } }); @@ -1112,55 +1483,15 @@ }, render: function() { return ( -
    -
    - +
    + - - ); - } - }); - - NewTopicView.ContactsList = React.createClass({ - getInitialState: function() { - return { - selectedUser: null - }; - }, - handleSelectionChange: function(sel) { - if (this.props.type === "p2p") { - this.props.onContactSelected(sel); - } else { - console.log(sel); - } - }, - render: function() { - var contactNodes = []; - // Don't attempt to create P2P topics which already exist. Server will reject the duplicates. - var me = Tinode.getMeTopic(); - Tinode.getFndTopic().subscribers(function(s) { - var cont = me ? me.getContact(s.user) : null; - var key = cont ? cont.topic : s.user; - - contactNodes.push( - - ); - }, this); - return ( -
    -
      - {contactNodes} -
    ); } @@ -1172,13 +1503,21 @@ getInitialState: function() { return { owner: false, - updated: false, address: null, + groupTopic: undefined, fullName: undefined, avatar: null, - private: null + private: null, + selectedContact: null, + muted: undefined, + access: null, + auth: null, + anon: null, + contactList: [], + showMemberPanel: this.props.showMemberPanel }; }, + componentWillMount: function() { var topic = Tinode.getTopic(this.props.topic); if (!topic) { @@ -1186,49 +1525,125 @@ } topic.onMetaDesc = this.onMetaDesc; - var defacs = topic.getDefaultAccess() || {}; - var acs = topic.getAccessMode(); - this.setState({ - owner: acs && acs.isOwner(), - fullName: topic.public ? topic.public.fn : undefined, - avatar: makeImageUrl(topic.public ? topic.public.photo : null), - private: topic.private, - address: topic.name, - auth: defacs.auth, - anon: defacs.anon - }); + topic.onSubsUpdated = this.onSubsUpdated; + this.resetDesc(topic); + this.resetSubs(topic); + + this.props.onInitFind(); }, + componentWillUnmount: function() { var topic = Tinode.getTopic(this.props.topic); if (!topic) { return; } topic.onMetaDesc = undefined; + topic.onSubsUpdated = undefined; + }, + + resetSubs: function(topic) { + var contacts = []; + topic.subscribers(function(sub){ + contacts.push(sub); + }, this); + this.setState({ + contactList: contacts + }); + }, + + resetDesc: function(topic) { + var defacs = topic.getDefaultAccess() || {}; + var acs = topic.getAccessMode(); + this.setState({ + owner: acs && acs.isOwner(), + 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, + address: topic.name, + groupTopic: (topic.getType() === "grp"), + access: acs.getMode(), + auth: defacs.auth, + anon: defacs.anon + }); }, onMetaDesc: function(desc) { - // Update form with changed values + var topic = Tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + this.resetDesc(topic); + }, + + onSubsUpdated: function() { + var topic = Tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + this.resetSubs(topic); }, handleFullNameUpdate: function(fn) { - this.setState({updated: true, fullName: fn}); - }, - handlePrivateUpdate: function(priv) { - this.setState({updated: true, private: priv}); - }, - handleImageChanged: function(img) { - this.setState({updated: true, avatar: img}); + if (this.state.fullName !== fn) { + this.setState({fullName: fn}); + this.props.onSubmit(this.props.topic, + vcard(fn, this.state.avatar), + null); + } }, - handleSubmit: function(e) { - e.preventDefault(); - if (this.state.updated) { + handlePrivateUpdate: function(priv) { + if (this.state.priv !== priv) { + this.setState({private: priv}); this.props.onSubmit(this.props.topic, - vcard(this.state.fullName,this.state.avatar), - this.state.private); + null, + priv); } }, + handleImageChanged: function(img) { + this.setState({avatar: img}); + this.props.onSubmit(this.props.topic, + vcard(this.state.fullName, img), + null); + }, + + handleMuted: function(e) { + var checked = e.target.checked; + this.setState({muted: checked}); + this.props.onChangePermissions(this.props.topic, checked ? "-P" : "+P"); + }, + + handleShowAddMembers: function() { + // this.props.onAddMember(this.props.topic); + this.setState({showMemberPanel: true}); + }, + + handleHideAddMembers: function() { + // this.props.onAddMember(this.props.topic); + this.setState({showMemberPanel: false}); + }, + + handleMemberUpdateRequest: function(members, added, removed) { + this.props.onMemberUpdateRequest(this.props.topic, added, removed); + this.setState({showMemberPanel: false}); + }, + + handleLeave: function() { + this.props.onLeaveTopic(this.props.topic); + }, + + handleMemberSelected: function(uid) { + this.setState({selectedContact: uid}); + }, + + handleContextMenu: function(item) { + console.log("context menu: " + item); + }, + render: function() { return (
    @@ -1238,43 +1653,100 @@
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    Address: {this.state.address}
    - {this.state.auth || this.state.anon ? -
    -
    -
    -
    Auth: {this.state.auth}
    -
    Anon: {this.state.anon}
    -
    + {this.state.showMemberPanel ? + + : +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + + {this.state.address} +
    +
    + + {this.state.access} +
    +
    + + +
    + {this.state.auth || this.state.anon ? +
    +
    +
    +
    Auth: {this.state.auth}
    +
    Anon: {this.state.anon}
    +
    +
    : null } +
    +
    + {this.state.groupTopic ? +
    +
    + +
    +
    + {this.state.sharer ? + + person_add Add members + + : null} + {!this.state.owner ? + + exit_to_app Leave + + : null} +
    + +
    + : + + }
    -
    - -
    - + }
    ); } @@ -1690,13 +2162,16 @@ subscriptions: {}, displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT), showInfoPanel: false, + infoPanelShowMembers: false, mobilePanel: 'sidepanel', contextMenuVisible: false, contextMenuBounds: null, contextMenuClickAt: null, contextMenuTopic: '', contextMenuItems: [], - serverVersion: 'no connection' + serverVersion: 'no connection', + contactsSearchQuery: '', + foundContacts: [] }; }, componentDidMount: function() { @@ -1787,6 +2262,7 @@ loginDisabled: false, subscriptions: {}, showInfoPanel: false, + infoPanelShowMembers: false, mobilePanel: 'sidepanel', contextMenuVisible: false, contextMenuBounds: null, @@ -1810,6 +2286,44 @@ Tinode.getTopic(data.topic).noteRecv(data.seq); }, RECEIVED_DELAY); }, + + /* Fnd topic: find contacts by tokens */ + tnInitFind: function() { + var fnd = Tinode.getFndTopic(); + fnd.onSubsUpdated = this.tnFndSubsUpdated; + fnd.onMetaDesc = this.tnFndMetaDesc; + if (fnd.isSubscribed()) { + this.setState({contactsSearchQuery: fnd.private ? fnd.private.toString() : ''}); + this.tnFndSubsUpdated(); + } else { + fnd.subscribe({get: {sub: {}, desc: {}}}); + } + }, + + tnFndMetaDesc: function(desc) { + if (desc.private) { + this.setState({contactsSearchQuery: desc.private.toString()}); + } + }, + + tnFndSubsUpdated: function() { + var contacts = []; + // Don't attempt to create P2P topics which already exist. Server will reject the duplicates. + Tinode.getFndTopic().subscribers(function(s) { + contacts.push(s); + }); + this.setState({foundContacts: contacts}); + }, + /** 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: function(query) { + var find = Tinode.getFndTopic(); + find.setMeta({desc: {private: query}}).then(function(ctrl) { + find.getMeta({sub: {}, desc: {}}); + }); + }, + // User clicked on a contact in the side panel or deleted a contact. handleTopicSelected: function(topicName) { if (topicName) { @@ -1858,7 +2372,6 @@ }, handleUpdateAccountRequest: function(password, pub, priv) { - this.handleSidepanelCancel(); if (pub || priv) { Tinode.getMeTopic().setMeta({desc: {public: pub, private: priv}}); } @@ -1899,7 +2412,8 @@ errorText: '', topicSelected: peerName, sidePanelSelected: 'contacts', - mobilePanel: 'topic-view' + mobilePanel: 'topic-view', + showInfoPanel: false }); } else { var topic = peerName ? Tinode.newTopicWith(peerName) : Tinode.newTopic(); @@ -1913,7 +2427,9 @@ errorText: '', topicSelected: topic.name, sidePanelSelected: 'contacts', - mobilePanel: 'topic-view' + mobilePanel: 'topic-view', + showInfoPanel: true, + infoPanelShowMembers: true }); }).catch(function(err) { instance.setState({errorText: err.message}); @@ -1921,19 +2437,42 @@ } }, + //handleManageGroupMembers: function(topicName) { + // FIXME(gene): launch group member manager + //onAddMember}, + handleTopicUpdateRequest: function(topicName, pub, priv) { var topic = Tinode.getTopic(topicName); - topic.setMeta({desc: {public: pub, private: priv}}); + if (topic) { + topic.setMeta({desc: {public: pub, private: priv}}); + } }, - /** 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: function(query) { - var find = Tinode.getFndTopic(); - find.setMeta({desc: {private: query}}).then(function() { - find.getMeta({sub: {}, desc: {}}); + + handleChangePermissions: function(topicName, want) { + var topic = Tinode.getTopic(topicName); + if (topic) { + var am = topic.getAccessMode(); + am.updateWant(want); + topic.setMeta({sub: {mode: am.getWant()}}); + } + }, + + handleLeaveUnsubRequest: function(topicName) { + var topic = Tinode.getTopic(topicName); + if (!topic) { + return; + } + var instance = this; + topic.leave(true).then(function(ctrl) { + // Hide MessageView and InfoView panels. + instance.setState({ + topicSelected: '', + showInfoPanel: false, + infoPanelShowMembers: false }); + }); }, + handleDialogCancel: function() { this.setState({dialogSelected: null}); }, @@ -2014,11 +2553,34 @@ } }, handleShowInfoView: function() { - this.setState({showInfoPanel: true}); + this.setState({showInfoPanel: true, infoPanelShowMembers: false}); }, handleHideInfoView: function() { - this.setState({showInfoPanel: false}); + this.setState({showInfoPanel: false, infoPanelShowMembers: false}); }, + handleMemberUpdateRequest: function(topicName, added, removed) { + if (!topicName) { + return; + } + + var topic = Tinode.getTopic(topicName); + if (!topic) { + return; + } + + if (added && added.length > 0) { + added.map(function(uid) { + topic.invite(uid, null, "Join my group!"); + }); + } + + if (removed && removed.length > 0) { + removed.map(function(uid) { + topic.delSubscription(uid); + }); + } + }, + render: function() { return (
    @@ -2058,11 +2620,15 @@ onCreateAccount={this.handleNewAccountRequest} onUpdateAccount={this.handleUpdateAccountRequest} onTopicSelected={this.handleTopicSelected} - onSearchContacts={this.handleSearchContacts} onCreateTopic={this.handleNewTopicRequest} onNewTopic={this.handleNewTopic} onCancel={this.handleSidepanelCancel} + onInitFind={this.tnInitFind} + contactsSearchQuery={this.state.contactsSearchQuery} + foundContacts={this.state.foundContacts} + onSearchContacts={this.handleSearchContacts} + showContextMenu={this.handleShowContextMenu} /> : null