// tslint:disable: no-backbone-get-set-outside-model

import React from 'react';

import classNames from 'classnames';

import { SessionCompositionBox, StagedAttachmentType } from './SessionCompositionBox';

import { ClosedGroup, Constants, Utils } from '../../../session';
import _ from 'lodash';
import { AttachmentUtil, GoogleChrome } from '../../../util';
import { ConversationHeaderWithDetails } from '../../conversation/ConversationHeader';
import { SessionRightPanelWithDetails } from './SessionRightPanel';
import { SessionTheme } from '../../../state/ducks/SessionTheme';
import { DefaultTheme, useTheme } from 'styled-components';
import { SessionMessagesList } from './SessionMessagesList';
import { LightboxGallery, MediaItemType } from '../../LightboxGallery';
import { Message } from '../../conversation/media-gallery/types/Message';

import { AttachmentType, save } from '../../../types/Attachment';
import { ToastUtils, UserUtils } from '../../../session/utils';
import * as MIME from '../../../types/MIME';
import { SessionFileDropzone } from './SessionFileDropzone';
import { ConversationType } from '../../../state/ducks/conversations';
import { MessageView } from '../../MainViewController';
import { pushUnblockToSend } from '../../../session/utils/Toast';
import { MessageDetail } from '../../conversation/MessageDetail';
import { ConversationController } from '../../../session/conversations';
import { getMessageById, getPubkeysInPublicConversation } from '../../../data/data';
import autoBind from 'auto-bind';
import { getDecryptedMediaUrl } from '../../../session/crypto/DecryptedAttachmentsManager';
import { deleteOpenGroupMessages } from '../../../interactions/conversation';
import {
  ConversationNotificationSetting,
  ConversationNotificationSettingType,
  ConversationTypeEnum,
} from '../../../models/conversation';
import { updateMentionsMembers } from '../../../state/ducks/mentionsInput';
import { sendDataExtractionNotification } from '../../../session/messages/outgoing/controlMessage/DataExtractionNotificationMessage';

import { SessionButtonColor } from '../SessionButton';
import { AddModeratorsDialog } from '../../conversation/ModeratorsAddDialog';
import { RemoveModeratorsDialog } from '../../conversation/ModeratorsRemoveDialog';
import { UpdateGroupNameDialog } from '../../conversation/UpdateGroupNameDialog';
import { UpdateGroupMembersDialog } from '../../conversation/UpdateGroupMembersDialog';
import { InviteContactsDialog } from '../../conversation/InviteContactsDialog';

interface State {
  // Message sending progress
  messageProgressVisible: boolean;
  sendingProgress: number;
  prevSendingProgress: number;
  // Sending failed:  -1
  // Not send yet:     0
  // Sending message:  1
  // Sending success:  2
  sendingProgressStatus: -1 | 0 | 1 | 2;

  unreadCount: number;
  selectedMessages: Array<string>;
  displayScrollToBottomButton: boolean;

  showOverlay: boolean;
  showRecordingView: boolean;
  showOptionsPane: boolean;

  // if set, the `More Info` of a message screen is shown on top of the conversation.
  messageDetailShowProps?: any; // FIXME set the type for this

  stagedAttachments: Array<StagedAttachmentType>;
  isDraggingFile: boolean;

  // quoted message
  quotedMessageTimestamp?: number;
  quotedMessageProps?: any;

  // lightbox options
  lightBoxOptions?: LightBoxOptions;
  modal: JSX.Element | null;
}

export interface LightBoxOptions {
  media: Array<MediaItemType>;
  attachment: any;
}

interface Props {
  ourNumber: string;
  selectedConversationKey: string;
  selectedConversation?: ConversationType;
  theme: DefaultTheme;
  messages: Array<any>;
  actions: any;
}

export class SessionConversation extends React.Component<Props, State> {
  private readonly compositionBoxRef: React.RefObject<HTMLDivElement>;
  private readonly messageContainerRef: React.RefObject<HTMLDivElement>;
  private dragCounter: number;
  private publicMembersRefreshTimeout?: NodeJS.Timeout;
  private readonly updateMemberList: () => any;

  constructor(props: any) {
    super(props);

    const unreadCount = this.props.selectedConversation?.unreadCount || 0;
    this.state = {
      messageProgressVisible: false,
      sendingProgress: 0,
      prevSendingProgress: 0,
      sendingProgressStatus: 0,
      unreadCount,
      selectedMessages: [],
      displayScrollToBottomButton: false,
      showOverlay: false,
      showRecordingView: false,
      showOptionsPane: false,
      stagedAttachments: [],
      isDraggingFile: false,
      modal: null,
    };
    this.compositionBoxRef = React.createRef();
    this.messageContainerRef = React.createRef();
    this.dragCounter = 0;
    this.updateMemberList = _.debounce(this.updateMemberListBouncy.bind(this), 1000);

    autoBind(this);
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  public componentDidUpdate(prevProps: Props, prevState: State) {
    const {
      selectedConversationKey: newConversationKey,
      selectedConversation: newConversation,
    } = this.props;
    const { selectedConversationKey: oldConversationKey } = prevProps;

    // if the convo is valid, and it changed, register for drag events
    if (newConversationKey && newConversation && newConversationKey !== oldConversationKey) {
      // Pause thread to wait for rendering to complete
      setTimeout(() => {
        const div = this.messageContainerRef.current;
        div?.addEventListener('dragenter', this.handleDragIn);
        div?.addEventListener('dragleave', this.handleDragOut);
        div?.addEventListener('dragover', this.handleDrag);
        div?.addEventListener('drop', this.handleDrop);
      }, 100);

      // if the conversation changed, we have to stop our refresh of member list
      if (this.publicMembersRefreshTimeout) {
        global.clearInterval(this.publicMembersRefreshTimeout);
        this.publicMembersRefreshTimeout = undefined;
      }
      // shown convo changed. reset the list of members quotable
      window?.inboxStore?.dispatch(updateMentionsMembers([]));
      // if the newConversation changed, and is public, start our refresh members list
      if (newConversation.isPublic) {
        // this is a debounced call.
        void this.updateMemberList();
        // run this only once every minute if we don't change the visible conversation.
        // this is a heavy operation (like a few thousands members can be here)
        this.publicMembersRefreshTimeout = global.setInterval(this.updateMemberList, 60000);
      }
    }
    // if we do not have a model, unregister for events
    if (!newConversation) {
      const div = this.messageContainerRef.current;
      div?.removeEventListener('dragenter', this.handleDragIn);
      div?.removeEventListener('dragleave', this.handleDragOut);
      div?.removeEventListener('dragover', this.handleDrag);
      div?.removeEventListener('drop', this.handleDrop);
      if (this.publicMembersRefreshTimeout) {
        global.clearInterval(this.publicMembersRefreshTimeout);
        this.publicMembersRefreshTimeout = undefined;
      }
    }
    if (newConversationKey !== oldConversationKey) {
      void this.loadInitialMessages();
      this.setState({
        showOptionsPane: false,
        selectedMessages: [],
        displayScrollToBottomButton: false,
        showOverlay: false,
        showRecordingView: false,
        stagedAttachments: [],
        isDraggingFile: false,
        messageDetailShowProps: undefined,
        quotedMessageProps: undefined,
        quotedMessageTimestamp: undefined,
      });
    }
  }

  public componentWillUnmount() {
    const div = this.messageContainerRef.current;
    div?.removeEventListener('dragenter', this.handleDragIn);
    div?.removeEventListener('dragleave', this.handleDragOut);
    div?.removeEventListener('dragover', this.handleDrag);
    div?.removeEventListener('drop', this.handleDrop);

    if (this.publicMembersRefreshTimeout) {
      global.clearInterval(this.publicMembersRefreshTimeout);
      this.publicMembersRefreshTimeout = undefined;
    }
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~ RENDER METHODS ~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  public render() {
    const {
      showRecordingView,
      showOptionsPane,
      quotedMessageProps,
      lightBoxOptions,
      selectedMessages,
      isDraggingFile,
      stagedAttachments,
      messageDetailShowProps,
    } = this.state;
    const selectionMode = !!selectedMessages.length;

    const { selectedConversation, selectedConversationKey, messages, actions } = this.props;

    if (!selectedConversation || !messages) {
      // return an empty message view
      return <MessageView />;
    }
    const conversationModel = ConversationController.getInstance().get(selectedConversationKey);
    // TODO VINCE: OPTIMISE FOR NEW SENDING???
    const sendMessageFn = (
      body: any,
      attachments: any,
      quote: any,
      preview: any,
      groupInvitation: any
    ) => {
      if (!conversationModel) {
        return;
      }
      void conversationModel.sendMessage(body, attachments, quote, preview, groupInvitation);
      if (this.messageContainerRef.current) {
        // force scrolling to bottom on message sent
        // this will mark all messages as read, and reset the conversation unreadCount
        (this.messageContainerRef
          .current as any).scrollTop = this.messageContainerRef.current?.scrollHeight;
      }
    };
    const showMessageDetails = !!messageDetailShowProps;

    const isPublic = selectedConversation.isPublic || false;

    const isPrivate = selectedConversation.type === ConversationTypeEnum.PRIVATE;
    return (
      <SessionTheme theme={this.props.theme}>
        <div className="conversation-header">{this.renderHeader()}</div>

        {/* <SessionProgress
            visible={this.state.messageProgressVisible}
            value={this.state.sendingProgress}
            prevValue={this.state.prevSendingProgress}
            sendStatus={this.state.sendingProgressStatus}
            resetProgress={this.resetSendingProgress}
          /> */}

        <div
          // if you change the classname, also update it on onKeyDown
          className={classNames('conversation-content', selectionMode && 'selection-mode')}
          tabIndex={0}
          onKeyDown={this.onKeyDown}
          role="navigation"
        >
          <div className={classNames('conversation-info-panel', showMessageDetails && 'show')}>
            {showMessageDetails && <MessageDetail {...messageDetailShowProps} />}
          </div>

          {lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)}

          <div className="conversation-messages">
            <SessionMessagesList {...this.getMessagesListProps()} />

            {showRecordingView && <div className="conversation-messages__blocking-overlay" />}
            {isDraggingFile && <SessionFileDropzone />}
          </div>

          <SessionCompositionBox
            isBlocked={selectedConversation.isBlocked}
            left={selectedConversation.left}
            isKickedFromGroup={selectedConversation.isKickedFromGroup}
            isPrivate={isPrivate}
            isPublic={isPublic}
            selectedConversationKey={selectedConversationKey}
            selectedConversation={selectedConversation}
            sendMessage={sendMessageFn}
            stagedAttachments={stagedAttachments}
            onMessageSending={this.onMessageSending}
            onMessageSuccess={this.onMessageSuccess}
            onMessageFailure={this.onMessageFailure}
            onLoadVoiceNoteView={this.onLoadVoiceNoteView}
            onExitVoiceNoteView={this.onExitVoiceNoteView}
            showLeftPaneSection={actions.showLeftPaneSection}
            showSettingsSection={actions.showSettingsSection}
            quotedMessageProps={quotedMessageProps}
            removeQuotedMessage={() => {
              void this.replyToMessage(undefined);
            }}
            textarea={this.compositionBoxRef}
            clearAttachments={this.clearAttachments}
            removeAttachment={this.removeAttachment}
            onChoseAttachments={this.onChoseAttachments}
            theme={this.props.theme}
            updateConfirmModal={actions.updateConfirmModal}
          />
        </div>

        {this.state.modal ? this.state.modal : null}
        <div className={classNames('conversation-item__options-pane', showOptionsPane && 'show')}>
          <SessionRightPanelWithDetails {...this.getRightPanelProps()} />
        </div>
      </SessionTheme>
    );
  }

  public renderHeader() {
    const headerProps = this.getHeaderProps();
    return <ConversationHeaderWithDetails {...headerProps} />;
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~ GETTER METHODS ~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  public async loadInitialMessages() {
    const { selectedConversation, selectedConversationKey } = this.props;

    if (!selectedConversation) {
      return;
    }
    const conversationModel = ConversationController.getInstance().get(selectedConversationKey);
    const unreadCount = await conversationModel.getUnreadCount();
    const messagesToFetch = Math.max(
      Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT,
      unreadCount
    );
    this.props.actions.fetchMessagesForConversation({
      conversationKey: selectedConversationKey,
      count: messagesToFetch,
    });
  }

  public getHeaderProps() {
    const { selectedConversationKey, ourNumber } = this.props;
    const { selectedMessages, messageDetailShowProps } = this.state;
    const conversation = ConversationController.getInstance().getOrThrow(selectedConversationKey);
    const expireTimer = conversation.get('expireTimer');
    const expirationSettingName = expireTimer
      ? window.Whisper.ExpirationTimerOptions.getName(expireTimer || 0)
      : null;

    const members = conversation.get('members') || [];

    // exclude mentions_only settings for private chats as this does not make much sense
    const notificationForConvo = ConversationNotificationSetting.filter(n =>
      conversation.isPrivate() ? n !== 'mentions_only' : true
    ).map((n: ConversationNotificationSettingType) => {
      // this link to the notificationForConvo_all, notificationForConvo_mentions_only, ...
      return { value: n, name: window.i18n(`notificationForConvo_${n}`) };
    });

    const headerProps = {
      id: conversation.id,
      name: conversation.getName(),
      phoneNumber: conversation.getNumber(),
      profileName: conversation.getProfileName(),
      avatarPath: conversation.getAvatarPath(),
      isMe: conversation.isMe(),
      isBlocked: conversation.isBlocked(),
      isGroup: !conversation.isPrivate(),
      isPrivate: conversation.isPrivate(),
      isPublic: conversation.isPublic(),
      isAdmin: conversation.isAdmin(ourNumber),
      members,
      subscriberCount: conversation.get('subscriberCount'),
      isKickedFromGroup: conversation.get('isKickedFromGroup'),
      left: conversation.get('left'),
      expirationSettingName,
      showBackButton: Boolean(messageDetailShowProps),
      timerOptions: window.Whisper.ExpirationTimerOptions.map((item: any) => ({
        name: item.getName(),
        value: item.get('seconds'),
      })),
      notificationForConvo,
      currentNotificationSetting: conversation.get('triggerNotificationsFor'),
      hasNickname: !!conversation.getNickname(),
      selectionMode: !!selectedMessages.length,

      onSetDisappearingMessages: conversation.updateExpirationTimer,
      onSetNotificationForConvo: conversation.setNotificationOption,
      onDeleteMessages: conversation.deleteMessages,
      onDeleteSelectedMessages: this.deleteSelectedMessages,
      onClearNickname: conversation.clearNickname,
      onCloseOverlay: () => {
        this.setState({ selectedMessages: [] });
      },

      onGoBack: () => {
        this.setState({
          messageDetailShowProps: undefined,
        });
      },

      onUpdateGroupName: () => {
        window.Whisper.events.trigger('updateGroupName', conversation);
      },

      onBlockUser: () => {
        void conversation.block();
      },
      onUnblockUser: () => {
        void conversation.unblock();
      },
      onCopyPublicKey: () => {
        conversation.copyPublicKey();
      },
      onLeaveGroup: () => {
        window.Whisper.events.trigger('leaveClosedGroup', conversation);
      },
      onInviteContacts: () => {
        window.Whisper.events.trigger('inviteContacts', conversation);
      },

      onMarkAllRead: () => {
        void conversation.markReadBouncy(Date.now());
      },

      onAddModerators: () => {
        window.Whisper.events.trigger('addModerators', conversation);
      },

      onRemoveModerators: () => {
        window.Whisper.events.trigger('removeModerators', conversation);
      },

      onAvatarClick: (pubkey: any) => {
        this.toggleRightPanel();
      },
    };

    return headerProps;
  }

  public getMessagesListProps() {
    const {
      selectedConversation,
      selectedConversationKey,
      ourNumber,
      messages,
      actions,
    } = this.props;
    const { quotedMessageTimestamp, selectedMessages } = this.state;

    return {
      selectedMessages,
      ourPrimary: ourNumber,
      conversationKey: selectedConversationKey,
      messages,
      resetSelection: this.resetSelection,
      quotedMessageTimestamp,
      conversation: selectedConversation as ConversationType,
      selectMessage: this.selectMessage,
      deleteMessage: this.deleteMessage,
      fetchMessagesForConversation: actions.fetchMessagesForConversation,
      replyToMessage: this.replyToMessage,
      showMessageDetails: this.showMessageDetails,
      onClickAttachment: this.onClickAttachment,
      onDownloadAttachment: this.saveAttachment,
      messageContainerRef: this.messageContainerRef,
      onDeleteSelectedMessages: this.deleteSelectedMessages,
      updateSessionConversationModal: this.updateSessionConversationModal,
    };
  }

  /**
   * Setting this to a JSX element that will be rendered if non-null.
   * @param update Value to set the modal state to
   */
  private updateSessionConversationModal(update: JSX.Element | null) {
    this.setState({
      ...this.state,
      modal: update,
    });
  }
  // tslint:disable: member-ordering

  // tslint:disable-next-line: max-func-body-length
  public getRightPanelProps() {
    const { selectedConversationKey } = this.props;
    const conversation = ConversationController.getInstance().getOrThrow(selectedConversationKey);
    const ourPrimary = window.storage.get('primaryDevicePubKey');

    const members = conversation.get('members') || [];
    const isAdmin = conversation.isMediumGroup()
      ? true
      : conversation.isPublic()
      ? conversation.isAdmin(ourPrimary)
      : false;

    return {
      id: conversation.id,
      name: conversation.getName(),
      memberCount: members.length,
      phoneNumber: conversation.getNumber(),
      profileName: conversation.getProfileName(),
      avatarPath: conversation.getAvatarPath(),
      isKickedFromGroup: conversation.get('isKickedFromGroup'),
      left: conversation.get('left'),
      isGroup: !conversation.isPrivate(),
      isPublic: conversation.isPublic(),
      isAdmin,
      isBlocked: conversation.isBlocked(),

      timerOptions: window.Whisper.ExpirationTimerOptions.map((item: any) => ({
        name: item.getName(),
        value: item.get('seconds'),
      })),

      onSetDisappearingMessages: (seconds: any) => {
        if (seconds > 0) {
          void conversation.updateExpirationTimer(seconds);
        } else {
          void conversation.updateExpirationTimer(null);
        }
      },

      onGoBack: () => {
        this.toggleRightPanel();
      },

      onUpdateGroupName: () => {
        // warrick: remove trigger once everything is cleaned up
        // window.Whisper.events.trigger('updateGroupName', conversation);
        const avatarPath = conversation.getAvatarPath();
        const groupName = conversation.getName();
        const groupId = conversation.id;
        const members = conversation.get('members') || [];
        const isPublic = conversation.isPublic();

        let isAdmin = true;
        let titleText;

        titleText = window.i18n('updateGroupDialogTitle', groupName);

        if (isPublic) {
          // fix the title
          // I'd much prefer to integrate mods with groupAdmins
          // but lets discuss first...
          isAdmin = conversation.isAdmin(window.storage.get('primaryDevicePubKey'));
        }

        const onClose = () => {
          this.setState({ ...this.state, modal: null });
        };

        const onUpdateGroupNameSubmit = (newGroupName: string, newAvatarPath: string) => {
          if (newGroupName !== groupName || newAvatarPath !== avatarPath) {
            void ClosedGroup.initiateGroupUpdate(groupId, newGroupName, members, newAvatarPath);
          }
        };

        this.setState({
          ...this.state,
          modal: (
            <UpdateGroupNameDialog
              titleText={titleText}
              pubkey={conversation.id}
              isPublic={conversation.isPublic()}
              groupName={groupName}
              okText={window.i18n('ok')}
              cancelText={window.i18n('cancel')}
              isAdmin={isAdmin}
              i18n={window.i18n}
              onSubmit={onUpdateGroupNameSubmit}
              onClose={onClose}
              // avatar stuff
              avatarPath={avatarPath || ''}
              theme={this.props.theme}
            />
          ),
        });
      },
      onUpdateGroupMembers: async () => {
        // window.Whisper.events.trigger('updateGroupMembers', conversation);
        // return;

        if (conversation.isMediumGroup()) {
          // make sure all the members' convo exists so we can add or remove them
          await Promise.all(
            conversation
              .get('members')
              .map(m =>
                ConversationController.getInstance().getOrCreateAndWait(
                  m,
                  ConversationTypeEnum.PRIVATE
                )
              )
          );
        }

        const groupName = conversation.getName();
        const isPublic = conversation.isPublic();
        const groupId = conversation.id;
        const members = conversation.get('members') || [];
        const avatarPath = conversation.getAvatarPath();
        const theme = this.props.theme;

        const titleText = window.i18n('updateGroupDialogTitle', groupName);

        const ourPK = Utils.UserUtils.getOurPubKeyStrFromCache();

        let admins = conversation.get('groupAdmins');
        const isAdmin = conversation.get('groupAdmins')?.includes(ourPK) ? true : false;

        const convos = ConversationController.getInstance()
          .getConversations()
          .filter(d => !!d);

        let existingMembers = conversation.get('members') || [];
        let existingZombies = conversation.get('zombies') || [];

        let contactsAndMembers = convos.filter(
          d => existingMembers.includes(d.id) && d.isPrivate() && !d.isMe()
        );

        // contactsAndMembers = _.uniqBy(contactsAndMembers, true, d => d.id);
        contactsAndMembers = _.uniqBy(contactsAndMembers, 'id');

        // at least make sure it's an array
        if (!Array.isArray(existingMembers)) {
          existingMembers = [];
        }

        const onClose = () => {
          this.setState({
            ...this.state,
            modal: null,
          });
        };

        const onSubmit = async (newMembers: Array<string>) => {
          const allMembersAfterUpdate = _.concat(newMembers, [ourPK]);

          if (!isAdmin) {
            window.log.warn('Skipping update of members, we are not the admin');
            return;
          }
          // new members won't include the zombies. We are the admin and we want to remove them not matter what

          // We need to NOT trigger an group update if the list of member is the same.
          // we need to merge all members, including zombies for this call.

          // we consider that the admin ALWAYS wants to remove zombies (actually they should be removed
          // automatically by him when the LEFT message is received)
          const allExistingMembersWithZombies = _.uniq(existingMembers.concat(existingZombies));

          const notPresentInOld = allMembersAfterUpdate.filter(
            (m: string) => !allExistingMembersWithZombies.includes(m)
          );

          // be sure to include zombies in here
          const membersToRemove = allExistingMembersWithZombies.filter(
            (m: string) => !allMembersAfterUpdate.includes(m)
          );

          const xor = _.xor(membersToRemove, notPresentInOld);
          if (xor.length === 0) {
            window.log.info('skipping group update: no detected changes in group member list');

            return;
          }

          // If any extra devices of removed exist in newMembers, ensure that you filter them
          // Note: I think this is useless
          const filteredMembers = allMembersAfterUpdate.filter(
            (member: string) => !_.includes(membersToRemove, member)
          );

          void ClosedGroup.initiateGroupUpdate(groupId, groupName, filteredMembers, avatarPath);
        };

        this.setState({
          ...this.state,
          modal: (
            // tslint:disable-next-line: use-simple-attributes
            <UpdateGroupMembersDialog
              titleText={titleText}
              isPublic={isPublic}
              admins={admins || []}
              onSubmit={onSubmit}
              onClose={onClose}
              okText={window.i18n('ok')}
              cancelText={window.i18n('cancel')}
              contactList={contactsAndMembers}
              isAdmin={isAdmin}
              existingMembers={existingMembers}
              existingZombies={existingZombies}
              theme={this.props.theme}
            />
          ),
        });
      },
      onInviteContacts: () => {
        this.setState({
          ...this.state,
          modal: (
            <InviteContactsDialog
              convo={conversation}
              onClose={() => {
                this.setState({ ...this.state, modal: null });
              }}
              theme={this.props.theme}
            />
          ),
        });
      },
      onLeaveGroup: () => {
        window.Whisper.events.trigger('leaveClosedGroup', conversation);
      },
      onAddModerators: () => {
        // window.Whisper.events.trigger('addModerators', conversation);
        this.setState({
          ...this.state,
          modal: (
            <AddModeratorsDialog
              convo={conversation}
              onClose={() => {
                this.setState({ ...this.state, modal: null });
              }}
              theme={this.props.theme}
            />
          ),
        });
      },

      onRemoveModerators: () => {
        // window.Whisper.events.trigger('removeModerators', conversation);
        this.setState({
          ...this.state,
          modal: (
            <RemoveModeratorsDialog
              convo={conversation}
              onClose={() => {
                this.setState({ ...this.state, modal: null });
              }}
              theme={this.props.theme}
            />
          ),
        });
      },
      onShowLightBox: (lightBoxOptions?: LightBoxOptions) => {
        this.setState({ lightBoxOptions });
      },
    };
  }

  public toggleRightPanel() {
    const { showOptionsPane } = this.state;
    this.setState({ showOptionsPane: !showOptionsPane });
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  public updateSendingProgress(value: number, status: -1 | 0 | 1 | 2) {
    // If you're sending a new message, reset previous value to zero
    const prevSendingProgress = status === 1 ? 0 : this.state.sendingProgress;

    this.setState({
      sendingProgress: value,
      prevSendingProgress,
      sendingProgressStatus: status,
    });
  }

  public resetSendingProgress() {
    this.setState({
      sendingProgress: 0,
      prevSendingProgress: 0,
      sendingProgressStatus: 0,
    });
  }

  public onMessageSending() {
    // Set sending state 5% to show message sending
    const initialValue = 5;
    this.updateSendingProgress(initialValue, 1);
    if (this.state.quotedMessageTimestamp) {
      this.setState({
        quotedMessageTimestamp: undefined,
        quotedMessageProps: undefined,
      });
    }
  }

  public onMessageSuccess() {
    this.updateSendingProgress(100, 2);
  }

  public onMessageFailure() {
    this.updateSendingProgress(100, -1);
  }

  public async deleteMessagesById(messageIds: Array<string>, askUserForConfirmation: boolean) {
    // Get message objects
    const { selectedConversationKey, selectedConversation, messages } = this.props;

    const conversationModel = ConversationController.getInstance().getOrThrow(
      selectedConversationKey
    );
    if (!selectedConversation) {
      window?.log?.info('No valid selected conversation.');
      return;
    }
    const selectedMessages = messages.filter(message =>
      messageIds.find(selectedMessage => selectedMessage === message.id)
    );

    const multiple = selectedMessages.length > 1;

    // In future, we may be able to unsend private messages also
    // isServerDeletable also defined in ConversationHeader.tsx for
    // future reference
    const isServerDeletable = selectedConversation.isPublic;

    const warningMessage = (() => {
      if (selectedConversation.isPublic) {
        return multiple
          ? window.i18n('deleteMultiplePublicWarning')
          : window.i18n('deletePublicWarning');
      }
      return multiple ? window.i18n('deleteMultipleWarning') : window.i18n('deleteWarning');
    })();

    const doDelete = async () => {
      let toDeleteLocally;

      if (selectedConversation.isPublic) {
        // Get our Moderator status
        const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache();
        if (!ourDevicePubkey) {
          return;
        }

        const isAdmin = conversationModel.isAdmin(ourDevicePubkey);
        const isAllOurs = selectedMessages.every(
          message => ourDevicePubkey === message.attributes.source
        );

        if (!isAllOurs && !isAdmin) {
          ToastUtils.pushMessageDeleteForbidden();

          this.setState({ selectedMessages: [] });
          return;
        }

        toDeleteLocally = await deleteOpenGroupMessages(selectedMessages, conversationModel);
        if (toDeleteLocally.length === 0) {
          // Message failed to delete from server, show error?
          return;
        }
      } else {
        toDeleteLocally = selectedMessages;
      }

      await Promise.all(
        toDeleteLocally.map(async message => {
          await conversationModel.removeMessage(message.id);
        })
      );

      // Update view and trigger update
      this.setState({ selectedMessages: [] }, ToastUtils.pushDeleted);
    };

    let title = '';

    // Note:  keep that i18n logic separated so the scripts in tools/ find the usage of those
    if (isServerDeletable) {
      if (multiple) {
        title = window.i18n('deleteMessagesForEveryone');
      } else {
        title = window.i18n('deleteMessageForEveryone');
      }
    } else {
      if (multiple) {
        title = window.i18n('deleteMessages');
      } else {
        title = window.i18n('deleteMessage');
      }
    }

    const okText = window.i18n(isServerDeletable ? 'deleteForEveryone' : 'delete');

    if (askUserForConfirmation) {
      const onClickClose = () => {
        this.props.actions.updateConfirmModal(null);
      };

      this.props.actions.updateConfirmModal({
        title,
        message: warningMessage,
        okText,
        okTheme: SessionButtonColor.Danger,
        onClickOk: doDelete,
        onClickClose,
        closeAfterClick: true,
      });
    } else {
      void doDelete();
    }
  }

  public async deleteSelectedMessages() {
    await this.deleteMessagesById(this.state.selectedMessages, true);
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  public selectMessage(messageId: string) {
    const selectedMessages = this.state.selectedMessages.includes(messageId)
      ? // Add to array if not selected. Else remove.
        this.state.selectedMessages.filter(id => id !== messageId)
      : [...this.state.selectedMessages, messageId];

    this.setState({ selectedMessages });
  }

  public deleteMessage(messageId: string) {
    this.setState({ selectedMessages: [messageId] }, this.deleteSelectedMessages);
  }

  public resetSelection() {
    this.setState({ selectedMessages: [] });
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~ MICROPHONE METHODS ~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  private onLoadVoiceNoteView() {
    this.setState({
      showRecordingView: true,
      selectedMessages: [],
    });
  }

  private onExitVoiceNoteView() {
    this.setState({
      showRecordingView: false,
    });
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~ MESSAGE QUOTE ~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  private async replyToMessage(quotedMessageTimestamp?: number) {
    if (this.props.selectedConversation?.isBlocked) {
      pushUnblockToSend();
      return;
    }
    if (!_.isEqual(this.state.quotedMessageTimestamp, quotedMessageTimestamp)) {
      const { messages, selectedConversationKey } = this.props;
      const conversationModel = ConversationController.getInstance().getOrThrow(
        selectedConversationKey
      );

      let quotedMessageProps = null;
      if (quotedMessageTimestamp) {
        const quotedMessage = messages.find(m => m.attributes.sent_at === quotedMessageTimestamp);

        if (quotedMessage) {
          const quotedMessageModel = await getMessageById(quotedMessage.id);
          if (quotedMessageModel) {
            quotedMessageProps = await conversationModel.makeQuote(quotedMessageModel);
          }
        }
      }
      this.setState({ quotedMessageTimestamp, quotedMessageProps }, () => {
        this.compositionBoxRef.current?.focus();
      });
    }
  }

  private showMessageDetails(messageProps: any) {
    messageProps.onDeleteMessage = async (id: string) => {
      await this.deleteMessagesById([id], false);
      this.setState({ messageDetailShowProps: undefined });
    };

    this.setState({
      messageDetailShowProps: messageProps,
      showOptionsPane: false,
    });
  }

  private onClickAttachment(attachment: any, message: any) {
    // message is MessageTypeInConvo.propsForMessage I think
    const media = (message.attachments || []).map((attachmentForMedia: any) => {
      return {
        objectURL: attachmentForMedia.url,
        contentType: attachmentForMedia.contentType,
        attachment: attachmentForMedia,
        messageSender: message.authorPhoneNumber,
        messageTimestamp: message.direction !== 'outgoing' ? message.timestamp : undefined, // do not set this field when the message was sent from us
        // if it is set, this will trigger a sending of DataExtractionNotification to that user, but for an attachment we sent ourself.
      };
    });
    const lightBoxOptions: LightBoxOptions = {
      media,
      attachment,
    };
    this.setState({ lightBoxOptions });
  }

  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // ~~~~~~~~~~~ KEYBOARD NAVIGATION ~~~~~~~~~~~~
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  private onKeyDown(event: any) {
    const messageContainer = this.messageContainerRef.current;
    if (!messageContainer) {
      return;
    }
    const selectionMode = !!this.state.selectedMessages.length;
    const recordingMode = this.state.showRecordingView;
    const pageHeight = messageContainer.clientHeight;
    const arrowScrollPx = 50;
    const pageScrollPx = pageHeight * 0.8;
    if (event.key === 'Escape') {
      // EXIT MEDIA VIEW
      if (recordingMode) {
        // EXIT RECORDING VIEW
      }
      // EXIT WHAT ELSE?
    }
    if (event.target.classList.contains('conversation-content')) {
      switch (event.key) {
        case 'Escape':
          if (selectionMode) {
            this.resetSelection();
          }
          break;
        // Scrolling
        case 'ArrowUp':
          messageContainer.scrollBy(0, -arrowScrollPx);
          break;
        case 'ArrowDown':
          messageContainer.scrollBy(0, arrowScrollPx);
          break;
        case 'PageUp':
          messageContainer.scrollBy(0, -pageScrollPx);
          break;
        case 'PageDown':
          messageContainer.scrollBy(0, pageScrollPx);
          break;
        default:
      }
    }
  }

  private clearAttachments() {
    this.state.stagedAttachments.forEach(attachment => {
      if (attachment.url) {
        URL.revokeObjectURL(attachment.url);
      }
      if (attachment.videoUrl) {
        URL.revokeObjectURL(attachment.videoUrl);
      }
    });
    this.setState({ stagedAttachments: [] });
  }

  private removeAttachment(attachment: AttachmentType) {
    const { stagedAttachments } = this.state;
    const updatedStagedAttachments = (stagedAttachments || []).filter(
      m => m.fileName !== attachment.fileName
    );

    this.setState({ stagedAttachments: updatedStagedAttachments });
  }

  private addAttachments(newAttachments: Array<StagedAttachmentType>) {
    const { stagedAttachments } = this.state;
    let newAttachmentsFiltered: Array<StagedAttachmentType> = [];
    if (newAttachments?.length > 0) {
      if (newAttachments.some(a => a.isVoiceMessage) && stagedAttachments.length > 0) {
        throw new Error('A voice note cannot be sent with other attachments');
      }
      // do not add already added attachments
      newAttachmentsFiltered = newAttachments.filter(
        a => !stagedAttachments.some(b => b.file.path === a.file.path)
      );
    }

    this.setState({
      stagedAttachments: [...stagedAttachments, ...newAttachmentsFiltered],
    });
  }

  private renderLightBox({ media, attachment }: LightBoxOptions) {
    const selectedIndex =
      media.length > 1
        ? media.findIndex((mediaMessage: any) => mediaMessage.attachment.path === attachment.path)
        : 0;
    return (
      <LightboxGallery
        media={media}
        close={() => {
          this.setState({ lightBoxOptions: undefined });
        }}
        selectedIndex={selectedIndex}
        onSave={this.saveAttachment}
      />
    );
  }

  // THIS DOES NOT DOWNLOAD ANYTHING! it just saves it where the user wants
  private async saveAttachment({
    attachment,
    message,
    index,
    messageTimestamp,
    messageSender,
  }: {
    attachment: AttachmentType;
    message?: Message;
    index?: number;
    messageTimestamp?: number;
    messageSender: string;
  }) {
    const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
    attachment.url = await getDecryptedMediaUrl(attachment.url, attachment.contentType);
    save({
      attachment,
      document,
      getAbsolutePath: getAbsoluteAttachmentPath,
      timestamp: messageTimestamp || message?.received_at,
    });

    await sendDataExtractionNotification(
      this.props.selectedConversationKey,
      messageSender,
      messageTimestamp
    );
  }

  private async onChoseAttachments(attachmentsFileList: Array<File>) {
    if (!attachmentsFileList || attachmentsFileList.length === 0) {
      return;
    }

    // tslint:disable-next-line: prefer-for-of
    for (let i = 0; i < attachmentsFileList.length; i++) {
      await this.maybeAddAttachment(attachmentsFileList[i]);
    }
  }

  // tslint:disable: max-func-body-length cyclomatic-complexity
  private async maybeAddAttachment(file: any) {
    if (!file) {
      return;
    }

    const fileName = file.name;
    const contentType = file.type;

    const { stagedAttachments } = this.state;

    if (window.Signal.Util.isFileDangerous(fileName)) {
      ToastUtils.pushDangerousFileError();
      return;
    }

    if (stagedAttachments.length >= 32) {
      ToastUtils.pushMaximumAttachmentsError();
      return;
    }

    const haveNonImage = _.some(
      stagedAttachments,
      attachment => !MIME.isImage(attachment.contentType)
    );
    // You can't add another attachment if you already have a non-image staged
    if (haveNonImage) {
      ToastUtils.pushMultipleNonImageError();
      return;
    }

    // You can't add a non-image attachment if you already have attachments staged
    if (!MIME.isImage(contentType) && stagedAttachments.length > 0) {
      ToastUtils.pushCannotMixError();
      return;
    }
    const { VisualAttachment } = window.Signal.Types;

    const renderVideoPreview = async () => {
      const objectUrl = URL.createObjectURL(file);
      try {
        const type = 'image/png';

        const thumbnail = await VisualAttachment.makeVideoScreenshot({
          objectUrl,
          contentType: type,
          logger: window.log,
        });
        const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
        const url = window.Signal.Util.arrayBufferToObjectURL({
          data,
          type,
        });
        this.addAttachments([
          {
            file,
            size: file.size,
            fileName,
            contentType,
            videoUrl: objectUrl,
            url,
            isVoiceMessage: false,
          },
        ]);
      } catch (error) {
        URL.revokeObjectURL(objectUrl);
      }
    };

    const renderImagePreview = async () => {
      if (!MIME.isJPEG(contentType)) {
        const urlImage = URL.createObjectURL(file);
        if (!urlImage) {
          throw new Error('Failed to create object url for image!');
        }
        this.addAttachments([
          {
            file,
            size: file.size,
            fileName,
            contentType,
            url: urlImage,
            isVoiceMessage: false,
          },
        ]);
        return;
      }

      const url = await window.autoOrientImage(file);

      this.addAttachments([
        {
          file,
          size: file.size,
          fileName,
          contentType,
          url,
          isVoiceMessage: false,
        },
      ]);
    };

    let blob = null;

    try {
      blob = await AttachmentUtil.autoScale({
        contentType,
        file,
      });

      if (blob.file.size >= Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES) {
        ToastUtils.pushFileSizeErrorAsByte(Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES);
        return;
      }
    } catch (error) {
      window?.log?.error(
        'Error ensuring that image is properly sized:',
        error && error.stack ? error.stack : error
      );

      ToastUtils.pushLoadAttachmentFailure(error?.message);
      return;
    }

    try {
      if (GoogleChrome.isImageTypeSupported(contentType)) {
        // this does not add the preview to the message outgoing
        // this is just for us, for the list of attachments we are sending
        // the files are scaled down under getFiles()

        await renderImagePreview();
      } else if (GoogleChrome.isVideoTypeSupported(contentType)) {
        await renderVideoPreview();
      } else {
        this.addAttachments([
          {
            file,
            size: file.size,
            contentType,
            fileName,
            url: '',
            isVoiceMessage: false,
          },
        ]);
      }
    } catch (e) {
      window?.log?.error(
        `Was unable to generate thumbnail for file type ${contentType}`,
        e && e.stack ? e.stack : e
      );
      this.addAttachments([
        {
          file,
          size: file.size,
          contentType,
          fileName,
          isVoiceMessage: false,
          url: '',
        },
      ]);
    }
  }

  private handleDrag(e: any) {
    e.preventDefault();
    e.stopPropagation();
  }

  private handleDragIn(e: any) {
    e.preventDefault();
    e.stopPropagation();
    this.dragCounter++;
    if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
      this.setState({ isDraggingFile: true });
    }
  }

  private handleDragOut(e: any) {
    e.preventDefault();
    e.stopPropagation();
    this.dragCounter--;

    if (this.dragCounter === 0) {
      this.setState({ isDraggingFile: false });
    }
  }

  private handleDrop(e: DragEvent) {
    e.preventDefault();
    e.stopPropagation();

    if (e?.dataTransfer?.files && e.dataTransfer.files.length > 0) {
      void this.onChoseAttachments(Array.from(e.dataTransfer.files));
      e.dataTransfer.clearData();
      this.dragCounter = 0;
      this.setState({ isDraggingFile: false });
    }
  }

  private async updateMemberListBouncy() {
    const allPubKeys = await getPubkeysInPublicConversation(this.props.selectedConversationKey);

    window?.log?.info(`getPubkeysInPublicConversation returned '${allPubKeys?.length}' members`);

    const allMembers = allPubKeys.map((pubKey: string) => {
      const conv = ConversationController.getInstance().get(pubKey);
      const profileName = conv?.getProfileName() || 'Anonymous';

      return {
        id: pubKey,
        authorPhoneNumber: pubKey,
        authorProfileName: profileName,
      };
    });

    window.inboxStore?.dispatch(updateMentionsMembers(allMembers));
  }
}
