You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			995 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			995 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			JavaScript
		
	
/* global
 | 
						|
  $,
 | 
						|
  _,
 | 
						|
  extension,
 | 
						|
  i18n,
 | 
						|
  Signal,
 | 
						|
  storage,
 | 
						|
  Whisper,
 | 
						|
*/
 | 
						|
 | 
						|
// eslint-disable-next-line func-names
 | 
						|
(function() {
 | 
						|
  'use strict';
 | 
						|
 | 
						|
  window.Whisper = window.Whisper || {};
 | 
						|
  const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
 | 
						|
 | 
						|
  Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
 | 
						|
    render_attributes() {
 | 
						|
      return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
 | 
						|
    },
 | 
						|
  });
 | 
						|
 | 
						|
  Whisper.ConversationView = Whisper.View.extend({
 | 
						|
    className() {
 | 
						|
      return ['conversation', this.model.get('type')].join(' ');
 | 
						|
    },
 | 
						|
    id() {
 | 
						|
      return `conversation-${this.model.cid}`;
 | 
						|
    },
 | 
						|
    template: $('#conversation').html(),
 | 
						|
    render_attributes() {
 | 
						|
      return {
 | 
						|
        'disable-inputs': false,
 | 
						|
        'send-message': i18n('sendMessage'),
 | 
						|
      };
 | 
						|
    },
 | 
						|
    initialize(options) {
 | 
						|
      this.listenTo(this.model, 'destroy', this.stopListening);
 | 
						|
      this.listenTo(this.model, 'newmessage', this.addMessage);
 | 
						|
      this.listenTo(this.model, 'opened', this.onOpened);
 | 
						|
      this.listenTo(this.model, 'prune', this.onPrune);
 | 
						|
      this.listenTo(this.model, 'unload', () => this.unload('model trigger'));
 | 
						|
      this.listenTo(this.model, 'typing-update', this.renderTypingBubble);
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'show-identity',
 | 
						|
        this.showSafetyNumber
 | 
						|
      );
 | 
						|
      this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
 | 
						|
      this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'scroll-to-message',
 | 
						|
        this.scrollToMessage
 | 
						|
      );
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'show-contact-detail',
 | 
						|
        this.showContactDetail
 | 
						|
      );
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'show-lightbox',
 | 
						|
        this.showLightbox
 | 
						|
      );
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'download',
 | 
						|
        this.downloadAttachment
 | 
						|
      );
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'open-conversation',
 | 
						|
        this.openConversation
 | 
						|
      );
 | 
						|
      this.listenTo(
 | 
						|
        this.model.messageCollection,
 | 
						|
        'show-message-detail',
 | 
						|
        this.showMessageDetail
 | 
						|
      );
 | 
						|
 | 
						|
      this.throttledGetProfiles = _.throttle(
 | 
						|
        this.model.getProfiles.bind(this.model),
 | 
						|
        1000 * 60 * 5 // five minutes
 | 
						|
      );
 | 
						|
      this.debouncedMaybeGrabLinkPreview = _.debounce(
 | 
						|
        this.maybeGrabLinkPreview.bind(this),
 | 
						|
        200
 | 
						|
      );
 | 
						|
 | 
						|
      this.render();
 | 
						|
 | 
						|
      this.window = options.window;
 | 
						|
 | 
						|
      Whisper.events.on('mediaPermissionsChanged', () =>
 | 
						|
        this.toggleMicrophone()
 | 
						|
      );
 | 
						|
 | 
						|
      this.memberView.render();
 | 
						|
 | 
						|
      this.$messageField = this.$('.send-message');
 | 
						|
 | 
						|
      this.onResize = this.forceUpdateMessageFieldSize.bind(this);
 | 
						|
      this.window.addEventListener('resize', this.onResize);
 | 
						|
 | 
						|
      this.onFocus = () => {
 | 
						|
        if (!this.isHidden()) {
 | 
						|
          this.markRead();
 | 
						|
        }
 | 
						|
      };
 | 
						|
      this.window.addEventListener('focus', this.onFocus);
 | 
						|
 | 
						|
      extension.windows.onClosed(() => {
 | 
						|
        this.unload('windows closed');
 | 
						|
      });
 | 
						|
 | 
						|
      this.$('.send-message').focus(this.focusBottomBar.bind(this));
 | 
						|
      this.$('.send-message').blur(this.unfocusBottomBar.bind(this));
 | 
						|
 | 
						|
      this.selectMember = this.selectMember.bind(this);
 | 
						|
 | 
						|
      const updateMemberList = async () => {
 | 
						|
        const allPubKeys = await window.Signal.Data.getPubkeysInPublicConversation(
 | 
						|
          this.model.id
 | 
						|
        );
 | 
						|
 | 
						|
        const allMembers = allPubKeys.map(pubKey => {
 | 
						|
          const conv = window.getConversationController().get(pubKey);
 | 
						|
          let profileName = 'Anonymous';
 | 
						|
          if (conv) {
 | 
						|
            profileName = conv.getProfileName();
 | 
						|
          }
 | 
						|
          return {
 | 
						|
            id: pubKey,
 | 
						|
            authorPhoneNumber: pubKey,
 | 
						|
            authorProfileName: profileName,
 | 
						|
          };
 | 
						|
        });
 | 
						|
 | 
						|
        window.lokiPublicChatAPI.setListOfMembers(allMembers);
 | 
						|
      };
 | 
						|
 | 
						|
      if (this.model.isPublic()) {
 | 
						|
        updateMemberList();
 | 
						|
        setInterval(updateMemberList, 10000);
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    events: {
 | 
						|
      keydown: 'onKeyDown',
 | 
						|
      'submit .send': 'handleSubmitPressed',
 | 
						|
      'input .send-message': 'handleInputEvent',
 | 
						|
      'keydown .send-message': 'handleInputEvent',
 | 
						|
      'keyup .send-message': 'onKeyUp',
 | 
						|
      click: 'onClick',
 | 
						|
      'click .bottom-bar': 'focusMessageField',
 | 
						|
      'click .capture-audio .microphone': 'captureAudio',
 | 
						|
      'click .module-scroll-down': 'scrollToBottom',
 | 
						|
      'focus .send-message': 'focusBottomBar',
 | 
						|
      'change .file-input': 'toggleMicrophone',
 | 
						|
      'blur .send-message': 'unfocusBottomBar',
 | 
						|
      'force-resize': 'forceUpdateMessageFieldSize',
 | 
						|
 | 
						|
      'change input.file-input': 'onChoseAttachment',
 | 
						|
 | 
						|
      dragover: 'onDragOver',
 | 
						|
      dragleave: 'onDragLeave',
 | 
						|
      // TODO(Loki): restore when we support attachments
 | 
						|
      // drop: 'onDrop',
 | 
						|
      // paste: 'onPaste',
 | 
						|
    },
 | 
						|
 | 
						|
    onPaste(e) {
 | 
						|
      this.fileInput.onPaste(e);
 | 
						|
    },
 | 
						|
 | 
						|
    onPrune() {
 | 
						|
      if (!this.model.messageCollection.length || !this.lastActivity) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const oneHourAgo = Date.now() - 60 * 60 * 1000;
 | 
						|
      if (this.isHidden() && this.lastActivity < oneHourAgo) {
 | 
						|
        this.unload('inactivity');
 | 
						|
      } else if (this.view.atBottom()) {
 | 
						|
        this.trim();
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    unload(reason) {
 | 
						|
      window.log.info(
 | 
						|
        'unloading conversation',
 | 
						|
        this.model.idForLogging(),
 | 
						|
        'due to:',
 | 
						|
        reason
 | 
						|
      );
 | 
						|
 | 
						|
      this.fileInput.remove();
 | 
						|
 | 
						|
      if (this.captureAudioView) {
 | 
						|
        this.captureAudioView.remove();
 | 
						|
      }
 | 
						|
      if (this.banner) {
 | 
						|
        this.banner.remove();
 | 
						|
      }
 | 
						|
      if (this.lastSeenIndicator) {
 | 
						|
        this.lastSeenIndicator.remove();
 | 
						|
      }
 | 
						|
      if (this.scrollDownButton) {
 | 
						|
        this.scrollDownButton.remove();
 | 
						|
      }
 | 
						|
      if (this.quoteView) {
 | 
						|
        this.quoteView.remove();
 | 
						|
      }
 | 
						|
      if (this.lightBoxView) {
 | 
						|
        this.lightBoxView.remove();
 | 
						|
      }
 | 
						|
      if (this.lightboxGalleryView) {
 | 
						|
        this.lightboxGalleryView.remove();
 | 
						|
      }
 | 
						|
      if (this.panels && this.panels.length) {
 | 
						|
        for (let i = 0, max = this.panels.length; i < max; i += 1) {
 | 
						|
          const panel = this.panels[i];
 | 
						|
          panel.remove();
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      this.window.removeEventListener('resize', this.onResize);
 | 
						|
      this.window.removeEventListener('focus', this.onFocus);
 | 
						|
 | 
						|
      this.view.remove();
 | 
						|
 | 
						|
      this.remove();
 | 
						|
 | 
						|
      this.model.messageCollection.forEach(model => {
 | 
						|
        model.trigger('unload');
 | 
						|
      });
 | 
						|
      this.model.messageCollection.reset([]);
 | 
						|
    },
 | 
						|
 | 
						|
    trim() {
 | 
						|
      const MAX = 100;
 | 
						|
      const toRemove = this.model.messageCollection.length - MAX;
 | 
						|
      if (toRemove <= 0) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const models = [];
 | 
						|
      for (let i = 0; i < toRemove; i += 1) {
 | 
						|
        const model = this.model.messageCollection.at(i);
 | 
						|
        models.push(model);
 | 
						|
      }
 | 
						|
 | 
						|
      if (!models.length) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      window.log.info(
 | 
						|
        'trimming conversation',
 | 
						|
        this.model.idForLogging(),
 | 
						|
        'of',
 | 
						|
        models.length,
 | 
						|
        'old messages'
 | 
						|
      );
 | 
						|
 | 
						|
      this.model.messageCollection.remove(models);
 | 
						|
      _.forEach(models, model => {
 | 
						|
        model.trigger('unload');
 | 
						|
      });
 | 
						|
    },
 | 
						|
    renderTypingBubble() {
 | 
						|
      const timers = this.model.contactTypingTimers || {};
 | 
						|
      const records = _.values(timers);
 | 
						|
      const mostRecent = _.first(_.sortBy(records, 'timestamp'));
 | 
						|
 | 
						|
      if (!mostRecent && this.typingBubbleView) {
 | 
						|
        this.typingBubbleView.remove();
 | 
						|
        this.typingBubbleView = null;
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    updateUnread() {
 | 
						|
      this.resetLastSeenIndicator();
 | 
						|
      // Waiting for scrolling caused by resetLastSeenIndicator to settle down
 | 
						|
      setTimeout(this.markRead.bind(this), 1);
 | 
						|
    },
 | 
						|
 | 
						|
    onLoaded() {
 | 
						|
      const view = this.loadingScreen;
 | 
						|
      if (view) {
 | 
						|
        const openDelta = Date.now() - this.openStart;
 | 
						|
        window.log.info(
 | 
						|
          'Conversation',
 | 
						|
          this.model.idForLogging(),
 | 
						|
          'took',
 | 
						|
          openDelta,
 | 
						|
          'milliseconds to load'
 | 
						|
        );
 | 
						|
        this.loadingScreen = null;
 | 
						|
        view.remove();
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    onOpened() {
 | 
						|
      this.openStart = Date.now();
 | 
						|
      this.lastActivity = Date.now();
 | 
						|
 | 
						|
      this.model.updateLastMessage();
 | 
						|
 | 
						|
      if (this.model.isRss()) {
 | 
						|
        $('.compose').hide();
 | 
						|
        $('.conversation-stack').removeClass('conversation-stack-no-border');
 | 
						|
        $('.conversation-stack').addClass('conversation-stack-border');
 | 
						|
      } else {
 | 
						|
        $('.compose').show();
 | 
						|
        $('.conversation-stack').removeClass('conversation-stack-border');
 | 
						|
        $('.conversation-stack').addClass('conversation-stack-no-border');
 | 
						|
      }
 | 
						|
 | 
						|
      // We schedule our catch-up decrypt right after any in-progress fetch of
 | 
						|
      //   messages from the database, then ensure that the loading screen is only
 | 
						|
      //   dismissed when that is complete.
 | 
						|
      const messagesLoaded = this.inProgressFetch || Promise.resolve();
 | 
						|
 | 
						|
      // eslint-disable-next-line more/no-then
 | 
						|
      messagesLoaded.then(this.onLoaded.bind(this), this.onLoaded.bind(this));
 | 
						|
 | 
						|
      this.view.resetScrollPosition();
 | 
						|
      this.$el.trigger('force-resize');
 | 
						|
      this.focusMessageField();
 | 
						|
      this.renderTypingBubble();
 | 
						|
 | 
						|
      if (this.inProgressFetch) {
 | 
						|
        // eslint-disable-next-line more/no-then
 | 
						|
        this.inProgressFetch.then(this.updateUnread.bind(this));
 | 
						|
      } else {
 | 
						|
        this.updateUnread();
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    async loadMoreMessages() {
 | 
						|
      if (this.inProgressFetch) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      this.view.measureScrollPosition();
 | 
						|
      const startingHeight = this.view.scrollHeight;
 | 
						|
 | 
						|
      // We delay this work to let scrolling/layout settle down first
 | 
						|
      setTimeout(() => {
 | 
						|
        this.view.measureScrollPosition();
 | 
						|
        const endingHeight = this.view.scrollHeight;
 | 
						|
        const delta = endingHeight - startingHeight;
 | 
						|
        const height = this.view.outerHeight;
 | 
						|
 | 
						|
        const newScrollPosition = this.view.scrollPosition + delta - height;
 | 
						|
        this.view.$el.scrollTop(newScrollPosition);
 | 
						|
      }, 1);
 | 
						|
    },
 | 
						|
 | 
						|
    addMessage(message) {
 | 
						|
      // This is debounced, so it won't hit the database too often.
 | 
						|
      // We do this here because we don't want convo.messageCollection to have
 | 
						|
      //   anything in it unless it has an associated view. This is so, when we
 | 
						|
      //   fetch on open, it's clean.
 | 
						|
      this.model.addSingleMessage(message);
 | 
						|
 | 
						|
      if (message.isOutgoing()) {
 | 
						|
        this.removeLastSeenIndicator();
 | 
						|
      }
 | 
						|
      if (this.lastSeenIndicator) {
 | 
						|
        this.lastSeenIndicator.increment(1);
 | 
						|
      }
 | 
						|
 | 
						|
      if (!this.isHidden() && !window.isFocused()) {
 | 
						|
        // The conversation is visible, but window is not focused
 | 
						|
        if (!this.lastSeenIndicator) {
 | 
						|
          this.resetLastSeenIndicator({ scroll: false });
 | 
						|
        } else if (
 | 
						|
          this.view.atBottom() &&
 | 
						|
          this.model.get('unreadCount') === this.lastSeenIndicator.getCount()
 | 
						|
        ) {
 | 
						|
          // The count check ensures that the last seen indicator is still in
 | 
						|
          //   sync with the real number of unread, so we can scroll to it.
 | 
						|
          //   We only do this if we're at the bottom, because that signals that
 | 
						|
          //   the user is okay with us changing scroll around so they see the
 | 
						|
          //   right unseen message first.
 | 
						|
          this.resetLastSeenIndicator({ scroll: true });
 | 
						|
        }
 | 
						|
      } else if (!this.isHidden() && window.isFocused()) {
 | 
						|
        // The conversation is visible and in focus
 | 
						|
        this.markRead();
 | 
						|
 | 
						|
        // When we're scrolled up and we don't already have a last seen indicator
 | 
						|
        //   we add a new one.
 | 
						|
        if (!this.view.atBottom() && !this.lastSeenIndicator) {
 | 
						|
          this.resetLastSeenIndicator({ scroll: false });
 | 
						|
        }
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    onClick() {
 | 
						|
      // If there are sub-panels open, we don't want to respond to clicks
 | 
						|
      if (!this.panels || !this.panels.length) {
 | 
						|
        this.markRead();
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    findNewestVisibleUnread() {
 | 
						|
      const collection = this.model.messageCollection;
 | 
						|
      const { length } = collection;
 | 
						|
      const viewportBottom = this.view.outerHeight;
 | 
						|
      const unreadCount = this.model.get('unreadCount') || 0;
 | 
						|
 | 
						|
      // Start with the most recent message, search backwards in time
 | 
						|
      let foundUnread = 0;
 | 
						|
      for (let i = length - 1; i >= 0; i -= 1) {
 | 
						|
        // Search the latest 30, then stop if we believe we've covered all known
 | 
						|
        //   unread messages. The unread should be relatively recent.
 | 
						|
        // Why? local notifications can be unread but won't be reflected the
 | 
						|
        //   conversation's unread count.
 | 
						|
        if (i > 30 && foundUnread >= unreadCount) {
 | 
						|
          return null;
 | 
						|
        }
 | 
						|
 | 
						|
        const message = collection.at(i);
 | 
						|
        if (!message.get('unread')) {
 | 
						|
          // eslint-disable-next-line no-continue
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        foundUnread += 1;
 | 
						|
 | 
						|
        const el = this.$(`#${message.id}`);
 | 
						|
        const position = el.position();
 | 
						|
        // This message is likely not loaded yet in the DOM
 | 
						|
        if (!position) {
 | 
						|
          // should this be break?
 | 
						|
 | 
						|
          // eslint-disable-next-line no-continue
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
        const { top } = position;
 | 
						|
 | 
						|
        // We're fully below the viewport, continue searching up.
 | 
						|
        if (top > viewportBottom) {
 | 
						|
          // eslint-disable-next-line no-continue
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        // If the bottom fits on screen, we'll call it visible. Even if the
 | 
						|
        //   message is really tall.
 | 
						|
        const height = el.height();
 | 
						|
        const bottom = top + height;
 | 
						|
        if (bottom <= viewportBottom) {
 | 
						|
          return message;
 | 
						|
        }
 | 
						|
 | 
						|
        // Continue searching up.
 | 
						|
      }
 | 
						|
 | 
						|
      return null;
 | 
						|
    },
 | 
						|
 | 
						|
    async showMembers(e, providedMembers, options = {}) {
 | 
						|
      _.defaults(options, { needVerify: false });
 | 
						|
 | 
						|
      const model = providedMembers || this.model.contactCollection;
 | 
						|
      const view = new Whisper.GroupMemberList({
 | 
						|
        model,
 | 
						|
        // we pass this in to allow nested panels
 | 
						|
        listenBack: this.listenBack.bind(this),
 | 
						|
        needVerify: options.needVerify,
 | 
						|
      });
 | 
						|
 | 
						|
      this.listenBack(view);
 | 
						|
    },
 | 
						|
 | 
						|
    forceSend({ contact, message }) {
 | 
						|
      message.resend(contact.id);
 | 
						|
    },
 | 
						|
 | 
						|
    showContactDetail({ contact, hasSignalAccount }) {
 | 
						|
      const regionCode = storage.get('regionCode');
 | 
						|
      const { contactSelector } = Signal.Types.Contact;
 | 
						|
 | 
						|
      const view = new Whisper.ReactWrapperView({
 | 
						|
        Component: Signal.Components.ContactDetail,
 | 
						|
        className: 'contact-detail-pane panel',
 | 
						|
        props: {
 | 
						|
          contact: contactSelector(contact, {
 | 
						|
            regionCode,
 | 
						|
            getAbsoluteAttachmentPath,
 | 
						|
          }),
 | 
						|
          hasSignalAccount,
 | 
						|
          onSendMessage: () => {
 | 
						|
            const number =
 | 
						|
              contact.number && contact.number[0] && contact.number[0].value;
 | 
						|
            if (number) {
 | 
						|
              this.openConversation(number);
 | 
						|
            }
 | 
						|
          },
 | 
						|
        },
 | 
						|
        onClose: () => {
 | 
						|
          this.resetPanel();
 | 
						|
          this.updateHeader();
 | 
						|
        },
 | 
						|
      });
 | 
						|
 | 
						|
      this.listenBack(view);
 | 
						|
      this.updateHeader();
 | 
						|
    },
 | 
						|
 | 
						|
    listenBack(view) {
 | 
						|
      this.panels = this.panels || [];
 | 
						|
      if (this.panels.length > 0) {
 | 
						|
        this.panels[0].$el.hide();
 | 
						|
      }
 | 
						|
      this.panels.unshift(view);
 | 
						|
      view.$el.insertBefore(this.$('.panel').first());
 | 
						|
    },
 | 
						|
    resetPanel() {
 | 
						|
      if (!this.panels || !this.panels.length) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const view = this.panels.shift();
 | 
						|
 | 
						|
      if (this.panels.length > 0) {
 | 
						|
        this.panels[0].$el.show();
 | 
						|
      }
 | 
						|
      view.remove();
 | 
						|
 | 
						|
      if (this.panels.length === 0) {
 | 
						|
        this.$el.trigger('force-resize');
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    setDisappearingMessages(seconds) {
 | 
						|
      if (seconds > 0) {
 | 
						|
        this.model.updateExpirationTimer(seconds);
 | 
						|
      } else {
 | 
						|
        this.model.updateExpirationTimer(null);
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    stripQuery(text, cursorPos) {
 | 
						|
      const end = text.slice(cursorPos).search(/[^a-fA-F0-9]/);
 | 
						|
      const mentionEnd = end === -1 ? text.length : cursorPos + end;
 | 
						|
 | 
						|
      const stripped = text.substr(0, mentionEnd);
 | 
						|
 | 
						|
      const mentionStart = stripped.lastIndexOf('@');
 | 
						|
 | 
						|
      const query = stripped.substr(mentionStart, mentionEnd - mentionStart);
 | 
						|
 | 
						|
      return [stripped.substr(0, mentionStart), query, text.substr(mentionEnd)];
 | 
						|
    },
 | 
						|
 | 
						|
    selectMember(member) {
 | 
						|
      const cursorPos = this.$messageField[0].selectionStart;
 | 
						|
      // Note: skipping the middle value here
 | 
						|
      const [prev, , end] = this.stripQuery(
 | 
						|
        this.$messageField.val(),
 | 
						|
        cursorPos
 | 
						|
      );
 | 
						|
 | 
						|
      const handle = this.memberView.addPubkeyMapping(
 | 
						|
        member.authorProfileName,
 | 
						|
        member.authorPhoneNumber
 | 
						|
      );
 | 
						|
 | 
						|
      let firstHalf = `${prev}${handle}`;
 | 
						|
      let newCursorPos = firstHalf.length;
 | 
						|
 | 
						|
      const needExtraWhitespace = end.length === 0 || /\b/.test(end[0]);
 | 
						|
      if (needExtraWhitespace) {
 | 
						|
        firstHalf += ' ';
 | 
						|
        newCursorPos += 1;
 | 
						|
      }
 | 
						|
 | 
						|
      const result = firstHalf + end;
 | 
						|
 | 
						|
      this.$messageField.val(result);
 | 
						|
      this.$messageField[0].selectionStart = newCursorPos;
 | 
						|
      this.$messageField[0].selectionEnd = newCursorPos;
 | 
						|
      this.$messageField.trigger('input');
 | 
						|
    },
 | 
						|
 | 
						|
    async sendMessage(e) {
 | 
						|
      this.removeLastSeenIndicator();
 | 
						|
      this.model.clearTypingTimers();
 | 
						|
 | 
						|
      const input = this.$messageField;
 | 
						|
 | 
						|
      const message = this.memberView.replaceMentions(input.val());
 | 
						|
 | 
						|
      try {
 | 
						|
        if (!message.length && !this.fileInput.hasFiles()) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        const attachments = await this.fileInput.getFiles();
 | 
						|
        const sendDelta = Date.now() - this.sendStart;
 | 
						|
        window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
 | 
						|
 | 
						|
        this.model.sendMessage(
 | 
						|
          message,
 | 
						|
          attachments,
 | 
						|
          this.quote,
 | 
						|
          this.getLinkPreview()
 | 
						|
        );
 | 
						|
 | 
						|
        input.val('');
 | 
						|
        this.memberView.deleteMention();
 | 
						|
        this.setQuoteMessage(null);
 | 
						|
        this.resetLinkPreview();
 | 
						|
        this.focusMessageFieldAndClearDisabled();
 | 
						|
        this.forceUpdateMessageFieldSize(e);
 | 
						|
        this.fileInput.clearAttachments();
 | 
						|
      } catch (error) {
 | 
						|
        window.log.error(
 | 
						|
          'Error pulling attached files before send',
 | 
						|
          error && error.stack ? error.stack : error
 | 
						|
        );
 | 
						|
      } finally {
 | 
						|
        this.focusMessageFieldAndClearDisabled();
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    handleDeleteOrBackspace(event, isDelete) {
 | 
						|
      const $input = this.$messageField[0];
 | 
						|
      const text = this.$messageField.val();
 | 
						|
 | 
						|
      // Only handle the case when nothing is selected
 | 
						|
      if ($input.selectionDirection !== 'none') {
 | 
						|
        // Note: if this ends up deleting a handle, we should
 | 
						|
        // (ideally) check if we need to update the mapping in
 | 
						|
        // `this.memberView`, but that's not vital as we already
 | 
						|
        // reset it on every 'send'
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const mentions = this.memberView.pendingMentions();
 | 
						|
 | 
						|
      const _ = window.Lodash; // no underscore.js please
 | 
						|
      const predicate = isDelete ? _.startsWith : _.endsWith;
 | 
						|
 | 
						|
      const pos = $input.selectionStart;
 | 
						|
      const part = isDelete ? text.substr(pos) : text.substr(0, pos);
 | 
						|
 | 
						|
      const curMention = _.keys(mentions).find(key => predicate(part, key));
 | 
						|
 | 
						|
      if (!curMention) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      event.preventDefault();
 | 
						|
 | 
						|
      const beforeMention = isDelete
 | 
						|
        ? text.substr(0, pos)
 | 
						|
        : text.substr(0, pos - curMention.length);
 | 
						|
      const afterMention = isDelete
 | 
						|
        ? text.substr(pos + curMention.length)
 | 
						|
        : text.substr(pos);
 | 
						|
 | 
						|
      const resText = beforeMention + afterMention;
 | 
						|
      // NOTE: this doesn't work well with undo/redo, perhaps
 | 
						|
      // we should fix it one day
 | 
						|
      this.$messageField.val(resText);
 | 
						|
 | 
						|
      const nextPos = isDelete ? pos : pos - curMention.length;
 | 
						|
 | 
						|
      $input.selectionStart = nextPos;
 | 
						|
      $input.selectionEnd = nextPos;
 | 
						|
 | 
						|
      this.memberView.deleteMention(curMention);
 | 
						|
    },
 | 
						|
 | 
						|
    handleLeftRight(event, isLeft) {
 | 
						|
      // Return next cursor position candidate before we take
 | 
						|
      // various modifier keys into account
 | 
						|
      const nextPos = (text, cursorPos, isLeft2, isAltPressed) => {
 | 
						|
        // If the next char is ' ', skip it if Alt is pressed
 | 
						|
        let pos = cursorPos;
 | 
						|
        if (isAltPressed) {
 | 
						|
          const nextChar = isLeft2
 | 
						|
            ? text.substr(pos - 1, 1)
 | 
						|
            : text.substr(pos, 1);
 | 
						|
          if (nextChar === ' ') {
 | 
						|
            pos = isLeft2 ? pos - 1 : pos + 1;
 | 
						|
          }
 | 
						|
        }
 | 
						|
 | 
						|
        const part = isLeft2 ? text.substr(0, pos) : text.substr(pos);
 | 
						|
 | 
						|
        const mentions = this.memberView.pendingMentions();
 | 
						|
 | 
						|
        const predicate = isLeft2
 | 
						|
          ? window.Lodash.endsWith
 | 
						|
          : window.Lodash.startsWith;
 | 
						|
 | 
						|
        const curMention = _.keys(mentions).find(key => predicate(part, key));
 | 
						|
 | 
						|
        const offset = curMention ? curMention.length : 1;
 | 
						|
 | 
						|
        const resPos = isLeft2 ? Math.max(0, pos - offset) : pos + offset;
 | 
						|
 | 
						|
        return resPos;
 | 
						|
      };
 | 
						|
 | 
						|
      event.preventDefault();
 | 
						|
 | 
						|
      const $input = this.$messageField[0];
 | 
						|
 | 
						|
      const posStart = $input.selectionStart;
 | 
						|
      const posEnd = $input.selectionEnd;
 | 
						|
 | 
						|
      const text = this.$messageField.val();
 | 
						|
 | 
						|
      const posToChange =
 | 
						|
        $input.selectionDirection === 'forward' ? posEnd : posStart;
 | 
						|
 | 
						|
      let newPos = nextPos(text, posToChange, isLeft, event.altKey);
 | 
						|
 | 
						|
      // If command (macos) key is pressed, go to the beginning/end
 | 
						|
      // (this shouldn't affect Windows, but we should double check that)
 | 
						|
      if (event.metaKey) {
 | 
						|
        newPos = isLeft ? 0 : text.length;
 | 
						|
      }
 | 
						|
 | 
						|
      // Alt would normally make the cursor go until the next whitespace,
 | 
						|
      // but we need to take the presence of a mention into account
 | 
						|
      if (event.altKey || event.ctrlKey) {
 | 
						|
        const searchFrom = isLeft ? posToChange - 1 : posToChange + 1;
 | 
						|
        const toSearch = isLeft
 | 
						|
          ? text.substr(0, searchFrom)
 | 
						|
          : text.substr(searchFrom);
 | 
						|
 | 
						|
        // Note: we don't seem to support tabs etc, thus no /\s/
 | 
						|
        let nextAltPos = isLeft
 | 
						|
          ? toSearch.lastIndexOf(' ')
 | 
						|
          : toSearch.indexOf(' ');
 | 
						|
 | 
						|
        if (nextAltPos === -1) {
 | 
						|
          nextAltPos = isLeft ? 0 : text.length;
 | 
						|
        } else if (isLeft) {
 | 
						|
          nextAltPos += 1;
 | 
						|
        }
 | 
						|
 | 
						|
        if (isLeft) {
 | 
						|
          newPos = Math.min(newPos, nextAltPos);
 | 
						|
        } else {
 | 
						|
          newPos = Math.max(newPos, nextAltPos + searchFrom);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      // ==== Handle selection business ====
 | 
						|
      let newPosStart = newPos;
 | 
						|
      let newPosEnd = newPos;
 | 
						|
 | 
						|
      let direction = $input.selectionDirection;
 | 
						|
 | 
						|
      if (event.shiftKey) {
 | 
						|
        if (direction === 'none' || direction === 'forward') {
 | 
						|
          if (isLeft) {
 | 
						|
            direction = 'backward';
 | 
						|
          } else {
 | 
						|
            direction = 'forward';
 | 
						|
          }
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        direction = 'none';
 | 
						|
      }
 | 
						|
 | 
						|
      if (direction === 'forward') {
 | 
						|
        newPosStart = posStart;
 | 
						|
      } else if (direction === 'backward') {
 | 
						|
        newPosEnd = posEnd;
 | 
						|
      }
 | 
						|
 | 
						|
      if (newPosStart === newPosEnd) {
 | 
						|
        direction = 'none';
 | 
						|
      }
 | 
						|
 | 
						|
      $input.setSelectionRange(newPosStart, newPosEnd, direction);
 | 
						|
    },
 | 
						|
 | 
						|
    // Note: not only input, but keypresses too (rename?)
 | 
						|
    handleInputEvent(event) {
 | 
						|
      // Note: schedule the member list handler shortly afterwards, so
 | 
						|
      // that the input element has time to update its cursor position to
 | 
						|
      // what the user would expect
 | 
						|
      if (this.model.get('type') === 'group') {
 | 
						|
        window.requestAnimationFrame(this.maybeShowMembers.bind(this, event));
 | 
						|
      }
 | 
						|
 | 
						|
      const keyCode = event.which || event.keyCode;
 | 
						|
 | 
						|
      if (
 | 
						|
        keyCode === 13 &&
 | 
						|
        !event.altKey &&
 | 
						|
        !event.shiftKey &&
 | 
						|
        !event.ctrlKey
 | 
						|
      ) {
 | 
						|
        // enter pressed - submit the form now
 | 
						|
        event.preventDefault();
 | 
						|
        this.$('.bottom-bar form').submit();
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const keyPressedLeft = keyCode === 37;
 | 
						|
      const keyPressedUp = keyCode === 38;
 | 
						|
      const keyPressedRight = keyCode === 39;
 | 
						|
      const keyPressedDown = keyCode === 40;
 | 
						|
      const keyPressedTab = keyCode === 9;
 | 
						|
 | 
						|
      const preventDefault = keyPressedUp || keyPressedDown || keyPressedTab;
 | 
						|
 | 
						|
      if (this.memberView.membersShown() && preventDefault) {
 | 
						|
        if (keyPressedDown) {
 | 
						|
          this.memberView.selectDown();
 | 
						|
        } else if (keyPressedUp) {
 | 
						|
          this.memberView.selectUp();
 | 
						|
        } else if (keyPressedTab) {
 | 
						|
          // Tab is treated as Enter in this context
 | 
						|
          this.handleSubmitPressed();
 | 
						|
        }
 | 
						|
 | 
						|
        const $selected = this.$('.member-selected');
 | 
						|
        if ($selected.length) {
 | 
						|
          $selected[0].scrollIntoView({ behavior: 'smooth' });
 | 
						|
        }
 | 
						|
        event.preventDefault();
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      if (keyPressedLeft || keyPressedRight) {
 | 
						|
        this.$messageField.trigger('input');
 | 
						|
        this.handleLeftRight(event, keyPressedLeft);
 | 
						|
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      const keyPressedDelete = keyCode === 46;
 | 
						|
      const keyPressedBackspace = keyCode === 8;
 | 
						|
 | 
						|
      if (keyPressedDelete) {
 | 
						|
        this.handleDeleteOrBackspace(event, true);
 | 
						|
      }
 | 
						|
 | 
						|
      if (keyPressedBackspace) {
 | 
						|
        this.handleDeleteOrBackspace(event, false);
 | 
						|
      }
 | 
						|
 | 
						|
      this.updateMessageFieldSize();
 | 
						|
    },
 | 
						|
 | 
						|
    updateMessageFieldSize() {
 | 
						|
      this.toggleMicrophone();
 | 
						|
 | 
						|
      this.view.measureScrollPosition();
 | 
						|
 | 
						|
      const $attachmentPreviews = this.$('.attachment-previews');
 | 
						|
      const $bottomBar = this.$('.bottom-bar');
 | 
						|
      const includeMargin = true;
 | 
						|
      const quoteHeight = this.quoteView
 | 
						|
        ? this.quoteView.$el.outerHeight(includeMargin)
 | 
						|
        : 0;
 | 
						|
 | 
						|
      const height =
 | 
						|
        this.$messageField.outerHeight() +
 | 
						|
        $attachmentPreviews.outerHeight() +
 | 
						|
        quoteHeight +
 | 
						|
        parseInt($bottomBar.css('min-height'), 10);
 | 
						|
 | 
						|
      $bottomBar.outerHeight(height);
 | 
						|
 | 
						|
      this.view.scrollToBottomIfNeeded();
 | 
						|
    },
 | 
						|
 | 
						|
    async maybeShowMembers(event) {
 | 
						|
      const filterMembers = (caseSensitiveQuery, member) => {
 | 
						|
        const { authorPhoneNumber, authorProfileName } = member;
 | 
						|
 | 
						|
        const profileName = authorProfileName
 | 
						|
          ? authorProfileName.toLowerCase()
 | 
						|
          : '';
 | 
						|
        const query = caseSensitiveQuery.toLowerCase();
 | 
						|
 | 
						|
        if (authorPhoneNumber.includes(query) || profileName.includes(query)) {
 | 
						|
          return true;
 | 
						|
        }
 | 
						|
        return false;
 | 
						|
      };
 | 
						|
 | 
						|
      // This is not quite the same as stripQuery
 | 
						|
      // as this one searches until the current
 | 
						|
      // cursor position
 | 
						|
      const getQuery = (srcLine, cursorPos) => {
 | 
						|
        const input = srcLine.substr(0, cursorPos);
 | 
						|
 | 
						|
        const atPos = input.lastIndexOf('@');
 | 
						|
        if (atPos === -1) {
 | 
						|
          return null;
 | 
						|
        }
 | 
						|
 | 
						|
        // Whitespace is required right before @ unless
 | 
						|
        // the beginning of line
 | 
						|
        if (atPos > 0 && /\w/.test(input.substr(atPos - 1, 1))) {
 | 
						|
          return null;
 | 
						|
        }
 | 
						|
 | 
						|
        const query = input.substr(atPos + 1);
 | 
						|
 | 
						|
        // No whitespaces allowed in a query
 | 
						|
        if (/\s/.test(query)) {
 | 
						|
          return null;
 | 
						|
        }
 | 
						|
 | 
						|
        return query;
 | 
						|
      };
 | 
						|
 | 
						|
      let allMembers;
 | 
						|
 | 
						|
      if (this.model.isPublic()) {
 | 
						|
        // const api = await this.model.getPublicSendData();
 | 
						|
        // not quite in the right format tho yet...
 | 
						|
        // let members = await api.getSubscribers();
 | 
						|
        let members = await window.lokiPublicChatAPI.getListOfMembers();
 | 
						|
        members = members
 | 
						|
          .filter(d => !!d)
 | 
						|
          .filter(d => d.authorProfileName !== 'Anonymous');
 | 
						|
        allMembers = _.uniq(members, true, d => d.authorPhoneNumber);
 | 
						|
      } else {
 | 
						|
        const members = this.model.get('members');
 | 
						|
        if (!members || members.length === 0) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        const privateConvos = window
 | 
						|
          .getConversationController()
 | 
						|
          .getConversations()
 | 
						|
          .filter(d => d.isPrivate());
 | 
						|
        const memberConvos = members
 | 
						|
          .map(m => privateConvos.find(c => c.id === m))
 | 
						|
          .filter(c => !!c && c.getLokiProfile());
 | 
						|
 | 
						|
        allMembers = memberConvos.map(c => ({
 | 
						|
          id: c.id,
 | 
						|
          authorPhoneNumber: c.id,
 | 
						|
          authorProfileName: c.getLokiProfile().displayName,
 | 
						|
        }));
 | 
						|
      }
 | 
						|
 | 
						|
      const cursorPos = event.target.selectionStart;
 | 
						|
 | 
						|
      // can't use pubkeyPattern here, as we are matching incomplete
 | 
						|
      // pubkeys (including the single @)
 | 
						|
      const query = getQuery(event.target.value, cursorPos);
 | 
						|
 | 
						|
      let membersToShow = [];
 | 
						|
      if (query !== null) {
 | 
						|
        membersToShow =
 | 
						|
          query !== ''
 | 
						|
            ? allMembers.filter(m => filterMembers(query, m))
 | 
						|
            : allMembers;
 | 
						|
      }
 | 
						|
 | 
						|
      membersToShow = membersToShow.map(m =>
 | 
						|
        _.pick(m, ['authorPhoneNumber', 'authorProfileName', 'id'])
 | 
						|
      );
 | 
						|
 | 
						|
      this.memberView.updateMembers(membersToShow);
 | 
						|
    },
 | 
						|
 | 
						|
    forceUpdateMessageFieldSize(event) {
 | 
						|
      if (this.isHidden()) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      this.view.scrollToBottomIfNeeded();
 | 
						|
      this.updateMessageFieldSize(event);
 | 
						|
    },
 | 
						|
 | 
						|
    isHidden() {
 | 
						|
      return (
 | 
						|
        (this.$el.css('display') !== 'none' &&
 | 
						|
          this.$el.css('display') !== '') ||
 | 
						|
        this.$('.panel').css('display') === 'none'
 | 
						|
      );
 | 
						|
    },
 | 
						|
  });
 | 
						|
})();
 |