diff --git a/README.md b/README.md index 455ef325..827f8eb5 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-we This is work in progress. Bugs are expected. The app was tested in the latest Chrome & Firefox only. -Try it live at http://api.tinode.co/x/example-react-js/. Login as one of `alice`, `bob`, `carol`, `dave`, `frank`. -Password is `123`, e.g. password for `alice` is `alice123`. +Try it (possibly older version) live at http://api.tinode.co/x/example-react-js/. Login as one of `alice`, `bob`, `carol`, `dave`, `frank`. Password is `123`, e.g. password for `alice` is `alice123`. License: [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0) @@ -15,4 +14,13 @@ Photos from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/p Background pattern from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution. -![App screenshot](web-topic.png) +## Responsive design + +### Desktop + +Desktop web: full app + +### Mobile + +Mobile web: contacts +Mobile web: chat diff --git a/css/base.css b/css/base.css index 7a082438..4810e881 100644 --- a/css/base.css +++ b/css/base.css @@ -418,6 +418,7 @@ ul.menu > li.separator:hover { position: relative; width: 3rem; height: 3rem; + font-size: 1.8rem; overflow: hidden; padding: 0; } @@ -566,9 +567,15 @@ ul.menu > li.separator:hover { text-decoration: none; } +#topic-caption-panel .avatar-box { + width: 3rem; + height: 3rem; + margin-right: 0.35rem; +} + #hide-message-view { display: block; - margin-right: 0.5rem; + margin-right: 0.35rem; } #topic-users { @@ -619,6 +626,11 @@ ul.menu > li.separator:hover { margin: 0 0.5rem; } +/* Group chats have avatars on the left. Make left margin smaller */ +.chat-box.group { + margin-left: -0.4rem; +} + .chat-box li { display: flex; margin: 0; @@ -626,7 +638,7 @@ ul.menu > li.separator:hover { align-items: flex-end; } -.chat-box li.right { +.chat-box .right { justify-content: flex-end; } @@ -650,12 +662,12 @@ ul.menu > li.separator:hover { } .chat-box li.single { - margin-top: 0.75rem; - margin-bottom: 0.75rem; + margin-top: 0.25rem; + margin-bottom: 0.25rem; } .chat-box li.first { - margin-top: 0.75rem; + margin-top: 0.25rem; margin-bottom: 0.15rem; } @@ -666,11 +678,19 @@ ul.menu > li.separator:hover { .chat-box li.last { margin-top: 0.15rem; - margin-bottom: 0.75rem; + margin-bottom: 0.25rem; +} + +.chat-box .left .bubble { + background-color: #c5e1a5; +} + +.chat-box .right .bubble { + background-color: #fafafa; } /* Left triangle placed bottom left flush. */ -.chat-box li.left .tip::before { +.chat-box .left .tip::before { content: ''; position: absolute; width: 0; @@ -680,6 +700,7 @@ ul.menu > li.separator:hover { top: auto; bottom: 0; border: 10px solid; + color: #c5e1a5; border-top-color: transparent; border-right-color: transparent; border-bottom-color: inherit; @@ -687,7 +708,7 @@ ul.menu > li.separator:hover { } /* Right triangle placed bottom right flush. */ -.chat-box li.right .tip::before { +.chat-box .right .tip::before { content: ''; position: absolute; display: flex; @@ -699,6 +720,7 @@ ul.menu > li.separator:hover { top: auto; bottom: 0; border: 10px solid; + color: #fafafa; border-top-color: transparent; border-right-color: transparent; border-bottom-color: inherit; @@ -706,30 +728,36 @@ ul.menu > li.separator:hover { } .chat-box .avatar-box { - width: 3rem; - text-align: center; -} - -.chat-box .avatar-box img { - width: 2.4rem; - display: block; position: relative; - left: 0.3rem; - border-radius: 33%; + width: 1.4rem; + height: 1.4rem; + font-size: 0.8rem; + margin-bottom: 0.8rem; + margin-right: 0.8rem; } .chat-box .bubble p { - display: flex; - justify-content: space-between; + color: #212121; margin: 0; } +/* The timestamp is a floating element. Need to ensure p is expanded + * to accomodate it. It's usually called clearfix. */ +.chat-box .bubble p:after { + content: "."; + display: block; + height: 0; + clear: both; + visibility: hidden; +} .chat-box .timestamp { display: inline-block; + position:relative; font-size: 80%; color: #777; - padding-left: 1.5em; - padding-top: 0.5em; + padding-left: 1rem; + top: 0.5em; + float: right; white-space: nowrap; } @@ -745,6 +773,14 @@ ul.menu > li.separator:hover { margin-top: 0.75rem; } +/* The name of the person who sent the message + * (group chat only) + */ +.chat-box .author { + font-size: 80%; + color:#777; +} + /* END of talk bubbles */ /* END of topic/mesages view */ @@ -881,69 +917,126 @@ ul.tabbar li.active > a:after { /* Letter tile base style */ .lettertile { - display: flex; - color: rgba(250, 250, 250, 0.8); /* #FAFAFA, gray 50 */ - font-size: 1.8rem; - line-height: 1; - width: 3rem; - height: 3rem; + width: 100%; + padding-bottom: 100%; border-radius: 50%; + position: relative; +} + +/* Responsive square box for text tiles */ +.lettertile > div { + display: flex; + position: absolute; + top: 0; bottom: 0; left: 0; right: 0; text-transform: uppercase; align-items: center; justify-content: center; text-align: center; + border-radius: 50%; + line-height: 1; + color: rgba(250, 250, 250, 0.8); /* default text color #FAFAFA, gray 50 */ } /* Colors of letter tiles */ .lettertile.color0 { - background-color: #c5e1a5; -} -.lettertile.color1 { background-color: #ef9a9a; } -.lettertile.color2 { +.lettertile.color1 { background-color: #90caf9; } -.lettertile.color3 { - background-color: #fff59d; -} -.lettertile.color4 { +.lettertile.color2 { background-color: #b0bec5; } -.lettertile.color5 { - background-color: #f48fb1; -} -.lettertile.color6 { +.lettertile.color3 { background-color: #b39ddb; } -.lettertile.color7 { - background-color: #9fa8da; -} -.lettertile.color8 { +.lettertile.color4 { background-color: #ffab91; } -.lettertile.color9 { - background-color: #ffe082; -} -.lettertile.color10 { +.lettertile.color5 { background-color: #a5d6a7; } -.lettertile.color11 { - background-color: #bcaaa4; -} -.lettertile.color12 { +.lettertile.color6 { background-color: #eeeeee; } +.lettertile.color7 { + background-color: #e6ee9c; +} +.lettertile.color8 { + background-color: #c5e1a5; +} +.lettertile.color9 { + background-color: #fff59d; +} +.lettertile.color10 { + background-color: #f48fb1; +} +.lettertile.color11 { + background-color: #9fa8da; +} +.lettertile.color12 { + background-color: #ffe082; +} .lettertile.color13 { - background-color: #80deea; + background-color: #bcaaa4; } .lettertile.color14 { - background-color: #e6ee9c; + background-color: #80deea; } .lettertile.color15 { background-color: #ce93d8; } +/* Darker version of letter tile colors */ +.lettertile.dark-color0 { + background-color: #C62828; +} +.lettertile.dark-color1 { + background-color: #AD1457; +} +.lettertile.dark-color2 { + background-color: #6A1B9A; +} +.lettertile.dark-color3 { + background-color: #4527A0; +} +.lettertile.dark-color4 { + background-color: #283593; +} +.lettertile.dark-color5 { + background-color: #1565C0; +} +.lettertile.dark-color6 { + background-color: #0277BD; +} +.lettertile.dark-color7 { + background-color: #00838F; +} +.lettertile.dark-color8 { + background-color: #00695C; +} +.lettertile.dark-color9 { + background-color: #2E7D32; +} +.lettertile.dark-color10 { + background-color: #558B2F; +} +.lettertile.dark-color11 { + background-color: #9E9D24; +} +.lettertile.dark-color12 { + background-color: #F9A825; +} +.lettertile.dark-color13 { + background-color: #FF8F00; +} +.lettertile.dark-color14 { + background-color: #EF6C00; +} +.lettertile.dark-color15 { + background-color: #D84315; +} + /* Material icons resized from the default 24pt */ .material-icons { font-size: 1.4rem; diff --git a/index.html b/index.html index 0368c083..658e81d7 100644 --- a/index.html +++ b/index.html @@ -51,16 +51,17 @@ // Short representation of time in the past. function shortDateFormat(then) { + var locale = window.navigator.userLanguage || window.navigator.language; var now = new Date(); if (then.getFullYear() == now.getFullYear()) { if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) { - return then.toLocaleTimeString(undefined, {hour12: false, hour: '2-digit', minute: '2-digit'}); + return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'}); } else { - return then.toLocaleDateString(undefined, + return then.toLocaleDateString(locale, {hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); } } - return then.toLocaleDateString(undefined, + return then.toLocaleDateString(locale, {hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); } @@ -127,103 +128,59 @@ e.preventDefault(); e.stopPropagation(); this.props.hide(); - this.props.onResult(this.props.items[e.currentTarget.dataset.id]); + this.props.onAction(this.props.topic, this.props.items[e.currentTarget.dataset.id]); }, + render: function() { if (!this.props.visible) { return null; } + + // Ensure that menu is inside the app-container. + var hSize = 12 * REM_SIZE; + var vSize = REM_SIZE * (0.7 + this.props.items.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: this.props.position.left + 'px', - top: this.props.position.top + 'px' + left: left + "px", + top: top + "px" }; var count = 0; - var menuItems = []; var instance = this; + var menu = []; this.props.items.map(function(item) { - if (item === "-") { - menuItems.push( + menu.push( + item === "-" ?
  • - ); - } else { - menuItems.push( + :
  • {item}
  • - ); - } + ); count++; }); return ( ); } }); - - var ContextMenuMixin = { - handleContextClick: function(e) { - e.preventDefault(); - e.stopPropagation(); - this.props.showContextMenu(e.pageY, e.pageX, this.getMenuItems(), this.handleMenuAction); - }, - handleMenuAction: function(action) { - var topic = Tinode.getTopic(this.props.topic); - var metaParam = function(p) { - var am = topic.getAccessMode(); - am.updateWant(p); - topic.setMeta({sub: {mode: am.getWant()}}); - } - switch (action) { - case "Edit...": - // TODO(gene): implement topic editing - console.log("Topic editing not implemented"); - break; - case "Clear messages": - topic.delMessagesAll(false).then(function(){ - console.log("refresh message list"); - }); - break; - case "Mute": - metaParam('-P'); - break; - case "Unmute": - metaParam('+P'); - break; - case "Block": - metaParam('-J'); - break; - case "Unblock": - metaParam('+J'); - break; - case "Delete": - topic.delTopic(); - break; - default: break; - } - }, - getMenuItems: function() { - var acs = Tinode.getMeTopic().getAccessMode(this.props.topic); - if (Tinode.getTopicType(this.props.topic) === "grp") { - return ["Edit...", "Clear messages", acs.isMuted() ? "Unmute" : "Mute", - !acs.isJoiner() ? "Unblock" : "Block", "Delete"]; - } else { - return ["Clear messages", acs.isMuted() ? "Unmute" : "Mute", - !acs.isJoiner() ? "Unblock" : "Block", "Delete"]; - } - } - }; /* END Popup/dropdown menu */ /* BEGIN Lettertile: Avatar box: either a bitmap or a letter tile or a stock icon. */ - var LetterTile = React.createClass({ - render: function() { - var avatar = null; + class LetterTile extends React.PureComponent { + render() { + var avatar; 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); - avatar =
    {letter}
    + avatar = (
    {letter}
    ) } else { avatar = (Tinode.getTopicType(this.props.topic) === "grp") ? group : @@ -231,30 +188,38 @@ } } else if (this.props.avatar) { avatar = avatar; + } else { + avatar = null; } - return (
    {avatar}
    ); + return avatar; } - }); + } /* END Lettertile */ /* BEGIN Combobox for selecting host name */ - var HostSelector = React.createClass({ - getInitialState: function() { - return { + class HostSelector extends React.PureComponent { + constructor(props) { + super(props); + this.state = { hostName: this.props.serverAddress, changed: false }; - }, - handleHostNameChange: function(e) { + this.handleHostNameChange = this.handleHostNameChange.bind(this); + this.handleEditingFinished = this.handleEditingFinished.bind(this); + } + + handleHostNameChange(e) { this.setState({hostName: e.target.value, changed: true}); - }, - handleEditingFinished: function() { + } + + handleEditingFinished() { if (this.state.changed) { this.setState({changed: false}); this.props.onServerAddressChange(this.state.hostName.trim()); } - }, - render: function() { + } + + render() { var hostOptions = []; for (var key in KNOWN_HOSTS) { var item = KNOWN_HOSTS[key]; @@ -273,7 +238,7 @@ ); } - }); + } /* END Combobox for selecting host name */ /* BEGIN Login: a login form */ @@ -701,12 +666,18 @@ /* BEGIN Contact list (list of topics) */ /* A single topic */; var Contact = React.createClass({ - mixins: [ContextMenuMixin], handleClick: function(e) { e.preventDefault(); e.stopPropagation(); this.props.onSelected(this.props.topic); }, + + handleContextClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.showContextMenu(this.props.topic, e.pageY, e.pageX); + }, + render: function() { var title = this.props.title; if (!title) { @@ -741,9 +712,10 @@ }); /* - */ @@ -755,9 +727,7 @@ var count = this.props.count > 9 ? "9+" : this.props.count; showUnreadBadge = {count}; } - return ( - {showUnreadBadge} - ); + return showUnreadBadge; } }); @@ -1068,41 +1038,32 @@ READ: 2 }; - // Material colors, shade #200; 16 colors. - var messageColors = [ - {bgColor: "#c5e1a5", color: "#212121"}, {bgColor: "#ef9a9a", color: "#212121"}, - {bgColor: "#90caf9", color: "#212121"}, {bgColor: "#fff59d", color: "#212121"}, - {bgColor: "#b0bec5", color: "#212121"}, {bgColor: "#f48fb1", color: "#212121"}, - {bgColor: "#b39ddb", color: "#212121"}, {bgColor: "#9fa8da", color: "#212121"}, - {bgColor: "#ffab91", color: "#212121"}, {bgColor: "#ffe082", color: "#212121"}, - {bgColor: "#a5d6a7", color: "#212121"}, {bgColor: "#bcaaa4", color: "#212121"}, - {bgColor: "#eeeeee", color: "#212121"}, {bgColor: "#80deea", color: "#212121"}, - {bgColor: "#e6ee9c", color: "#212121"}, {bgColor: "#ce93d8", color: "#212121"} - ]; - // Colors for user's own messages - var selfColor = {bgColor: "#fafafa", color: "#212121"}; var Message = React.createClass({ render: function() { - var liClass = (this.props.response ? "left" : "right") + " " + this.props.sequence; - var colors; - colors = this.props.response ? { - backgroundColor: messageColors[this.props.userIndex].bgColor, - borderColor: messageColors[this.props.userIndex].bgColor, - color: messageColors[this.props.userIndex].color - } : { - backgroundColor: selfColor.bgColor, - borderColor: selfColor.bgColor, - color: selfColor.color - }; + 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")); return ( -
  • -
    -

    {this.props.content} - -

    +
  • + {this.props.userFrom && this.props.response ? +
    + {fullDisplay ? + : + null} +
    : + null} +
    +
    +

    {this.props.content} + +

    +
    + {fullDisplay ?
    {this.props.userName}
    : null}
  • ); @@ -1128,7 +1089,7 @@ } return ( - + {timestamp}{'\u00a0'}{marker} ); @@ -1136,12 +1097,12 @@ }); var MessagesView = React.createClass({ - mixins: [ContextMenuMixin], getInitialState: function() { return { messages: [], topic: '', - title: '' + title: '', + avatar: null }; }, // Scroll last message into view on component update e.g. on message received. @@ -1195,7 +1156,10 @@ }, handleDescChange: function(desc) { if (desc.public) { - this.setState({title: desc.public.fn}); + this.setState({ + title: desc.public.fn, + avatar: makeImageUrl(desc.public.photo) + }); } }, handleSubsUpdated: function(uids) { @@ -1234,11 +1198,18 @@ console.log("other change in topic: " + info.what); } }, + handleContextClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.showContextMenu(this.state.topic, e.pageY, e.pageX); + }, + render: function() { var component = null; if (this.state.topic) { var messageNodes = []; var topic = Tinode.getTopic(this.state.topic); + var groupTopic = topic.getType() === "grp"; var previousFrom = null; for (var i=0; i + userFrom={userFrom} userName={userName} userAvatar={userAvatar} + sequence={sequence} received={msgReceived} key={msg.seq} /> ); } @@ -1282,15 +1266,23 @@ // TODO(gene): also handle user agent in c.seen.ua } } - + var avatar = this.state.avatar || true; component = (
    {this.props.displayMobile ? arrow_back - : + + : null} + {this.props.displayMobile ? +
    + + +
    + : null}
    {this.state.title}
    {lastSeen}
    @@ -1303,7 +1295,7 @@
    -
      +
        {messageNodes}
    @@ -1447,6 +1439,11 @@ subscriptions: {}, displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT), mobilePanel: 'sidepanel', + contextMenuVisible: false, + contextMenuBounds: null, + contextMenuClickAt: null, + contextMenuTopic: '', + contextMenuItems: [], serverVersion: 'no connection' }; }, @@ -1539,9 +1536,10 @@ subscriptions: {}, mobilePanel: 'sidepanel', contextMenuVisible: false, - contextMenuPosition: {top: 0, left: 0}, - contextMenuItems: null, - handleContextMenuClick: undefined, + contextMenuBounds: null, + contextMenuClickAt: null, + contextMenuTopic: '', + contextMenuItems: [], serverVersion: 'no connection' }); }, @@ -1663,31 +1661,84 @@ handleDialogCancel: function() { this.setState({dialogSelected: null}); }, - handleShowContextMenu: function(y, x, items, callback) { - var bounds = ReactDOM.findDOMNode(this).getBoundingClientRect(); - // Ensure that menu is inside the app-container. - var hSize = 12 * REM_SIZE; - var vSize = REM_SIZE * (0.7 + items.length * 2.5); - var pos = { - left: (bounds.right - x < hSize) ? (x - bounds.left - hSize) : (x - bounds.left), - top: (bounds.bottom - y < vSize) ? (y - bounds.top - vSize) : (y - bounds.top) - }; - + handleShowContextMenu: function(topicName, y, x) { + var muted = false, blocked = false; + var topic = Tinode.getTopic(topicName); + if (topic) { + var acs = topic.getAccessMode(); + muted = acs && acs.isMuted(); + blocked = acs && !acs.isJoiner(); + } this.setState({ contextMenuVisible: true, - contextMenuPosition: pos, - contextMenuItems: items, - handleContextMenuClick: callback + contextMenuClickAt: {x: x, y: y}, + contextMenuTopic: topicName, + contextMenuItems: [ + "Info", + "Clear messages", + muted ? "Unmute" : "Mute", + blocked ? "Unblock" : "Block", + "Delete" + ], + contextMenuBounds: ReactDOM.findDOMNode(this).getBoundingClientRect() }); }, handleHideContextMenu: function() { this.setState({ contextMenuVisible: false, - contextMenuPosition: {top: 0, left: 0}, - contextMenuItems: null, - handleContextMenuClick: undefined + contextMenuClickAt: null, + contextMenuTopic: '', + contextMenuBounds: null }); }, + // Menu click. + handleContextMenuAction: function(topicName, action) { + if (!topicName || !action) { + return; + } + + var topic = Tinode.getTopic(topicName); + if (!topic) { + return; + } + + var metaParam = function(want) { + var am = topic.getAccessMode(); + am.updateWant(want); + topic.setMeta({sub: {mode: am.getWant()}}); + } + + switch (action) { + case "Info": + // TODO(gene): implement topic info + console.log("Topic info not implemented"); + break; + case "Clear messages": + topic.delMessagesAll(false).then(function(){ + console.log("refresh message list"); + }); + break; + case "Mute": + metaParam('-P'); + break; + case "Unmute": + metaParam('+P'); + break; + case "Block": + metaParam('-J'); + break; + case "Unblock": + metaParam('+J'); + break; + case "Delete": + topic.delTopic(); + break; + default: + console.log("Unknown menu action '" + action + "'"); + break; + } + }, + render: function() { return (
    @@ -1697,11 +1748,13 @@ onCancel={this.handleDialogCancel} /> + topic={this.state.contextMenuTopic} + items={this.state.contextMenuItems} + hide={this.handleHideContextMenu} + onAction={this.handleContextMenuAction} />