diff options
author | Ivan Sein <ivan@struktur.de> | 2017-11-03 13:03:39 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-11-03 13:03:39 +0100 |
commit | d83c3d250eb249f3308b7fc7925272ddf756e8ef (patch) | |
tree | e79fef5ab96fdd00ad634ba96c38b0d430b5971d | |
parent | 23aaa401ef0ec6fcd9a5910a824d2f66e4f5e1cd (diff) | |
parent | 46ce6aae1e767091d819c53b116d05425ec16226 (diff) |
Merge pull request #462 from nextcloud/add-basic-frontend-for-chat
Add basic frontend for chat
-rw-r--r-- | css/comments.css | 184 | ||||
-rw-r--r-- | js/app.js | 16 | ||||
-rw-r--r-- | js/models/chatmessage.js | 81 | ||||
-rw-r--r-- | js/models/chatmessagecollection.js | 194 | ||||
-rw-r--r-- | js/views/chatview.js | 268 | ||||
-rw-r--r-- | templates/index-public.php | 4 | ||||
-rw-r--r-- | templates/index.php | 4 |
7 files changed, 751 insertions, 0 deletions
diff --git a/css/comments.css b/css/comments.css new file mode 100644 index 000000000..9bdd77423 --- /dev/null +++ b/css/comments.css @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2016 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +#commentsTabView .emptycontent { + margin-top: 0; +} + +#commentsTabView .newCommentForm { + position: relative; + margin-bottom: 20px; +} + +#commentsTabView .newCommentForm .message { + width: calc(100% - 81px); /* 36 (left margin) + 30 (right padding) + 15 (right padding of surrounding box) */ + margin-left: 36px; + padding-right: 30px; + display: block; +} + +#commentsTabView .newCommentForm .submit { + position: absolute; + bottom: 0px; + right: 8px; + width: 30px; + margin: 0; + padding: 7px 9px; + background-color: transparent; + border: none; + opacity: .3; +} +#commentsTabView .newCommentForm .submit:hover, +#commentsTabView .newCommentForm .submit:focus { + opacity: 1; +} + +#commentsTabView .newCommentForm .submitLoading { + background-position: left; +} + +#commentsTabView .newCommentForm .cancel { + margin-right: 6px; +} + +#commentsTabView .newCommentForm div.message { + resize: none; +} + +#commentsTabView .newCommentForm div.message:empty:before { + content: attr(data-placeholder); + color: grey; +} + +#commentsTabView .comment { + position: relative; + margin-bottom: 30px; +} + +#commentsTabView .comment .avatar, +.atwho-view-ul * .avatar{ + width: 32px; + height: 32px; + line-height: 32px; +} + +#commentsTabView .comment .message .avatar, +.atwho-view-ul * .avatar +{ + display: inline-block; +} + +#activityTabView li.comment.collapsed .activitymessage, +#commentsTabView .comment.collapsed .message { + white-space: pre-wrap; +} + +#activityTabView li.comment.collapsed .activitymessage, +#commentsTabView .comment.collapsed .message { + max-height: 70px; + overflow: hidden; +} + +#activityTabView li.comment .message-overlay, +#commentsTabView .comment .message-overlay { + display: none; +} + +#activityTabView li.comment.collapsed .message-overlay, +#commentsTabView .comment.collapsed .message-overlay { + display: block; + position: absolute; + z-index: 2; + height: 50px; + pointer-events: none; + left: 0; + right: 0; + bottom: 0; + background: -moz-linear-gradient(rgba(255,255,255,0), rgba(255,255,255,1)); + background: -webkit-linear-gradient(rgba(255,255,255,0), rgba(255,255,255,1)); + background: -o-linear-gradient(rgba(255,255,255,0), rgba(255,255,255,1)); + background: -ms-linear-gradient(rgba(255,255,255,0), rgba(255,255,255,1)); + background: linear-gradient(rgba(255,255,255,0), rgba(255,255,255,1)); + filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,startColorstr='#00FFFFFF', endColorstr='#FFFFFFFF'); + background-repeat: no-repeat; +} + +#commentsTabView .authorRow>div { + display: inline-block; + vertical-align: middle; +} + +#commentsTabView .authorRow>div.hidden { + display: none !important; +} + +#commentsTabView .comments li .message .avatar-name-wrapper, +.atwho-view-ul * .avatar-name-wrapper, +#commentsTabView .comment .authorRow { + position: relative; +} + +.atwho-view-ul * .avatar-name-wrapper { + white-space: nowrap; +} + +#commentsTabView .comment .author, +#commentsTabView .comment .date { + opacity: .5; +} +#commentsTabView .comment .author { + margin-left: 5px; +} +#commentsTabView .comment .date { + position: absolute; + right: 0; + top: 5px; +} + +#commentsTabView .comments li .message { + padding-left: 40px; +} + +#commentsTabView .comment .action { + opacity: 0; + vertical-align: middle; + display: inline-block; +} + +#commentsTabView .comment:hover .action { + opacity: 0.3; +} + +#commentsTabView .comment .action:hover { + opacity: 1; +} + +#commentsTabView .comment .action.delete { + position: absolute; + right: 0; +} + +#commentsTabView .comment.disabled { + opacity: 0.3; +} + +#commentsTabView .comment.disabled .action { + visibility: hidden; +} + +#commentsTabView .message.error { + color: #e9322d; + border-color: #e9322d; + box-shadow: 0 0 6px #f8b9b7; +} + +.app-files .action-comment { + padding: 16px 14px; +} @@ -391,6 +391,9 @@ guestNameModel: this._localStorageModel }); this._sidebarView.setCallInfoView(callInfoView); + + this._messageCollection.setRoomToken(this.activeRoom.get('token')); + this._messageCollection.receiveMessages(); }, setPageTitle: function(title){ if (title) { @@ -472,6 +475,19 @@ this.disable(); }); + this._messageCollection = new OCA.SpreedMe.Models.ChatMessageCollection(null, {token: null}); + this._chatView = new OCA.SpreedMe.Views.ChatView({ + collection: this._messageCollection, + id: 'commentsTabView', + className: 'chat tab' + }); + + this._sidebarView.addTab('chat', { label: t('spreed', 'Chat') }, this._chatView); + + this._messageCollection.listenTo(roomChannel, 'leaveCurrentCall', function() { + this.stopReceivingMessages(); + }); + $(document).on('click', this.onDocumentClick); OC.Util.History.addOnPopStateHandler(_.bind(this._onPopState, this)); }, diff --git a/js/models/chatmessage.js b/js/models/chatmessage.js new file mode 100644 index 000000000..2449be730 --- /dev/null +++ b/js/models/chatmessage.js @@ -0,0 +1,81 @@ +/* global Backbone, OC, OCA */ + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +(function(OCA, OC, Backbone) { + 'use strict'; + + OCA.SpreedMe = OCA.SpreedMe || {}; + OCA.SpreedMe.Models = OCA.SpreedMe.Models || {}; + + /** + * Model for chat messages. + * + * ChatMessage can be used as the model of a ChatMessageCollection or as a + * standalone model. When used as a standalone model the room token must be + * provided in the constructor options (as "token"). + * + * In any case, "create" is the only synchronization method allowed; chat + * messages can not be edited nor deleted, and they can not be got + * individually either, but as a list through ChatMessageCollection. + * + * To send a new message create a standalone ChatMessage object and call + * "save". + */ + var ChatMessage = Backbone.Model.extend({ + + defaults: { + actorType: '', + actorId: '', + actorDisplayName: '', + timestamp: 0, + message: '' + }, + + url: function() { + if (this.token === undefined) { + throw 'Missing parameter token'; + } + + return OC.linkToOCS('apps/spreed/api/v1/chat', 2) + this.token; + }, + + initialize: function(options) { + // Only needed in standalone mode; when used as the model of a + // ChatMessageCollection the synchronization is performed by the + // collection instead. + this.token = options.token; + }, + + sync: function(method, model, options) { + if (method !== 'create') { + throw 'Synchronization method not supported by ChatMessage: ' + method; + } + + return Backbone.Model.prototype.sync.call(this, method, model, options); + } + + }); + + OCA.SpreedMe.Models.ChatMessage = ChatMessage; + +})(OCA, OC, Backbone); diff --git a/js/models/chatmessagecollection.js b/js/models/chatmessagecollection.js new file mode 100644 index 000000000..9919e7543 --- /dev/null +++ b/js/models/chatmessagecollection.js @@ -0,0 +1,194 @@ +/* global Backbone, OC, OCA */ + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +(function(OCA, OC, Backbone) { + 'use strict'; + + OCA.SpreedMe = OCA.SpreedMe || {}; + OCA.SpreedMe.Models = OCA.SpreedMe.Models || {}; + + /** + * Collection for chat messages. + * + * The ChatMessageCollection gives read access to all the chat messages from + * a specific chat room. The room token must be provided in the constructor + * options (as "token"), either as an actual room token or as null. It is + * possible to change the room of a ChatMessageCollection at any time by + * calling "setRoomToken". In any case, although null is supported as a + * temporal or reset value, note that an actual room token must be set + * before synchronizing the collection. + * + * "read" is the only synchronization method allowed; chat messages can not + * be edited nor deleted, and to send a new message a standalone ChatMessage + * should be used instead. + * + * To get the messages from the server "receiveMessages" should be used. It + * will enable polling to the server and automatically update the collection + * when new messages are received. Once enabled, the polling will go on + * indefinitely. Due to this "stopReceivingMessages" must be called once + * the ChatMessageCollection is no longer needed. + */ + var ChatMessageCollection = Backbone.Collection.extend({ + + model: OCA.SpreedMe.Models.ChatMessage, + + initialize: function(models, options) { + if (options.token === undefined) { + throw 'Missing parameter token'; + } + + this._lastFetch = null; + + this.setRoomToken(options.token); + }, + + parse: function(result) { + return result.ocs.data; + }, + + set: function(models, options) { + // The server returns the messages sorted from newest to oldest, + // which causes some issues with the default implementation of + // collections. If several messages were received at once they would + // be added to the collection in that same order, so the newest + // message would be the first one added and the oldest message would + // be the last one added (and the model id of the newest one would + // be lower than the model id of the oldest one). If another group + // of messages were received now then the newest message would be + // added to the collection after the oldest message from the + // previous group. Therefore, the models in the collection would not + // follow an absolute order from the newest message to the oldest + // one, but a local order for each group of messages fetched. + // + // Just sorting the collection is not a solution either. Setting + // "sort: true" as a fetch option would keep the collection sorted + // (although the ids of the models would still have the same problem + // described above), but the "add" events would be triggered anyway + // in the original order of the messages passed to "set". + // + // The best solution, besides changing the server to return the + // messages sorted from oldest to newest, is to sort the models + // passed to "set" from oldest to newest. + if (models !== undefined && models !== null && models.ocs !== undefined && models.ocs.data !== undefined) { + models.ocs.data = _.sortBy(models.ocs.data, function(model) { + return model.timestamp; + }); + } + + return Backbone.Collection.prototype.set.call(this, models, options); + }, + + /** + * Changes the room that this ChatMessageCollection gets its messages + * from. + * + * When a token is set this collection is reset, so the messages from + * the previous room are removed. + * + * If polling was currently being done to the previous room it will be + * automatically stopped. Note, however, that "receiveMessages" must be + * explicitly called if needed. + * + * @param string|null token the token of the room. + */ + setRoomToken: function(token) { + this.stopReceivingMessages(); + + this.token = token; + + this.offset = 0; + + this._waitTimeUntilRetry = 1; + + if (token !== null) { + this.url = OC.linkToOCS('apps/spreed/api/v1/chat', 2) + token; + } else { + this.url = null; + } + + this.reset(); + }, + + receiveMessages: function() { + this.receiveMessagesAgain = true; + + this._lastFetch = this.fetch({ + data: { + // The notOlderThan parameter could be used to limit the + // messages to those shown since the user opened the chat + // window. However, it can not be used as a way to keep + // track of the last message received. For example, even if + // unlikely, if two messages were sent at the same time and + // received the same timestamp in two different PHP + // processes, it could happen that one of them was committed + // to the database and read by another process waiting for + // new messages while the second message was not committed + // yet and thus not returned. Then, when the reading process + // checks the messages again, it would miss the second one + // due to its timestamp being the same as the last one it + // received. + offset: this.offset + }, + success: _.bind(this._successfulFetch, this), + error: _.bind(this._failedFetch, this) + }); + }, + + stopReceivingMessages: function() { + this.receiveMessagesAgain = false; + + if (this._lastFetch !== null) { + this._lastFetch.abort(); + } + }, + + _successfulFetch: function(collection, response) { + this.offset += response.ocs.data.length; + + this._lastFetch = null; + + this._waitTimeUntilRetry = 1; + + if (this.receiveMessagesAgain) { + this.receiveMessages(); + } + }, + + _failedFetch: function() { + this._lastFetch = null; + + if (this.receiveMessagesAgain) { + _.delay(_.bind(this.receiveMessages, this), this._waitTimeUntilRetry * 1000); + + // Increase the wait time until retry to at most 64 seconds. + if (this._waitTimeUntilRetry < 64) { + this._waitTimeUntilRetry *= 2; + } + } + } + + }); + + OCA.SpreedMe.Models.ChatMessageCollection = ChatMessageCollection; + +})(OCA, OC, Backbone); diff --git a/js/views/chatview.js b/js/views/chatview.js new file mode 100644 index 000000000..5e1c9ff4a --- /dev/null +++ b/js/views/chatview.js @@ -0,0 +1,268 @@ +/* global autosize, Backbone, Handlebars, OC, OCA */ + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +(function(OCA, OC, Backbone, Handlebars, autosize) { + 'use strict'; + + OCA.SpreedMe = OCA.SpreedMe || {}; + OCA.SpreedMe.Views = OCA.SpreedMe.Views || {}; + + var TEMPLATE = + '<ul class="comments">' + + '</ul>' + + '<div class="emptycontent"><div class="icon-comment"></div>' + + '<p>{{emptyResultLabel}}</p></div>' + + '<div class="loading hidden" style="height: 50px"></div>'; + + var ADD_COMMENT_TEMPLATE = + '<div class="newCommentRow comment" data-id="{{id}}">' + + ' <div class="authorRow">' + + ' <div class="avatar" data-username="{{actorId}}"></div>' + + ' <div class="author">{{actorDisplayName}}</div>' + + ' </div>' + + ' <form class="newCommentForm">' + + ' <div contentEditable="true" class="message" data-placeholder="{{newMessagePlaceholder}}">{{message}}</div>' + + ' <input class="submit icon-confirm" type="submit" value="" />' + + ' <div class="submitLoading icon-loading-small hidden"></div>'+ + ' </form>' + + '</div>'; + + var COMMENT_TEMPLATE = + '<li class="comment" data-id="{{id}}">' + + ' <div class="authorRow">' + + ' <div class="avatar" {{#if actorId}}data-username="{{actorId}}"{{/if}}> </div>' + + ' <div class="author">{{actorDisplayName}}</div>' + + ' <div class="date has-tooltip live-relative-timestamp" data-timestamp="{{timestamp}}" title="{{altDate}}">{{date}}</div>' + + ' </div>' + + ' <div class="message">{{{formattedMessage}}}</div>' + + '</li>'; + + var ChatView = Backbone.View.extend({ + + events: { + 'submit .newCommentForm': '_onSubmitComment', + }, + + initialize: function() { + this.listenTo(this.collection, 'reset', this.render); + this.listenTo(this.collection, 'add', this._onAddModel); + }, + + template: function(params) { + if (!this._template) { + this._template = Handlebars.compile(TEMPLATE); + } + return this._template(params); + }, + + addCommentTemplate: function(params) { + if (!this._addCommentTemplate) { + this._addCommentTemplate = Handlebars.compile(A |