diff --git a/css/base.css b/css/base.css index 0224ad66..aee3b0a2 100644 --- a/css/base.css +++ b/css/base.css @@ -347,35 +347,35 @@ ul.menu > li.separator:hover { } /* List of contacts */ -#contacts-panel { +.contacts-panel { overflow-y: auto; margin-top: 1rem;: margin-bottom: auto; height: 100%; } -#contact-box { +.contact-box { list-style: none; } -#contact-box > li { +.contact-box > li { display: flex; padding: 0.35rem 0.25rem 0.5rem 0.35rem; min-height: 3.5rem; } -#contact-box > li:hover { +.contact-box > li:hover { background-color: #eceff1; /* blue-gray 50 highlight */ } -#contact-box > li.selected { +.contact-box > li.selected { background-color: #cfd8dc; /* blue-gray 100 highlight */ } -#contact-box .contact-title { +.contact-box .contact-title { font-size: 1.05rem; } /* The down arrow head - trigger for context menu in contacts list */ -#contact-box li .menuTrigger { +.contact-box li .menuTrigger { position: relative; margin-left: auto; width: 1.5rem; @@ -383,19 +383,19 @@ ul.menu > li.separator:hover { border-radius: 50%; background-color: transparent; } -#contact-box li:hover .menuTrigger { +.contact-box li:hover .menuTrigger { background-color: white; } -#contact-box li .menuTrigger a { +.contact-box li .menuTrigger a { color: transparent; } -#contact-box li:hover .menuTrigger a { +.contact-box li:hover .menuTrigger a { color: #757575; } /* Avatar and online-offline indicator */ -#contact-box .avatar-box { +.contact-box .avatar-box { margin-right: 0.35rem; position: relative; width: 3rem; @@ -403,25 +403,32 @@ ul.menu > li.separator:hover { overflow: hidden; padding: 0; } -#contact-box .avatar { +.contact-box .avatar { width: 3rem; height: auto; border-radius: 50%; } +.contact-box .text-box { + display: flex; + flex-direction: column; + justify-content: space-around; +} /* Online-offline indicator positioned inside avatar-box */ -#contact-box .online, .offline { - font-size: 1.5em; - line-height: 1; +.contact-box .online, .offline { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; position: absolute; - right: -0.1rem; - bottom: -0.1rem; + right: 0; + bottom: 0; } -#contact-box .online { - color: #4caf50; +.contact-box .online { + background-color: #4caf50; } -#contact-box .offline { - color: #888; +.contact-box .offline { + background-color: #888; } /* END of avatar box */ @@ -840,6 +847,7 @@ ul.menu > li.separator:hover { border-radius: 1em; padding: 0.25em; min-width: 1.75em; + margin-left: 0.5rem; text-align: center; } diff --git a/index.html b/index.html index e8e611c5..5cbdc6e5 100644 --- a/index.html +++ b/index.html @@ -66,68 +66,6 @@ return photo ? 'data:image/' + photo.type + ';base64,' + photo.data : null; } - /* BEGIN Popup/dropdown menu */ - var Menu = React.createClass({ - getInitialState: function() { - return { - visible: this.props.visible - }; - }, - componentWillUnmount: function() { - document.removeEventListener('mousedown', this.handlePageClick, false); - }, - componentWillReceiveProps: function(nextProps) { - this.setState({visible: nextProps.visible}); - if (nextProps.visible) { - document.addEventListener('mousedown', this.handlePageClick, false); - } else { - document.removeEventListener('mousedown', this.handlePageClick, false); - } - }, - handlePageClick: function(e) { - if (ReactDOM.findDOMNode(this).contains(e.target)) { - console.log("click in menu, exiting"); - return; - } - e.stopPropagation(); - document.removeEventListener('mousedown', this.handlePageClick); - console.log("click outside of menu"); - this.setState({visible: false}); - }, - handleClick: function(e) { - e.preventDefault(); - e.stopPropagation(); - this.setState({visible: false}); - this.props.onResult(this.props.topic, this.props.items[e.currentTarget.dataset.id]); - }, - render: function() { - if (!this.state.visible) { - return null; - } - var count = 0; - var menuItems = []; - var instance = this; - this.props.items.map(function(item) { - if (item === "-") { - menuItems.push( -
  • - ); - } else { - menuItems.push( -
  • {item}
  • - ); - } - count++; - }); - return ( - - ); - } - }); - /* END Popup/dropdown menu */ - /* BEGIN Login: a login form */ var LoginView = React.createClass({ getInitialState: function() { @@ -224,9 +162,9 @@ fn: this.state.name.trim(), }; if (this.state.imageDataUrl) { - dataStart = this.state.imageDataUrl.indexOf(","); + var dataStart = this.state.imageDataUrl.indexOf(","); vcard.photo = { - data: this.state.imageDataUrl.substring(dataStart), + data: this.state.imageDataUrl.substring(dataStart+1), type: "jpg" }; } @@ -288,8 +226,11 @@ } ctx.imageSmoothingEnabled = true; ctx.drawImage(this, sx, sy, size, size, 0, 0, 64, 64); - instance.setState({dataUrl: canvas.toDataURL("image/jpg")}); + var bits = canvas.toDataURL("image/jpg"); canvas = null; + + instance.setState({dataUrl: bits}); + instance.props.onImageChanged(bits); }; img.src = URL.createObjectURL(e.target.files[0]); }, @@ -310,94 +251,6 @@ }); /* END Account registration */ - /* BEGIN Create new topic and invite users or send an invite */ - var NewTopicView = React.createClass({ - getInitialState: function() { - return { - tabSelected: "p2p", - fn: '', // full/formatted name - search: null, // search string - imageDataUrl: null - }; - }, - componentWillMount: function() { - var fnd = Tinode.getFndTopic(); - fnd.onSubsUpdated = this.fndSubsUpdated; - fnd.subscribe() - .then(function() { - fnd.setMeta({desc: {private: ["tel:17025550005"]}}); - }) - .then(function() { - fnd.getMeta({sub: {}}); - }); - }, - componentWillUnmount: function() { - var fnd = Tinode.getFndTopic(); - fnd.onSubsUpdated = undefined; - }, - fndSubsUpdated: function(keys) { - console.log(keys); - //this.forceUpdate(); - }, - handleTabClick: function(e) { - e.preventDefault(); - this.setState({tabSelected: e.currentTarget.dataset.id}); - }, - handleFnChange: function(e) { - this.setState({fn: e.target.value}); - }, - handleImageChanged: function(img) { - this.setState({imageDataUrl: img}); - }, - handleGroupSubmit: function(e) { - e.preventDefault(); - }, - render: function() { - var submitClasses = "blue"; - if (this.props.disabled) { - submitClasses += " disabled"; - } - return ( -
    -
    - -
    - {this.state.tabSelected === "grp" ? -
    -
    -
    - -
    - -
    -
    - -
    -
    - : null} -
    -
    - - - search - -
    -
    -
    - ); - } - }); - /* END Create new topic and invite users or send an invite */ - /* BEGIN Tinode config panel */ var SettingsView = React.createClass({ getInitialState: function() { @@ -571,13 +424,14 @@ this.props.state === 'contacts' ? : this.props.state === 'new-topic' ? : + onCreateTopic={this.props.onCreateTopic} /> : null} @@ -614,29 +468,27 @@ /* END EditAccount */ /* BEGIN Contact list (list of topics) */ - /* A single topic */ - var contactMenuItems = ["Delete chat", "Mute", "Block"]; + /* A single topic */; var Contact = React.createClass({ - getInitialState: function() { - return { - menuShownFor: null - }; - }, - handleClick: function() { + handleClick: function(e) { + e.preventDefault(); + e.stopPropagation(); this.props.onSelected(this.props.topic); }, handleContextClick: function(e) { e.preventDefault(); e.stopPropagation(); - this.setState({menuShownFor: this.props.topic}); + console.log("pageX=" + e.pageX + ", pageY=" + e.pageY); + this.props.showContextMenu(e.pageY, e.pageX, ["Delete chat", "Mute", "Block"], this.handleMenuAction); }, - handleMenuAction: function(topic, action) { - console.log("Menu clicked '" + action + "' for topic " + topic); - this.setState({menuShownFor: null}); + handleMenuAction: function(action) { + console.log("Menu clicked '" + action + "' for topic " + this.props.topic); }, render: function() { var title = this.props.title; - if (title.length > 30) { + if (!title) { + title = unknown; + } else if (title.length > 30) { title = title.substring(0, 28) + "..."; } var selected = (this.props.selected === this.props.topic) ? "selected" : null; @@ -649,26 +501,31 @@
  • {avatar} - ● + {this.props.now ? : null}
    -
    - {title} -

    {this.props.comment}

    +
    +
    {title} + {this.props.unread > 0 ? : null}
    + {this.props.comment ?
    {this.props.comment}
    : null}
    - - - arrow_drop_down - - - + {this.props.showContextMenu ? + + + expand_more + + : null}
  • ); } }); + /* + + */ + /* The counter of unread messages in the topic */ Contact.Unread = React.createClass({ render: function() { @@ -697,6 +554,12 @@ me.onData = this.tnInvite; me.onSubsUpdated = this.tnMeSubsUpdated; }, + componentWillUnmount: function() { + var me = Tinode.getMeTopic(); + me.onContactUpdate = undefined; + me.onData = undefined; + me.onSubsUpdated = undefined; + }, tnMeContactUpdate: function(what, cont) { if (what === "on" || what === "off" || what === "read") { this.forceUpdate(); @@ -719,7 +582,7 @@ tnMeSubsUpdated: function(names) { this.forceUpdate(); }, - onSelectionChange: function(sel) { + handleSelectionChange: function(sel) { this.setState({selected: sel}); this.props.onTopicSelected(sel); }, @@ -732,20 +595,21 @@ } contactNodes.push( ); }, this); return ( -
    -
      +
      +
        {contactNodes}
      @@ -754,6 +618,176 @@ }); /* END Contact list */ + /* BEGIN Create new topic and invite users or send an invite */ + var NewTopicView = React.createClass({ + getInitialState: function() { + return { + tabSelected: "p2p", + contactSelected: null + }; + }, + componentWillMount: function() { + var fnd = Tinode.getFndTopic(); + fnd.onSubsUpdated = this.fndSubsUpdated; + fnd.subscribe() + .then(function() { + fnd.getMeta({sub: {}}); + }); + }, + componentWillUnmount: function() { + var fnd = Tinode.getFndTopic(); + fnd.onSubsUpdated = undefined; + }, + fndSubsUpdated: function() { + //console.log(keys); + this.forceUpdate(); + }, + handleTabClick: function(e) { + e.preventDefault(); + this.setState({tabSelected: e.currentTarget.dataset.id, contactSelected: null}); + }, + handleContactSelected: function(sel) { + if (this.state.tabSelected === "p2p") { + this.props.onCreateTopic(sel, undefined); + } else { + console.log("handle group invite " + sel); + } + }, + handleGroupSubmit: function(name, dataUrl) { + //this.setState({errorCleared: false}); + 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); + }, + render: function() { + return ( +
      +
      + +
      + {this.state.tabSelected === "grp" ? + : + } +
      + ); + } + }); + + NewTopicView.Group = React.createClass({ + getInitialState: function() { + return { + fn: '', // full/formatted name + imageDataUrl: null + }; + }, + handleFnChange: function(e) { + this.setState({fn: e.target.value}); + }, + handleImageChanged: function(img) { + console.log(img); + this.setState({imageDataUrl: img}); + }, + handleSubmit: function(e) { + e.preventDefault(); + this.props.onSubmit(this.state.fn.trim(), this.state.imageDataUrl); + }, + render: function() { + var submitClasses = "blue"; + if (this.props.disabled) { + submitClasses += " disabled"; + } + return ( +
      +
      +
      + +
      + +
      +
      + +
      +
      + ); + } + }); + + NewTopicView.SelectContacts = React.createClass({ + getInitialState: function() { + return { + search: null, + selectedUser: null + }; + }, + handleSelectionChange: function(sel) { + if (this.props.type === "p2p") { + this.props.onContactSelected(sel); + } else { + console.log(sel); + } + }, + handleSearchChange: function(e) { + this.setState({search: e.target.value}); + }, + handleSearch: function(e) { + e.preventDefault(); + console.log("will search for '" + this.state.search + "'"); + }, + render: function() { + var contactNodes = []; + Tinode.getFndTopic().subscribers(function(s) { + contactNodes.push( + + ); + }, this); + return ( +
      +
      + +
      +
      +
        + {contactNodes} +
      +
      +
      + ); + } + }); + + /* END Create new topic and invite users or send an invite */ + /* BEGIN Conversation panel */ var Received = { UNDEF: -2, @@ -886,10 +920,12 @@ } }, handleDescChange: function(desc) { - this.setState({title: desc.public.fn}); + if (desc.public) { + this.setState({title: desc.public.fn}); + } }, handleSubsUpdated: function(uids) { - + console.log("topic subscriptions updated"); }, handleNewMessage: function(msg) { this.setState({messages: this.state.messages.concat(msg)}); @@ -1098,6 +1134,74 @@ }); /* End of modal dialog wrapper */ + /* BEGIN Popup/dropdown menu */ + var ContextMenu = React.createClass({ + componentWillUnmount: function() { + this.toggle(false); + }, + componentWillReceiveProps: function(nextProps) { + this.toggle(nextProps.visible); + }, + toggle: function(visible) { + if (visible) { + document.addEventListener('mousedown', this.handlePageClick, false); + document.addEventListener('keyup', this.handleEscapeKey, false); + } else { + document.removeEventListener('mousedown', this.handlePageClick, false); + document.removeEventListener('keyup', this.handleEscapeKey, false); + } + }, + handlePageClick: function(e) { + if (ReactDOM.findDOMNode(this).contains(e.target)) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.props.hide(); + }, + handleEscapeKey: function(e) { + if (e.keyCode === 27) { + this.props.hide(); + } + }, + handleClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.hide(); + this.props.onResult(this.props.items[e.currentTarget.dataset.id]); + }, + render: function() { + if (!this.props.visible) { + return null; + } + var position = { + left: this.props.position.left + 'px', + top: this.props.position.top + 'px' + }; + var count = 0; + var menuItems = []; + var instance = this; + this.props.items.map(function(item) { + if (item === "-") { + menuItems.push( +
    • + ); + } else { + menuItems.push( +
    • {item}
    • + ); + } + count++; + }); + return ( +
        + {menuItems} +
      + ); + } + }); + /* END Popup/dropdown menu */ + /* The top-level class to hold all fuinctionality together */ var AppView = React.createClass({ getInitialState: function() { @@ -1106,6 +1210,7 @@ sidePanelTitle: null, sidePanelAvatar: null, dialogSelected: null, + contextMenuVisible: false, loggedIn: false, errorText: '', topicSelected: '', @@ -1142,7 +1247,11 @@ topicSelected: '', loginDisabled: false, subscriptions: {}, - mobilePanel: 'sidepanel' + mobilePanel: 'sidepanel', + contextMenuVisible: false, + contextMenuPosition: {top: 0, left: 0}, + contextMenuItems: null, + handleContextMenuClick: undefined }); }, // User clicked Login button in the side panel. @@ -1178,16 +1287,6 @@ Tinode.getTopic(data.topic).noteRecv(data.seq); }, RECEIVED_DELAY); }, - // Registration of a new account. - handleNewAccount: function(login_, password_, public_, private_) { - var instance = this; - Tinode.connect().then(function() { - return Tinode.createUserBasic(login_, password_, - {login: "basic", public: public_, private: private_}); - }).catch(function(err) { - instance.setState({errorText: err.message}); - }); - }, // User clicked on a contact in the side panel. handleTopicSelected: function(topicName) { this.setState({topicSelected: topicName, mobilePanel: 'topic-view'}); @@ -1202,9 +1301,19 @@ topic.publish(msg); }, // User chose a Sign Up menu item. - handleSignUp: function() { + handleNewAccount: function() { this.setState({sidePanelSelected: 'register'}); }, + // Actual registration of a new account. + handleNewAccountRequest: function(login_, password_, public_, private_) { + var instance = this; + Tinode.connect().then(function() { + return Tinode.createUserBasic(login_, password_, + {login: "basic", public: public_, private: private_}); + }).catch(function(err) { + instance.setState({errorText: err.message}); + }); + }, // User chose Settings menu item. handleSettings: function() { this.setState({sidePanelSelected: this.state.loggedIn ? 'edit-account' : 'settings'}); @@ -1217,12 +1326,53 @@ this.setState({sidePanelSelected: 'login'}); } }, + // User clicked a (+) menu item. handleNewTopic: function() { this.setState({sidePanelSelected: 'new-topic'}); }, + // Create of a new topic. New P2P topic requires peer's name. + handleNewTopicRequest: function(peerName, vcard) { + var instance = this; + var topic = peerName ? Tinode.getTopic(peerName) : Tinode.newTopic(); + var params = {get: {desc: {}, sub: {}, data: {}}}; + params.set = peerName ? undefined : {set: {desc: {public: vcard}}}; + topic.subscribe(params).then(function() { + if (peerName) { + instance.setState({ + errorText: '', + topicSelected: topic.name, + sidePanelSelected: 'contacts', + mobilePanel: 'topic-view' + }); + } else { + console.log("new group topic created: " + topic.name); + } + }).catch(function(err) { + instance.setState({errorText: err.message}); + }); + }, handleDialogCancel: function() { this.setState({dialogSelected: null}); }, + handleShowContextMenu: function(y, x, items, callback) { + console.log("SHOW ContextMenu called"); + var bounds = ReactDOM.findDOMNode(this).getBoundingClientRect(); + this.setState({ + contextMenuVisible: true, + contextMenuPosition: {top: (y - bounds.top), left: (x - bounds.left)}, + contextMenuItems: items, + handleContextMenuClick: callback + }); + }, + handleHideContextMenu: function() { + console.log("HIDE ContextMenu called"); + this.setState({ + contextMenuVisible: false, + contextMenuPosition: {top: 0, left: 0}, + contextMenuItems: null, + handleContextMenuClick: undefined + }); + }, render: function() { return (
      @@ -1231,6 +1381,13 @@ onOk={this.handleDialogCancel} onCancel={this.handleDialogCancel} /> + + + onCancel={this.handleSidepanelCancel} + showContextMenu={this.handleShowContextMenu} />