diff --git a/clientapp/helpers/chatHelpers.js b/clientapp/helpers/chatHelpers.js new file mode 100644 index 0000000..807fd91 --- /dev/null +++ b/clientapp/helpers/chatHelpers.js @@ -0,0 +1,107 @@ +/*global app, $*/ +"use strict"; + +var _ = require('underscore'); + + +module.exports = { + initializeScroll: function () { + $(this.$scrollContainer).scroll(_.bind(_.throttle(this.handleScroll, 300), this)); + this.pinnedToBottom = true; + this.lastScrollTop = 0; + }, + scrollPageLoad: function () { + if (typeof this.lastScrollPosition === 'number') { + this.scrollTo(this.lastScrollPosition); + } else { + this.scrollToBottom(); + } + }, + scrollPageUnload: function () { + this.savePosition(); + this.trimOldChats(); + }, + savePosition: function () { + this.lastScrollPosition = this.pinnedToBottom ? '' : this.$scrollContainer.scrollTop(); + }, + trimOldChats: function () { + var self = this; + var removedIds; + if (this.pinnedToBottom) { + _.delay(function () { + removedIds = self.collection.trimOlderChats(); + removedIds.forEach(function (id) { + self.$('#chat' + id).remove(); + }); + }, 500); + } + }, + handleScroll: function (e) { + var scrollTop = this.$scrollContainer[0].scrollTop; + var direction = scrollTop > this.lastScrollTop ? 'down' : 'up'; + if (direction === 'up' && !this.isBottom()) { + this.pinnedToBottom = false; + } else if (this.isBottom()) { + this.handleAtBottom(); + } + this.lastScrollTop = scrollTop; + }, + scrollIfPinned: function (animate) { + if (this.pinnedToBottom) this.scrollToBottom(animate); + }, + handleAtBottom: function () { + if (this.isVisible()) { + this.pinnedToBottom = true; + } + }, + isBottom: function () { + var scrollTop = this.$scrollContainer[0].scrollTop; + var scrollHeight = this.$scrollContainer[0].scrollHeight; + var height = this.$scrollContainer.height(); + var fromBottom = scrollHeight - (scrollTop + height); + return fromBottom < 40 || $('body').is(':animated'); + }, + resizeInput: function () { + var height; + var scrollHeight; + var newHeight; + var newPadding; + var paddingDelta; + var maxHeight = 102; + + this.$chatInput.removeAttr('style'); + height = this.$chatInput.height() + 10, + scrollHeight = this.$chatInput.get(0).scrollHeight, + newHeight = scrollHeight + 2; + + if (newHeight > maxHeight) newHeight = maxHeight; + if (newHeight > height) { + this.$chatInput.css('height', newHeight); + newPadding = newHeight + 21; + paddingDelta = newPadding - parseInt(this.$messageList.css('paddingBottom'), 10); + if (!!paddingDelta) { + this.$messageList.css('paddingBottom', newPadding); + } + } + }, + scrollTo: function (height, animate) { + if (animate) { + this.$scrollContainer.animate({ + scrollTop: height + }, { + duration: 500, + queue: false + }); + } else { + this.$scrollContainer.scrollTop(height); + } + }, + scrollToBottom: function (animate) { + if (!this.isVisible()) return; + var height = this.$scrollContainer[0].scrollHeight; + this.scrollTo(height, animate); + }, + isVisible: function () { + return app.currentPage === this; + } +}; diff --git a/clientapp/models/me.js b/clientapp/models/me.js index 2bbdfe9..07866e1 100644 --- a/clientapp/models/me.js +++ b/clientapp/models/me.js @@ -25,7 +25,8 @@ module.exports = HumanModel.define({ connected: ['bool', true, false], shouldAskForAlertsPermission: ['bool', true, false], hasFocus: ['bool', true, false], - _activeContact: ['string', true, ''] + _activeContact: ['string', true, ''], + displayName: ['string', true, 'Me'] }, collections: { contacts: Contacts, diff --git a/clientapp/models/message.js b/clientapp/models/message.js index 074934e..7634d7c 100644 --- a/clientapp/models/message.js +++ b/clientapp/models/message.js @@ -2,6 +2,7 @@ "use strict"; var HumanModel = require('human-model'); +var templates = require('../templates'); module.exports = HumanModel.define({ @@ -26,6 +27,16 @@ module.exports = HumanModel.define({ return me.isMe(this.from); } }, + sender: { + deps: ['from', 'mine'], + fn: function () { + if (this.mine) { + return me; + } else { + return me.getContact(this.from); + } + } + }, delayed: { deps: ['delay'], fn: function () { @@ -81,6 +92,31 @@ module.exports = HumanModel.define({ } return me.getContact(this.from.bare).displayName; } + }, + partialTemplateHtml: { + deps: ['edited', 'pending', 'body'], + cache: false, + fn: function () { + return templates.includes.bareMessage({message: this}); + } + }, + templateHtml: { + fn: function () { + return templates.includes.wrappedMessage({message: this}); + } + }, + classList: { + cache: false, + fn: function () { + var res = []; + + if (this.mine) res.push('mine'); + if (this.pending) res.push('pending'); + if (this.delayed) res.push('delayed'); + if (this.edited) res.push('edited'); + + return res.join(' '); + } } }, session: { @@ -115,5 +151,8 @@ module.exports = HumanModel.define({ edited: this.edited }; app.storage.archive.add(data); + }, + shouldGroupWith: function (previous) { + return previous && previous.from.bare === this.from.bare; } }); diff --git a/clientapp/pages/chat.js b/clientapp/pages/chat.js index 3c7515f..df7ace2 100644 --- a/clientapp/pages/chat.js +++ b/clientapp/pages/chat.js @@ -1,17 +1,27 @@ /*global $, app, me, client*/ "use strict"; +var _ = require('underscore'); var BasePage = require('./base'); var templates = require('../templates'); var Message = require('../views/message'); var MessageModel = require('../models/message'); +var chatHelpers = require('../helpers/chatHelpers'); -module.exports = BasePage.extend({ +module.exports = BasePage.extend(chatHelpers).extend({ template: templates.pages.chat, initialize: function (spec) { this.editMode = false; this.model.fetchHistory(); + + this.listenTo(this, 'pageloaded', this.handlePageLoaded); + this.listenTo(this, 'pageunloaded', this.handlePageUnloaded); + + this.listenTo(this.model.messages, 'change:body', this.refreshModel); + this.listenTo(this.model.messages, 'change:edited', this.refreshModel); + this.listenTo(this.model.messages, 'change:pending', this.refreshModel); + this.render(); }, events: { @@ -40,14 +50,47 @@ module.exports = BasePage.extend({ }); }, render: function () { + if (this.rendered) return this; + this.rendered = true; + this.renderAndBind(); + this.typingTimer = null; this.$chatInput = this.$('.chatBox textarea'); + this.$chatBox = this.$('.chatBox'); this.$messageList = this.$('.messages'); - this.renderCollection(this.model.messages, Message, this.$('.messages')); - this.registerBindings(); + this.$scrollContainer = this.$messageList; + + this.listenTo(this.model.messages, 'add', this.handleChatAdded); + + this.renderCollection(); + + $(window).on('resize', _.bind(this.handleWindowResize, this)); + + this.initializeScroll(); + return this; }, + handlePageLoaded: function () { + this.scrollPageLoad(); + this.handleWindowResize(); + }, + handlePageUnloaded: function () { + this.scrollPageUnload(); + }, + renderCollection: function () { + var self = this; + var previous; + var bottom = this.isBottom() || this.$messageList.is(':empty'); + this.model.messages.each(function (model, i) { + self.appendModel(model); + }); + this.scrollIfPinned(); + }, + handleWindowResize: function () { + this.scrollIfPinned(); + this.$chatInput.trigger('keyup'); + }, handleKeyDown: function (e) { clearTimeout(this.typingTimer); if (e.which === 13 && !e.shiftKey) { @@ -86,29 +129,6 @@ module.exports = BasePage.extend({ }); } }, - resizeInput: function () { - var height; - var scrollHeight; - var newHeight; - var newPadding; - var paddingDelta; - var maxHeight = 102; - - this.$chatInput.removeAttr('style'); - height = this.$chatInput.height() + 10, - scrollHeight = this.$chatInput.get(0).scrollHeight, - newHeight = scrollHeight + 2; - - if (newHeight > maxHeight) newHeight = maxHeight; - if (newHeight > height) { - this.$chatInput.css('height', newHeight); - newPadding = newHeight + 21; - paddingDelta = newPadding - parseInt(this.$messageList.css('paddingBottom'), 10); - if (!!paddingDelta) { - this.$messageList.css('paddingBottom', newPadding); - } - } - }, pausedTyping: function () { if (this.typing) { this.typing = false; @@ -130,7 +150,7 @@ module.exports = BasePage.extend({ chatState: 'active' }; if (this.editMode) { - message.replace = this.model.lastSentMessage.id || this.model.lastSentMessage.cid; + message.replace = this.model.lastSentMessage.id; } var id = client.sendMessage(message); @@ -141,7 +161,6 @@ module.exports = BasePage.extend({ this.model.lastSentMessage.correct(message); } else { var msgModel = new MessageModel(message); - msgModel.cid = id; this.model.messages.add(msgModel); this.model.lastSentMessage = msgModel; } @@ -150,5 +169,32 @@ module.exports = BasePage.extend({ this.typing = false; this.$chatInput.removeClass('editing'); this.$chatInput.val(''); + }, + handleChatAdded: function (model) { + this.appendModel(model, true); + }, + refreshModel: function (model) { + var existing = this.$('#chat' + model.cid); + console.log(model); + console.log(model.classList); + existing.replaceWith(model.partialTemplateHtml); + }, + appendModel: function (model, preload) { + var self = this; + var isGrouped = model.shouldGroupWith(this.lastModel); + var newEl, first, last; + + if (isGrouped) { + newEl = $(model.partialTemplateHtml); + last = this.$messageList.find('li').last(); + last.find('.messageWrapper').append(newEl); + last.addClass('chatGroup'); + } else { + newEl = $(model.templateHtml); + this.$messageList.append(newEl); + } + this.lastModel = model; + + this.scrollIfPinned(); } }); diff --git a/clientapp/templates.js b/clientapp/templates.js index be24648..3289ea0 100644 --- a/clientapp/templates.js +++ b/clientapp/templates.js @@ -27,6 +27,21 @@ exports.head = function anonymous(locals) { return buf.join(""); }; +// bareMessage.jade compiled template +exports.includes.bareMessage = function anonymous(locals) { + var buf = []; + with (locals || {}) { + buf.push("' + jade.escape(null == (jade.interp = message.formattedTime) ? "" : jade.interp) + '

' + jade.escape(null == (jade.interp = message.body) ? "" : jade.interp) + "

"); + } + return buf.join(""); +}; + // contactListItem.jade compiled template exports.includes.contactListItem = function anonymous(locals) { var buf = []; @@ -59,6 +74,15 @@ exports.includes.message = function anonymous(locals) { return buf.join(""); }; +// messageGroup.jade compiled template +exports.includes.messageGroup = function anonymous(locals) { + var buf = []; + with (locals || {}) { + buf.push("
  • "); + } + return buf.join(""); +}; + // mucListItem.jade compiled template exports.includes.mucListItem = function anonymous(locals) { var buf = []; @@ -82,6 +106,29 @@ exports.includes.mucMessage = function anonymous(locals) { return buf.join(""); }; +// wrappedMessage.jade compiled template +exports.includes.wrappedMessage = function anonymous(locals) { + var buf = []; + with (locals || {}) { + buf.push('
  • ' + jade.escape(null == (jade.interp = message.sender.displayName + ":  ") ? "" : jade.interp) + '
    ' + jade.escape(null == (jade.interp = message.formattedTime) ? "" : jade.interp) + '

    ' + jade.escape(null == (jade.interp = message.body) ? "" : jade.interp) + "

  • "); + } + return buf.join(""); +}; + // growlMessage.jade compiled template exports.misc.growlMessage = function anonymous(locals) { var buf = []; diff --git a/clientapp/templates/includes/bareMessage.jade b/clientapp/templates/includes/bareMessage.jade new file mode 100644 index 0000000..3777a74 --- /dev/null +++ b/clientapp/templates/includes/bareMessage.jade @@ -0,0 +1,3 @@ +.message(id='chat'+message.cid, class=message.classList) + span.timestamp=message.formattedTime + p.body=message.body diff --git a/clientapp/templates/includes/messageGroup.jade b/clientapp/templates/includes/messageGroup.jade new file mode 100644 index 0000000..369f9b0 --- /dev/null +++ b/clientapp/templates/includes/messageGroup.jade @@ -0,0 +1 @@ +li diff --git a/clientapp/templates/includes/wrappedMessage.jade b/clientapp/templates/includes/wrappedMessage.jade new file mode 100644 index 0000000..aaa2b8c --- /dev/null +++ b/clientapp/templates/includes/wrappedMessage.jade @@ -0,0 +1,6 @@ +li + a.messageAvatar(href='#') + img(src=message.sender.avatar, alt=message.sender.displayName, data-placement="below") + span.name=message.sender.displayName + ":  " + .messageWrapper + include bareMessage diff --git a/public/css/app/chat.styl b/public/css/app/chat.styl index 3527654..39a5700 100644 --- a/public/css/app/chat.styl +++ b/public/css/app/chat.styl @@ -72,15 +72,39 @@ position: relative z-index: 1 list-style: none - margin: 0px padding: 0px width: 100% + min-height: 40px display: block borderbox() + border: 1px solid blue &:last-child .message border: none + &.chatGroup + border: 1px solid red + + .messageAvatar + position: relative + display: inline-block + float: left + width: 30px + height: 30px + margin: 0px 8px 8px 8px + + img + avatar() + border: 1px solid red + .name + text-indent: -9999em + position: absolute + width: 1px + + .messageWrapper + margin-left: 50px + border-left: 1px solid red + .message font-size: 12px margin: 0px diff --git a/public/css/otalk.css b/public/css/otalk.css index e5086c8..fa3d54f 100644 --- a/public/css/otalk.css +++ b/public/css/otalk.css @@ -689,17 +689,49 @@ h3 { position: relative; z-index: 1; list-style: none; - margin: 0px; padding: 0px; width: 100%; + min-height: 40px; display: block; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; + border: 1px solid #00f; } .messages li:last-child .message { border: none; } +.messages li.chatGroup { + border: 1px solid #f00; +} +.messages li .messageAvatar { + position: relative; + display: inline-block; + float: left; + width: 30px; + height: 30px; + margin: 0px 8px 8px 8px; +} +.messages li .messageAvatar img { + width: 30px; + height: 30px; + -moz-border-radius: 50px; + -webkit-border-radius: 50px; + -khtml-border-radius: 50px; + -o-border-radius: 50px; + -border-radius: 50px; + border-radius: 50px; + border: 1px solid #f00; +} +.messages li .messageAvatar .name { + text-indent: -9999em; + position: absolute; + width: 1px; +} +.messages .messageWrapper { + margin-left: 50px; + border-left: 1px solid #f00; +} .messages .message { font-size: 12px; margin: 0px;