3 var _ = require('lodash');
4 var Backbone = require('backbone');
5 var Marionette = require('backbone.marionette');
6 var cocktail = require('backbone.cocktail');
7 var autolink = require('autolink'); // eslint-disable-line node/no-missing-require
8 var clientEnv = require('gitter-client-env');
9 var context = require('gitter-web-client-context');
10 const log = require('../../utils/log');
11 const { getBackendForRoom } = require('gitter-web-shared/backend-utils');
12 var toggleClass = require('../../utils/toggle-class');
13 var MenuBuilder = require('../../utils/menu-builder');
14 var appEvents = require('../../utils/appevents');
15 var apiClient = require('../../components/api-client');
16 var userNotifications = require('../../components/user-notifications');
17 var Dropdown = require('../controls/dropdown');
18 var KeyboardEventMixin = require('../keyboard-events-mixin');
19 var headerViewTemplate = require('./tmpl/headerViewTemplate.hbs');
20 var getHeaderViewOptions = require('gitter-web-shared/templates/get-header-view-options');
21 var ProfileMenu = require('../profile-menu/profile-menu-view');
23 require('../behaviors/tooltip');
24 require('transloadit');
26 var TRANSLOADIT_DEFAULT_OPTIONS = {
33 var HeaderView = Marionette.ItemView.extend({
34 template: headerViewTemplate,
37 change: 'renderIfRequired'
41 leftMenuToggle: '.js-header-view-left-menu-toggle',
42 avatarImage: '.js-chat-header-avatar-image',
43 groupAvatarUploadForm: '.js-chat-header-group-avatar-upload-form',
44 groupAvatarFileInput: '.js-chat-header-group-avatar-upload-input',
45 groupAvatarSignatureInput: '.js-chat-header-group-avatar-upload-signature',
46 groupAvatarParamsInput: '.js-chat-header-group-avatar-upload-params',
47 groupAvatarProgress: '.js-chat-header-group-avatar-upload-progress',
48 cog: '.js-chat-settings',
49 dropdownMenu: '#cog-dropdown',
50 topic: '.js-room-topic',
51 topicWrapper: '.js-room-topic-wrapper',
52 topicActivator: '.js-room-topic-edit-activator',
53 name: '.js-chat-name',
54 favourite: '.js-favourite-button',
55 orgrooms: '.js-chat-header-org-page-action'
59 'click @ui.leftMenuToggle': 'onLeftMenuToggleClick',
60 'change @ui.groupAvatarFileInput': 'onGroupAvatarUploadChange',
61 'click @ui.cog': 'showDropdown',
62 'click @ui.favourite': 'toggleFavourite',
63 'dblclick @ui.topicActivator': 'showInput',
64 'keydown textarea': 'detectKeys'
68 'room-topic.edit': 'showInput'
73 '.js-chat-header-group-avatar-upload-label': { placement: 'right' },
74 '.js-chat-name': { titleFn: 'getChatNameTitle', placement: 'right' },
75 '.js-chat-header-org-page-action': { placement: 'left' },
76 '.js-favourite-button': { placement: 'left' },
77 '.js-chat-settings': { placement: 'left' }
81 initialize: function(options) {
82 this.rightToolbarModel = options.rightToolbarModel;
83 this.menuItemsCollection = new Backbone.Collection([]);
87 serializeData: function() {
88 var data = this.model.toJSON();
90 var isStaff = context.isStaff();
91 var isAdmin = context.isTroupeAdmin();
92 var canChangeGroupAvatar = data.groupId && (isStaff || isAdmin);
93 const matrixRoomLink = context.troupe().get('matrixRoomLink');
96 headerView: getHeaderViewOptions(data),
97 user: !!context.isLoggedIn(),
98 archives: this.options.archives,
99 shouldShowPlaceholderRoomTopic: data.userCount <= 1,
100 canChangeGroupAvatar: canChangeGroupAvatar,
107 buildDropdown: function() {
108 if (context.isLoggedIn()) {
109 this.dropdown = new Dropdown({
110 // `allowClickPropagation` is true because some of the dropdown items are
111 // handled by the global public/js/components/link-handler.js
112 // If you don't want an item handled by the `link-handler` then
113 // add `data-disable-routing` (`dataset: { disableRouting: 1 }`) to the item
114 allowClickPropagation: true,
115 collection: this.menuItemsCollection,
118 // Do not set the target element for now as it's re-rendered on room
119 // change. We'll set it dynamically before showing the dropdown
122 // Other dropdown items may be handled by the global link-handler navigation
123 this.listenTo(this.dropdown, 'selected', function(e) {
124 var href = e.get('href');
125 if (href === '#leave') {
127 } else if (href === '#hide') {
129 } else if (href === '#notifications') {
130 this.requestBrowserNotificationsPermission();
131 } else if (href === '#favourite') {
132 this.toggleFavourite();
138 getChatNameTitle: function() {
139 var model = this.model;
140 if (model.get('public')) {
141 return 'Anyone can join';
144 if (model.get('oneToOne')) {
145 return 'This chat is just between you two';
148 var backend = model.get('backend');
150 switch (backend && backend.type) {
152 return 'All repo collaborators can join';
155 return 'All org members can join';
158 return `All GitLab ${backend.linkPath} group members can join`;
161 return `All GitLab ${backend.linkPath} project members can join`;
164 return 'Only invited users can join';
168 onRender: function() {
170 // Deal with re-renders
171 this.dropdown.hide();
174 this.setupProfileMenu();
176 var topicEl = this.ui.topic[0];
182 showDropdown: function() {
183 this.dropdown.setTargetElement(this.ui.cog[0]);
184 this.menuItemsCollection.reset(this.createMenu());
185 this.dropdown.show();
188 setupProfileMenu: function() {
189 if (context.isLoggedIn()) {
190 //If an instance of the profile menu exists destroy it to remove listeners etc
191 if (this.profileMenu) {
192 this.profileMenu.destroy();
194 //Make a new profile menu
195 const profileMenuElement = document.querySelector('#profile-menu');
196 if (profileMenuElement) {
197 this.profileMenu = new ProfileMenu({ el: profileMenuElement });
199 this.profileMenu.render();
204 // eslint-disable-next-line max-statements
205 createMenu: function() {
207 var isStaff = context.isStaff();
208 var isAdmin = context.isTroupeAdmin();
209 var isRoomMember = context.isRoomMember();
210 var isOneToOne = this.model.get('oneToOne');
211 var url = this.model.get('url');
212 var staffOrAdmin = isStaff || isAdmin;
214 var menuBuilder = new MenuBuilder();
216 menuBuilder.addConditional(!isOneToOne, { title: 'Add people to this room', href: '#add' });
217 menuBuilder.addConditional(!isOneToOne, { title: 'Share this chat room', href: '#share' });
218 menuBuilder.addDivider();
219 menuBuilder.addConditional(isRoomMember, {
220 title: `${this.model.get('favourite') ? 'Unfavourite' : 'Favourite'} room`,
223 menuBuilder.addConditional(isRoomMember, { title: 'Notifications', href: '#notifications' });
226 var settingMenuItem = c.isNativeDesktopApp
228 title: 'Integrations',
229 href: clientEnv['basePath'] + url + '#integrations',
231 dataset: { disableRouting: 1 }
233 : { title: 'Integrations', href: '#integrations' };
235 menuBuilder.addConditional(isAdmin, settingMenuItem);
237 menuBuilder.addConditional(staffOrAdmin, { title: 'Tags', href: '#tags' });
238 menuBuilder.addConditional(staffOrAdmin, { title: 'Settings', href: '#settings' });
239 menuBuilder.addConditional(staffOrAdmin, { title: 'Permissions', href: '#permissions' });
240 menuBuilder.addDivider();
242 menuBuilder.add({ title: 'Archives', href: url + '/archives/all', target: '_blank' });
244 const backend = getBackendForRoom(this.model);
245 const type = backend && backend.type;
246 const linkPath = backend && backend.linkPath;
248 const isGitHubObject = type === 'GH_REPO' || type === 'GH_ORG';
249 menuBuilder.addConditional(isGitHubObject, {
250 title: 'Open in GitHub',
251 href: `https://www.github.com/${linkPath}`,
254 const isGitlabObject = type === 'GL_GROUP';
255 menuBuilder.addConditional(isGitlabObject, {
256 title: 'Open in GitLab',
257 href: `https://gitlab.com/${linkPath}`,
261 const group = this.model.get('group');
263 const homeUri = group.homeUri;
265 title: 'Community home',
270 menuBuilder.addDivider();
272 menuBuilder.addConditional(isAdmin, { title: 'Export room data', href: '#export-room-data' });
273 menuBuilder.addConditional(isAdmin, { title: 'Delete this room', href: '#delete' });
274 menuBuilder.addConditional(isRoomMember, { title: 'Leave this room', href: '#leave' });
277 menuBuilder.addConditional(isRoomMember, { title: 'Hide this room', href: '#hide' });
279 return menuBuilder.getItems();
282 leaveRoom: function() {
283 if (!context.isLoggedIn()) return;
285 apiClient.room.delete('/users/' + context.getUserId(), {}).then(function() {
286 appEvents.trigger('navigation', '/home', 'home', ''); // TODO: figure out a title
287 //context.troupe().set('roomMember', false);
291 hideRoom: async function() {
292 // Hide the room in the UI immediately
293 this.model.set('lastAccessTime', null);
296 await apiClient.user.delete(`/rooms/${this.model.id}`);
298 // Go back to the user home so it looks like something happened to the user
299 // Otherwise, the room disappears from your left-menu but you remain in the room which can be a bit confusing
300 appEvents.trigger('navigation', '/home', 'home', '');
302 log.error('Error hiding room', { exception: err });
303 appEvents.triggerParent('user_notification', {
304 title: 'Error hiding room',
305 text: `Check the devtools console for more details: ${err.message}`
310 toggleFavourite: function() {
311 if (!context.isLoggedIn()) return;
313 this.model.set('favourite', !this.model.get('favourite'));
314 var isFavourite = !!this.model.get('favourite');
316 apiClient.userRoom.put('', { favourite: isFavourite });
319 saveTopic: function() {
320 var topic = this.$el.find('textarea').val();
321 context.troupe().set('topic', topic);
323 apiClient.room.put('', { topic: topic });
325 this.editingTopic = false;
328 cancelEditTopic: function() {
329 this.editingTopic = false;
333 detectKeys: function(e) {
334 this.detectReturn(e);
335 this.detectEscape(e);
338 detectReturn: function(e) {
339 if (e.keyCode === 13 && (!e.ctrlKey && !e.shiftKey)) {
347 detectEscape: function(e) {
348 if (e.keyCode === 27) {
349 // found escape, cancel edit
350 this.cancelEditTopic();
354 showInput: function() {
355 if (!context.isTroupeAdmin()) return;
356 if (this.editingTopic === true) return;
357 this.editingTopic = true;
359 var unsafeText = this.model.get('topic');
361 this.oldTopic = unsafeText;
363 toggleClass(this.ui.topicActivator[0], 'is-editing', true);
364 toggleClass(this.ui.topicWrapper[0], 'is-editing', true);
365 toggleClass(this.ui.topic[0], 'is-editing', true);
367 this.ui.topic.html('<textarea class="topic-input"></textarea>');
369 var textarea = this.ui.topic.find('textarea').val(unsafeText);
371 setTimeout(function() {
376 requestBrowserNotificationsPermission: function() {
377 userNotifications.requestAccess();
380 // Is HeaderView rendered as a part of archive view?
381 isArchive: () => !!context().archive,
383 // Look at the attributes that have changed
384 // and decide whether to re-render
385 renderIfRequired: function() {
386 var model = this.model;
388 function changedContains(changedAttributes) {
389 var changed = model.changed;
390 if (!changed) return;
391 for (var i = 0; i < changedAttributes.length; i++) {
392 if (changed.hasOwnProperty(changedAttributes[i])) return true;
410 // The template may have been set to false
411 // by the Isomorphic layout
412 this.options.template = headerViewTemplate;
415 // If it is a new chat header, we can edit the topic again
416 this.editingTopic = false;
420 onLeftMenuToggleClick() {
421 appEvents.trigger('dispatchVueAction', 'toggleLeftMenu', true);
424 onGroupAvatarUploadChange: function() {
425 this.uploadGroupAvatar();
428 updateProgressBar: function(spec) {
429 var bar = this.ui.groupAvatarProgress;
430 var value = spec.value && spec.value * 100 + '%';
431 bar.css('width', value);
434 resetProgressBar: function() {
435 this.ui.groupAvatarProgress.addClass('hidden');
436 this.updateProgressBar({
441 handleUploadStart: function() {
442 this.ui.groupAvatarProgress.removeClass('hidden');
443 this.updateProgressBar({
444 // Just show some progress
449 handleUploadProgress: function(done, expected) {
450 this.updateProgressBar({
451 value: done / expected
455 handleUploadSuccess: function(/*res*/) {
456 this.resetProgressBar();
457 appEvents.triggerParent('user_notification', {
458 title: 'Avatar upload complete',
459 text: 'Wait a few moments for your new avatar to appear...'
462 // TODO: Make this work not on refresh
466 appEvents.triggerParent('navigation', null, null, null, {
473 var urlParse = require('url-parse');
474 var urlJoin = require('url-join');
475 var avatars = require('gitter-web-avatars');
476 setTimeout(function() {
477 var currentRoom = context.troupe();
478 var currentGroup = this.groupsCollection.get(currentRoom.get('groupId'));
480 // Assemble the new URL
481 // We cache bust the long-running one so we can show the updated avatar
482 // When the user refreshes, they will go back to using the version avatar URL
483 var unversionedAvatarUrl = avatars.getForGroupId(currentGroup.get('id'));
484 var parsedAvatarUrl = urlParse(unversionedAvatarUrl, true);
485 parsedAvatarUrl.query.cacheBuster = Math.ceil(Math.random() * 9999);
486 var newAvatarUrl = parsedAvatarUrl.toString();
488 currentGroup.set('avatarUrl', newAvatarUrl);
489 currentRoom.set('avatarUrl', newAvatarUrl);
490 // TODO: This does not work because it is empty and is not shared with parent frame
491 if(this.roomCollection) {
492 console.log(this.roomCollection.where({ groupId: currentGroup.get('id') }));
498 handleUploadError: function(err) {
499 appEvents.triggerParent('user_notification', {
500 title: 'Error Uploading File',
503 this.resetProgressBar();
506 uploadGroupAvatar: function() {
507 var currentRoom = context.troupe();
512 // For groups that were created within page lifetime
513 var groupId = currentRoom.get('groupId');
515 this.handleUploadStart();
518 .get('/generate-signature', {
524 this.ui.groupAvatarParamsInput[0].setAttribute('value', res.params);
525 this.ui.groupAvatarSignatureInput[0].setAttribute('value', res.sig);
527 var formData = new FormData(this.ui.groupAvatarUploadForm[0]);
529 this.ui.groupAvatarUploadForm.unbind('submit.transloadit');
530 this.ui.groupAvatarUploadForm.transloadit(
531 _.extend(TRANSLOADIT_DEFAULT_OPTIONS, {
533 onStart: this.handleUploadStart.bind(this),
534 onProgress: this.handleUploadProgress.bind(this),
535 onSuccess: this.handleUploadSuccess.bind(this),
536 onError: this.handleUploadError.bind(this)
540 this.ui.groupAvatarUploadForm.trigger('submit.transloadit');
546 cocktail.mixin(HeaderView, KeyboardEventMixin);
547 module.exports = HeaderView;