client/app/pods/components/discussion-window/component.js

Summary

Maintainability
D
2 days
Test Coverage
//
//   Copyright 2009-2014 Ilkka Oksanen <iao@iki.fi>
//
//   Licensed under the Apache License, Version 2.0 (the "License");
//   you may not use this file except in compliance with the License.
//   You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
//   Unless required by applicable law or agreed to in writing,
//   software distributed under the License is distributed on an "AS
//   IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
//   express or implied.  See the License for the specific language
//   governing permissions and limitations under the License.
//

/* globals $ */

import { autorun } from 'mobx';
import { debounce, scheduleOnce, bind, cancel, throttle, run } from '@ember/runloop';
import EmberObject, { computed, observer } from '@ember/object';
import { on } from '@ember/object/evented';
import { alias } from '@ember/object/computed';
import Component from '@ember/component';
import PerfectScrollbar from 'perfect-scrollbar';
import Favico from 'favico.js';
import isMobile from 'ismobilejs';
import socket from '../../../utils/socket';
import windowStore from '../../../stores/WindowStore';
import { dispatch } from '../../../utils/dispatcher';
import { play } from '../../../utils/sound';

let faviconCounter = 0;

const favicon = new Favico({
  animation: 'slide'
});

document.addEventListener('visibilitychange', () => {
  if (!document.hidden) {
    faviconCounter = 0;
    favicon.reset();
  }
});

export default Component.extend({
  init(args) {
    this._super(args);

    this.content = EmberObject.create();

    const window = windowStore.windows.get(this.windowId);
    this.window = window;

    this.disposers = [
      autorun(() => {
        const newMostRecentMessage = window.sortedMessages[window.sortedMessages.length - 1];

        if (newMostRecentMessage && newMostRecentMessage.gid > this.mostRecentGid) {
          this.mostRecentGid = newMostRecentMessage.gid;
          this._lineAdded(newMostRecentMessage);
        }

        this.set('content.sortedMessages', window.sortedMessages);
      }),
      autorun(() => this.set('content.visible', window.visible)),
      autorun(() => this.set('content.notDelivered', window.notDelivered)),
      autorun(() => this.set('content.windowId', window.windowId)),
      autorun(() => this.set('content.userId', window.userId)),
      autorun(() => this.set('content.network', window.network)),
      autorun(() => this.set('content.type', window.type)),
      autorun(() => this.set('content.name', window.name)),
      autorun(() => this.set('content.row', window.row)),
      autorun(() => this.set('content.column', window.column)),
      autorun(() => this.set('content.password', window.password)),
      autorun(() => this.set('content.alerts', window.alerts)), // TODO: This is object!
      autorun(() => this.set('content.desktop', window.desktop)),
      autorun(() => this.set('content.operatorNames', window.operatorNames)),
      autorun(() => this.set('content.voiceNames', window.voiceNames)),
      autorun(() => this.set('content.userNames', window.userNames)),
      autorun(() => this.set('content.minimizedNamesList', window.minimizedNamesList)),
      autorun(() => this.set('content.decoratedTitle', window.decoratedTitle)),
      autorun(() => this.set('content.decoratedTopic', window.decoratedTopic)),
      autorun(() => this.set('content.simplifiedName', window.simplifiedName)),
      autorun(() => this.set('content.tooltipTopic', window.tooltipTopic)),
      autorun(() => this.set('content.explainedType', window.explainedType)),
      autorun(() => this.set('content.newMessagesCount', window.newMessagesCount))
    ];
  },

  didDestroyElement() {
    console.log('DELETE');
    this.disposers.forEach(element => element());
  },

  classNames: ['window'],

  classNameBindings: [
    'animating:velocity-animating:',
    'expanded:expanded:',
    'visible:visible:hidden',
    'ircServerWindow:irc-server-window:',
    'type'
  ],

  expanded: false,
  animating: false,
  scrollLock: false,
  fetchingMore: false,
  noOlderMessages: false,

  linesAmount: null,
  prependPosition: 0,

  $messagePanel: null,
  $messagesEndAnchor: null,
  logModeEnabled: false,
  scrollHandlersAdded: false,
  elementInserted: false,

  mostRecentGid: 0,
  scrollTimer: null,
  lazyImageTimer: null,

  participants: null,

  row: alias('content.row'),
  column: alias('content.column'),
  desktop: alias('content.desktop'),
  notDelivered: alias('content.notDelivered'),
  visible: alias('content.visible'),

  logOrMobileModeEnabled: computed('logModeEnabled', function () {
    return this.logModeEnabled || isMobile().any;
  }),

  fullBackLog: computed('content.messages.[]', function () {
    return this.get('content.messages.length') >= socket.maxBacklogMsgs;
  }),

  beginningReached: computed('fullBackLog', 'noOlderMessages', function () {
    return !this.fullBackLog || this.noOlderMessages;
  }),

  ircServerWindow: computed('content.userId', function () {
    return this.get('content.userId') === 'i0' ? 'irc-server-window' : '';
  }),

  isGroup: computed('content.type', function () {
    return this.get('content.type') === 'group';
  }),

  type: computed('content.type', function () {
    if (this.get('content.type') === 'group') {
      return 'group';
    }
    if (this.get('content.userId') === 'i0') {
      return 'server-1on1';
    }
    return 'private-1on1';
  }),

  hiddenIfLogMode: computed('logModeEnabled', function () {
    return this.logModeEnabled ? 'hidden' : '';
  }),

  hiddenIfMinimizedUserNames: computed('content.minimizedNamesList', function () {
    return this.get('content.minimizedNamesList') ? 'hidden' : '';
  }),

  wideUnlessminimizedNamesList: computed('content.minimizedNamesList', function () {
    return this.get('content.minimizedNamesList') ? '' : 'window-members-wide';
  }),

  windowChanged: observer('row', 'column', 'desktop', function () {
    if (this.elementInserted) {
      this.sendAction('relayout', { animate: true });
    }
  }),

  visibilityObserver: on(
    'init',
    observer('visible', function () {
      if (this.elementInserted) {
        this.sendAction('relayout', { animate: false });
      }
    })
  ),

  nickCompletionObserver: on(
    'init',
    observer('content.userNames.[]', 'content.voiceNames.[]', 'content.operatorNames.[]', function () {
      const operators = this.get('content.operatorNames') || [];

      debounce(
        this,
        function () {
          this.set('participants', operators.concat(this.get('content.voiceNames'), this.get('content.userNames')));
        },
        1000
      );
    })
  ),

  _lineAdded(message) {
    if (!message || !windowStore.initDone) {
      return;
    }

    const cat = message.cat;
    const importantMessage = cat === 'msg' || cat === 'action';

    if (document.hidden && importantMessage) {
      // Browser title notification
      if (this.get('content.alerts.title')) {
        favicon.badge(++faviconCounter);
      }

      // Sound notification
      if (this.get('content.alerts.sound')) {
        play();
      }

      if (
        this.get('content.alerts.notification') &&
        'Notification' in window &&
        Notification.permission !== 'denied' &&
        cat === 'msg'
      ) {
        const src = message.type === 'group' ? message.simplifiedName : '1on1';

        const ntf = new Notification(`${message.nick} (${src})`, {
          body: message.body,
          icon: message.avatarUrl
        });

        ntf.onclick = function () {
          parent.focus(); // eslint-disable-line no-restricted-globals
          window.focus(); // just in case, older browsers
          this.close();
        };

        setTimeout(() => ntf.close(), 5000);
      }
    }

    scheduleOnce('afterRender', this, function () {
      this._goToBottom(true);
    });
  },

  actions: {
    expand() {
      this.set('expanded', true);
      this.sendAction('relayout', { animate: true });
    },

    compress() {
      this.set('expanded', false);
      this.sendAction('relayout', { animate: true });
    },

    browse() {
      this.set('logModeEnabled', true);
      this.set('expanded', true);
      this.sendAction('relayout', { animate: true });
    },

    toggleMemberListWidth() {
      dispatch('TOGGLE_MEMBER_LIST_WIDTH', {
        window: this.window
      });

      scheduleOnce('afterRender', this, function () {
        this._goToBottom(true);
      });
    },

    processLine(msg) {
      dispatch('PROCESS_LINE', {
        body: msg,
        window: this.window
      });
    },

    editMessage(gid, msg) {
      dispatch('EDIT_MESSAGE', {
        body: msg,
        gid,
        window: this.window
      });
    },

    deleteMessage(gid) {
      dispatch('EDIT_MESSAGE', {
        body: '',
        gid,
        window: this.window
      });
    },

    close() {
      dispatch('CLOSE_WINDOW', {
        window: this.window
      });
    },

    menu(modalName) {
      dispatch('OPEN_MODAL', {
        name: modalName,
        model: this.window
      });
    },

    jumpToBottom() {
      this.set('scrollLock', false);
      this._goToBottom(true);
    },

    fetchMore() {
      this._requestMoreMessages();
    },

    upload(files) {
      dispatch('UPLOAD_FILES', {
        files,
        window: this.window
      });

      this.$('input[name="files"]').val('');
    }
  },

  move(dim, duration) {
    this.set('animating', true);

    this.$()
      .velocity('stop')
      .velocity(dim, {
        duration,
        visibility: 'visible',
        complete: bind(this, function () {
          this.set('animating', false);
          this._goToBottom(false, () => {
            this._showImages(); // Make sure window shows the images after scrolling
          });
        })
      });
  },

  mouseDown(event) {
    if ($(event.target).hasClass('fa-arrows')) {
      event.preventDefault();
      this.sendAction('dragWindowStart', this, event);
    }
  },

  didInsertElement() {
    this.sendAction('register', this);
    this.set('elementInserted', true);

    this.$messagePanel = this.$('.window-messages');
    this.$messagesEndAnchor = this.$('.window-messages-end');

    this.$('.window-caption').tooltip();
    this.$messagePanel.tooltip({
      selector: '.timestamp',
      placement: 'right'
    });

    let selectedUserId;

    const membersEl = this.$('.window-members')[0];
    const network = this.get('content.network');

    if (membersEl) {
      new PerfectScrollbar(membersEl); // eslint-disable-line no-new
    }

    this.$('.window-members').contextmenu({
      target: '#window-contextMenu',
      before(e) {
        const $target = $(e.target);

        if ($target.hasClass('window-members')) {
          return false;
        }

        e.preventDefault();
        const $row = $target.closest('.member-row');

        const selectedNick = $row.data('nick');
        const avatar = $row.find('.gravatar').attr('src');
        selectedUserId = $row.data('userid');

        this.getMenu().find('li').eq(0).html(`<img class="menu-avatar" src="${avatar}">${selectedNick}`);

        // Only MAS users can be added to a contacts list.
        $('.window-contexMenu-request-friend').toggle(selectedUserId.charAt(0) === 'm');

        return true;
      },
      onItem(context, e) {
        const action = $(e.target).data('action');

        if (action === 'chat') {
          dispatch('START_CHAT', {
            userId: selectedUserId,
            network
          });
        } else {
          dispatch('REQUEST_FRIEND', {
            userId: selectedUserId
          });
        }

        e.preventDefault();
      }
    });

    this.$('.window-members').click(function (e) {
      $(this).contextmenu('show', e);
      e.preventDefault();
      return false;
    });

    this.$messagePanel.magnificPopup({
      type: 'image',
      delegate: '.user-img:not(.user-img-close)',
      closeOnContentClick: true,
      image: {
        verticalFit: false,
        titleSrc(item) {
          const href = item.el.attr('href');

          return `<small>Link to the original image:</small><a href="${href}" target="_blank">${href}</a>`;
        }
      }
    });

    this.$('.btn-file input').change(e => this.send('upload', e.target.files));

    this.sendAction('relayout', { animate: false });
  },

  willDestroyElement() {
    this.$messagesEndAnchor.velocity('stop');
    this.$().velocity('stop');

    this.sendAction('unregister', this);

    cancel(this.scrollTimer);
    cancel(this.lazyImageTimer);

    this.sendAction('relayoutAfterRender', { animate: true });
  },

  willRender() {
    if (this.didPrepend) {
      const $panel = this.$messagePanel;
      const toBottom = $panel.prop('scrollHeight') - $panel.scrollTop();

      this.prependPosition = toBottom;
    }
  },

  didRender() {
    if (this.didPrepend) {
      const $panel = this.$messagePanel;
      const oldPos = $panel.prop('scrollHeight') - this.prependPosition;

      $panel.scrollTop(oldPos);
      this.didPrepend = false;
    }
  },

  _goToBottom(animate, callback) {
    if (this.scrollLock || !this.$messagesEndAnchor) {
      return;
    }

    const duration = animate ? 200 : 0;

    this.$messagesEndAnchor.velocity('stop').velocity('scroll', {
      container: this.$messagePanel,
      duration,
      easing: 'spring',
      offset: -1 * this.$messagePanel.innerHeight() + 15, // 5px padding plus some extra
      complete: bind(this, function () {
        if (callback) {
          callback();
        }

        if (!this.scrollHandlersAdded) {
          this._addScrollHandler();
          this._addLazyImageScrollHandler();
          this.scrollHandlersAdded = true;
        }
      })
    });
  },

  _addScrollHandler() {
    const handler = function () {
      if (this.animating) {
        return;
      }

      const $panel = this.$messagePanel;
      const scrollPos = $panel.scrollTop();

      const scrollBottomThreshold = 30; // User doesn't need to scroll exactly to the end.
      const scrollTopThreshold = 30; // Or to up to trigger fetching of more messages.

      const bottomPosition = $panel.prop('scrollHeight') - scrollBottomThreshold;
      const topPosition = scrollTopThreshold;

      if (scrollPos < topPosition) {
        this._requestMoreMessages();
      }

      if (scrollPos + $panel.innerHeight() >= bottomPosition) {
        this.set('scrollLock', false);
      } else {
        this.set('scrollLock', true);
      }

      console.log(`scrollLock: ${this.scrollLock}`);
    };

    this.$messagePanel.on('scroll', () => {
      // Delay nust be longer than goToBottom animation
      this.scrollTimer = throttle(this, handler, 250, false);
    });
  },

  _addLazyImageScrollHandler() {
    this.$messagePanel.on('scroll', () => {
      this.lazyImageTimer = throttle(this, this._showImages, 250, false);
    });
  },

  _showImages() {
    const $imgContainers = this.$('ul[data-has-images="true"]');

    if (!$imgContainers) {
      return;
    }

    const placeHolderHeight = 31;
    const panelHeight = this.$messagePanel.height();
    const that = this;

    $imgContainers.each(function () {
      const $imgContainer = $(this);
      // We want to know image's position in .window-messages container div. For position()
      // to work correctly, .window-messages has to have position set to 'relative'. See
      // jQuery offsetParent() documentation for details.
      const pos = $imgContainer.position().top;

      if (pos + placeHolderHeight >= 0 && pos <= panelHeight) {
        // Images of this message are in view port. Start to lazy load images.

        const componentId = $imgContainer.parent().parent().parent().prop('id');

        const component = window.MasApp.__container__.lookup('-view-registry:main')[componentId];

        if (!component) {
          return;
        }

        const images = component.images || [];

        for (let i = 0; i < images.length; i++) {
          const image = images[i];

          if (!image.source) {
            // Image hasn't been already loaded
            that._loadImage(image, $imgContainer, i);
          }
        }
      }
    });
  },

  _loadImage(image, $container, index) {
    image.set('source', image.url);

    const $image = $container.find('img').eq(index);
    const that = this;

    $image.one('load error', e => {
      if (e.type === 'error') {
        $image.parent().hide(); // Container list element
      }

      run(() => {
        console.log('Lazy loaded image');
        that._goToBottom(true);
      });
    });
  },

  _requestMoreMessages() {
    if (this.fetchingMore || this.noOlderMessages) {
      return;
    }

    this.set('fetchingMore', true);

    dispatch('FETCH_OLDER_MESSAGES', {
      window: this.window,
      successCb: foundMessages => {
        if (foundMessages) {
          this.didPrepend = true;
        } else {
          this.set('noOlderMessages', true);
        }

        this.set('fetchingMore', false);
      }
    });
  }
});