contactsmenu.js 13 KB

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