<template>
  <div :class="{'chat-root': true, 'fill-height': true, 'd-flex': true, 'flex-column': true, 'channel': roomDisplayType == ROOM_TYPE_CHANNEL}" :style="chatContainerStyle">
    <ChatHeaderPrivate class="chat-header flex-grow-0 flex-shrink-0"
      v-on:header-click="onHeaderClick"
      v-on:view-room-details="viewRoomDetails"
      v-on:purge="showPurgeConfirmation = true"
      v-on:download="downloadingChat = true"
      v-if="!useFileModeNonAdmin && $matrix.isDirectRoom(room)" />
      <ChatHeader class="chat-header flex-grow-0 flex-shrink-0"
      v-on:header-click="onHeaderClick"
      v-on:view-room-details="viewRoomDetails"
      v-on:purge="showPurgeConfirmation = true"
      v-on:download="downloadingChat = true"
      v-else-if="!useFileModeNonAdmin" />
    <AudioLayout ref="chatContainer" class="auto-audio-player-root" v-if="useVoiceMode" :room="room"
      :events="events" :autoplay="!showRecorder"
      :timelineSet="timelineSet"
      :readMarker="readMarker"
      :recordingMembers="typingMembers"
      v-on:start-recording="setShowRecorder()"
      v-on:loadnext="handleScrolledToLatest(false)"
      v-on:loadprevious="handleScrolledToOldest()"
      v-on:mark-read="sendRR"
      v-on:sendclap="sendClapReactionAtTime"
      />
      <VoiceRecorder class="audio-layout" v-if="useVoiceMode" :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
          v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />

    <SendAttachmentsLayout 
      v-if="room && useFileModeNonAdmin" 
      :room="room"
      v-on:pick-file="showAttachmentPicker(false)"
      v-on:add-files="(files) => addAttachments(files)"
      :batch="uploadBatch"
      v-on:close="closeFileMode"
      :showBackButton="false"
      :defaultRootMessageText="$t('file_mode.files')"
    />

    <div v-if="!useVoiceMode && !useFileModeNonAdmin" :class="{'chat-content': true, 'flex-grow-1': true, 'flex-shrink-1': true, 'invisible': !initialLoadDone}" ref="chatContainer"
      v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
      <div ref="messageOperationsStrut" class="message-operations-strut">
        <component :is="messageOperationsComponent" ref="messageOperations" :style="opStyle" 
          v-if="showMessageOperations" 
          v-on:close="showContextMenu = false;" 
          :emojis="recentEmojis" 
          :originalEvent="selectedEvent" 
          :timelineSet="timelineSet"
          v-on:pin="pin(selectedEvent)"
          v-on:unpin="unpin(selectedEvent)"
          v-on:addreaction="addReaction" 
          v-on:addquickreaction="addQuickReaction"
          v-on:addreply="addReply(selectedEvent)" 
          v-on:edit="edit(selectedEvent)" 
          v-on:redact="redact(selectedEvent)"
          v-on:download="download(selectedEvent)" 
          v-on:more="
            isEmojiQuickReaction=true;
            showMoreMessageOperations({event: selectedEvent, anchor: $event.anchor})
          " 
          :userCanSendReactionAndAnswerPoll="$matrix.userCanSendReactionAndAnswerPollInCurrentRoom"
          :userCanSendMessage="$matrix.userCanSendMessageInCurrentRoom"
          :userCanPin="canCreatePoll"
        />
      </div>

      <!-- Handle resizes, e.g. when soft keyboard is shown/hidden -->
      <resize-observer ref="chatContainerResizer" @notify="handleChatContainerResize" />

      <component :is="roomWelcomeHeader" v-on:close="closeRoomWelcomeHeader"></component>

      <!-- If we have a retention timer, it means we have active message retention. Show header. -->
      <WelcomeHeaderChannelUser v-if="retentionTimer && !roomWelcomeHeader && newlyJoinedRoom" />

      <div v-for="(event) in filteredEvents" :key="event.getId()" :eventId="event !== ROOM_READ_MARKER_EVENT_PLACEHOLDER ? event.getId() : undefined">
               
        <!-- DAY Marker, shown for every new day in the timeline -->
        <div v-if="!reverseOrder && showDayMarkerBeforeEvent(event) && !!event.component && event !== ROOM_READ_MARKER_EVENT_PLACEHOLDER" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>

        <div :ref="event.getId()">
          <MessageErrorHandler>
            <div class="message-wrapper" v-on:touchstart="
              (e) => {
                touchStart(e, event);
              }
              " v-on:touchend="touchEnd" v-on:touchcancel="touchCancel" v-on:touchmove="touchMove">

              <!-- Note: For threaded media messages, IF there is only one item we show that media item as a single component.
                We might therefore get calls to v-on:contextMenu that has the event set to that single media item, not the top level thread event
                that is really displayed in the flow. Therefore, we rewrite these events with "{event: event, anchor: $event.anchor}",
                see below. Otherwise things like context menus won't work as designed.
              -->
              <component :is="event.component" :room="room" :originalEvent="event" :nextEvent="event.nextDisplayedEvent"
                :timelineSet="timelineSet" v-on:send-quick-reaction="sendQuickReaction"
                :componentFn="componentForEvent"
                v-on:contextMenu="showContextMenuForEvent({event: event, anchor: $event.anchor})"
                v-on:own-avatar-clicked="viewProfile"
                v-on:other-avatar-clicked="showAvatarMenuForEvent({event: event, anchor: $event.anchor})"
                v-on:download="download(event)"
                v-on:poll-closed="pollWasClosed(event)"
                v-on:more="
                  isEmojiQuickReaction = true;
                  showMoreMessageOperations({event: event, anchor: $event.anchor})
                  "
                v-on:layout-change="onLayoutChange"
                v-on:addQuickHeartReaction="addQuickHeartReaction({event, position: $event.position})"
                />
              <!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
              <!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}<br /><br /></div> -->
            </div>
          </MessageErrorHandler>
        </div>

        <!-- Day marker when reverseOrder is set -->
        <div v-if="reverseOrder && showDayMarkerBeforeEvent(event) && !!event.component && event !== ROOM_READ_MARKER_EVENT_PLACEHOLDER" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>

      </div>

      <NoHistoryRoomWelcomeHeader v-if="showNoHistoryRoomWelcomeHeader" />

    </div>

    <!-- "Scroll to end"-button -->
    <v-btn v-if="!useVoiceMode" icon="arrow_downward" :class="{'scroll-to-end': true, 'reversed': reverseOrder, 'hidden': !showScrollToEnd}" theme="dark" size="x-small" elevation="0" color="black"
      @click.stop="scrollToEndOfTimeline">
    </v-btn>

    <!-- Input area -->
    <v-container v-if="!useVoiceMode && !useFileModeNonAdmin && room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
      <div :class="[replyToEvent ? 'iput-area-inner-box' : '']">
        <v-row class="ma-0 pa-0">
          <div v-if="replyToEvent" class="row">
            <div class="col">
              <div class="font-weight-medium">{{ $t("message.replying_to", { user: senderDisplayName }) }}</div>
              <div v-if="replyToContentType === 'm.text'" class="reply-text" :title="replyToEvent.getContent().body">
                {{ latestReply }}
              </div>
              <div v-if="replyToContentType === 'm.thread' || replyToContentType === 'io.element.thread'">{{ replyToThreadMessage }}</div>
              <div v-if="replyToContentType === 'm.image'">{{ $t("message.reply_image") }}</div>
              <div v-if="replyToContentType === 'm.audio'">{{ $t("message.reply_audio_message") }}</div>
              <div v-if="replyToContentType === 'm.video'">{{ $t("message.reply_video") }}</div>
              <div v-if="replyToContentType === 'm.poll'">{{ $t("message.reply_poll") }}</div>
            </div>
            <div class="col col-auto" v-if="replyToContentType !== 'm.text'">
              <img v-if="replyToContentType === 'm.image'" width="50px" height="50px" :src="replyToImg"
                class="rounded" />
              <v-icon v-if="replyToContentType === 'm.audio'">$vuetify.icons.audio_message</v-icon>
              <v-icon v-if="replyToContentType === 'm.video'">$vuetify.icons.video_message</v-icon>
              <v-icon v-if="replyToContentType === 'm.poll'" theme="light">$vuetify.icons.poll</v-icon>
            </div>
            <div class="col col-auto">
              <v-btn icon="cancel" size="default" elevation="0" color="black" @click.stop="cancelEditReply">
              </v-btn>
            </div>
          </div>

          <!-- CONTACT IS TYPING -->
          <div class="typing">
            {{ typingMembersString }}
          </div>
        </v-row>
        <v-row class="input-area-inner align-center" v-show="!showRecorder" v-if="$matrix.userCanSendMessageInCurrentRoom">
          <v-col class="flex-grow-1 flex-shrink-1 ma-0 pa-0">
            <v-textarea height="undefined" ref="messageInput" full-width auto-grow rows="1" v-model="currentInput"
              no-resize class="input-area-text input-plain" :placeholder="$t('message.your_message')" hide-details
              background-color="white" v-on:keydown.enter.prevent="
                () => {
                  sendCurrentTextMessage();
                }
              " />
          </v-col>

          <v-col class="input-area-button text-center flex-grow-0 flex-shrink-1" v-if="editedEvent">
            <v-btn icon="cancel" elevation="0" color="black" @click.stop="cancelEditReply">
            </v-btn>
          </v-col>

          <v-col v-if="(!currentInput || currentInput.length == 0) && canCreatePoll && !replyToEvent"
            class="input-area-button text-center flex-grow-0 flex-shrink-1">
            <v-btn icon @click="showCreatePollDialog = true">
              <v-icon size="24">$vuetify.icons.poll</v-icon>
            </v-btn>
          </v-col>

          <v-col class="input-area-button text-center flex-grow-0 flex-shrink-1"
            v-if="!$config.disableMediaSharing && (!currentInput || currentInput.length == 0 || showRecorder)">
            <v-btn icon="mic" :color="showRecorder ? 'black' : 'white'" v-if="canRecordAudio" class="mic-button" ref="mic_button" elevation="0" v-blur
              v-longTap:250="[showRecordingUI, startRecording]">
            </v-btn>
            <v-btn icon="mic" :color="showRecorder ? 'black' : 'white'" v-else class="mic-button" ref="mic_button" elevation="0" v-blur
              @click.stop="showNoRecordingAvailableDialog = true">
            </v-btn>
          </v-col>

          <v-col class="input-area-button text-center flex-grow-0 flex-shrink-1" v-else-if="currentInput && currentInput.length > 0">
            <v-btn :icon="editedEvent ? 'save' : 'arrow_upward'" size="default" elevation="0" color="black" @click.stop="sendCurrentTextMessage"
              :disabled="sendButtonDisabled">
            </v-btn>
          </v-col>

          <v-col class="input-area-button text-center flex-grow-0 flex-shrink-1 input-more-icon">
            <v-btn size="small" elevation="0" v-blur @click.stop="
              isEmojiQuickReaction = false;
              showMoreMessageOperations($event)
            " icon="$vuetify.icons.addReaction">
            </v-btn>
          </v-col>

          <v-col v-if="$config.shortCodeStickers" class="input-area-button text-center flex-grow-0 flex-shrink-1">
            <v-btn id="btn-stickers" icon="face" @click="showStickerPicker"
              :disabled="attachButtonDisabled">
            </v-btn>
          </v-col>

          <v-col v-if="!$config.disableMediaSharing" class="input-area-button text-center flex-grow-0 flex-shrink-1">
            <label icon flat ref="attachmentLabel">
              <v-btn icon @click="() => showAttachmentPicker(true)"
                :disabled="attachButtonDisabled">
                <v-icon size="36">add_circle_outline</v-icon>
              </v-btn>
            </label>
          </v-col>
        </v-row>
        <VoiceRecorder :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
          v-on:close="showRecorder = false" v-on:file="onVoiceRecording" />
      </div>
      <div v-if="!useVoiceMode && room && !$matrix.userCanSendMessageInCurrentRoom" class="input-area-read-only">{{ $t("message.not_allowed_to_send") }}</div>
    </v-container>

    <form ref="attachmentForm">
      <input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
        accept="image/*,audio/*,video/*,.mp3,.mp4,.wav,.m4a,.pdf,application/pdf,.apk,application/vnd.android.package-archive,.ipa,.zip,application/zip,application/x-zip-compressed,multipart/x-zip" class="d-none" multiple/>
    </form>

    <SendAttachmentsLayout 
      v-if="uploadBatch && uploadBatch.attachments.length > 0 && !useFileModeNonAdmin" 
      :room="room"
      v-on:pick-file="showAttachmentPicker(false)"
      v-on:add-files="(files) => addAttachments(files)"
      :batch="uploadBatch"
      v-on:close="() => { uploadBatch = undefined }"
      :title="room.name"
    />

    <MessageOperationsBottomSheet ref="messageOperationsSheet">
      <EmojiPicker ref="emojiPicker" 
        :native="true"
        @select="emojiSelected" 
        :additional-groups="additionalEmojiGroups" 
        :group-names="emojiGroupNames" 
        :group-icons="additionalEmojiGroupIcons"
        :group-order="['recently_used']"
        disable-skin-tones 
        :static-texts="{ placeholder: $t('emoji.search')}"/>
    </MessageOperationsBottomSheet>

    <StickerPickerBottomSheet ref="stickerPickerSheet" v-on:selectSticker="sendSticker" />

    <!-- Loading indicator -->
    <v-container fluid class="loading-indicator fill-height" v-if="!initialLoadDone || loading">
      <v-row align="center" justify="center">
        <v-col class="text-center">
          <v-progress-circular indeterminate color="primary"></v-progress-circular>
        </v-col>
      </v-row>
    </v-container>

    <RoomInfoBottomSheet ref="roomInfoSheet" />

    <!-- Dialog for audio recording not supported! -->
    <v-dialog v-model="showNoRecordingAvailableDialog" class="ma-0 pa-0" width="80%">
      <v-card>
        <v-card-title>{{ $t("voice_recorder.not_supported_title") }}</v-card-title>
        <v-card-text>{{ $t("voice_recorder.not_supported_text") }} </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn id="btn-ok" color="primary" variant="text" @click="showNoRecordingAvailableDialog = false">{{
          $t("menu.ok")
          }}</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

    <CreatePollDialog v-model="showCreatePollDialog" :room="room" />

    <UserProfileDialog
      v-model="showProfileDialog"
      :activeMember="compActiveMember"
      :room="room"
    />

    <!-- PURGE ROOM POPUP -->
    <PurgeRoomDialog v-model="showPurgeConfirmation" :room="room" />

    <RoomExport :room="room" v-if="downloadingChat" v-on:close="downloadingChat = false" />

    <!-- Heart animation -->
    <div :class="['heart-wrapper', { 'is-active': heartAnimation }]" :style="hearAnimationPosition">
      <div :class="['heart', { 'is-active': heartAnimation }]" />
    </div>
  </div>
</template>

<script>
import { TimelineWindow, EventTimeline } from "matrix-js-sdk";
import { toRaw } from "vue";
import util, { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE, ROOM_TYPE_CHANNEL } from "../plugins/utils";
import MessageOperations from "./messages/MessageOperations.vue";
import ChatHeader from "./ChatHeader";
import ChatHeaderPrivate from "./ChatHeaderPrivate.vue";
import VoiceRecorder from "./VoiceRecorder";
import RoomInfoBottomSheet from "./RoomInfoBottomSheet";
import WelcomeHeaderRoom from "./welcome_headers/WelcomeHeaderRoom";
import WelcomeHeaderDirectChat from "./welcome_headers/WelcomeHeaderDirectChat";
import WelcomeHeaderChannel from "./welcome_headers/WelcomeHeaderChannel";
import WelcomeHeaderChannelUser from "./welcome_headers/WelcomeHeaderChannelUser";
import NoHistoryRoomWelcomeHeader from "./NoHistoryRoomWelcomeHeader.vue";
import MessageOperationsBottomSheet from "./MessageOperationsBottomSheet";
import StickerPickerBottomSheet from "./StickerPickerBottomSheet";
import UserProfileDialog from "./UserProfileDialog.vue"
import BottomSheet from "./BottomSheet.vue";
import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
import AudioLayout from "./AudioLayout.vue";
import SendAttachmentsLayout from "./file_mode/SendAttachmentsLayout.vue";
import roomTypeMixin from "./roomTypeMixin";
import roomMembersMixin from "./roomMembersMixin";
import PurgeRoomDialog from "../components/PurgeRoomDialog";
import MessageErrorHandler from "./MessageErrorHandler";
import MessageOperationsChannel from './messages/channel/MessageOperationsChannel.vue';
import prettyBytes from "pretty-bytes";
import RoomExport from "./RoomExport.vue";
import EmojiPicker from 'vue3-emoji-picker';
import 'vue3-emoji-picker/css';
import emitter from 'tiny-emitter/instance';
import { markRaw } from "vue";
import timerIcon from '@/assets/icons/ic_timer.svg';
import proofmode from "../plugins/proofmode.js";
import { consoleWarn } from "vuetify/lib/util/console.mjs";

const READ_RECEIPT_TIMEOUT = 5000; /* How long a message must have been visible before the read marker is updated */
const WINDOW_BUFFER_SIZE = 0.3; /** Relative window height of when we start paginating. Always keep this much loaded before and after our scroll position!  */

// from https://kirbysayshi.com/2013/08/19/maintaining-scroll-position-knockoutjs-list.html
function ScrollPosition(node) {
  this.node = node;
  this.previousScrollHeightMinusTop = 0;
  this.previousScrollTop = 0;
  this.readyFor = "up";
}

ScrollPosition.prototype.restore = function () {
  if (this.readyFor === "up") {
    this.node.scrollTop = this.node.scrollHeight - this.previousScrollHeightMinusTop;
  } else {
    this.node.scrollTop = this.previousScrollTop;
  }
};

ScrollPosition.prototype.prepareFor = function (direction) {
  this.readyFor = direction || "up";
  if (this.readyFor === "up") {
    this.previousScrollHeightMinusTop = this.node.scrollHeight - this.node.scrollTop;
  } else {
    this.previousScrollTop = this.node.scrollTop;
  }
};

export default {
  name: "Chat",
  mixins: [chatMixin, roomTypeMixin, roomMembersMixin],
  components: {
    ChatHeader,
    ChatHeaderPrivate,
    MessageOperations,
    VoiceRecorder,
    RoomInfoBottomSheet,
    WelcomeHeaderRoom,
    WelcomeHeaderDirectChat,
    NoHistoryRoomWelcomeHeader,
    MessageOperationsBottomSheet,
    StickerPickerBottomSheet,
    BottomSheet,
    CreatePollDialog,
    AudioLayout,
    SendAttachmentsLayout,
    UserProfileDialog,
    PurgeRoomDialog,
    WelcomeHeaderChannelUser,
    MessageErrorHandler,
    MessageOperationsChannel,
    RoomExport,
    EmojiPicker,
  },

  data() {
    return {
      ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
      ROOM_READ_MARKER_EVENT_PLACEHOLDER: ROOM_READ_MARKER_EVENT_PLACEHOLDER,

      waitingForRoomObject: false,
      events: [],
      currentInput: "",
      typingMembers: [],
      timelineSet: null,
      timelineWindow: null,

      /** true if we are currently paginating */
      timelineWindowPaginating: false,

      scrollPosition: null,
      scrollUpdateTimer: null,
      uploadBatch: undefined,
      showEmojiPicker: false,
      selectedEvent: null,
      editedEvent: null,
      replyToEvent: null,
      replyToImg: null,
      replyToContentType: null,
      showCreatePollDialog: false,
      showNoRecordingAvailableDialog: false,
      showContextMenu: false,
      showContextMenuAnchor: null,
      initialLoadDone: false,
      loading: false, // Set this to true during long operations to show a "spinner" overlay
      showRecorder: false,
      showRecorderPTT: false, // True to open the voice recorder in push-to-talk mode.

      /**
       * Current chat container size. We need to keep track of this so that if and when
       * a soft keyboard is shown/hidden we can restore the scroll position correctly.
       * If we don't, the keyboard will simply overflow the message we are answering to etc.
       */
      chatContainerSize: 0,

      /**
       * True if we should show the "scroll to end" marker in the chat. For now at least, we use a simple
       * method here, basically just "if we can scroll, show it".
       */
      showScrollToEnd: false,

      /** A timer for read receipts. */
      rrTimer: null,

      /** Last event we sent a Read Receipt/Read Marker for */
      lastRR: null,

      /** If we just created this room, show a small welcome header with info */
      hideRoomWelcomeHeader: false,
      newlyJoinedRoom: false,

      /** An array of recent emojis. Used in the "message operations" popup. */
      recentEmojis: [],

      /** Calculated style for message operations. We position the "popup" at the selected message. */
      opStyle: "",

      isEmojiQuickReaction: true,
      emojiGroupNames:  {
        "smileys_people": this.$t("emoji.categories.peoples"),
        "animals_nature": this.$t("emoji.categories.nature"),
        "food_drink": this.$t("emoji.categories.foods"),
        "activities": this.$t("emoji.categories.activity"),
        "travel_places": this.$t("emoji.categories.places"),
        "objects": this.$t("emoji.categories.objects"),
        "symbols": this.$t("emoji.categories.symbols"),
        "flags": this.$t("emoji.categories.flags"),
        "recently_used": this.$t("emoji.categories.frequently"),
      },

      /**
       * A timer to handle message retention/auto deletion
       */
      retentionTimer: null,
      showProfileDialog: false,
      showPurgeConfirmation: false,
      heartAnimation: false,
      heartPosition: {
        top: 0,
        left: 0
      },
      reverseOrder: false,
      downloadingChat: false
    };
  },

  mounted() {
    emitter.on('audio-playback-ended', this.audioPlaybackEnded);
    const container = this.chatContainer;
    if (container) {
      this.scrollPosition = new ScrollPosition(container);
      if (this.$refs.chatContainerResizer) {
        this.chatContainerSize = this.$refs.chatContainerResizer.$el.clientHeight;
      }
    }
  },

  beforeUnmount() {
    emitter.off('audio-playback-ended', this.audioPlaybackEnded);
    this.$audioPlayer.pause();
    this.stopRRTimer();
    if (this.retentionTimer) {
      clearInterval(this.retentionTimer);
      this.retentionTimer = null;
    }
  },

  unmounted() {
    this.$matrix.off("Room.timeline", this.onEvent);
    this.$matrix.off("RoomMember.typing", this.onUserTyping);
  },

  computed: {
    additionalEmojiGroups() {
      return { 'recently_used': this.recentEmojis }
    },
    additionalEmojiGroupIcons() {
      return { 'recently_used': timerIcon }
    },
    latestReply() {
      const contents = this.replyToEvent ? this.replyToEvent.getContent().body : "";
      const contentArr = contents.split("\n").reverse();
      if (contentArr[0] === "") {
        contentArr.shift();
      }
      return (contentArr && contentArr.length > 0) ? contentArr[0].replace(/^> (<.*> )?/g, "") : "";
    },
    heartEmoji() {
      return "❤️";
    },
    compActiveMember() {
      const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId
      return this.joinedAndInvitedMembers.find(({userId}) => userId === currentUserId)
    },
    chatContainer() {
      const container = this.$refs.chatContainer;
      if (this.useVoiceMode) {
        return container.$el;
      }
      return container;
    },
    senderDisplayName() {
      return this.room.getMember(this.replyToEvent.sender.userId).name;
    },
    currentUser() {
      return this.$store.state.auth.user;
    },
    room() {
      return this.$matrix.currentRoom;
    },
    roomId() {
      if (!this.$matrix.ready && this.currentUser) {
        // If we have a user already, wait for ready state. If not, we
        // dont want to return here, because we want to redirect to "join".
        return null; // Not ready yet...
      }
      if (this.room) {
        return this.room.roomId;
      }
      return this.$matrix.currentRoomId;
    },
    roomAliasOrId() {
      if (this.room) {
        return this.room.getCanonicalAlias() || this.room.roomId;
      }
      return this.$matrix.currentRoomId;
    },
    readMarker() {
      if (this.lastRR) {
        // If we have sent a RR, use that as read marker (so we don't have to wait for server round trip)
        return this.lastRR.getId();
      }
      return this.fullyReadMarker || this.room.getEventReadUpTo(this.$matrix.currentUserId, false);
    },
    fullyReadMarker() {
      const readEvent = this.room && this.room.getAccountData("m.fully_read");
      if (readEvent) {
        return readEvent.getContent().event_id;
      }
      return null;
    },
    attachButtonDisabled() {
      return this.editedEvent != null || this.replyToEvent != null || this.currentInput.length > 0;
    },
    sendButtonDisabled() {
      return this.currentInput.length == 0;
    },
    typingMembersString() {
      const count = this.typingMembers.length;
      if (count > 1) {
        return this.$t("message.users_are_typing", { count: count });
      } else if (count > 0) {
        return this.$t("message.user_is_typing", {
          user: this.typingMembers[0].name,
        });
      } else {
        return "";
      }
    },
    showMessageOperations() {
      return this.selectedEvent && this.showContextMenu;
    },
    canRecordAudio() {
      return util.browserCanRecordAudio();
    },
    canCreatePoll() {
      // We say that if you can redact events, you are allowed to create polls.
      const me = this.room && this.room.getMember(this.$matrix.currentUserId);
      let isAdmin =
        me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel);
      return isAdmin;
    },
    useVoiceMode: {
      get: function () {
        if (!this.$config.experimental_voice_mode) return false;
        return (util.roomDisplayTypeOverride(this.room) || this.roomDisplayType) === ROOM_TYPE_VOICE_MODE;
      },
    },
    useFileModeNonAdmin: {
      get: function() {
        return this.roomDisplayType === ROOM_TYPE_FILE_MODE && !this.canCreatePoll; // TODO - Check user or admin
      }
    },

    /**
     * If we have no events and the room is encrypted, show info about this
     * to the user.
     */
    showNoHistoryRoomWelcomeHeader() {
      return this.filteredEvents.length == 0 && this.room && this.$matrix.matrixClient.isRoomEncrypted(this.room.roomId);
    },

    filteredEvents() {
      let events = this.events;

      if (this.room && this.$matrix.matrixClient.isRoomEncrypted(this.room.roomId)) {
        if (this.room.getHistoryVisibility() == "joined") {
          // For encrypted rooms where history is set to "joined" we can't read old events.
          // We might, however, have old status events from room creation etc.
          // We filter out anything that happened before our own join event.
          for (let idx = this.events.length - 1; idx >= 0; idx--) {
            const e = this.events[idx];
            if (e.getType() == "m.room.member" &&
              e.getContent().membership == "join" &&
              (!e.getPrevContent() || e.getPrevContent().membership != "join") &&
              e.getStateKey() == this.$matrix.currentUserId) {
              // Our own join event.
              events = this.events.slice(idx + 1);
            }
          }
        }
      }

      // Filter out relations and redactions
      events = this.events.toReversed().filter((e) => !e.isRelation() && !e.isRedaction());

      // If Channel, remove all redacted events as well.
      if (this.room && this.room.displayType == ROOM_TYPE_CHANNEL) {
        events = events.filter((e) => !e.isRedacted());
      }

      // Add read marker, if it is not newer than the "latest" message we are going to display
      //
      let showReadMarker = false;
      let lastDisplayedEvent = undefined;
      events = events.flatMap((e) => {
        let result = [];
        if (e.isEncrypted()) {
          if (e.getClearContent()) {
            e.component = this.componentForEvent(e, false);
          } else {
            this.$matrix.matrixClient.decryptEventIfNeeded(e).then(() => {
              e.component = this.componentForEvent(e, false);
            });
          }
        } else {
          e.component = this.componentForEvent(e, false);
        }
        if (e.getId() == this.readMarker && showReadMarker) {
          const readMarkerEvent = ROOM_READ_MARKER_EVENT_PLACEHOLDER;
          readMarkerEvent["component"] = this.componentForEvent(readMarkerEvent, false);
          if (readMarkerEvent.component) {
            e["nextDisplayedEvent"] = lastDisplayedEvent;
          }
          result.push(readMarkerEvent);
        }
        if (e.component) {
          e["nextDisplayedEvent"] = lastDisplayedEvent;
          lastDisplayedEvent = e;
          if (e.getSender() !== this.$matrix.currentUserId) {
            showReadMarker = true;
          }
        }
        result.push(e);
        return result;
      })
      return (this.reverseOrder ? events : events.toReversed()) // Reverse back if needed
    },

    roomCreatedByUsRecently() {
      const createEvent = this.room && this.room.currentState.getStateEvents("m.room.create", "");
      if (createEvent) {
        const creatorId = createEvent.getContent().creator;
        return (creatorId == this.$matrix.currentUserId && createEvent.getLocalAge() < 5 * 60000 /* 5 minutes */);
      }
      return false;
    },

    isDirectRoom() {
      return this.room && this.room.getJoinRule() == "invite" && this.joinedAndInvitedMembers.length == 2;
    },

    isPublicRoom() {
      return this.room && this.room.getJoinRule() == "public";
    },

    showRoomWelcomeHeader() {
      return this.roomWelcomeHeader != null;
    },
    roomWelcomeHeader() {
      if (!this.hideRoomWelcomeHeader && this.roomCreatedByUsRecently) {
        if (this.roomDisplayType == ROOM_TYPE_CHANNEL) {
          return markRaw(WelcomeHeaderChannel);
        }
        if (this.isDirectRoom) {
          return markRaw(WelcomeHeaderDirectChat);
        }
        return markRaw(WelcomeHeaderRoom);
      }
      return null;
    },
    messageOperationsComponent() {
      if (this.room.displayType == ROOM_TYPE_CHANNEL) {
        return markRaw(MessageOperationsChannel);
      }
      return markRaw(MessageOperations);
    },
    chatContainerStyle() {
      if (this.$config.chat_backgrounds && this.room && this.roomId) {
        const roomType =  this.isDirectRoom ? "direct" : this.isPublicRoom ? "public" : "invite";
        let backgrounds = this.$config.chat_backgrounds[roomType] || this.$config.chat_backgrounds["all"];
        if (backgrounds) {
          const numBackgrounds = backgrounds.length;

          // If we have several backgrounds set, use the room ID to calculate
          // an int hash value, then take mod of that to select a background to use.
          // That way, we always get the same one, since room IDs don't change.

          // From: https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
          const hashCode = function (s) {
            var hash = 0,
              i, chr;
            if (s.length === 0) return hash;
            for (i = 0; i < s.length; i++) {
              chr = s.charCodeAt(i);
              hash = ((hash << 5) - hash) + chr;
              hash |= 0; // Convert to 32bit integer
            }
            return hash;
          }

          // Adapted from: https://stackoverflow.com/questions/5717093/check-if-a-javascript-string-is-a-url
          const validUrl = function (s) {
            let url;
            try {
              url = new URL(s, window.location);
            } catch (err) {
              return false;
            }
            return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:";
          }

          const index = Math.abs(hashCode(this.roomId)) % numBackgrounds;
          const background = backgrounds[index];
          if (background && validUrl(background)) {
            return "background-image: url(" + background + ");background-repeat: repeat";
          }
        }
      }
      return "";
    },

    /**
     * If we are replying to a (media) thread, this is the hint we show when replying.
     */
    replyToThreadMessage() {
      if (this.replyToEvent && this.timelineSet) {
        return this.$t("message.sent_media", {count: this.timelineSet.relations
          .getAllChildEventsForEvent(this.replyToEvent.getId())
          .filter((e) => util.downloadableTypes().includes(e.getContent().msgtype)).length});
      }
      return "";
    },
    hearAnimationPosition() {
      return {
        '--top': this.heartPosition.top,
        '--left': this.heartPosition.left
      };
    }
  },

  watch: {
    initialLoadDone: {
      immediate: true,
      handler(value, oldValue) {
        if (value && !oldValue) {
          this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => this.setParentThread(event));
          this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => this.setReplyToEvent(event));
          console.log("Loading finished!");
          this.updateRetentionTimer();
        } else if (!value) {
          if (this.retentionTimer) {
            clearInterval(this.retentionTimer);
            this.retentionTimer = null;
          }
        }
      }
    },
    roomId: {
      immediate: true,
      handler(value, oldValue) {
        if (value && value == oldValue) {
          return; // No change.
        }
        console.log("Chat: Current room changed to " + (value ? value : "null"));

        // Clear old events
        this.$matrix.off("Room.timeline", this.onEvent);
        this.$matrix.off("RoomMember.typing", this.onUserTyping);

        this.waitingForRoomObject = false;
        this.events = [];
        this.timelineWindow = null;
        this.typingMembers = [];
        this.initialLoadDone = false;
        this.hideRoomWelcomeHeader = false;
        this.newlyJoinedRoom = false;

        // Stop RR timer
        this.stopRRTimer();
        this.lastRR = null;

        if (this.roomId) {
          this.$matrix.isJoinedToRoom(this.roomId).then(joined => {
            if (!joined) {
              this.onRoomNotJoined();
            } else {
              if (this.room) {
                this.onRoomJoined(this.roomDisplayType == ROOM_TYPE_CHANNEL ? null : this.readMarker);
              } else {
                this.waitingForRoomObject = true;
                return; // no room, wait for it (we know we are joined so need to wait for sync to complete)
              }
            }
          });
        } else {
          this.setInitialLoadDone();
          return; // no room
        }
      },
    },
    room() {
      // Were we waiting?
      if (this.room && this.room.roomId == this.roomId && this.waitingForRoomObject) {
        this.waitingForRoomObject = false;
        this.onRoomJoined(this.roomDisplayType == ROOM_TYPE_CHANNEL ? null : this.readMarker);
      }
    },
    showMessageOperations(show) {
      if (show) {
        this.$nextTick(() => {
          // Calculate where to show the context menu.
          //
          const ref = this.selectedEvent && this.$refs[this.selectedEvent.getId()];
          var top = 0;
          var left = 0;
          if (ref && ref[0]) {
            if (this.showContextMenuAnchor) {
              var rectAnchor = this.showContextMenuAnchor.getBoundingClientRect();
              var rectChat = this.$refs.messageOperationsStrut.getBoundingClientRect();
              var rectOps = this.$refs.messageOperations.$el.getBoundingClientRect();
              if (this.room.displayType == ROOM_TYPE_CHANNEL) {
                top = rectAnchor.top - rectChat.top;
                let right = rectChat.right - rectAnchor.right; 
                this.opStyle = "top:" + top + "px;right:" + right + "px";
                return;
              } else {
              top = rectAnchor.top - rectChat.top - 50;
              left = rectAnchor.left - rectChat.left - 75;
              if (left + rectOps.width + 10 >= rectChat.right) {
                left = rectChat.right - rectOps.width - 10; // No overflow
              } else if (left < 0) {
                left = 0;
              }
            }
            }
          }
          this.opStyle = "top:" + top + "px;left:" + left + "px";
        });
      }
    },
    showRecorder(show) {
      if (this.useVoiceMode) {
        // Send typing indicators when recorder UI is opened/closed
        this.$matrix.matrixClient.sendTyping(this.roomId, show, 10 * 60 * 1000);
      }
    }
  },

  methods: {
    /**
     * Set initialLoadDone to 'true'. This will process all events, setting threadParent and replyEvent if needed (see watcher for "initialLoadDone")
     */
    setInitialLoadDone() {
      this.initialLoadDone = true;
    },

    /**
     * Set events to display. At the same time, filter out messages that are past rentention period etc. Also, filter pinned events "at the top"
     */
    setEvents(events, onlyIfLengthChanges = false) {
      let updated = this.filterOutOldAndInvisible(events);

      // Handle pinning
      //
      if (this.room) {
        const pinnedEvents = this.$matrix.getPinnedEvents(this.room);
        updated.forEach((e) => {
          e["isPinned"] = pinnedEvents.includes(e.threadParent ? e.threadParent.getId() : e.getId());
          e["isChannelMessage"] = (this.room && this.roomDisplayType == ROOM_TYPE_CHANNEL);
        });

        updated = updated.sort((e1, e2) => {
          if (!e1.isPinned && !e2.isPinned) return 0;
          else if (e1.isPinned && !e2.isPinned) return this.reverseOrder ? 1 : -1;
          else if (e2.isPinned && !e1.isPinned) return this.reverseOrder ? -1 : 1;
          else {
            // Look at order in "pinned" value in the m.room.pinned_events event!
            return pinnedEvents.indexOf(e1.getId()) < pinnedEvents.indexOf(e2.getId()) ? (this.reverseOrder ? 1 : -1) : (this.reverseOrder ? -1 : 1)
          }
        });
      }
      if (!onlyIfLengthChanges || updated.length != this.events.length) {
        this.events = updated; // Changed
      }
    },

    filterOutOldAndInvisible(events) {
      return this.removeTimedOutEvents(events.filter((e) => e.messageVisibility().visible));
    },

    updateRetentionTimer(maybeEvent) {
      const retentionEvent = maybeEvent || (this.room && this.room.currentState.getStateEvents("m.room.retention", ""));
      if (retentionEvent) {
        const maxLifetime = parseInt(retentionEvent.getContent().max_lifetime);
        if (maxLifetime) {
          if (!this.retentionTimer) {
            this.retentionTimer = setInterval(this.onRetentionTimer, 60000);
          }
          return;
        }
      }
      if (this.retentionTimer) {
          clearInterval(this.retentionTimer);
          this.retentionTimer = null;
        }
      },

    removeTimedOutEvents(events) {
      const retentionEvent = this.room && this.room.currentState.getStateEvents("m.room.retention", "");
      let maxLifetime = 0;
      if (retentionEvent) {
        maxLifetime = parseInt(retentionEvent.getContent().max_lifetime);
      }
      return events.filter((e) => {
        if (maxLifetime > 0 && !e.isState()) { // Keep all state events
          if (e.getLocalAge() < maxLifetime) {
            return true;
          }
          e.applyVisibilityEvent({ visible: false, eventId: e.getId(), reason: null});
          return true;
        }
        return true;
      });
    },

    onRetentionTimer() {
      this.setEvents(this.events, true);
    },

    onRoomJoined(initialEventId) {
      // If our own join event is less than a minute old, consider this a "newly joined" room.
      // 
      // Previously tried to look at initialEventId, but it seems like "this.room.getEventReadUpTo(this.$matrix.currentUserId, false)"
      // always returns an event id? Strange. I would expect it to be null on a fresh room.
      //
      const joinEvent = this.room && this.room.currentState.getStateEvents("m.room.member", this.$matrix.currentUserId);
      if (joinEvent) {
        this.newlyJoinedRoom = joinEvent.getLocalAge() < 1 * 60000 /* 1 minute */;
      }

      this.reverseOrder = (this.room && this.roomDisplayType == ROOM_TYPE_CHANNEL);
      this.room["displayType"] = this.roomDisplayType;

      // Listen to events
      this.$matrix.on("Room.timeline", this.onEvent);
      this.$matrix.on("RoomMember.typing", this.onUserTyping);

      console.log("Read up to " + initialEventId);

      //initialEventId = null;
      this.timelineSet = this.room.getUnfilteredTimelineSet();
      this.timelineWindow = new TimelineWindow(this.$matrix.matrixClient, this.timelineSet, {});
      const self = this;
      this.timelineWindow
        .load(initialEventId, 20)
        .then(() => {
          self.setEvents(self.timelineWindow.getEvents());

          const getMoreIfNeeded = function _getMoreIfNeeded() {
            const container = self.$refs.chatContainer;
            if (
              container &&
              container.scrollHeight <= (1 + 2 * WINDOW_BUFFER_SIZE) * container.clientHeight &&
              self.timelineWindow &&
              self.timelineWindow.canPaginate(EventTimeline.BACKWARDS)
            ) {
              return self.timelineWindow.paginate(EventTimeline.BACKWARDS, 10, true, 5).then((success) => {
                self.setEvents(self.timelineWindow.getEvents());
                if (success) {
                  return _getMoreIfNeeded.call(self);
                } else {
                  return Promise.reject("Failed to paginate");
                }
              });
            } else {
              return Promise.resolve("Done");
            }
          }.bind(self);

          getMoreIfNeeded()
            .catch((err) => {
              console.log("ERROR " + err);
            })
            .finally(() => {
//                 const [timelineEvents, threadedEvents, unknownRelations] =
//                         this.room.partitionThreadedEvents(self.events);
// this.$matrix.matrixClient.processAggregatedTimelineEvents(this.room, timelineEvents);
//                     //room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
//                     this.$matrix.matrixClient.processThreadEvents(this.room, threadedEvents, true);
//                     unknownRelations.forEach((event) => this.room.relations.aggregateChildEvent(event));

              this.setInitialLoadDone();
                if (initialEventId && !this.showRoomWelcomeHeader) {
                  const event = this.room.findEventById(initialEventId);
                  this.$nextTick(() => {
                    if (event && event.parentThread) {
                    self.scrollToEvent(event.parentThread.getId());
                  } else {
                    self.scrollToEvent(initialEventId);
                  }
                  });
              } else if (this.showRoomWelcomeHeader) {
                self.onScroll();
              }
              self.restartRRTimer();
            });
        })
        .catch((err) => {
          console.log("Error fetching events!", err, this);
          if (initialEventId) {
            // Try again without initial event!
            this.onRoomJoined(null);
          } else {
            // Error. Done loading.
            this.setEvents(this.timelineWindow.getEvents());
            this.setInitialLoadDone();
          }
        })
        .finally(() => {
          for (var event of this.events) {
            this.$matrix.matrixClient.decryptEventIfNeeded(event, {});
          }
        });
    },

    onRoomNotJoined() {
      this.$navigation.push(
        {
          name: "Join",
          params: { 
            roomId: util.sanitizeRoomId(this.roomAliasOrId), 
            join: this.$route.params.join
          },
          query: this.$route.query
        },
        0
      );
    },

    scrollToEndOfTimeline() {
      if (this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
        this.loading = true;
        // Instead of paging though ALL history, just reload a timeline at the live marker...
        var timelineSet = this.room.getUnfilteredTimelineSet();
        var timelineWindow = new TimelineWindow(this.$matrix.matrixClient, timelineSet, {});
        const self = this;
        timelineWindow
          .load(null, 20)
          .then(() => {
            self.timelineSet = timelineSet;
            self.timelineWindow = timelineWindow;
            self.setEvents(self.timelineWindow.getEvents());
          })
          .finally(() => {
            this.loading = false;
          });
      } else {
        // Can't paginate, just scroll to bottom of window!
        this.smoothScrollToLatest();
      }
    },

    touchX(event) {
      if (event.type.indexOf("mouse") !== -1) {
        return event.clientX;
      }
      return event.touches[0].clientX;
    },
    touchY(event) {
      if (event.type.indexOf("mouse") !== -1) {
        return event.clientY;
      }
      return event.touches[0].clientY;
    },
    touchStart(e, event) {
      if (this.selectedEvent != event) {
        this.showContextMenu = false;
      }
      this.selectedEvent = event;
      this.touchStartX = this.touchX(e);
      this.touchStartY = this.touchY(e);
      this.touchTimer = setTimeout(this.touchTimerElapsed, 500);
    },
    touchEnd() {
      this.touchTimer && clearTimeout(this.touchTimer);
    },
    touchCancel() {
      this.touchTimer && clearTimeout(this.touchTimer);
    },
    touchMove(e) {
      this.touchCurrentX = this.touchX(e);
      this.touchCurrentY = this.touchY(e);
      var tapTolerance = 4;
      var touchMoved =
        Math.abs(this.touchStartX - this.touchCurrentX) > tapTolerance ||
        Math.abs(this.touchStartY - this.touchCurrentY) > tapTolerance;
      if (touchMoved) {
        this.touchTimer && clearTimeout(this.touchTimer);
      }
    },

    /**
     * Triggered when our "long tap" timer hits.
     */
    touchTimerElapsed() {
      this.showContextMenu = true;
    },

    /**
     * If chat container is shrunk (probably because soft keyboard is shown) adjust
     * the scroll position so that e.g. if we were looking at the last message when
     * moving focus to the input field, we would still see the last message. Otherwise
     * if would be hidden behind the keyboard.
     */
    handleChatContainerResize({ ignoredWidth, height }) {
      const delta = height - this.chatContainerSize;
      this.chatContainerSize = height;
      const container = this.chatContainer;
      if (container && delta < 0) {
        container.scrollTop -= delta;
      }
    },

    paginateBackIfNeeded() {
      this.$nextTick(() => {
        const container = this.chatContainer;
        if (container && container.scrollHeight <= container.clientHeight) {
          this.handleScrolledToOldest();
        }
      });
    },
    onScroll(ignoredevent) {
      const container = this.chatContainer;
      if (!container) {
        return;
      }
      const bufferHeight = container.clientHeight * WINDOW_BUFFER_SIZE;
      if (container.scrollTop <= bufferHeight) {
        // Scrolled to top
        if (this.reverseOrder) {
          this.handleScrolledToLatest(false);
        } else {
          this.handleScrolledToOldest();
        }
      } else if (container.scrollHeight - container.scrollTop.toFixed(0) - container.clientHeight <= bufferHeight) {
        if (this.reverseOrder) {
          this.handleScrolledToOldest();
        } else {
          this.handleScrolledToLatest(false);
        }
      }
      this.delayedUpdateOfScrollToBottom();
      this.restartRRTimer();
    },

    setParentThread(event) {
        const parentEvent = this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId);
        if (parentEvent) {
          parentEvent["isMxThread"] = true;
          event["parentThread"] = parentEvent;
        } else {
          // Try to load from server.
          this.$matrix.matrixClient.getEventTimeline(this.timelineSet, event.threadRootId)
            .then((tl) => {
            if (tl) {
              const parentEvent = tl.getEvents().find((e) => e.getId() === event.threadRootId);
              if (parentEvent) {
                this.setEvents(this.timelineWindow.getEvents());
                const fn = () => {
                  parentEvent["isMxThread"] = true;
                  event["parentThread"] = parentEvent;
                };
                if (this.initialLoadDone) {
                  const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
                  const element = document.querySelector(sel);
                  if (element) {
                    this.onLayoutChange({action: fn, element: element});
                  } else {
                    fn();
                  }
                } else {
                  fn();
                }
              }
            }
          }).catch(e => console.error(e));
        }
    },

    setReplyToEvent(event) {
        const parentEvent = this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId);
        if (parentEvent) {
          event["replyEvent"] = parentEvent;
        } else {
          // Try to load from server.
          this.$matrix.matrixClient.getEventTimeline(this.timelineSet, event.replyEventId)
            .then((tl) => {
            if (tl) {
              const parentEvent = tl.getEvents().find((e) => e.getId() === event.replyEventId);
              if (parentEvent) {
                this.setEvents(this.timelineWindow.getEvents());
                const fn = () => {event["replyEvent"] = parentEvent;};
                if (this.initialLoadDone) {
                  const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
                  const element = document.querySelector(sel);
                  if (element) {
                    this.onLayoutChange({action: fn, element: element});
                  } else {
                    fn();
                  }
                } else {
                  fn();
                }
              }
            }
          }).catch(e => console.error(e));
        }
    },

    delayedUpdateOfScrollToBottom() {
      if (this.scrollUpdateTimer) {
        clearTimeout(this.scrollUpdateTimer);
      }
      this.scrollUpdateTimer = setTimeout(() => {
        this.scrollUpdateTimer = null;
        const container = this.chatContainer;
        if (!container) {
          return;
        }
        this.showScrollToEnd =
          container.scrollHeight === container.clientHeight
            ? false
            : (this.reverseOrder ? (container.scrollTop.toFixed(0) > 0) : (container.scrollHeight - container.scrollTop.toFixed(0) > container.clientHeight)) ||
            (this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS));
      }, 1000);
    },

    onEvent(event) {
      //console.log("OnEvent", JSON.stringify(event));
      if (event.getRoomId() !== this.roomId) {
        return; // Not for this room
      }

      if (this.initialLoadDone && event.threadRootId && !event.parentThread) {
        this.setParentThread(event);
      }
      if (this.initialLoadDone && event.replyEventId && !event.replyEvent) {
        this.setReplyToEvent(event);
      }

      const loadingDone = this.initialLoadDone;
      this.$matrix.matrixClient.decryptEventIfNeeded(event, {});

      if (this.initialLoadDone && !this.useVoiceMode) {
        this.paginateBackIfNeeded();
      }

      if (loadingDone && event.forwardLooking && (!event.isRelation() || event.isMxThread || event.threadRootId || event.parentThread)) {
        // If we are at bottom, scroll to see new events...
        var scrollToSeeNew = !event.isRedaction() && event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
        this.handleScrolledToLatest(scrollToSeeNew || !this.showScrollToEnd);

        // If kick or ban event, redirect to "goodbye"...
        if (event.getType() === "m.room.member" &&
          event.getStateKey() == this.$matrix.currentUserId &&
          (event.getPrevContent() || {}).membership == "join" &&
          (
            (event.getContent().membership == "leave" && event.getSender() != this.currentUserId) ||
            (event.getContent().membership == "ban"))
        ) {
          this.$store.commit("setCurrentRoomId", null);
          const wasPurged = event.getContent().reason == "Room Deleted";
          this.$navigation.push({ name: "Goodbye", params: { roomWasPurged: wasPurged } }, -1);
        }

        else if (event.getType() === "m.room.retention") {
          this.updateRetentionTimer(event);
        }
      }
    },

    onUserTyping(event, member) {
      if (member.roomId !== this.roomId) {
        return; // Not for this room
      }
      if (member.typing) {
        if (!this.typingMembers.includes(member)) {
          this.typingMembers.push(member);
        }
      } else {
        const index = this.typingMembers.indexOf(member);
        if (index > -1) {
          this.typingMembers.splice(index, 1);
        }
      }
      //console.log("Typing: ", this.typingMembers);
    },

    sendCurrentTextMessage() {
      // DOn't have "enter" send messages while in recorder.
      if (this.currentInput.length > 0 && !this.showRecorder) {
        this.sendMessage(this.currentInput);
        this.currentInput = "";
        this.editedEvent = null; //TODO - Is this a good place to reset this?
        this.replyToEvent = null;
      }
    },

    sendMessage(text) {
      if (text && text.length > 0) {
        util
          .sendTextMessage(this.$matrix.matrixClient, this.roomId, text, this.editedEvent, this.replyToEvent)
          .then(() => {
            console.log("Sent message");
          })
          .catch((err) => {
            console.log("Failed to send:", err);
          });
      }
    },

    /**
     * Show attachment picker to select file
     */
    showAttachmentPicker(reset) {
      if (reset) {
        this.uploadBatch?.cancel();
        this.uploadBatch = null;
      }
      this.$refs.attachment.click();
    },

    addAttachment(file) {
      if (file) {
        if (!this.uploadBatch) {
          this.uploadBatch = this.$matrix.attachmentManager.createUpload(this.room);
        }
        this.uploadBatch?.addAttachment(this.$matrix.attachmentManager.createAttachment(file));
      }
    },

    /**
     * Handle picked attachment
     */
    handlePickedAttachment(event) {
      this.addAttachments(Object.values(event.target.files));
      // Reset value
      this.$refs.attachmentForm.reset();
    },

    addAttachments(files) {
      files.forEach(file => this.addAttachment(file));
    },

    showStickerPicker() {
      this.$refs.stickerPickerSheet.open();
    },

    /**
     * Called by message components that need to change their layout. This will avoid "jumping" in the UI, because
     * we remember scroll position, apply the layout change, then restore the scroll.
     * NOTE: we use "parentElement" below, because it is expected to be called with "element" set to the message component
     * and the message component in turn being wrapped by a "message-wrapper" element (see html above).
     * @param {} action A function that performs desired layout changes.
     * @param {*} element Root element for the chat message.
     */
    onLayoutChange(event) {
      const { action, element } = event;
      if (!element || !element.parentElement || this.useVoiceMode || this.useFileModeNonAdmin) {
        action();
        return
      }
      const container = this.chatContainer;
      this.scrollPosition.prepareFor(element.parentElement.offsetTop >= container.scrollTop ? "down" : "up");
      action();
      this.$nextTick(() => {
        // restore scroll position!
        this.scrollPosition.restore();
      });
    },

    handleScrolledToOldest() {
      if (
        this.timelineWindow &&
        this.timelineWindow.canPaginate(EventTimeline.BACKWARDS) &&
        !this.timelineWindowPaginating
      ) {
        this.timelineWindowPaginating = true;
        this.timelineWindow
          .paginate(EventTimeline.BACKWARDS, 10, true)
          .then((success) => {
            if (success && this.scrollPosition) {
              this.scrollPosition.prepareFor(this.reverseOrder ? "down" : "up");
              this.setEvents(this.timelineWindow.getEvents());
              this.$nextTick(() => {
                // restore scroll position!
                console.log("Restore scroll!");
                this.scrollPosition.restore();
              });
            }
          })
          .finally(() => {
            this.timelineWindowPaginating = false;
          });
      }
    },

    handleScrolledToLatest(smoothScrollToLatest) {
      if (
        this.timelineWindow &&
        this.timelineWindow.canPaginate(EventTimeline.FORWARDS) &&
        !this.timelineWindowPaginating
      ) {
        this.timelineWindowPaginating = true;
        this.timelineWindow
          .paginate(EventTimeline.FORWARDS, 10, true)
          .then((success) => {
            if (success) {
              this.setEvents(this.timelineWindow.getEvents());
              if (!this.useVoiceMode && this.scrollPosition) {
                this.scrollPosition.prepareFor(this.reverseOrder ? "up" : "down");
                this.$nextTick(() => {
                  // restore scroll position!
                  console.log("Restore scroll!");
                  this.scrollPosition.restore();
                  if (smoothScrollToLatest) {
                    this.smoothScrollToLatest();
                  }
                });
              }
            }
          })
          .finally(() => {
            this.timelineWindowPaginating = false;
          });
      }
    },

    /**
     * Scroll so that the given event is at the middle of the chat view (if more events) or else at the bottom.
     */
    scrollToEvent(eventId) {
      console.log("Scroll to event", eventId);
      const container = this.chatContainer;
      const ref = this.$refs[eventId];
      if (container && ref) {
        const parent = container.getBoundingClientRect();
        const item = ref[0].getBoundingClientRect();
        let offsetY = (parent.bottom - parent.top) / 2;
        if (ref[0].clientHeight > offsetY) {
          offsetY = this.reverseOrder ? 0 : Math.max(0, (parent.bottom - parent.top) - ref[0].clientHeight);
        }
        const targetY = parent.top + offsetY;
        const currentY = item.top;
        const y = container.scrollTop + (currentY - targetY);
        this.$nextTick(() => {
          container.scrollTo(0, y);
        });
      }
    },

    smoothScrollToLatest() {
      this.$nextTick(() => {
        // TODO - fix this. We need to wait until the "lastChild" below has been
        // laid out correctly. If we do it "too early" it will have zero height and
        // thus not correctly scrolled into the viewport. So we wait a few ticks...
        window.requestAnimationFrame(() => {
        window.requestAnimationFrame(() => {
        window.requestAnimationFrame(() => {
        window.requestAnimationFrame(() => {
        const container = this.chatContainer;
        if (container && container.children.length > 0) {
          if (this.reverseOrder) {
            const firstChild = container.children[0];
            window.requestAnimationFrame(() => {
              firstChild.scrollIntoView({
                behavior: "smooth",
                block: "end",
                inline: "nearest",
              });
            });
          } else {
            const lastChild = container.children[container.children.length - 1];
            window.requestAnimationFrame(() => {
              lastChild.scrollIntoView({
                behavior: "smooth",
                block: "start",
                inline: "nearest",
              });
            });
          }
        }
      });
    })
    })
    })
    })
    },

    showMoreMessageOperations(e) {
      this.addReaction(e);
    },

    addReaction(e) {
      const event = e.event;
      // Store the event we are reacting to, so that we know where to
      // send when the picker closes.
      this.selectedEvent = event;
      this.$refs.messageOperationsSheet.open();
      this.showEmojiPicker = true;
    },

    addQuickReaction(e) {
      this.sendQuickReaction({ reaction: e.emoji, event: e.event });
    },

    addQuickHeartReaction(e) {
      this.heartPosition = e.position
      this.sendQuickReaction({ reaction: this.heartEmoji, event: e.event }, true);
    },

    setReplyToImage(event) {
      util
        .getThumbnail(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, event, this.$config)
        .then((url) => {
          this.replyToImg = url;
        })
        .catch((err) => {
          console.log("Failed to fetch thumbnail: ", err);
        });
    },

    addReply(event) {
      this.replyToEvent = event;
      this.$refs.messageInput.focus();
      if (event.parentThread || event.isThreadRoot || event.isMxThread) {
        this.replyToContentType = util.threadMessageType();
      } else {
        this.replyToContentType = event.getContent().msgtype || 'm.poll';
      }
      this.setReplyToImage(event);
    },

    edit(event) {
      this.editedEvent = event;
      this.currentInput = event.getContent().body;
      this.$refs.messageInput.focus();
    },

    redact(event) {
      let promises = [];
      if ((event.isThreadRoot || event.isMxThread) && this.timelineSet) {
        // If this is a thread message, make sure to redact all children as well.
        const children = this.timelineSet.relations.getAllChildEventsForEvent(event.getId()).filter(e => util.downloadableTypes().includes(e.getContent().msgtype));
        promises = children.map((c) => {
          return this.$matrix.matrixClient.redactEvent(c.getRoomId(), c.getId(), undefined, { reason: "redactedMedia"});
        });
      }
      promises.push(this.$matrix.matrixClient.redactEvent(event.getRoomId(), event.getId(), undefined, { reason: "redactedThread"}));
      Promise.allSettled(promises)
        .then(() => {
          console.log("Message redacted");
        })
        .catch((err) => {
          console.log("Redaction failed: ", err);
        });
    },

    download(event) {
      if ((event.isThreadRoot || event.isMxThread) && this.timelineSet) {
        const children = this.timelineSet.relations.getAllChildEventsForEvent(event.getId()).filter(e => util.downloadableTypes().includes(e.getContent().msgtype));
        children.forEach(child => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, child));
      } else {
        util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, event);
      }
    },

    pin(event) {
      const eventToPin = event.parentThread ? event.parentThread : event;
      this.$matrix.setEventPinned(this.room, eventToPin, true);
    },

    unpin(event) {
      const eventToUnpin = event.parentThread ? event.parentThread : event;
      this.$matrix.setEventPinned(this.room, eventToUnpin, false);
    },

    cancelEditReply() {
      this.currentInput = "";
      this.editedEvent = null;
      this.replyToEvent = null;
    },

    emojiSelected(e) {
      if (this.isEmojiQuickReaction) {
        // When quick emoji picker is clicked
        if (this.selectedEvent) {
          const event = this.selectedEvent;
          this.selectedEvent = null;
          this.sendQuickReaction({ reaction: e.i, event: event });
        }
      } else {
        // When text input emoji picker is clicked
        this.currentInput = `${this.currentInput} ${e.i}`;
        this.$refs.messageInput.focus();
      }

      let emojis = this.recentEmojis;
      const existingIdx = emojis.findIndex(emoji => emoji.u == e.u);
      if (existingIdx >= 0) {
        emojis.splice(existingIdx, 1);
      }
      emojis.splice(0, 0, markRaw(e));
      if (emojis.length > 5) {
        emojis.splice(5, emojis.length - 5);
      }
      this.recentEmojis = emojis;

      this.showEmojiPicker = false;
      this.$refs.messageOperationsSheet.close();
    },

    sendClapReactionAtTime(e) {
      util
        .sendQuickReaction(this.$matrix.matrixClient, this.roomId, "👏", e.event, { timeOffset: e.timeOffset.toFixed(0)})
        .then(() => {
          console.log("Send clap reaction at time", e.timeOffset);
        })
        .catch((err) => {
          console.log("Failed to send clap reaction:", err);
        });
    },

    showHeartAnimation() {
      const self = this;
      this.heartAnimation = true;
      setTimeout(() => {
        self.heartAnimation = false;
      }, 1000)
    },

    sendQuickReaction(e, heartAnimationFlag = false) {
      let previousReaction = null;

      // Figure out if we have already sent this emoji, in that case redact it again (toggle)
      //
      const reactions = this.timelineSet.relations.getChildEventsForEvent(e.event.getId(), 'm.annotation', 'm.reaction');
      if (reactions && reactions._eventsCount > 0) {
        const relations = reactions.getRelations();
        for (const r of relations) {
          const emoji = r.getRelation().key;
          const sender = r.getSender();
          if (emoji == e.reaction && sender == this.$matrix.currentUserId) {
            previousReaction = r.isRedacted() ? null : r;
          }
        }
      }
      if (previousReaction) {
        this.redact(previousReaction);
      } else {
      util
        .sendQuickReaction(this.$matrix.matrixClient, this.roomId, e.reaction, e.event)
        .then(() => {
          console.log("Quick reaction message");
        })
        .catch((err) => {
          console.log("Failed to send quick reaction:", err);
        });

        if(heartAnimationFlag) {
          this.showHeartAnimation();
        }
      }
    },

    sendSticker(stickerShortCode) {
      this.sendMessage(stickerShortCode);
    },

    showContextMenuForEvent(e) {
      const event = e.event;
      if (this.selectedEvent == event) {
        this.showContextMenuAnchor = e.anchor;
        this.showContextMenu = !this.showContextMenu;
      } else {
        this.showContextMenu = false;
        this.$nextTick(() => {
          this.selectedEvent = event;
          this.showContextMenuAnchor = e.anchor;
          this.showContextMenu = true;
        })
      }
    },

    showAvatarMenuForEvent(e) {
      const event = e.event;
      this.selectedEvent = event;

      this.showProfileDialog = true
    },

    viewProfile() {
      this.$navigation.push({ name: "Profile" }, 1);
    },

    closeContextMenusIfOpen(e) {
      if (this.showContextMenu) {
        this.showContextMenu = false;
        this.showContextMenuAnchor = null;
        this.selectedEvent = null;
        e.preventDefault();
      }
    },

    /** Stop Read Receipt timer */
    stopRRTimer() {
      if (this.rrTimer) {
        clearTimeout(this.rrTimer);
        this.rrTimer = null;
      }
    },

    /**
     * Start/restart the timer to Read Receipts.
     */
    restartRRTimer() {
      this.stopRRTimer();

      if (this.$matrix.currentRoomBeingPurged) {
        return;
      }
      if (!this.useVoiceMode && !this.useFileModeNonAdmin) {
        this.rrTimer = setTimeout(() => { this.rrTimerElapsed() }, READ_RECEIPT_TIMEOUT);
      }
    },

    rrTimerElapsed() {
      this.rrTimer = null;
      this.sendRR();
      this.restartRRTimer();
    },

    sendRR(onlyTheseEvents) {
      let lastTimestamp = 0;
      if (this.lastRR) {
        lastTimestamp = this.lastRR.getTs();
      }

      let eventsToConsider = onlyTheseEvents;
      if (!eventsToConsider) {
        // If events to consider is not given (used by Audio layout) then consider all visible events (with some restrictions).
        let visibleEventIds = (util.findVisibleElements(this.chatContainer) || []).filter(el => el.hasAttribute("eventId")).map(el => el.getAttribute("eventId"));

        eventsToConsider = this.events.filter(e => {
          if (e.getTs() > lastTimestamp &&
            // Make sure it's not a local echo event...
            !e.getId().startsWith("~")) {
            if (e.isRelation()) {
              return visibleEventIds.includes(e.getAssociatedId());
            } else if (e.getType() == "m.room.pinned_events") {
              // Ignore the pin event. If we pin an older message it does not automatically mean we have read the
              // messages between.
              return false;
            } else {
              return visibleEventIds.includes(e.getId());
            }
          }
          return false;
        });
      }

      eventsToConsider.sort((a, b) => b.getTs() - a.getTs());
      if (eventsToConsider.length > 0 && eventsToConsider[0] != this.lastRR) {
        const eventToSendRRFor = eventsToConsider[0];

        // Send read receipt
        this.$matrix.matrixClient.setRoomReadMarkers(this.room.roomId, eventToSendRRFor.getId(), eventToSendRRFor)
          .then(() => {
            console.log("RR sent for event", eventToSendRRFor.getId(), eventToSendRRFor.getType());
            this.lastRR = eventToSendRRFor;
          })
          .catch((err) => {
            console.log("Failed to update read marker: ", err);
          })
          .finally(() => {
            this.restartRRTimer();
          });
      }
    },

    showRecordingUI() {
      this.showRecorderPTT = false;
      this.showRecorder = true;
    },

    startRecording() {
      this.showRecorderPTT = true;
      this.showRecorder = true;
    },

    onVoiceRecording(event) {
      const batch = this.$matrix.attachmentManager.createUpload(this.room);
      batch.addAttachment(this.$matrix.attachmentManager.createAttachment(event.file));
      var text = undefined;
      if (this.currentInput && this.currentInput.length > 0) {
        text = this.currentInput;
        this.currentInput = "";
      }
      batch.send(text)
        .then(() => {
          this.showRecorder = false;
          // Log event
          this.$analytics.event("Audio", "Voice message sent");
        })
        .catch((err) => {
          console.error("Failed to send voice message", err);
        })
    },

    closeRoomWelcomeHeader() {
      this.hideRoomWelcomeHeader = true;
      this.$nextTick(() => {
        // We change the layout when removing the welcome header, so call
        // onScroll here to handle updates (e.g. remove the "scroll to last" if we now
        // can see all messages).
        this.onScroll();
      });
    },

    formatBytes(bytes) {
      return prettyBytes(bytes);
    },

    onHeaderClick() {
      const invitations = this.$matrix.invites.length;
      const joinedRooms = this.$matrix.joinedRooms;
      if (invitations == 0 && joinedRooms && joinedRooms.length == 1 && joinedRooms[0].roomId == this.room.roomId) {
        // Only joined to this room, go directly to room details!
        this.$navigation.push({ name: "RoomInfo" });
        return;
      }
      this.$refs.roomInfoSheet.open();
    },
    viewRoomDetails() {
      this.$navigation.push({ name: "RoomInfo" });
    },
    pollWasClosed(ignoredE) {
      let div = document.createElement("div");
      div.classList.add("toast");
      div.innerText = this.$t("poll_create.results_shared");
      this.chatContainer.parentElement.appendChild(div);
      setTimeout(() => {
        this.chatContainer.parentElement.removeChild(div);
      }, 3000);
    },
    setShowRecorder() {
      if (this.canRecordAudio) {
        this.showRecorder = true;
      } else {
        this.showNoRecordingAvailableDialog = true;
      }
    },

    /**
     * Called when an audio message has played to the end. We listen to this so we can optionally auto-play
     * the next audio event.
     * @param matrixEvent The event that stopped playing
     */
    audioPlaybackEnded(matrixEventId) {
      if (!this.useVoiceMode) { // Voice mode has own autoplay handling inside "AudioLayout"!
        // Auto play consecutive audio messages, either incoming or sent.
        let event = this.events.find(e => e.getId() === matrixEventId);
        if (event) {
          event = event.parentThread ? event.parentThread : event;
          if (event.nextDisplayedEvent) {
            const nextEvent = event.nextDisplayedEvent;
            if (nextEvent.getContent().msgtype === "m.audio") {
              // Yes, audio event!
              this.$audioPlayer.play(nextEvent, this.timelineSet);
            } else if (nextEvent.isMxThread) {
              const children = this.timelineSet.relations.getAllChildEventsForEvent(nextEvent.getId());
              if (children && children.length > 0 && children[0].getContent().msgtype === "m.audio") {
                // Yes, audio event!
                this.$audioPlayer.play(children[0], this.timelineSet);
              }
            }
          }
        }
      }
    },

    closeFileMode() {
      this.uploadBatch?.cancel();
      this.uploadBatch = undefined;
      this.$matrix.leaveRoomAndNavigate(this.room.roomId)
        .catch((err) => {
          console.log("Error leaving", err);
        });
    }
  },
};
</script>

<style lang="scss">
@use "@/assets/css/chat.scss" as *;
.heart-wrapper {
  position: fixed;
  z-index: -1;

  .heart {
    width: 100px;
    height: 100px;
    background: url("../assets/heart.png") no-repeat;
    background-position: 0 0;
    cursor: pointer;
    transition: background-position 1s steps(28);
    transition-duration: 0s;
    visibility: hidden;


    &.is-active {
      transition-duration: 1s;
      background-position: -2800px 0;
      visibility: visible;
      z-index: 10000;
    }
  }
  &.is-active {
    z-index: 1000;
    top: var(--top);
    left: var(--left);
  }
}
</style>
