diff --git a/README.md b/README.md index 827f8eb5..43b3b4b7 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,13 @@ Background pattern from http://subtlepatterns.com/, commercial and non-commercia ### Desktop -Desktop web: full app +

+ Desktop web: full app +

### Mobile -Mobile web: contacts -Mobile web: chat +

+ Mobile web: contacts + Mobile web: chat +

diff --git a/css/base.css b/css/base.css index 4810e881..5a8495f8 100644 --- a/css/base.css +++ b/css/base.css @@ -18,6 +18,15 @@ html, body { box-shadow: inset 0 10rem #3949AB; /* the deep-plue stripe accross the top */ } +tt { + font-family: Roboto Mono, monospace; +} + +hr { + height: 1px; + color: #666; +} + a { color: #2196f3; text-decoration: none; @@ -34,11 +43,18 @@ a:focus { } form { - font-size: 1.2rem; + font-size: 1.1rem; + padding: 0.5rem 0.75rem; + line-height: 1.75; } form label { - font-size: 1rem; + font-size: 1.1rem; +} + +form label.small { + color: #1e88e5; + font-size: 0.95rem; } img.avatar { @@ -131,13 +147,12 @@ button.white:focus, button.white:hover, button.blue:active { textarea, input { font-family: inherit; - font-size: 1.05rem; + font-size: 1.1rem; background: transparent; border: none; margin: 0.25rem 0; padding: 0 0.125rem; outline: none; - box-sizing: border-box; color: #666; } textarea { @@ -148,7 +163,6 @@ input[type="password"], textarea { height: 2.5rem; border-bottom: 1px solid #ccc; - border-radius: 0.2rem; box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1); transition: all 0.2s ease-in-out; width: 100%; @@ -160,6 +174,12 @@ input[type="password"]:focus { box-shadow: 0 1px 0 rgba(33, 150, 243, 0.25); } +input[type="text"]::placeholder, +input[type="password"]::placeholder, +textarea::placeholder { + color: #bbb; +} + /* Scrollbar styling for Chrome. Firefox does not support scrollbar styling. */ ::-webkit-scrollbar { width: 0.6rem; @@ -176,12 +196,6 @@ input[type="password"]:focus { /* END of input and buttons */ -form { - padding: 0.5rem 0.75rem; - line-height: 1.75; - font-size: 1.2rem; -} - /* BEGIN Popup/dropdown menu */ ul.menu { @@ -254,6 +268,14 @@ ul.menu > li.separator:hover { height: 100%; } +#info-view { + display: flex; + flex-direction: column; + width: 24rem; + min-width: 24rem; + height: 100%; +} + @media (max-width: 960px) { #mountPoint { padding: 0; @@ -268,6 +290,10 @@ ul.menu > li.separator:hover { #sidepanel { width: 100%; } + + #info-view { + width: 100%; + } } /* Generic announcement text, centered vertically and horizontally */ @@ -281,11 +307,38 @@ ul.menu > li.separator:hover { font-size: 1.4rem; } -/* Class to alternatively hide sidepanel/topic-view */ +/* Class to alternatively hide sidepanel/topic-view/info-view */ .nodisplay { display: none!important; } +/* BEGIN InPlaceEdit styles */ +.in-place-edit { + display: inline-block; + font-family: inherit; + font-size: 1.1rem; + height: 2.5rem; + line-height: 1.5; + margin: 0.25rem 0.125rem; + -x-padding: 0 0.125rem; + width: 100%; + vertical-align: baseline; + border-bottom: 1px dashed #ddd; + cursor: pointer; + overflow: hidden; +} + +.in-place-edit.disabled { + border-bottom: none; + cursor: default; +} + +.in-place-edit.placeholder { + color: #bbb; +} + +/* END of InPlaceEdit styles */ + /* END of top-level layout */ /* Sidepanel styles */ @@ -293,30 +346,37 @@ ul.menu > li.separator:hover { font-size: 1.4rem; } -.dialog-buttons { - display: flex; - padding: 0.5em; - justify-content: flex-end; -} - -#side-caption-panel { +.caption-panel { display: flex; align-items: center; flex: none; padding: 0.5em; height: 4em; - background-color: #2196f3; /* blue 500 */ - color: #bbdefb; /* light-blue 100 */ - box-shadow: 0 1px 2px gray; z-index: 1; /* otherwise the shadow becomes invisible */ } +.dialog-buttons { + display: flex; + padding: 0.5em; + justify-content: flex-end; +} + +.avatar-box { + flex-grow: 0; + flex-shrink: 0; +} + +#side-caption-panel { + background-color: #2196f3; /* blue 500 */ + color: #bbdefb; /* light-blue 100 */ +} + #side-caption-panel a { color: #bbdefb; /* blue 100 */ - padding-left: 0.5em; - vertical-align: -1em; /* icons are bigger than the declared font size */ + padding-left: 0.5rem; + line-height: 1; } #side-caption-panel :hover { @@ -330,11 +390,16 @@ ul.menu > li.separator:hover { #self-avatar { width: 3rem; + min-width: 3rem; height: 3rem; flex: none; border-radius: 50%; } +#self-avatar.avatar-box { + font-size: 1.8rem; +} + /* Application settings */ #settings-form ul { list-style: none; @@ -417,6 +482,7 @@ ul.menu > li.separator:hover { margin-right: 0.35rem; position: relative; width: 3rem; + min-width: 3rem; height: 3rem; font-size: 1.8rem; overflow: hidden; @@ -472,7 +538,7 @@ ul.menu > li.separator:hover { .panel-form-two { display: flex; flex-direction: column; - width: 100%; + flex-grow: 1; } @@ -481,36 +547,32 @@ ul.menu > li.separator:hover { position: relative; text-align: center; vertical-align: middle; - width: 4.5rem; - height: auto; - box-sizing: border-box; + flex-shrink: 0; + width: 7rem; + height: 7rem; margin-left: 0.5rem; padding: 0; + border-radius: 50%; } .avatar-upload .blank { + text-align: center; + vertical-align: middle; border: 1px dashed #999; - border-radius: 0.75rem; font-size: 1rem; - width: 4.5rem; - height: 4.5rem; + width: 7rem; + height: 7rem; overflow: hidden; color: #999; - vertical-align: middle; - line-height: 4.5rem; - box-sizing: border-box; - margin: 0; - padding: 0; + line-height: 7rem; + border-radius: 50%; } .avatar-upload .preview { - border-radius: 0.75rem; - width: 4.5rem; - height: 4.5rem; + width: 7rem; + height: 7rem; overflow: hidden; - box-sizing: border-box; - margin: 0; - padding: 0; + border-radius: 50%; } .avatar-upload .inputfile.hidden { @@ -526,14 +588,14 @@ ul.menu > li.separator:hover { vertical-align: middle; text-align: center; cursor: pointer; - width: 2.5rem; - height: 2.5rem; - line-height: 3rem; + width: 3rem; + height: 3rem; + line-height: 4rem; border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.4); position: absolute; - top: 3.875rem; - left: 1rem; + top: 6rem; + left: 2rem; background-color: #fafafa; } @@ -552,24 +614,23 @@ ul.menu > li.separator:hover { /* Topic/messages view */ #topic-caption-panel { - display: flex; - align-items:center; - justify-content: flex-start; - flex: none; - padding: 0.5rem; - height: 4rem; background-color: #b0bec5; - box-shadow: 0 1px 2px gray; - z-index: 1; /* otherwise the shadow becomes invisible */ } #topic-caption-panel a { color: #757575; text-decoration: none; } +#topic-caption-panel :hover { + color: black; +} + + #topic-caption-panel .avatar-box { width: 3rem; + min-width: 3rem; height: 3rem; + font-size: 1.8rem; margin-right: 0.35rem; } @@ -730,7 +791,9 @@ ul.menu > li.separator:hover { .chat-box .avatar-box { position: relative; width: 1.4rem; + min-width: 1.4rem; height: 1.4rem; + min-height: 1.4rem; font-size: 0.8rem; margin-bottom: 0.8rem; margin-right: 0.8rem; @@ -784,6 +847,24 @@ ul.menu > li.separator:hover { /* END of talk bubbles */ /* END of topic/mesages view */ +/* InfoView styles */ +#info-caption-panel { + background-color: #b0bec5; +} +#info-caption-panel a { + color: #757575; + line-height: 1; + text-decoration: none; +} +#info-caption-panel :hover { + color: black; +} + +#info-title { + margin-right: auto; +} + +/* END of InfoView */ /* Modal dialog */ .dialog-overlay { @@ -915,7 +996,7 @@ ul.tabbar li.active > a:after { color: #2196f3; /* blue 500 */ } -/* Letter tile base style */ +/* Letter tile base style, responsive square box */ .lettertile { width: 100%; padding-bottom: 100%; @@ -923,9 +1004,11 @@ ul.tabbar li.active > a:after { position: relative; } -/* Responsive square box for text tiles */ +/* Div with letter tile content */ .lettertile > div { display: flex; + flex-shrink: 0; + flex-grow: 0; position: absolute; top: 0; bottom: 0; left: 0; right: 0; text-transform: uppercase; diff --git a/index.html b/index.html index 658e81d7..52b5e744 100644 --- a/index.html +++ b/index.html @@ -67,6 +67,8 @@ // Make a data URL from public.photo function makeImageUrl(photo) { + //console.log("makeImageUrl"); + //console.log(photo); return (photo && photo.type && photo.data) ? 'data:image/' + photo.type + ';base64,' + photo.data : null; } @@ -94,6 +96,26 @@ return host; } + // Create VCard which represents topic 'public' info + function vcard(fn, imageDataUrl) { + var card = null; + + if ((fn && fn.trim()) || imageDataUrl) { + card = {}; + if (fn) { + card.fn = fn.trim(); + } + if (imageDataUrl) { + var dataStart = imageDataUrl.indexOf(","); + card.photo = { + data: imageDataUrl.substring(dataStart+1), + type: "jpg" + }; + } + } + return card; + } + /* BEGIN Popup/dropdown menu */ var ContextMenu = React.createClass({ componentWillUnmount: function() { @@ -172,6 +194,15 @@ }); /* END Popup/dropdown menu */ + /* The X menu to be displayed in title bars */ + var MenuCancel = React.createClass({ + render: function() { + return ( + close + ); + } + }); + /* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */ class LetterTile extends React.PureComponent { render() { @@ -196,6 +227,66 @@ } /* END Lettertile */ + /* BEGIN In-place text editor. Shows text with an icon + * which toggles it into an input field */ + var InPlaceEdit = React.createClass({ + getInitialState: function() { + return { + active: this.props.state, + text: this.props.text || "", + changed: false + }; + }, + + handeTextChange: function(e) { + this.setState({text: e.target.value}); + }, + + handleKeyPress: function(e) { + if (e.keyCode === 27) { + this.setState({text: this.props.text, active: false}); + } + }, + + handleStartEditing: function() { + ReactDOM.findDOMNode(this).focus(); + this.setState({active: !this.props.readOnly}); + }, + + handleEditingFinished: function() { + this.setState({active: false}); + if (this.state.text !== this.props.text) { + this.props.onFinished(this.state.text); + } + }, + + render: function() { + var spanText = this.props.type === "password" ? + "••••••••" : this.state.text; + var spanClass = "in-place-edit" + + (this.props.readOnly ? " disabled" : ""); + if (!spanText) { + spanText = this.props.placeholder; + spanClass += " placeholder"; + } + return ( + this.state.active ? + + : + {spanText} + ); + } + }); + + /* END InPlaceEdit */ + /* BEGIN Combobox for selecting host name */ class HostSelector extends React.PureComponent { constructor(props) { @@ -338,22 +429,10 @@ // FIXME(gene): report an error here. } else { this.setState({errorCleared: false}); - var vcard = null; - - if ((this.state.fn && this.state.fn.trim()) || this.state.imageDataUrl) { - vcard = {}; - if (this.state.fn) { - vcard.fn = this.state.fn.trim(); - } - if (this.state.imageDataUrl) { - var dataStart = this.state.imageDataUrl.indexOf(","); - vcard.photo = { - data: this.state.imageDataUrl.substring(dataStart+1), - type: "jpg" - }; - } - } - this.props.onCreateAccount(this.state.login.trim(), this.state.password.trim(), vcard); + this.props.onCreateAccount( + this.state.login.trim(), + this.state.password.trim(), + vcard(this.state.fn, this.state.imageDataUrl)); } }, render: function() { @@ -362,26 +441,24 @@ submitClasses += " disabled"; } return ( -
-
-
-
- - - -
- + +
+
+ + +
- -
- -
- -
+ +
+ +
+ +
+ ); } }); @@ -389,7 +466,7 @@ var AvatarUpload = React.createClass({ getInitialState: function() { return { - dataUrl: null + dataUrl: this.props.avatar }; }, handleFileUpload(e) { @@ -398,7 +475,7 @@ img.crossOrigin = 'Anonymous'; img.onload = function() { var canvas = document.createElement('CANVAS'); - canvas.width = canvas.height = 64; + canvas.width = canvas.height = 128; var ctx = canvas.getContext('2d'); var sx = 0, sy = 0, size; if (this.height > this.width) { @@ -409,7 +486,7 @@ sx = (this.width - size) / 2; } ctx.imageSmoothingEnabled = true; - ctx.drawImage(this, sx, sy, size, size, 0, 0, 64, 64); + ctx.drawImage(this, sx, sy, size, size, 0, 0, canvas.width, canvas.height); var bits = canvas.toDataURL("image/jpg"); canvas = null; @@ -423,12 +500,14 @@
{this.state.dataUrl ? : -
64×64
} - +
128×128
} + {this.props.readOnly ? null : + } + {this.props.readOnly ? null : + }
); } @@ -492,22 +571,22 @@ var SideNavbar = React.createClass({ render: function() { return ( -
-
+
{this.props.title}
{this.props.state === 'login' ? : this.props.state === 'settings' ? - : + : this.props.state === 'edit-account' ? - : + : this.props.state === 'register' ? - : + : this.props.state === 'contacts' ? : this.props.state === 'new-topic' ? - : + : null}
); @@ -526,14 +605,6 @@ } }); - SideNavbar.MenuCancel = React.createClass({ - render: function() { - return ( - close - ); - } - }) - SideNavbar.MenuContacts = React.createClass({ render: function() { return ( @@ -614,7 +685,10 @@ /> : this.props.state === 'edit-account' ? - : + : this.props.state === 'contacts' ? -

Not implemented

+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
Login: {this.state.login}
+
Address: {this.state.address}
+
Default access mode:
+
  Auth: {this.state.auth}
+
  Anon: {this.state.anon}
+
+
+ +
); } @@ -866,20 +1019,7 @@ } }, handleGroupSubmit: function(name, dataUrl) { - var vcard = null; - if (name || dataUrl) { - vcard = { - fn: name, - }; - if (dataUrl) { - var dataStart = dataUrl.indexOf(","); - vcard.photo = { - data: dataUrl.substring(dataStart+1), - type: "jpg" - }; - } - } - this.props.onCreateTopic(undefined, vcard); + this.props.onCreateTopic(undefined, vcard(name, dataUrl)); }, render: function() { return ( @@ -1026,9 +1166,118 @@ ); } }); - /* END Create new topic and invite users or send an invite */ + /* BEGIN InfoView: panel with topic/user info */ + var InfoView = React.createClass({ + getInitialState: function() { + return { + updated: false, + address: null, + fullName: undefined, + avatar: null, + private: null + }; + }, + componentWillMount: function() { + var topic = Tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + + topic.onMetaDesc = this.onMetaDesc; + var defacs = topic.getDefaultAccess() || {}; + this.setState({ + 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 + }); + }, + componentWillUnmount: function() { + var topic = Tinode.getTopic(this.props.topic); + if (!topic) { + return; + } + topic.onMetaDesc = undefined; + }, + + onMetaDesc: function(desc) { + // Update form with changed values + }, + + 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}); + }, + + handleSubmit: function(e) { + e.preventDefault(); + if (this.state.updated) { + this.props.onSubmit(this.props.topic, + vcard(this.state.fullName,this.state.avatar), + this.state.private); + } + }, + + render: function() { + return ( +
+
+
Info
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
Address: {this.state.address}
+ {this.state.auth || this.state.anon ? +
+
Default access mode:
+
  Auth: {this.state.auth}
+
  Anon: {this.state.anon}
+
+ : + null + } +
+
+ +
+
+
+ ); + } + }); + /* END InfoView */ + /* BEGIN Conversation panel */ var Received = { UNDEF: -2, @@ -1132,6 +1381,7 @@ // Unbind the previous topic from this component. if (this.state.topic) { var oldTopic = Tinode.getTopic(this.state.topic); + oldTopic.leave(false); oldTopic.onData = undefined; oldTopic.onInfo = undefined; oldTopic.onMetaDesc = undefined; @@ -1269,9 +1519,9 @@ var avatar = this.state.avatar || true; component = (
-
+
{this.props.displayMobile ? - + arrow_back : @@ -1287,7 +1537,7 @@
{this.state.title}
{lastSeen}
-
users online and menu buttons
+
more_vert @@ -1438,6 +1688,7 @@ loginDisabled: false, subscriptions: {}, displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT), + showInfoPanel: false, mobilePanel: 'sidepanel', contextMenuVisible: false, contextMenuBounds: null, @@ -1534,6 +1785,7 @@ topicSelected: '', loginDisabled: false, subscriptions: {}, + showInfoPanel: false, mobilePanel: 'sidepanel', contextMenuVisible: false, contextMenuBounds: null, @@ -1561,14 +1813,22 @@ handleTopicSelected: function(topicName) { if (topicName) { // Contact selected - this.setState({topicSelected: topicName, mobilePanel: 'topic-view'}); + this.setState({ + topicSelected: topicName, + mobilePanel: 'topic-view', + showInfoPanel: false + }); } else { // Currently selected contact deleted - this.setState({topicSelected: null, mobilePanel: 'sidepanel'}); + this.setState({ + topicSelected: null, + mobilePanel: 'sidepanel', + showInfoPanel: false + }); } }, // In mobile view user requested to show sidepanel - handleHideMessageView: function() { + handleHideMessagesView: function() { this.setState({mobilePanel: 'sidepanel'}); }, // User clicked on a message send button. @@ -1585,8 +1845,8 @@ var instance = this; Tinode.connect(this.state.serverAddress) .then(function() { - return Tinode.createUserBasic(login_, password_, - {login: "basic", public: public_, private: private_}); + return Tinode.createAccountBasic(login_, password_, + {login: true, public: public_, private: private_}); }) .then(function() { instance.handleLoginSuccessful(instance); @@ -1595,6 +1855,15 @@ instance.setState({errorText: err.message}); }); }, + + handleUpdateAccountRequest: function(password, pub, priv) { + this.handleSidepanelCancel(); + var me = Tinode.getMeTopic(); + me.setMeta({desc: {public: pub, private: priv}}); + if (password) { + Tinode.updateAccountBasic(null, Tinode.getCurrentLogin(), password); + } + }, // User chose Settings menu item. handleSettings: function() { this.setState({sidePanelSelected: this.state.myUserId ? 'edit-account' : 'settings'}); @@ -1619,7 +1888,7 @@ this.setState({sidePanelSelected: 'new-topic'}); }, // Create of a new topic. New P2P topic requires peer's name. - handleNewTopicRequest: function(peerName, vcard) { + handleNewTopicRequest: function(peerName, pub) { var instance = this; // Check if user clicked on an existing connection. @@ -1635,7 +1904,7 @@ var params = {get: {desc: {}, data: {}}}; if (!peerName) { params.get.sub = {}; // Fetch subs for grp topics only. Subscriptions for a p2p topic are useless. - params.set = {desc: {public: vcard}}; + params.set = {desc: {public: pub}}; } topic.subscribe(params).then(function() { instance.setState({ @@ -1649,6 +1918,11 @@ }); } }, + + handleTopicUpdateRequest: function(topicName, pub, priv) { + var topic = Tinode.getTopic(topicName); + 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 */ @@ -1710,8 +1984,7 @@ switch (action) { case "Info": - // TODO(gene): implement topic info - console.log("Topic info not implemented"); + this.handleShowInfoView(); break; case "Clear messages": topic.delMessagesAll(false).then(function(){ @@ -1738,7 +2011,12 @@ break; } }, - + handleShowInfoView: function() { + this.setState({showInfoPanel: true}); + }, + handleHideInfoView: function() { + this.setState({showInfoPanel: false}); + }, render: function() { return (
@@ -1776,6 +2054,7 @@ onSettings={this.handleSettings} onLoginRequest={this.handleLoginRequest} onCreateAccount={this.handleNewAccountRequest} + onUpdateAccount={this.handleUpdateAccountRequest} onTopicSelected={this.handleTopicSelected} onSearchContacts={this.handleSearchContacts} onCreateTopic={this.handleNewTopicRequest} @@ -1785,7 +2064,7 @@ showContextMenu={this.handleShowContextMenu} /> + {this.state.showInfoPanel ? + + : + null + }
); }