From d091793ceb1ab2a60133608e844e1d83a5de19f2 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Tue, 24 Jan 2017 07:47:14 +0100 Subject: [PATCH] Contacts menu * load list of contacts from the server * show last message of each contact Signed-off-by: Christoph Wurst --- core/Controller/ContactsMenuController.php | 61 +++ core/css/header.scss | 34 +- core/css/icons.scss | 4 + core/css/styles.scss | 115 ++++ core/img/places/contacts.svg | 1 + core/js/contactsmenu.js | 515 ++++++++++++++++++ core/js/core.json | 1 + core/js/js.js | 17 +- core/js/tests/specs/contactsmenuSpec.js | 255 +++++++++ core/routes.php | 1 + core/templates/layout.user.php | 5 + lib/composer/composer/ClassLoader.php | 10 +- lib/composer/composer/LICENSE | 2 +- lib/composer/composer/autoload_classmap.php | 14 + lib/composer/composer/autoload_static.php | 14 + .../Contacts/ContactsMenu/ActionFactory.php | 57 ++ .../ContactsMenu/ActionProviderStore.php | 82 +++ .../ContactsMenu/Actions/LinkAction.php | 103 ++++ .../Contacts/ContactsMenu/ContactsStore.php | 89 +++ lib/private/Contacts/ContactsMenu/Entry.php | 169 ++++++ lib/private/Contacts/ContactsMenu/Manager.php | 98 ++++ .../ContactsMenu/Providers/EMailProvider.php | 56 ++ lib/private/Server.php | 11 + lib/private/legacy/template.php | 1 + lib/public/Contacts/ContactsMenu/IAction.php | 65 +++ .../Contacts/ContactsMenu/IActionFactory.php | 54 ++ lib/public/Contacts/ContactsMenu/IEntry.php | 66 +++ .../Contacts/ContactsMenu/ILinkAction.php | 43 ++ .../Contacts/ContactsMenu/IProvider.php | 37 ++ .../Controller/ContactsMenuControllerTest.php | 73 +++ .../ContactsMenu/ActionFactoryTest.php | 67 +++ .../ContactsMenu/ActionProviderStoreTest.php | 82 +++ .../ContactsMenu/Actions/LinkActionTest.php | 90 +++ .../ContactsMenu/ContactsStoreTest.php | 121 ++++ tests/lib/Contacts/ContactsMenu/EntryTest.php | 113 ++++ .../lib/Contacts/ContactsMenu/ManagerTest.php | 100 ++++ .../Providers/EMailproviderTest.php | 69 +++ 37 files changed, 2666 insertions(+), 29 deletions(-) create mode 100644 core/Controller/ContactsMenuController.php create mode 100644 core/img/places/contacts.svg create mode 100644 core/js/contactsmenu.js create mode 100644 core/js/tests/specs/contactsmenuSpec.js create mode 100644 lib/private/Contacts/ContactsMenu/ActionFactory.php create mode 100644 lib/private/Contacts/ContactsMenu/ActionProviderStore.php create mode 100644 lib/private/Contacts/ContactsMenu/Actions/LinkAction.php create mode 100644 lib/private/Contacts/ContactsMenu/ContactsStore.php create mode 100644 lib/private/Contacts/ContactsMenu/Entry.php create mode 100644 lib/private/Contacts/ContactsMenu/Manager.php create mode 100644 lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php create mode 100644 lib/public/Contacts/ContactsMenu/IAction.php create mode 100644 lib/public/Contacts/ContactsMenu/IActionFactory.php create mode 100644 lib/public/Contacts/ContactsMenu/IEntry.php create mode 100644 lib/public/Contacts/ContactsMenu/ILinkAction.php create mode 100644 lib/public/Contacts/ContactsMenu/IProvider.php create mode 100644 tests/Core/Controller/ContactsMenuControllerTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/ActionFactoryTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/ActionProviderStoreTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/Actions/LinkActionTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/ContactsStoreTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/EntryTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/ManagerTest.php create mode 100644 tests/lib/Contacts/ContactsMenu/Providers/EMailproviderTest.php diff --git a/core/Controller/ContactsMenuController.php b/core/Controller/ContactsMenuController.php new file mode 100644 index 0000000000..edd2f2c8be --- /dev/null +++ b/core/Controller/ContactsMenuController.php @@ -0,0 +1,61 @@ + + * + * @author 2017 Christoph Wurst + * + * @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 . + * + */ + +namespace OC\Core\Controller; + +use OC\Contacts\ContactsMenu\Manager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +class ContactsMenuController extends Controller { + + /** @var Manager */ + private $manager; + + /** @var string */ + private $userId; + + /** + * @param IRequest $request + * @param string $UserId + * @param Manager $manager + */ + public function __construct(IRequest $request, $UserId, Manager $manager) { + parent::__construct('core', $request); + $this->userId = $UserId; + $this->manager = $manager; + } + + /** + * @NoAdminRequired + * + * @param string|null filter + * @return JSONResponse + */ + public function index($filter = null) { + return $this->manager->getEntries($this->userId, $filter); + } + +} diff --git a/core/css/header.scss b/core/css/header.scss index 619852faf6..50d270a6ff 100644 --- a/core/css/header.scss +++ b/core/css/header.scss @@ -20,10 +20,21 @@ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; +} - /* Dropdown menu arrow */ - &.menu:after, - .menu:after { +/* Header menu */ +.menu { + position: absolute; + top: 45px; + background-color: #fff; + box-shadow: 0 1px 10px rgba(150, 150, 150, 0.75); + border-radius: 0 0 3px 3px; + display: none; + box-sizing: border-box; + z-index: 2000; + + /* Dropdown arrow */ + &:after { border: 10px solid transparent; border-bottom-color: $color-main-background; bottom: 100%; @@ -199,19 +210,12 @@ nav { #navigation { position: relative; - top: 45px; left: -100%; width: 160px; - margin-top: 0; background-color: $color-main-background; box-shadow: 0 1px 10px $color-box-shadow; - border-radius: 3px; - border-top-left-radius: 0; - border-top-right-radius: 0; - display: none; - box-sizing: border-box; - z-index: 2000; &:after { + /* position of dropdown arrow */ left: 47%; bottom: 100%; border: solid transparent; @@ -407,17 +411,9 @@ nav { } #expanddiv { - position: absolute; right: 13px; - top: 45px; - z-index: 2000; - display: none; background: $color-main-background; box-shadow: 0 1px 10px $color-box-shadow; - border-radius: 3px; - border-top-left-radius: 0; - border-top-right-radius: 0; - box-sizing: border-box; &:after { /* position of dropdown arrow */ right: 13px; diff --git a/core/css/icons.scss b/core/css/icons.scss index 1ca29f2260..f9b73f5192 100644 --- a/core/css/icons.scss +++ b/core/css/icons.scss @@ -438,6 +438,10 @@ img, object, video, button, textarea, input, select { background-image: url('../img/places/calendar-dark.svg?v=1'); } +.icon-contacts { + background-image: url('../img/places/contacts.svg?v=1'); +} + .icon-contacts-dark { background-image: url('../img/places/contacts-dark.svg?v=1'); } diff --git a/core/css/styles.scss b/core/css/styles.scss index a6970336c1..ed11fd5215 100644 --- a/core/css/styles.scss +++ b/core/css/styles.scss @@ -1057,6 +1057,121 @@ span.ui-icon { margin: 3px 7px 30px 0; } +/* ---- CONTACTS MENU ---- */ + +#contactsmenu { + .menutoggle { + background-size: 16px 16px; + padding: 14px; + cursor: pointer; + opacity: .7; + } +} + +#contactsmenu > .menu { + /* show ~4.5 entries */ + height: 278px; + width: 350px; + right: 13px; + + &::after { + right: 61px; + } + + .emptycontent { + margin-top: 5vh !important; + margin-bottom: 2vh; + .icon-loading { + position: inherit; + } + .icon-search { + display: inline-block; + } + } + + .content { + max-height: calc(100% - 50px); + overflow-y: auto; + + .footer { + text-align: center; + + a { + display: block; + width: 100%; + padding: 12px 0; + opacity: .5; + } + } + } + + .contact { + display: flex; + position: relative; + align-items: center; + padding: 3px 3px 3px 10px; + border-bottom: 1px solid #eeeeee; + + :last-of-type { + border-bottom: none; + } + + .avatar { + height: 32px; + width: 32px; + display: inline-block; + } + + .body { + flex-grow: 1; + padding-left: 8px; + + div { + position: relative; + width: 100%; + } + + .full-name, .last-message { + /* TODO: don't use fixed width */ + max-width: 204px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .last-message { + opacity: .5; + } + } + + .top-action, .second-action, .other-actions { + width: 16px; + height: 16px; + padding: 14px; + opacity: .5; + cursor: pointer; + + :hover { + opacity: 1; + } + } + + /* actions menu */ + .menu { + top: 47px; + margin-right: 13px; + } + .popovermenu::after { + right: 2px; + } + } +} + + +#contactsmenu-search { + width: calc(100% - 16px); + margin: 8px; +} + /* ---- TOOLTIPS ---- */ .extra-data { diff --git a/core/img/places/contacts.svg b/core/img/places/contacts.svg new file mode 100644 index 0000000000..fb6a60c084 --- /dev/null +++ b/core/img/places/contacts.svg @@ -0,0 +1 @@ + diff --git a/core/js/contactsmenu.js b/core/js/contactsmenu.js new file mode 100644 index 0000000000..2991a0e84d --- /dev/null +++ b/core/js/contactsmenu.js @@ -0,0 +1,515 @@ +/* global OC.Backbone, Handlebars, Promise, _ */ + +/** + * @copyright 2017 Christoph Wurst + * + * @author 2017 Christoph Wurst + * + * @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 . + * + */ + +(function(OC, $, _, Handlebars) { + 'use strict'; + + var MENU_TEMPLATE = '' + + '' + + '
' + + '
'; + var CONTACTS_LIST_TEMPLATE = '' + + '{{#unless contacts.length}}' + + '
' + + ' ' + + '

' + t('core', 'No contacts found') + '

' + + '
' + + '{{/unless}}' + + '
' + + '{{#if contactsAppEnabled}}{{/if}}'; + var LOADING_TEMPLATE = '' + + '
' + + ' ' + + '

{{loadingText}}

' + + '
'; + var ERROR_TEMPLATE = '' + + '
' + + '

' + t('core', 'Could not load your contacts.') + '

' + + '
'; + var CONTACT_TEMPLATE = '' + + '{{#if contact.avatar}}' + + '' + + '{{else}}' + + '
' + + '{{/if}}' + + '
' + + '
{{contact.fullName}}
' + + '
{{contact.lastMessage}}
' + + '
' + + '{{#if contact.topAction}}' + + '' + + '{{/if}}' + + '{{#if contact.hasManyActions}}' + + ' ' + + ' ' + + '{{/if}}' + + '{{#if contact.hasTwoActions}}' + + '' + + '{{/if}}'; + + /** + * @class Contact + */ + var Contact = OC.Backbone.Model.extend({ + defaults: { + fullName: '', + lastMessage: '', + actions: [], + hasOneAction: false, + hasTwoActions: false, + hasManyActions: false + }, + + /** + * @returns {undefined} + */ + initialize: function() { + // Add needed property for easier template rendering + if (this.get('actions').length === 0) { + this.set('hasOneAction', true); + } else if (this.get('actions').length === 1) { + this.set('hasTwoActions', true); + this.set('secondAction', this.get('actions')[0]); + } else { + this.set('hasManyActions', true); + } + } + }); + + /** + * @class ContactCollection + */ + var ContactCollection = OC.Backbone.Collection.extend({ + model: Contact + }); + + /** + * @class ContactsListView + */ + var ContactsListView = OC.Backbone.View.extend({ + + /** @type {ContactsCollection} */ + _collection: undefined, + + /** @type {array} */ + _subViews: [], + + /** + * @param {object} options + * @returns {undefined} + */ + initialize: function(options) { + this._collection = options.collection; + }, + + /** + * @returns {self} + */ + render: function() { + var self = this; + self.$el.html(''); + self._subViews = []; + + self._collection.forEach(function(contact) { + var item = new ContactsListItemView({ + model: contact + }); + item.render(); + self.$el.append(item.$el); + item.on('toggle:actionmenu', self._onChildActionMenuToggle, self); + self._subViews.push(item); + }); + + return self; + }, + + /** + * Event callback to propagate opening (another) entry's action menu + * + * @param {type} $src + * @returns {undefined} + */ + _onChildActionMenuToggle: function($src) { + this._subViews.forEach(function(view) { + view.trigger('parent:toggle:actionmenu', $src); + }); + } + }); + + /** + * @class CotnactsListItemView + */ + var ContactsListItemView = OC.Backbone.View.extend({ + + /** @type {string} */ + className: 'contact', + + /** @type {undefined|function} */ + _template: undefined, + + /** @type {Contact} */ + _model: undefined, + + /** @type {boolean} */ + _actionMenuShown: false, + + events: { + 'click .icon-more': '_onToggleActionsMenu' + }, + + /** + * @param {object} data + * @returns {undefined} + */ + template: function(data) { + if (!this._template) { + this._template = Handlebars.compile(CONTACT_TEMPLATE); + } + return this._template(data); + }, + + /** + * @param {object} options + * @returns {undefined} + */ + initialize: function(options) { + this._model = options.model; + this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this); + }, + + /** + * @returns {self} + */ + render: function() { + this.$el.html(this.template({ + contact: this._model.toJSON() + })); + this.delegateEvents(); + + // Show placeholder iff no avatar is available (avatar is rendered as img, not div) + this.$('div.avatar').imageplaceholder(this._model.get('fullName')); + + return this; + }, + + /** + * Toggle the visibility of the action popover menu + * + * @private + * @returns {undefined} + */ + _onToggleActionsMenu: function() { + this._actionMenuShown = !this._actionMenuShown; + if (this._actionMenuShown) { + this.$('.menu').show(); + } else { + this.$('.menu').hide(); + } + this.trigger('toggle:actionmenu', this.$el); + }, + + /** + * @private + * @argument {jQuery} $src + * @returns {undefined} + */ + _onOtherActionMenuOpened: function($src) { + if (this.$el.is($src)) { + // Ignore + return; + } + this._actionMenuShown = false; + this.$('.menu').hide(); + } + }); + + /** + * @class ContactsMenuView + */ + var ContactsMenuView = OC.Backbone.View.extend({ + + /** @type {undefined|function} */ + _loadingTemplate: undefined, + + /** @type {undefined|function} */ + _errorTemplate: undefined, + + /** @type {undefined|function} */ + _contentTemplate: undefined, + + /** @type {undefined|function} */ + _contactsTemplate: undefined, + + /** @type {undefined|ContactCollection} */ + _contacts: undefined, + + events: { + 'input #contactsmenu-search': '_onSearch' + }, + + /** + * @returns {undefined} + */ + _onSearch: _.debounce(function() { + this.trigger('search', this.$('#contactsmenu-search').val()); + }, 700), + + /** + * @param {object} data + * @returns {string} + */ + loadingTemplate: function(data) { + if (!this._loadingTemplate) { + this._loadingTemplate = Handlebars.compile(LOADING_TEMPLATE); + } + return this._loadingTemplate(data); + }, + + /** + * @param {object} data + * @returns {string} + */ + errorTemplate: function(data) { + if (!this._errorTemplate) { + this._errorTemplate = Handlebars.compile(ERROR_TEMPLATE); + } + return this._errorTemplate(data); + }, + + /** + * @param {object} data + * @returns {string} + */ + contentTemplate: function(data) { + if (!this._contentTemplate) { + this._contentTemplate = Handlebars.compile(MENU_TEMPLATE); + } + return this._contentTemplate(data); + }, + + /** + * @param {object} data + * @returns {string} + */ + contactsTemplate: function(data) { + if (!this._contactsTemplate) { + this._contactsTemplate = Handlebars.compile(CONTACTS_LIST_TEMPLATE); + } + return this._contactsTemplate(data); + }, + + /** + * @param {object} options + * @returns {undefined} + */ + initialize: function(options) { + this.options = options; + }, + + /** + * @param {string} text + * @returns {undefined} + */ + showLoading: function(text) { + this.render(); + this._contacts = undefined; + this.$('.content').html(this.loadingTemplate({ + loadingText: text + })); + }, + + /** + * @returns {undefined} + */ + showError: function() { + this.render(); + this._contacts = undefined; + this.$('.content').html(this.errorTemplate()); + }, + + /** + * @param {object} viewData + * @param {string} searchTerm + * @returns {undefined} + */ + showContacts: function(viewData, searchTerm) { + this._contacts = viewData.contacts; + this.render({ + contacts: viewData.contacts + }); + + var list = new ContactsListView({ + collection: viewData.contacts + }); + list.render(); + this.$('.content').html(this.contactsTemplate({ + contacts: viewData.contacts, + searchTerm: searchTerm, + contactsAppEnabled: viewData.contactsAppEnabled, + contactsAppURL: OC.generateUrl('/apps/contacts') + })); + this.$('#contactsmenu-contacts').html(list.$el); + }, + + /** + * @param {object} data + * @returns {self} + */ + render: function(data) { + var searchVal = this.$('#contactsmenu-search').val(); + this.$el.html(this.contentTemplate(data)); + + // Focus search + this.$('#contactsmenu-search').val(searchVal); + this.$('#contactsmenu-search').focus(); + return this; + } + + }); + + /** + * @param {Object} options + * @param {jQuery} options.el + * @param {jQuery} options.trigger + * @class ContactsMenu + */ + var ContactsMenu = function(options) { + this.initialize(options); + }; + + ContactsMenu.prototype = { + /** @type {jQuery} */ + $el: undefined, + + /** @type {jQuery} */ + _$trigger: undefined, + + /** @type {ContactsMenuView} */ + _view: undefined, + + /** @type {Promise} */ + _contactsPromise: undefined, + + /** + * @param {Object} options + * @param {jQuery} options.el - the element to render the menu in + * @param {jQuery} options.trigger - the element to click on to open the menu + * @returns {undefined} + */ + initialize: function(options) { + this.$el = options.el; + this._$trigger = options.trigger; + + this._view = new ContactsMenuView({ + el: this.$el + }); + this._view.on('search', function(searchTerm) { + this._loadContacts(searchTerm); + }, this); + + OC.registerMenu(this._$trigger, this.$el, function() { + this._toggleVisibility(true); + }.bind(this)); + this.$el.on('beforeHide', function() { + this._toggleVisibility(false); + }.bind(this)); + }, + + /** + * @private + * @param {boolean} show + * @returns {Promise} + */ + _toggleVisibility: function(show) { + if (show) { + return this._loadContacts(); + } else { + this.$el.html(''); + return Promise.resolve(); + } + }, + + /** + * @private + * @param {string|undefined} searchTerm + * @returns {Promise} + */ + _getContacts: function(searchTerm) { + var url = OC.generateUrl('/contactsmenu/contacts'); + return Promise.resolve($.ajax(url, { + method: 'GET', + data: { + filter: searchTerm + } + })); + }, + + /** + * @param {string|undefined} searchTerm + * @returns {undefined} + */ + _loadContacts: function(searchTerm) { + var self = this; + + if (!self._contactsPromise) { + self._contactsPromise = self._getContacts(searchTerm); + } + + if (_.isUndefined(searchTerm) || searchTerm === '') { + self._view.showLoading(t('core', 'Loading your contacts …')); + } else { + self._view.showLoading(t('core', 'Looking for {term} …', { + term: searchTerm + })); + } + return self._contactsPromise.then(function(data) { + // Convert contact entries to Backbone collection + data.contacts = new ContactCollection(data.contacts); + + self._view.showContacts(data, searchTerm); + }, function(e) { + self._view.showError(); + console.error('could not load contacts', e); + }).then(function() { + // Delete promise, so that contacts are fetched again when the + // menu is opened the next time. + delete self._contactsPromise; + }).catch(console.error.bind(this)); + } + }; + + OC.ContactsMenu = ContactsMenu; + +})(OC, $, _, Handlebars); diff --git a/core/js/core.json b/core/js/core.json index 6494d4105f..aadd66a055 100644 --- a/core/js/core.json +++ b/core/js/core.json @@ -40,6 +40,7 @@ "sharedialogresharerinfoview.js", "sharedialogshareelistview.js", "octemplate.js", + "contactsmenu.js", "eventsource.js", "config.js", "public/appconfig.js", diff --git a/core/js/js.js b/core/js/js.js index 95c00dd644..03d831567d 100644 --- a/core/js/js.js +++ b/core/js/js.js @@ -654,8 +654,13 @@ var OCP = {}, /** * For menu toggling * @todo Write documentation + * + * @param {jQuery} $toggle + * @param {jQuery} $menuEl + * @param {function|undefined} toggle callback invoked everytime the menu is opened + * @returns {undefined} */ - registerMenu: function($toggle, $menuEl) { + registerMenu: function($toggle, $menuEl, toggle) { var self = this; $menuEl.addClass('menu'); $toggle.on('click.menu', function(event) { @@ -671,7 +676,7 @@ var OCP = {}, // close it self.hideMenus(); } - $menuEl.slideToggle(OC.menuSpeed); + $menuEl.slideToggle(OC.menuSpeed, toggle); OC._currentMenu = $menuEl; OC._currentMenuToggle = $toggle; }); @@ -1473,8 +1478,16 @@ function initCore() { }); } + function setupContactsMenu() { + new OC.ContactsMenu({ + el: $('#contactsmenu .menu'), + trigger: $('#contactsmenu .menutoggle') + }); + } + setupMainMenu(); setupUserMenu(); + setupContactsMenu(); // move triangle of apps dropdown to align with app name triangle // 2 is the additional offset between the triangles diff --git a/core/js/tests/specs/contactsmenuSpec.js b/core/js/tests/specs/contactsmenuSpec.js new file mode 100644 index 0000000000..1db9a6a955 --- /dev/null +++ b/core/js/tests/specs/contactsmenuSpec.js @@ -0,0 +1,255 @@ +/* global expect, sinon, _, spyOn, Promise */ + +/** + * @copyright 2017 Christoph Wurst + * + * @author 2017 Christoph Wurst + * + * @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 . + * + */ + +describe('Contacts menu', function() { + var $triggerEl, + $menuEl, + menu; + + /** + * @private + * @returns {Promise} + */ + function openMenu() { + return menu._toggleVisibility(true); + } + + beforeEach(function(done) { + $triggerEl = $('