contactsmenu.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. /* global OC.Backbone, Handlebars, Promise, _ */
  2. /**
  3. * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
  4. *
  5. * @author 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
  6. *
  7. * @license GNU AGPL version 3 or any later version
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. *
  22. */
  23. (function(OC, $, _, Handlebars) {
  24. 'use strict';
  25. var MENU_TEMPLATE = ''
  26. + '<label class="hidden-visually" for="contactsmenu-search">' + t('core', 'Search contacts …') + '</label>'
  27. + '<input id="contactsmenu-search" type="search" placeholder="' + t('core', 'Search contacts …') + '" value="{{searchTerm}}">'
  28. + '<div class="content">'
  29. + '</div>';
  30. var CONTACTS_LIST_TEMPLATE = ''
  31. + '{{#unless contacts.length}}'
  32. + '<div class="emptycontent">'
  33. + ' <div class="icon-search"></div>'
  34. + ' <h2>' + t('core', 'No contacts found') + '</h2>'
  35. + '</div>'
  36. + '{{/unless}}'
  37. + '<div id="contactsmenu-contacts"></div>'
  38. + '{{#if contactsAppEnabled}}<div class="footer"><a href="{{contactsAppURL}}">' + t('core', 'Show all contacts …') + '</a></div>{{/if}}';
  39. var LOADING_TEMPLATE = ''
  40. + '<div class="emptycontent">'
  41. + ' <div class="icon-loading"></div>'
  42. + ' <h2>{{loadingText}}</h2>'
  43. + '</div>';
  44. var ERROR_TEMPLATE = ''
  45. + '<div class="emptycontent">'
  46. + ' <div class="icon-search"></div>'
  47. + ' <h2>' + t('core', 'Could not load your contacts') + '</h2>'
  48. + '</div>';
  49. var CONTACT_TEMPLATE = ''
  50. + '{{#if contact.avatar}}'
  51. + '<img src="{{contact.avatar}}&size=32" class="avatar"'
  52. + 'srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">'
  53. + '{{else}}'
  54. + '<div class="avatar"></div>'
  55. + '{{/if}}'
  56. + '<div class="body">'
  57. + ' <div class="full-name">{{contact.fullName}}</div>'
  58. + ' <div class="last-message">{{contact.lastMessage}}</div>'
  59. + '</div>'
  60. + '{{#if contact.topAction}}'
  61. + '<a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}">'
  62. + ' <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}">'
  63. + '</a>'
  64. + '{{/if}}'
  65. + '{{#if contact.hasTwoActions}}'
  66. + '<a class="second-action" href="{{contact.secondAction.hyperlink}}" title="{{contact.secondAction.title}}">'
  67. + ' <img src="{{contact.secondAction.icon}}" alt="{{contact.secondAction.title}}">'
  68. + '</a>'
  69. + '{{/if}}'
  70. + '{{#if contact.hasManyActions}}'
  71. + ' <span class="other-actions icon-more"></span>'
  72. + ' <div class="menu popovermenu">'
  73. + ' <ul>'
  74. + ' {{#each contact.actions}}'
  75. + ' <li>'
  76. + ' <a href="{{hyperlink}}">'
  77. + ' <img src="{{icon}}" alt="">'
  78. + ' <span>{{title}}</span>'
  79. + ' </a>'
  80. + ' </li>'
  81. + ' {{/each}}'
  82. + ' </ul>'
  83. + ' </div>'
  84. + '{{/if}}';
  85. /**
  86. * @class Contact
  87. */
  88. var Contact = OC.Backbone.Model.extend({
  89. defaults: {
  90. fullName: '',
  91. lastMessage: '',
  92. actions: [],
  93. hasOneAction: false,
  94. hasTwoActions: false,
  95. hasManyActions: false
  96. },
  97. /**
  98. * @returns {undefined}
  99. */
  100. initialize: function() {
  101. // Add needed property for easier template rendering
  102. if (this.get('actions').length === 0) {
  103. this.set('hasOneAction', true);
  104. } else if (this.get('actions').length === 1) {
  105. this.set('hasTwoActions', true);
  106. this.set('secondAction', this.get('actions')[0]);
  107. } else {
  108. this.set('hasManyActions', true);
  109. }
  110. }
  111. });
  112. /**
  113. * @class ContactCollection
  114. */
  115. var ContactCollection = OC.Backbone.Collection.extend({
  116. model: Contact
  117. });
  118. /**
  119. * @class ContactsListView
  120. */
  121. var ContactsListView = OC.Backbone.View.extend({
  122. /** @type {ContactsCollection} */
  123. _collection: undefined,
  124. /** @type {array} */
  125. _subViews: [],
  126. /**
  127. * @param {object} options
  128. * @returns {undefined}
  129. */
  130. initialize: function(options) {
  131. this._collection = options.collection;
  132. },
  133. /**
  134. * @returns {self}
  135. */
  136. render: function() {
  137. var self = this;
  138. self.$el.html('');
  139. self._subViews = [];
  140. self._collection.forEach(function(contact) {
  141. var item = new ContactsListItemView({
  142. model: contact
  143. });
  144. item.render();
  145. self.$el.append(item.$el);
  146. item.on('toggle:actionmenu', self._onChildActionMenuToggle, self);
  147. self._subViews.push(item);
  148. });
  149. return self;
  150. },
  151. /**
  152. * Event callback to propagate opening (another) entry's action menu
  153. *
  154. * @param {type} $src
  155. * @returns {undefined}
  156. */
  157. _onChildActionMenuToggle: function($src) {
  158. this._subViews.forEach(function(view) {
  159. view.trigger('parent:toggle:actionmenu', $src);
  160. });
  161. }
  162. });
  163. /**
  164. * @class CotnactsListItemView
  165. */
  166. var ContactsListItemView = OC.Backbone.View.extend({
  167. /** @type {string} */
  168. className: 'contact',
  169. /** @type {undefined|function} */
  170. _template: undefined,
  171. /** @type {Contact} */
  172. _model: undefined,
  173. /** @type {boolean} */
  174. _actionMenuShown: false,
  175. events: {
  176. 'click .icon-more': '_onToggleActionsMenu'
  177. },
  178. /**
  179. * @param {object} data
  180. * @returns {undefined}
  181. */
  182. template: function(data) {
  183. if (!this._template) {
  184. this._template = Handlebars.compile(CONTACT_TEMPLATE);
  185. }
  186. return this._template(data);
  187. },
  188. /**
  189. * @param {object} options
  190. * @returns {undefined}
  191. */
  192. initialize: function(options) {
  193. this._model = options.model;
  194. this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this);
  195. },
  196. /**
  197. * @returns {self}
  198. */
  199. render: function() {
  200. this.$el.html(this.template({
  201. contact: this._model.toJSON()
  202. }));
  203. this.delegateEvents();
  204. // Show placeholder if no avatar is available (avatar is rendered as img, not div)
  205. this.$('div.avatar').imageplaceholder(this._model.get('fullName'));
  206. // Show tooltip for top action
  207. this.$('.top-action').tooltip({placement: 'left'});
  208. // Show tooltip for second action
  209. this.$('.second-action').tooltip({placement: 'left'});
  210. return this;
  211. },
  212. /**
  213. * Toggle the visibility of the action popover menu
  214. *
  215. * @private
  216. * @returns {undefined}
  217. */
  218. _onToggleActionsMenu: function() {
  219. this._actionMenuShown = !this._actionMenuShown;
  220. if (this._actionMenuShown) {
  221. this.$('.menu').show();
  222. } else {
  223. this.$('.menu').hide();
  224. }
  225. this.trigger('toggle:actionmenu', this.$el);
  226. },
  227. /**
  228. * @private
  229. * @argument {jQuery} $src
  230. * @returns {undefined}
  231. */
  232. _onOtherActionMenuOpened: function($src) {
  233. if (this.$el.is($src)) {
  234. // Ignore
  235. return;
  236. }
  237. this._actionMenuShown = false;
  238. this.$('.menu').hide();
  239. }
  240. });
  241. /**
  242. * @class ContactsMenuView
  243. */
  244. var ContactsMenuView = OC.Backbone.View.extend({
  245. /** @type {undefined|function} */
  246. _loadingTemplate: undefined,
  247. /** @type {undefined|function} */
  248. _errorTemplate: undefined,
  249. /** @type {undefined|function} */
  250. _contentTemplate: undefined,
  251. /** @type {undefined|function} */
  252. _contactsTemplate: undefined,
  253. /** @type {undefined|ContactCollection} */
  254. _contacts: undefined,
  255. /** @type {string} */
  256. _searchTerm: '',
  257. events: {
  258. 'input #contactsmenu-search': '_onSearch'
  259. },
  260. /**
  261. * @returns {undefined}
  262. */
  263. _onSearch: _.debounce(function(e) {
  264. var searchTerm = this.$('#contactsmenu-search').val();
  265. // IE11 triggers an 'input' event after the view has been rendered
  266. // resulting in an endless loading loop. To prevent this, we remember
  267. // the last search term to savely ignore some events
  268. // See https://github.com/nextcloud/server/issues/5281
  269. if (searchTerm !== this._searchTerm) {
  270. this.trigger('search', this.$('#contactsmenu-search').val());
  271. this._searchTerm = searchTerm;
  272. }
  273. }, 700),
  274. /**
  275. * @param {object} data
  276. * @returns {string}
  277. */
  278. loadingTemplate: function(data) {
  279. if (!this._loadingTemplate) {
  280. this._loadingTemplate = Handlebars.compile(LOADING_TEMPLATE);
  281. }
  282. return this._loadingTemplate(data);
  283. },
  284. /**
  285. * @param {object} data
  286. * @returns {string}
  287. */
  288. errorTemplate: function(data) {
  289. if (!this._errorTemplate) {
  290. this._errorTemplate = Handlebars.compile(ERROR_TEMPLATE);
  291. }
  292. return this._errorTemplate(data);
  293. },
  294. /**
  295. * @param {object} data
  296. * @returns {string}
  297. */
  298. contentTemplate: function(data) {
  299. if (!this._contentTemplate) {
  300. this._contentTemplate = Handlebars.compile(MENU_TEMPLATE);
  301. }
  302. return this._contentTemplate(data);
  303. },
  304. /**
  305. * @param {object} data
  306. * @returns {string}
  307. */
  308. contactsTemplate: function(data) {
  309. if (!this._contactsTemplate) {
  310. this._contactsTemplate = Handlebars.compile(CONTACTS_LIST_TEMPLATE);
  311. }
  312. return this._contactsTemplate(data);
  313. },
  314. /**
  315. * @param {object} options
  316. * @returns {undefined}
  317. */
  318. initialize: function(options) {
  319. this.options = options;
  320. },
  321. /**
  322. * @param {string} text
  323. * @returns {undefined}
  324. */
  325. showLoading: function(text) {
  326. this.render();
  327. this._contacts = undefined;
  328. this.$('.content').html(this.loadingTemplate({
  329. loadingText: text
  330. }));
  331. },
  332. /**
  333. * @returns {undefined}
  334. */
  335. showError: function() {
  336. this.render();
  337. this._contacts = undefined;
  338. this.$('.content').html(this.errorTemplate());
  339. },
  340. /**
  341. * @param {object} viewData
  342. * @param {string} searchTerm
  343. * @returns {undefined}
  344. */
  345. showContacts: function(viewData, searchTerm) {
  346. this._contacts = viewData.contacts;
  347. this.render({
  348. contacts: viewData.contacts
  349. });
  350. var list = new ContactsListView({
  351. collection: viewData.contacts
  352. });
  353. list.render();
  354. this.$('.content').html(this.contactsTemplate({
  355. contacts: viewData.contacts,
  356. searchTerm: searchTerm,
  357. contactsAppEnabled: viewData.contactsAppEnabled,
  358. contactsAppURL: OC.generateUrl('/apps/contacts')
  359. }));
  360. this.$('#contactsmenu-contacts').html(list.$el);
  361. },
  362. /**
  363. * @param {object} data
  364. * @returns {self}
  365. */
  366. render: function(data) {
  367. var searchVal = this.$('#contactsmenu-search').val();
  368. this.$el.html(this.contentTemplate(data));
  369. // Focus search
  370. this.$('#contactsmenu-search').val(searchVal);
  371. this.$('#contactsmenu-search').focus();
  372. return this;
  373. }
  374. });
  375. /**
  376. * @param {Object} options
  377. * @param {jQuery} options.el
  378. * @param {jQuery} options.trigger
  379. * @class ContactsMenu
  380. */
  381. var ContactsMenu = function(options) {
  382. this.initialize(options);
  383. };
  384. ContactsMenu.prototype = {
  385. /** @type {jQuery} */
  386. $el: undefined,
  387. /** @type {jQuery} */
  388. _$trigger: undefined,
  389. /** @type {ContactsMenuView} */
  390. _view: undefined,
  391. /** @type {Promise} */
  392. _contactsPromise: undefined,
  393. /**
  394. * @param {Object} options
  395. * @param {jQuery} options.el - the element to render the menu in
  396. * @param {jQuery} options.trigger - the element to click on to open the menu
  397. * @returns {undefined}
  398. */
  399. initialize: function(options) {
  400. this.$el = options.el;
  401. this._$trigger = options.trigger;
  402. this._view = new ContactsMenuView({
  403. el: this.$el
  404. });
  405. this._view.on('search', function(searchTerm) {
  406. this._loadContacts(searchTerm);
  407. }, this);
  408. OC.registerMenu(this._$trigger, this.$el, function() {
  409. this._toggleVisibility(true);
  410. }.bind(this), true);
  411. this.$el.on('beforeHide', function() {
  412. this._toggleVisibility(false);
  413. }.bind(this));
  414. },
  415. /**
  416. * @private
  417. * @param {boolean} show
  418. * @returns {Promise}
  419. */
  420. _toggleVisibility: function(show) {
  421. if (show) {
  422. return this._loadContacts();
  423. } else {
  424. this.$el.html('');
  425. return Promise.resolve();
  426. }
  427. },
  428. /**
  429. * @private
  430. * @param {string|undefined} searchTerm
  431. * @returns {Promise}
  432. */
  433. _getContacts: function(searchTerm) {
  434. var url = OC.generateUrl('/contactsmenu/contacts');
  435. return Promise.resolve($.ajax(url, {
  436. method: 'POST',
  437. data: {
  438. filter: searchTerm
  439. }
  440. }));
  441. },
  442. /**
  443. * @param {string|undefined} searchTerm
  444. * @returns {undefined}
  445. */
  446. _loadContacts: function(searchTerm) {
  447. var self = this;
  448. if (!self._contactsPromise) {
  449. self._contactsPromise = self._getContacts(searchTerm);
  450. }
  451. if (_.isUndefined(searchTerm) || searchTerm === '') {
  452. self._view.showLoading(t('core', 'Loading your contacts …'));
  453. } else {
  454. self._view.showLoading(t('core', 'Looking for {term} …', {
  455. term: searchTerm
  456. }));
  457. }
  458. return self._contactsPromise.then(function(data) {
  459. // Convert contact entries to Backbone collection
  460. data.contacts = new ContactCollection(data.contacts);
  461. self._view.showContacts(data, searchTerm);
  462. }, function(e) {
  463. self._view.showError();
  464. console.error('There was an error loading your contacts', e);
  465. }).then(function() {
  466. // Delete promise, so that contacts are fetched again when the
  467. // menu is opened the next time.
  468. delete self._contactsPromise;
  469. }).catch(console.error.bind(this));
  470. }
  471. };
  472. OC.ContactsMenu = ContactsMenu;
  473. })(OC, $, _, Handlebars);