implemented submenu actions in app-menu

implemented submenu actions in app-menu

<template>

      <router-link v-for="page in pages" v-if="page.navs.length == 0" :to="{name: page.name}" tag="li" active-class="active" :key="page.key" v-on:click.native="setOpenMenu(page.name)">

          {{page.name}}

      </router-link>

 0" :key="page.key" :class="{'active': current_Page == page.name}">

          {{page.name}}

          <router-link v-for="menu in page.navs" v-if="menu.view" :to="{name: menu.view}" tag="li" active-class="active" :key="menu.key" v-on:click.native="setOpenMenu(page.name)">

            {{menu.title}}

          </router-link>

              {{menu.title}}

      <button type="button" v-for="item in footer" v-if="item.type == 'footer_button'" :class="item.class" v-on:click="performAction(item.action)">{{item.caption}}</button>

        <router-link v-for="item in footer" v-if="item.path_type == 'internal'" active-class="active" :to="{name: item.link}" tag="li" :key="item.key">

        </router-link>

</template>

<template>

      <router-link v-for="page in pages" v-if="page.navs.length == 0" :to="{name: page.name}" tag="li" active-class="active" :key="page.key" v-on:click.native="setOpenMenu(page.name)">

          {{page.name}}

      </router-link>

 0" :key="page.key" :class="{'active': current_Page == page.name}">

          {{page.name}}

          <router-link v-for="menu in page.navs" :to="{name: menu.view}" tag="li" active-class="active" :key="menu.key" v-on:click.native="setOpenMenu(page.name)">

            {{menu.title}}

          </router-link>

      <button type="button" v-for="item in footer" v-if="item.type == 'footer_button'" :class="item.class" v-on:click="performAction(item.action)">{{item.caption}}</button>

        <router-link v-for="item in footer" v-if="item.path_type == 'internal'" active-class="active" :to="{name: item.link}" tag="li" :key="item.key">

        </router-link>

</template>


Formatting

Formatting

<template>

  <template v-if="!canAdd">

        Hey, before you can create a new screensaver, you'll need to
        set a local directory in the preferences window!

  </template>
  <saver-form
     v-bind:saver="saver"
     v-if="canAdd"></saver-form>

      <button class="btn btn-large btn-default cancel" v-on:click="closeWindow">Cancel</button>
      <button class="btn btn-large btn-positive save" v-on:click="saveData">Save</button>

</template>


Remove credit card message

Remove credit card message

<template>

      Your Membership

        <expired />
        <recurring-or-circle />
        <single-or-will-expire />

<circle-appeal />

        <link-email :ga-label="ga.userPortal.labels.membership" />

    <appeal />

    <contact-us :ga-label="ga.userPortal.labels.membership" is-membership>
      <template v-slot:text>
        To update your membership status, contact us at
      </template>
    </contact-us>

</template>

<template>

    <messages :num-messages="1">
      <template v-slot:messages="{ setMessageSeen }">
        <credit-card-message
          :ga-label="ga.userPortal.labels.membership"
          @setMessageSeen="setMessageSeen"
        />
      </template>
    </messages>

      Your Membership

        <expired />
        <recurring-or-circle />
        <single-or-will-expire />

<circle-appeal />

        <link-email :ga-label="ga.userPortal.labels.membership" />

    <appeal />

    <contact-us :ga-label="ga.userPortal.labels.membership" is-membership>
      <template v-slot:text>
        To update your membership status, contact us at
      </template>
    </contact-us>

</template>


Fixed KItemChooser when items do not contains icon stored as a string (tags)

<template>

    <k-autocomplete v-if="!isCompleted" class="col" ref="search" :services="services" @item-selected="onItemSelected" />

 0">
      <q-chip v-for="item in items" :key="item._id" :icon="itemIcon(item)" color="primary" @close="onItemRemoved(item)" closable>
        {{itemName(item)}}
      </q-chip>

</template>

<template>

    <k-autocomplete v-if="!isCompleted" class="col" ref="search" :services="services" @item-selected="onItemSelected" />

 0">
      <q-chip v-for="item in items" :key="item._id" :icon="item.icon" color="primary" @close="onItemRemoved(item)" closable>
        {{item.name}}
      </q-chip>

</template>


[前端-前台] 修复标签页SSR失败的问题

[前端-前台] 修复标签页SSR失败的问题

<template>

标签{{$route.params.tagName}}下的文章

      <blog-summary v-for="item in tagPager" :article="item" ></blog-summary>
      <pagination :page="1" :total-page="1" ></pagination>

    <my-footer></my-footer>

</template>

<template>

标签{{$route.params.tagName}}下的文章

      <blog-summary v-for="item in items" :article="item" ></blog-summary>
      <pagination :page="1" :total-page="1" ></pagination>

    <my-footer></my-footer>

</template>


Move unreadMessageElement from computed to a method

Depending on invocation order otherwise the computed
could be first used before the element was mounted to the DOM.
That would cache the computed and cause the element
to never exist although it was added to the DOM afterwards.

The problem with a cached "null" is that
"first unread message has not been seen yet, so don't move it"
then always exited the updating of the readmarker.

Signed-off-by: Joas Schilling <ab43a7c9cb5b2380afc4ddf8b3e2583169b39a02@schilljs.com>

<!--
  - @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@pm.me>
  -
  - @author Marco Ambrosini <marcoambrosini@pm.me>
  -
  - @license GNU AGPL version 3 or any later version
  -
  - This program is free software: you can redistribute it and/or modify
  - it under the terms of the GNU Affero General Public License as
  - published by the Free Software Foundation, either version 3 of the
  - License, or (at your option) any later version.
  -
  - This program is distributed in the hope that it will be useful,
  - but WITHOUT ANY WARRANTY; without even the implied warranty of
  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  - GNU Affero General Public License for more details.
  -
  - You should have received a copy of the GNU Affero General Public License
  - along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<docs>

This component is a wrapper for the list of messages. It's main purpose it to
get the messagesList array and loop through the list to generate the messages.

</docs>

<template>
	<!-- size and remain refer to the amount and initial height of the items that
	are outside of the viewport -->
	<div
		ref="scroller"
		class="scroller"
		@scroll="debounceHandleScroll">
		<div
			v-if="displayMessagesLoader"
			class="scroller__loading"
			disabled>
			<div
				class="icon-loading" />
		</div>
		<MessagesGroup
			v-for="(item, index) of messagesGroupedByAuthor"
			:key="item[0].id"
			:style="{ height: item.height + 'px' }"
			v-bind="item"
			:last-read-message-id="visualLastReadMessageId"
			:messages="item"
			:next-message-id="(messagesGroupedByAuthor[index + 1] && messagesGroupedByAuthor[index + 1][0].id) || 0"
			:previous-message-id="(index > 0 && messagesGroupedByAuthor[index - 1][messagesGroupedByAuthor[index - 1].length - 1].id) || 0" />
		<template v-if="!messagesGroupedByAuthor.length">
			<LoadingPlaceholder
				type="messages"
				:count="15" />
		</template>
		<transition name="fade">
			<button v-show="!isChatScrolledToBottom"
				:aria-label="scrollToBottomAriaLabel"
				class="scroll-to-bottom"
				@click="smoothScrollToBottom">
				<ChevronDown
					decorative
					title=""
					:size="20" />
			</button>
		</transition>
	</div>
</template>

<script>
import moment from '@nextcloud/moment'
import MessagesGroup from './MessagesGroup/MessagesGroup'
import Axios from '@nextcloud/axios'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import isInLobby from '../../mixins/isInLobby'
import { ATTENDEE } from '../../constants'
import debounce from 'debounce'
import { EventBus } from '../../services/EventBus'
import LoadingPlaceholder from '../LoadingPlaceholder'
import ChevronDown from 'vue-material-design-icons/ChevronDown'
import uniqueId from 'lodash/uniqueId'

export default {
	name: 'MessagesList',
	components: {
		LoadingPlaceholder,
		MessagesGroup,
		ChevronDown,
	},

	mixins: [
		isInLobby,
	],

	props: {
		/**
		 * The conversation token.
		 */
		token: {
			type: String,
			required: true,
		},

		isChatScrolledToBottom: {
			type: Boolean,
			required: true,
		},

		isVisible: {
			type: Boolean,
			default: true,
		},
	},

	data() {
		return {
			viewId: null,

			/**
			 * When scrolling to the top of the div .scroller we start loading previous
			 * messages. This boolean allows us to show/hide the loader.
			 */
			displayMessagesLoader: false,
			/**
			 * We store this value in order to determine whether the user has scrolled up
			 * or down at each iteration of the debounceHandleScroll method.
			 */
			previousScrollTopValue: null,

			pollingErrorTimeout: 1,

			loadingOldMessages: false,

			destroying: false,
		}
	},

	computed: {
		isWindowVisible() {
			return this.$store.getters.windowIsVisible() && this.isVisible
		},

		visualLastReadMessageId() {
			return this.$store.getters.getVisualLastReadMessageId(this.token)
		},

		/**
		 * Gets the messages array. We need this because the DynamicScroller needs an array to
		 * loop through.
		 *
		 * @return {Array}
		 */
		messagesList() {
			return this.$store.getters.messagesList(this.token)
		},
		/**
		 * Gets the messages object, which is structured so that the key of each message element
		 * corresponds to the id of the message, and makes it easy and efficient to access the
		 * individual message object.
		 *
		 * @return {object}
		 */
		messages() {
			// FIXME: remove if unused ?
			return this.$store.getters.messages(this.token)
		},
		/**
		 * Creates an array of messages grouped in nested arrays by same autor.
		 *
		 * @return {Array}
		 */
		messagesGroupedByAuthor() {
			const groups = []
			let lastMessage = null
			for (const message of this.messagesList) {
				if (message.systemMessage === 'message_deleted') {
					continue
				}

				if (!this.messagesShouldBeGrouped(message, lastMessage)) {
					// Add the date separator for different days
					if (this.messagesHaveDifferentDate(message, lastMessage)) {
						message.dateSeparator = this.generateDateSeparator(message)
					}

					groups.push([message])
					lastMessage = message
				} else {
					groups[groups.length - 1].push(message)
				}
			}
			return groups
		},

		/**
		 * In order for the state of the component to be sticky,
		 * the div .scroller must be scrolled to the bottom.
		 * When isSticky is true, as new messages are appended to the list, the div .scroller
		 * automatically scrolls down to the last message, if it's false, new messages are
		 * appended but the scrolling position is not altered.
		 *
		 * @return {boolean}
		 */
		isSticky() {
			return this.isChatScrolledToBottom
		},

		/**
		 * Returns whether the current participant is a participant of the
		 * current conversation or not.
		 *
		 * @return {boolean} true if it is already a participant, false
		 *          otherwise.
		 */
		isParticipant() {
			if (!this.conversation) {
				return false
			}

			const participantIndex = this.$store.getters.getParticipantIndex(this.token, this.$store.getters.getParticipantIdentifier())
			return participantIndex !== -1
		},

		conversation() {
			return this.$store.getters.conversation(this.token)
		},

		chatIdentifier() {
			return this.token + ':' + this.isParticipant + ':' + this.isInLobby + ':' + this.viewId
		},

		scrollToBottomAriaLabel() {
			return t('spreed', 'Scroll to bottom')
		},

		scroller() {
			return this.$refs.scroller
		},
	},

	watch: {
		isWindowVisible(visible) {
			if (visible) {
				this.onWindowFocus()
				// FIXME: the sidebar chat takes much longer to open, this is why we need a higher value here
				// need to investigate why the sidebar takes that long to open and is not even animated
				window.setTimeout(() => this.scrollToFocussedMessage(), 100)
			}
		},
		chatIdentifier: {
			immediate: true,
			handler(newValue, oldValue) {
				if (oldValue) {
					this.$store.dispatch('cancelLookForNewMessages', { requestId: oldValue })
				}
				this.handleStartGettingMessagesPreconditions()
			},
		},
	},

	mounted() {
		this.viewId = uniqueId('messagesList')
		this.scrollToBottom()
		EventBus.$on('scroll-chat-to-bottom', this.handleScrollChatToBottomEvent)
		EventBus.$on('smooth-scroll-chat-to-bottom', this.smoothScrollToBottom)
		EventBus.$on('focus-message', this.focusMessage)
		EventBus.$on('route-change', this.onRouteChange)
		subscribe('networkOffline', this.handleNetworkOffline)
		subscribe('networkOnline', this.handleNetworkOnline)
		window.addEventListener('focus', this.onWindowFocus)
	},

	beforeDestroy() {
		window.removeEventListener('focus', this.onWindowFocus)
		EventBus.$off('scroll-chat-to-bottom', this.handleScrollChatToBottomEvent)
		EventBus.$off('smooth-scroll-chat-to-bottom', this.smoothScrollToBottom)
		EventBus.$off('focus-message', this.focusMessage)
		EventBus.$off('route-change', this.onRouteChange)

		this.$store.dispatch('cancelLookForNewMessages', { requestId: this.chatIdentifier })
		this.destroying = true

		unsubscribe('networkOffline', this.handleNetworkOffline)
		unsubscribe('networkOnline', this.handleNetworkOnline)
	},

	methods: {
		/**
		 * Compare two messages to decide if they should be grouped
		 *
		 * @param {object} message1 The new message
		 * @param {string} message1.id The ID of the new message
		 * @param {string} message1.actorType Actor type of the new message
		 * @param {string} message1.actorId Actor id of the new message
		 * @param {string} message1.actorDisplayName Actor displayname of the new message
		 * @param {string} message1.systemMessage System message content of the new message
		 * @param {number} message1.timestamp Timestamp of the new message
		 * @param {null|object} message2 The previous message
		 * @param {string} message2.id The ID of the second message
		 * @param {string} message2.actorType Actor type of the previous message
		 * @param {string} message2.actorId Actor id of the previous message
		 * @param {string} message2.actorDisplayName Actor display name of previous message
		 * @param {string} message2.systemMessage System message content of the previous message
		 * @param {number} message2.timestamp Timestamp of the second message
		 * @return {boolean} Boolean if the messages should be grouped or not
		 */
		messagesShouldBeGrouped(message1, message2) {
			if (!message2) {
				return false // No previous message
			}

			if (message1.actorType === ATTENDEE.ACTOR_TYPE_BOTS // Don't group messages of commands and bots
				&& message1.actorId !== ATTENDEE.CHANGELOG_BOT_ID) { // Apart from the changelog bot
				return false
			}

			const message1IsSystem = message1.systemMessage.length !== 0
			const message2IsSystem = message2.systemMessage.length !== 0

			if (message1IsSystem !== message2IsSystem) {
				// Only group system messages with each others
				return false
			}

			if (!message1IsSystem // System messages are grouped independent from author
				&& ((message1.actorType !== message2.actorType // Otherwise the type and id need to match
					|| message1.actorId !== message2.actorId)
				|| (message1.actorType === ATTENDEE.ACTOR_TYPE.BRIDGED // Or, if the message is bridged, display names also need to match
					&& message1.actorDisplayName !== message2.actorDisplayName))) {
				return false
			}

			return !this.messagesHaveDifferentDate(message1, message2) // Posted on the same day
		},

		/**
		 * Check if 2 messages are from the same date
		 *
		 * @param {object} message1 The new message
		 * @param {string} message1.id The ID of the new message
		 * @param {number} message1.timestamp Timestamp of the new message
		 * @param {null|object} message2 The previous message
		 * @param {string} message2.id The ID of the second message
		 * @param {number} message2.timestamp Timestamp of the second message
		 * @return {boolean} Boolean if the messages have the same date
		 */
		messagesHaveDifferentDate(message1, message2) {
			return !message2 // There is no previous message
				|| this.getDateOfMessage(message1).format('YYYY-MM-DD') !== this.getDateOfMessage(message2).format('YYYY-MM-DD')
		},

		/**
		 * Generate the date header between the messages
		 *
		 * @param {object} message The message object
		 * @param {string} message.id The ID of the message
		 * @param {number} message.timestamp Timestamp of the message
		 * @return {string} Translated string of "<Today>, <November 11th, 2019>", "<3 days ago>, <November 8th, 2019>"
		 */
		generateDateSeparator(message) {
			const date = this.getDateOfMessage(message)
			const dayOfYear = date.format('YYYY-DDD')
			let relativePrefix = date.fromNow()

			// Use the relative day for today and yesterday
			const dayOfYearToday = moment().format('YYYY-DDD')
			if (dayOfYear === dayOfYearToday) {
				relativePrefix = t('spreed', 'Today')
			} else {
				const dayOfYearYesterday = moment().subtract(1, 'days').format('YYYY-DDD')
				if (dayOfYear === dayOfYearYesterday) {
					relativePrefix = t('spreed', 'Yesterday')
				}
			}

			// <Today>, <November 11th, 2019>
			return t('spreed', '{relativeDate}, {absoluteDate}', {
				relativeDate: relativePrefix,
				// 'LL' formats a localized date including day of month, month
				// name and year
				absoluteDate: date.format('LL'),
			}, undefined, {
				escape: false, // French "Today" has a ' in it
			})
		},

		/**
		 * Generate the date of the messages
		 *
		 * @param {object} message The message object
		 * @param {string} message.id The ID of the message
		 * @param {number} message.timestamp Timestamp of the message
		 * @return {object} MomentJS object
		 */
		getDateOfMessage(message) {
			if (message.id.toString().startsWith('temp-')) {
				return moment()
			}
			return moment.unix(message.timestamp)
		},

		scrollToFocussedMessage() {
			let focussed = null
			if (this.$route?.hash?.startsWith('#message_')) {
				// scroll to message in URL anchor
				focussed = this.focusMessage(this.$route.hash.substr(9), false)
			}

			if (!focussed && this.visualLastReadMessageId) {
				// scroll to last read message if visible in the current pages
				focussed = this.focusMessage(this.visualLastReadMessageId, false, false)
			}

			// TODO: in case the element is not in a page but does exist in the DB,
			// we need to scroll up / down to the page where it would exist after
			// loading said pages

			if (!focussed) {
				// if no anchor was present or the message to focus on did not exist,
				// scroll to bottom
				this.scrollToBottom()
			}

			// if no scrollbars, clear read marker directly as scrolling is not possible for the user to clear it
			// also clear in case lastReadMessage is zero which is due to an older bug
			if (this.visualLastReadMessageId === 0 || this.scroller.scrollHeight <= this.scroller.offsetHeight) {
				// clear after a delay, unless scrolling can resume in-between
				this.debounceUpdateReadMarkerPosition()
			}
		},

		async handleStartGettingMessagesPreconditions() {
			if (this.token && this.isParticipant && !this.isInLobby) {

				// prevent sticky mode before we have loaded anything
				this.setChatScrolledToBottom(false)

				this.$store.dispatch('setVisualLastReadMessageId', {
					token: this.token,
					id: this.conversation.lastReadMessage,
				})

				if (this.$store.getters.getFirstKnownMessageId(this.token) === null) {
					// first time load, initialize important properties
					this.$store.dispatch('setFirstKnownMessageId', {
						token: this.token,
						id: this.conversation.lastReadMessage,
					})
					this.$store.dispatch('setLastKnownMessageId', {
						token: this.token,
						id: this.conversation.lastReadMessage,
					})

					// get history before last read message
					await this.getOldMessages(true)
					// at this stage, the read marker will appear at the bottom of the view port since
					// we haven't fetched the messages that come after it yet
					// TODO: should we still show a spinner at this stage ?
				}

				let hasScrolled = false
				// if lookForNewMessages will long poll instead of returning existing messages,
				// scroll right away to avoid delays
				if (!this.$store.getters.hasMoreMessagesToLoad(this.token)) {
					hasScrolled = true
					await this.$nextTick(() => {
						this.scrollToFocussedMessage()
					})
				}

				// get new messages
				await this.lookForNewMessages()

				// don't scroll if lookForNewMessages was polling as we don't want
				// to scroll back to the read marker after receiving new messages later
				if (!hasScrolled) {
					this.scrollToFocussedMessage()
				}
			} else {
				this.$store.dispatch('cancelLookForNewMessages', { requestId: this.chatIdentifier })
			}
		},

		/**
		 * Fetches the messages of a conversation given the conversation token. Triggers
		 * a long-polling request for new messages.
		 */
		async lookForNewMessages() {
			// Once the history is received, starts looking for new messages.
			if (this._isBeingDestroyed || this._isDestroyed) {
				console.debug('Prevent getting new messages on a destroyed MessagesList')
				return
			}

			const followInNewMessages = this.conversation.lastMessage
				&& this.conversation.lastReadMessage === this.conversation.lastMessage.id

			await this.getNewMessages(followInNewMessages)
		},

		/**
		 * Get messages history.
		 *
		 * @param {boolean} includeLastKnown Include or exclude the last known message in the response
		 */
		async getOldMessages(includeLastKnown) {
			// Make the request
			try {
				this.loadingOldMessages = true
				await this.$store.dispatch('fetchMessages', {
					token: this.token,
					lastKnownMessageId: this.$store.getters.getFirstKnownMessageId(this.token),
					includeLastKnown,
				})

				this.loadingOldMessages = false
			} catch (exception) {
				if (Axios.isCancel(exception)) {
					console.debug('The request has been canceled', exception)
				}
				this.loadingOldMessages = false
			}
		},

		/**
		 * Creates a long polling request for a new message.
		 *
		 * @param {boolean} scrollToBottom Whether we should try to automatically scroll to the bottom
		 */
		async getNewMessages(scrollToBottom = true) {
			if (this.destroying) {
				return
			}

			// Make the request
			try {
				// TODO: move polling logic to the store and also cancel timers on cancel
				this.pollingErrorTimeout = 1
				await this.$store.dispatch('lookForNewMessages', {
					token: this.token,
					lastKnownMessageId: this.$store.getters.getLastKnownMessageId(this.token),
					requestId: this.chatIdentifier,
				})

				// Scroll to the last message if sticky
				if (scrollToBottom && this.isSticky) {
					this.smoothScrollToBottom()
				}
			} catch (exception) {
				if (Axios.isCancel(exception)) {
					console.debug('The request has been canceled', exception)
					return
				}

				if (exception.response && exception.response.status === 304) {
					// 304 - Not modified
					// This is not an error, so reset error timeout and poll again
					this.pollingErrorTimeout = 1
					setTimeout(() => {
						this.getNewMessages()
					}, 500)
					return
				}

				if (this.pollingErrorTimeout < 30) {
					// Delay longer after each error
					this.pollingErrorTimeout += 5
				}

				console.debug('Error happened while getting chat messages. Trying again in ', this.pollingErrorTimeout, exception)

				setTimeout(() => {
					this.getNewMessages()
				}, this.pollingErrorTimeout * 1000)
				return
			}

			setTimeout(() => {
				this.getNewMessages()
			}, 500)
		},

		debounceHandleScroll: debounce(function() {
			this.handleScroll()
		}, 50),
		/**
		 * When the div is scrolled, this method checks if it's been scrolled to the top
		 * or to the bottom of the list bottom.
		 */
		async handleScroll() {
			const scrollHeight = this.scroller.scrollHeight
			const scrollTop = this.scroller.scrollTop
			const scrollOffset = scrollHeight - scrollTop
			const elementHeight = this.scroller.clientHeight
			const tolerance = 10
			if (scrollOffset < elementHeight + tolerance && scrollOffset > elementHeight - tolerance) {
				this.setChatScrolledToBottom(true)
				this.displayMessagesLoader = false
				this.previousScrollTopValue = scrollTop
			} else if (scrollHeight > elementHeight && scrollTop < 800 && scrollTop <= this.previousScrollTopValue) {
				if (this.loadingOldMessages) {
					// already loading, don't do it twice
					return
				}
				if (scrollTop === 0) {
					this.displayMessagesLoader = true
				}
				await this.getOldMessages(false)
				this.displayMessagesLoader = false
				this.previousScrollTopValue = scrollTop
			} else {
				this.setChatScrolledToBottom(false)
				this.displayMessagesLoader = false
				this.previousScrollTopValue = scrollTop
			}

			this.debounceUpdateReadMarkerPosition()
		},

		/**
		 * Finds the last message that is fully visible in the scroller viewport
		 *
		 * Starts searching forward after the given message element until we reach
		 * the bottom of the viewport.
		 *
		 * @param {object} messageEl message element after which to start searching
		 * @return {object} DOM element for the last visible message
		 */
		findFirstVisibleMessage(messageEl) {
			let el = messageEl
			let previousEl = el
			const scrollTop = this.scroller.scrollTop
			while (el) {
				// is the message element fully visible with no intersection with the bottom border ?
				if (el.offsetTop - scrollTop >= 0) {
					// this means that the previous message we had was fully visible,
					// so we return that
					return previousEl
				}

				previousEl = el
				el = document.getElementById('message_' + el.getAttribute('data-next-message-id'))
			}

			return previousEl
		},

		/**
		 * Sync the visual marker position with what is currently in the store.
		 * This separation exists to avoid jumpy marker while scrolling.
		 *
		 * Also see updateReadMarkerPosition() for the backend update.
		 */
		refreshReadMarkerPosition() {
			if (!this.conversation) {
				return
			}
			console.debug('setVisualLastReadMessageId token=' + this.token + ' id=' + this.conversation.lastReadMessage)
			this.$store.dispatch('setVisualLastReadMessageId', {
				token: this.token,
				id: this.conversation.lastReadMessage,
			})
		},

		debounceUpdateReadMarkerPosition: debounce(function() {
			this.updateReadMarkerPosition()
		}, 1000),

		/**
		 * Finds the first visual unread message element
		 *
		 * @return {object} DOM element of the first unread message
		 */
		getUnreadMessageElement() {
			let el = document.getElementById('message_' + this.visualLastReadMessageId)
			if (el) {
				el = el.closest('.message')
			}

			return el
		},

		/**
		 * Recalculates the current read marker position based on the first visible element,
		 * but only do so if the previous marker was already seen.
		 *
		 * The new marker position will be sent to the backend but not applied visually.
		 * Visually, the marker will only move the next time the user is focussing back to this
		 * conversation in refreshReadMarkerPosition()
		 */
		updateReadMarkerPosition() {
			if (!this.conversation) {
				return
			}

			// to fix issues, this scenario should not happen
			if (this.conversation.lastReadMessage === 0) {
				console.debug('clearLastReadMessage because lastReadMessage was 0 token=' + this.token)
				this.$store.dispatch('clearLastReadMessage', { token: this.token, updateVisually: true })
				return
			}

			if (this.conversation.lastReadMessage === this.conversation.lastMessage?.id) {
				// already at bottom, nothing to do
				return
			}

			const unreadMessageElement = this.getUnreadMessageElement()

			// first unread message has not been seen yet, so don't move it
			if (!unreadMessageElement || unreadMessageElement.getAttribute('data-seen') !== 'true') {
				return
			}

			// if we're at bottom of the chat with no more new messages to load, then simply clear the marker
			if (this.isSticky && !this.$store.getters.hasMoreMessagesToLoad(this.token)) {
				console.debug('clearLastReadMessage because of isSticky token=' + this.token)
				this.$store.dispatch('clearLastReadMessage', { token: this.token })
				return
			}

			if (unreadMessageElement.offsetTop - this.scroller.scrollTop > 0) {
				// still visible, hasn't disappeared at the top yet
				return
			}

			const firstVisibleMessage = this.findFirstVisibleMessage(unreadMessageElement)
			if (!firstVisibleMessage) {
				console.warn('First visible message not found: ', firstVisibleMessage)
				return
			}

			const messageId = parseInt(firstVisibleMessage.getAttribute('data-message-id'), 10)
			if (messageId <= this.conversation.lastReadMessage) {
				// it was probably a scroll up, don't update
				return
			}

			// we don't update visually here, it will update the next time the
			// user focusses back on the conversation. See refreshReadMarkerPosition().
			console.debug('updateLastReadMessage token=' + this.token + ' messageId=' + messageId)
			this.$store.dispatch('updateLastReadMessage', { token: this.token, id: messageId, updateVisually: false })
		},

		/**
		 * @param {object} options Event options
		 * @param {boolean} options.force Set to true, if the chat should be scrolled to the bottom even when it was not before
		 */
		handleScrollChatToBottomEvent(options) {
			if ((options && options.force) || this.isChatScrolledToBottom) {
				this.scrollToBottom()
				this.setChatScrolledToBottom(true)
			}
		},

		/**
		 * Scrolls to the bottom of the list smoothly.
		 */
		smoothScrollToBottom() {
			this.$nextTick(function() {
				if (this.isWindowVisible && document.hasFocus()) {
					// scrollTo is used when the user is watching
					this.scroller.scrollTo({
						top: this.scroller.scrollHeight,
						behavior: 'smooth',
					})
					this.setChatScrolledToBottom(true)
				} else {
					// Otherwise we jump half a message and stop autoscrolling, so the user can read up
					if (this.scroller.scrollHeight - this.scroller.scrollTop - this.scroller.offsetHeight < 40) {
						// Single new line from the previous author is 35px so scroll half a line
						this.scroller.scrollTop += 10
					} else {
						// Single new line from the new author is 75px so scroll half an avatar
						this.scroller.scrollTop += 40
					}
					this.setChatScrolledToBottom(false)
				}
			})
		},
		/**
		 * Scrolls to the bottom of the list.
		 */
		scrollToBottom() {
			this.$nextTick(function() {
				this.scroller.scrollTop = this.scroller.scrollHeight
				this.setChatScrolledToBottom(true)
			})

		},

		/**
		 * Temporarily highlight the given message id with a fade out effect.
		 *
		 * @param {string} messageId message id
		 * @param {boolean} smooth true to smooth scroll, false to jump directly
		 * @param {boolean} highlightAnimation true to highlight and set focus to the message
		 * @return {boolean} true if element was found, false otherwise
		 */
		focusMessage(messageId, smooth = true, highlightAnimation = true) {
			const element = document.getElementById(`message_${messageId}`)
			if (!element) {
				// TODO: in some cases might need to trigger a scroll up if this is an older message
				console.warn('Message to focus not found in DOM', messageId)
				return false
			}

			this.$nextTick(async () => {
				// FIXME: this doesn't wait for the smooth scroll to end
				await element.scrollIntoView({
					behavior: smooth ? 'smooth' : 'auto',
					block: 'center',
					inline: 'nearest',
				})
				if (!smooth) {
					// scroll the viewport slightly further to make sure the element is about 1/3 from the top
					this.scroller.scrollTop += this.scroller.offsetHeight / 4
				}
				if (highlightAnimation) {
					element.focus()
					element.highlightAnimation()
				}
			})

			return true
		},

		/**
		 * gets the last known message id.
		 *
		 * @return {string} The last known message id.
		 */
		getLastKnownMessageId() {
			let i = this.messagesList.length - 1

			while (i >= 0) {
				if (!this.messagesList[i].id.toString().startsWith('temp-')) {
					return this.messagesList[i].id
				}
				i--
			}
			return '0'
		},
		/**
		 * gets the first message's id.
		 *
		 * @return {string}
		 */
		getFirstKnownMessageId() {
			return this.messagesList[0].id.toString()
		},

		handleNetworkOffline() {
			console.debug('Canceling message request as we are offline')
			if (this.cancelLookForNewMessages) {
				this.$store.dispatch('cancelLookForNewMessages', { requestId: this.chatIdentifier })
			}
		},

		handleNetworkOnline() {
			console.debug('Restarting polling of new chat messages')
			this.getNewMessages()
		},

		onRouteChange({ from, to }) {
			if (from.name === 'conversation'
				&& to.name === 'conversation'
				&& from.token === to.token
				&& from.hash !== to.hash) {

				// the hash changed, need to focus/highlight another message
				if (to.hash && to.hash.startsWith('#message_')) {
					// need some delay (next tick is too short) to be able to run
					// after the browser's native "scroll to anchor" from
					// the hash
					window.setTimeout(() => {
						// scroll to message in URL anchor
						this.focusMessage(to.hash.substr(9), true)
					}, 2)
				}
			}
		},

		setChatScrolledToBottom(boolean) {
			this.$emit('set-chat-scrolled-to-bottom', boolean)
			if (boolean) {
				// mark as read if marker was seen
				// we have to do this early because unfocussing the window will remove the stickiness
				this.debounceUpdateReadMarkerPosition()
			}
		},

		onWindowFocus() {
			this.refreshReadMarkerPosition()
		},
	},
}
</script>

<style lang="scss" scoped>
@import '../../assets/variables.scss';

.scroller {
	flex: 1 0;
	overflow-y: auto;
	overflow-x: hidden;
	&__loading {
		height: 50px;
		display: flex;
		justify-content: center;
	}
}

.scroll-to-bottom {
	position: absolute;
	width: 44px;
	height: 44px;
	bottom: 76px;
	right: 24px;
	z-index: 2;
	padding: 0;
	margin: 0;
	display: flex;
	align-items: center;
	justify-content: center;
}

</style>

<!--
  - @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@pm.me>
  -
  - @author Marco Ambrosini <marcoambrosini@pm.me>
  -
  - @license GNU AGPL version 3 or any later version
  -
  - This program is free software: you can redistribute it and/or modify
  - it under the terms of the GNU Affero General Public License as
  - published by the Free Software Foundation, either version 3 of the
  - License, or (at your option) any later version.
  -
  - This program is distributed in the hope that it will be useful,
  - but WITHOUT ANY WARRANTY; without even the implied warranty of
  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  - GNU Affero General Public License for more details.
  -
  - You should have received a copy of the GNU Affero General Public License
  - along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<docs>

This component is a wrapper for the list of messages. It's main purpose it to
get the messagesList array and loop through the list to generate the messages.

</docs>

<template>
	<!-- size and remain refer to the amount and initial height of the items that
	are outside of the viewport -->
	<div
		ref="scroller"
		class="scroller"
		@scroll="debounceHandleScroll">
		<div
			v-if="displayMessagesLoader"
			class="scroller__loading"
			disabled>
			<div
				class="icon-loading" />
		</div>
		<MessagesGroup
			v-for="(item, index) of messagesGroupedByAuthor"
			:key="item[0].id"
			:style="{ height: item.height + 'px' }"
			v-bind="item"
			:last-read-message-id="visualLastReadMessageId"
			:messages="item"
			:next-message-id="(messagesGroupedByAuthor[index + 1] && messagesGroupedByAuthor[index + 1][0].id) || 0"
			:previous-message-id="(index > 0 && messagesGroupedByAuthor[index - 1][messagesGroupedByAuthor[index - 1].length - 1].id) || 0" />
		<template v-if="!messagesGroupedByAuthor.length">
			<LoadingPlaceholder
				type="messages"
				:count="15" />
		</template>
		<transition name="fade">
			<button v-show="!isChatScrolledToBottom"
				:aria-label="scrollToBottomAriaLabel"
				class="scroll-to-bottom"
				@click="smoothScrollToBottom">
				<ChevronDown
					decorative
					title=""
					:size="20" />
			</button>
		</transition>
	</div>
</template>

<script>
import moment from '@nextcloud/moment'
import MessagesGroup from './MessagesGroup/MessagesGroup'
import Axios from '@nextcloud/axios'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import isInLobby from '../../mixins/isInLobby'
import { ATTENDEE } from '../../constants'
import debounce from 'debounce'
import { EventBus } from '../../services/EventBus'
import LoadingPlaceholder from '../LoadingPlaceholder'
import ChevronDown from 'vue-material-design-icons/ChevronDown'
import uniqueId from 'lodash/uniqueId'

export default {
	name: 'MessagesList',
	components: {
		LoadingPlaceholder,
		MessagesGroup,
		ChevronDown,
	},

	mixins: [
		isInLobby,
	],

	props: {
		/**
		 * The conversation token.
		 */
		token: {
			type: String,
			required: true,
		},

		isChatScrolledToBottom: {
			type: Boolean,
			required: true,
		},

		isVisible: {
			type: Boolean,
			default: true,
		},
	},

	data() {
		return {
			viewId: null,

			/**
			 * When scrolling to the top of the div .scroller we start loading previous
			 * messages. This boolean allows us to show/hide the loader.
			 */
			displayMessagesLoader: false,
			/**
			 * We store this value in order to determine whether the user has scrolled up
			 * or down at each iteration of the debounceHandleScroll method.
			 */
			previousScrollTopValue: null,

			pollingErrorTimeout: 1,

			loadingOldMessages: false,

			destroying: false,
		}
	},

	computed: {
		isWindowVisible() {
			return this.$store.getters.windowIsVisible() && this.isVisible
		},

		visualLastReadMessageId() {
			return this.$store.getters.getVisualLastReadMessageId(this.token)
		},

		/**
		 * Finds the first visual unread message element
		 *
		 * @return {object} DOM element of the first unread message
		 */
		unreadMessageElement() {
			let el = document.getElementById('message_' + this.visualLastReadMessageId)
			if (el) {
				el = el.closest('.message')
			}

			return el
		},

		/**
		 * Gets the messages array. We need this because the DynamicScroller needs an array to
		 * loop through.
		 *
		 * @return {Array}
		 */
		messagesList() {
			return this.$store.getters.messagesList(this.token)
		},
		/**
		 * Gets the messages object, which is structured so that the key of each message element
		 * corresponds to the id of the message, and makes it easy and efficient to access the
		 * individual message object.
		 *
		 * @return {object}
		 */
		messages() {
			// FIXME: remove if unused ?
			return this.$store.getters.messages(this.token)
		},
		/**
		 * Creates an array of messages grouped in nested arrays by same autor.
		 *
		 * @return {Array}
		 */
		messagesGroupedByAuthor() {
			const groups = []
			let lastMessage = null
			for (const message of this.messagesList) {
				if (message.systemMessage === 'message_deleted') {
					continue
				}

				if (!this.messagesShouldBeGrouped(message, lastMessage)) {
					// Add the date separator for different days
					if (this.messagesHaveDifferentDate(message, lastMessage)) {
						message.dateSeparator = this.generateDateSeparator(message)
					}

					groups.push([message])
					lastMessage = message
				} else {
					groups[groups.length - 1].push(message)
				}
			}
			return groups
		},

		/**
		 * In order for the state of the component to be sticky,
		 * the div .scroller must be scrolled to the bottom.
		 * When isSticky is true, as new messages are appended to the list, the div .scroller
		 * automatically scrolls down to the last message, if it's false, new messages are
		 * appended but the scrolling position is not altered.
		 *
		 * @return {boolean}
		 */
		isSticky() {
			return this.isChatScrolledToBottom
		},

		/**
		 * Returns whether the current participant is a participant of the
		 * current conversation or not.
		 *
		 * @return {boolean} true if it is already a participant, false
		 *          otherwise.
		 */
		isParticipant() {
			if (!this.conversation) {
				return false
			}

			const participantIndex = this.$store.getters.getParticipantIndex(this.token, this.$store.getters.getParticipantIdentifier())
			return participantIndex !== -1
		},

		conversation() {
			return this.$store.getters.conversation(this.token)
		},

		chatIdentifier() {
			return this.token + ':' + this.isParticipant + ':' + this.isInLobby + ':' + this.viewId
		},

		scrollToBottomAriaLabel() {
			return t('spreed', 'Scroll to bottom')
		},

		scroller() {
			return this.$refs.scroller
		},
	},

	watch: {
		isWindowVisible(visible) {
			if (visible) {
				this.onWindowFocus()
				// FIXME: the sidebar chat takes much longer to open, this is why we need a higher value here
				// need to investigate why the sidebar takes that long to open and is not even animated
				window.setTimeout(() => this.scrollToFocussedMessage(), 100)
			}
		},
		chatIdentifier: {
			immediate: true,
			handler(newValue, oldValue) {
				if (oldValue) {
					this.$store.dispatch('cancelLookForNewMessages', { requestId: oldValue })
				}
				this.handleStartGettingMessagesPreconditions()
			},
		},
	},

	mounted() {
		this.viewId = uniqueId('messagesList')
		this.scrollToBottom()
		EventBus.$on('scroll-chat-to-bottom', this.handleScrollChatToBottomEvent)
		EventBus.$on('smooth-scroll-chat-to-bottom', this.smoothScrollToBottom)
		EventBus.$on('focus-message', this.focusMessage)
		EventBus.$on('route-change', this.onRouteChange)
		subscribe('networkOffline', this.handleNetworkOffline)
		subscribe('networkOnline', this.handleNetworkOnline)
		window.addEventListener('focus', this.onWindowFocus)
	},

	beforeDestroy() {
		window.removeEventListener('focus', this.onWindowFocus)
		EventBus.$off('scroll-chat-to-bottom', this.handleScrollChatToBottomEvent)
		EventBus.$off('smooth-scroll-chat-to-bottom', this.smoothScrollToBottom)
		EventBus.$off('focus-message', this.focusMessage)
		EventBus.$off('route-change', this.onRouteChange)

		this.$store.dispatch('cancelLookForNewMessages', { requestId: this.chatIdentifier })
		this.destroying = true

		unsubscribe('networkOffline', this.handleNetworkOffline)
		unsubscribe('networkOnline', this.handleNetworkOnline)
	},

	methods: {
		/**
		 * Compare two messages to decide if they should be grouped
		 *
		 * @param {object} message1 The new message
		 * @param {string} message1.id The ID of the new message
		 * @param {string} message1.actorType Actor type of the new message
		 * @param {string} message1.actorId Actor id of the new message
		 * @param {string} message1.actorDisplayName Actor displayname of the new message
		 * @param {string} message1.systemMessage System message content of the new message
		 * @param {number} message1.timestamp Timestamp of the new message
		 * @param {null|object} message2 The previous message
		 * @param {string} message2.id The ID of the second message
		 * @param {string} message2.actorType Actor type of the previous message
		 * @param {string} message2.actorId Actor id of the previous message
		 * @param {string} message2.actorDisplayName Actor display name of previous message
		 * @param {string} message2.systemMessage System message content of the previous message
		 * @param {number} message2.timestamp Timestamp of the second message
		 * @return {boolean} Boolean if the messages should be grouped or not
		 */
		messagesShouldBeGrouped(message1, message2) {
			if (!message2) {
				return false // No previous message
			}

			if (message1.actorType === ATTENDEE.ACTOR_TYPE_BOTS // Don't group messages of commands and bots
				&& message1.actorId !== ATTENDEE.CHANGELOG_BOT_ID) { // Apart from the changelog bot
				return false
			}

			const message1IsSystem = message1.systemMessage.length !== 0
			const message2IsSystem = message2.systemMessage.length !== 0

			if (message1IsSystem !== message2IsSystem) {
				// Only group system messages with each others
				return false
			}

			if (!message1IsSystem // System messages are grouped independent from author
				&& ((message1.actorType !== message2.actorType // Otherwise the type and id need to match
					|| message1.actorId !== message2.actorId)
				|| (message1.actorType === ATTENDEE.ACTOR_TYPE.BRIDGED // Or, if the message is bridged, display names also need to match
					&& message1.actorDisplayName !== message2.actorDisplayName))) {
				return false
			}

			return !this.messagesHaveDifferentDate(message1, message2) // Posted on the same day
		},

		/**
		 * Check if 2 messages are from the same date
		 *
		 * @param {object} message1 The new message
		 * @param {string} message1.id The ID of the new message
		 * @param {number} message1.timestamp Timestamp of the new message
		 * @param {null|object} message2 The previous message
		 * @param {string} message2.id The ID of the second message
		 * @param {number} message2.timestamp Timestamp of the second message
		 * @return {boolean} Boolean if the messages have the same date
		 */
		messagesHaveDifferentDate(message1, message2) {
			return !message2 // There is no previous message
				|| this.getDateOfMessage(message1).format('YYYY-MM-DD') !== this.getDateOfMessage(message2).format('YYYY-MM-DD')
		},

		/**
		 * Generate the date header between the messages
		 *
		 * @param {object} message The message object
		 * @param {string} message.id The ID of the message
		 * @param {number} message.timestamp Timestamp of the message
		 * @return {string} Translated string of "<Today>, <November 11th, 2019>", "<3 days ago>, <November 8th, 2019>"
		 */
		generateDateSeparator(message) {
			const date = this.getDateOfMessage(message)
			const dayOfYear = date.format('YYYY-DDD')
			let relativePrefix = date.fromNow()

			// Use the relative day for today and yesterday
			const dayOfYearToday = moment().format('YYYY-DDD')
			if (dayOfYear === dayOfYearToday) {
				relativePrefix = t('spreed', 'Today')
			} else {
				const dayOfYearYesterday = moment().subtract(1, 'days').format('YYYY-DDD')
				if (dayOfYear === dayOfYearYesterday) {
					relativePrefix = t('spreed', 'Yesterday')
				}
			}

			// <Today>, <November 11th, 2019>
			return t('spreed', '{relativeDate}, {absoluteDate}', {
				relativeDate: relativePrefix,
				// 'LL' formats a localized date including day of month, month
				// name and year
				absoluteDate: date.format('LL'),
			}, undefined, {
				escape: false, // French "Today" has a ' in it
			})
		},

		/**
		 * Generate the date of the messages
		 *
		 * @param {object} message The message object
		 * @param {string} message.id The ID of the message
		 * @param {number} message.timestamp Timestamp of the message
		 * @return {object} MomentJS object
		 */
		getDateOfMessage(message) {
			if (message.id.toString().startsWith('temp-')) {
				return moment()
			}
			return moment.unix(message.timestamp)
		},

		scrollToFocussedMessage() {
			let focussed = null
			if (this.$route?.hash?.startsWith('#message_')) {
				// scroll to message in URL anchor
				focussed = this.focusMessage(this.$route.hash.substr(9), false)
			}

			if (!focussed && this.visualLastReadMessageId) {
				// scroll to last read message if visible in the current pages
				focussed = this.focusMessage(this.visualLastReadMessageId, false, false)
			}

			// TODO: in case the element is not in a page but does exist in the DB,
			// we need to scroll up / down to the page where it would exist after
			// loading said pages

			if (!focussed) {
				// if no anchor was present or the message to focus on did not exist,
				// scroll to bottom
				this.scrollToBottom()
			}

			// if no scrollbars, clear read marker directly as scrolling is not possible for the user to clear it
			// also clear in case lastReadMessage is zero which is due to an older bug
			if (this.visualLastReadMessageId === 0 || this.scroller.scrollHeight <= this.scroller.offsetHeight) {
				// clear after a delay, unless scrolling can resume in-between
				this.debounceUpdateReadMarkerPosition()
			}
		},

		async handleStartGettingMessagesPreconditions() {
			if (this.token && this.isParticipant && !this.isInLobby) {

				// prevent sticky mode before we have loaded anything
				this.setChatScrolledToBottom(false)

				this.$store.dispatch('setVisualLastReadMessageId', {
					token: this.token,
					id: this.conversation.lastReadMessage,
				})

				if (this.$store.getters.getFirstKnownMessageId(this.token) === null) {
					// first time load, initialize important properties
					this.$store.dispatch('setFirstKnownMessageId', {
						token: this.token,
						id: this.conversation.lastReadMessage,
					})
					this.$store.dispatch('setLastKnownMessageId', {
						token: this.token,
						id: this.conversation.lastReadMessage,
					})

					// get history before last read message
					await this.getOldMessages(true)
					// at this stage, the read marker will appear at the bottom of the view port since
					// we haven't fetched the messages that come after it yet
					// TODO: should we still show a spinner at this stage ?
				}

				let hasScrolled = false
				// if lookForNewMessages will long poll instead of returning existing messages,
				// scroll right away to avoid delays
				if (!this.$store.getters.hasMoreMessagesToLoad(this.token)) {
					hasScrolled = true
					await this.$nextTick(() => {
						this.scrollToFocussedMessage()
					})
				}

				// get new messages
				await this.lookForNewMessages()

				// don't scroll if lookForNewMessages was polling as we don't want
				// to scroll back to the read marker after receiving new messages later
				if (!hasScrolled) {
					this.scrollToFocussedMessage()
				}
			} else {
				this.$store.dispatch('cancelLookForNewMessages', { requestId: this.chatIdentifier })
			}
		},

		/**
		 * Fetches the messages of a conversation given the conversation token. Triggers
		 * a long-polling request for new messages.
		 */
		async lookForNewMessages() {
			// Once the history is received, starts looking for new messages.
			if (this._isBeingDestroyed || this._isDestroyed) {
				console.debug('Prevent getting new messages on a destroyed MessagesList')
				return
			}

			const followInNewMessages = this.conversation.lastMessage
				&& this.conversation.lastReadMessage === this.conversation.lastMessage.id

			await this.getNewMessages(followInNewMessages)
		},

		/**
		 * Get messages history.
		 *
		 * @param {boolean} includeLastKnown Include or exclude the last known message in the response
		 */
		async getOldMessages(includeLastKnown) {
			// Make the request
			try {
				this.loadingOldMessages = true
				await this.$store.dispatch('fetchMessages', {
					token: this.token,
					lastKnownMessageId: this.$store.getters.getFirstKnownMessageId(this.token),
					includeLastKnown,
				})

				this.loadingOldMessages = false
			} catch (exception) {
				if (Axios.isCancel(exception)) {
					console.debug('The request has been canceled', exception)
				}
				this.loadingOldMessages = false
			}
		},

		/**
		 * Creates a long polling request for a new message.
		 *
		 * @param {boolean} scrollToBottom Whether we should try to automatically scroll to the bottom
		 */
		async getNewMessages(scrollToBottom = true) {
			if (this.destroying) {
				return
			}

			// Make the request
			try {
				// TODO: move polling logic to the store and also cancel timers on cancel
				this.pollingErrorTimeout = 1
				await this.$store.dispatch('lookForNewMessages', {
					token: this.token,
					lastKnownMessageId: this.$store.getters.getLastKnownMessageId(this.token),
					requestId: this.chatIdentifier,
				})

				// Scroll to the last message if sticky
				if (scrollToBottom && this.isSticky) {
					this.smoothScrollToBottom()
				}
			} catch (exception) {
				if (Axios.isCancel(exception)) {
					console.debug('The request has been canceled', exception)
					return
				}

				if (exception.response && exception.response.status === 304) {
					// 304 - Not modified
					// This is not an error, so reset error timeout and poll again
					this.pollingErrorTimeout = 1
					setTimeout(() => {
						this.getNewMessages()
					}, 500)
					return
				}

				if (this.pollingErrorTimeout < 30) {
					// Delay longer after each error
					this.pollingErrorTimeout += 5
				}

				console.debug('Error happened while getting chat messages. Trying again in ', this.pollingErrorTimeout, exception)

				setTimeout(() => {
					this.getNewMessages()
				}, this.pollingErrorTimeout * 1000)
				return
			}

			setTimeout(() => {
				this.getNewMessages()
			}, 500)
		},

		debounceHandleScroll: debounce(function() {
			this.handleScroll()
		}, 50),
		/**
		 * When the div is scrolled, this method checks if it's been scrolled to the top
		 * or to the bottom of the list bottom.
		 */
		async handleScroll() {
			const scrollHeight = this.scroller.scrollHeight
			const scrollTop = this.scroller.scrollTop
			const scrollOffset = scrollHeight - scrollTop
			const elementHeight = this.scroller.clientHeight
			const tolerance = 10
			if (scrollOffset < elementHeight + tolerance && scrollOffset > elementHeight - tolerance) {
				this.setChatScrolledToBottom(true)
				this.displayMessagesLoader = false
				this.previousScrollTopValue = scrollTop
			} else if (scrollHeight > elementHeight && scrollTop < 800 && scrollTop <= this.previousScrollTopValue) {
				if (this.loadingOldMessages) {
					// already loading, don't do it twice
					return
				}
				if (scrollTop === 0) {
					this.displayMessagesLoader = true
				}
				await this.getOldMessages(false)
				this.displayMessagesLoader = false
				this.previousScrollTopValue = scrollTop
			} else {
				this.setChatScrolledToBottom(false)
				this.displayMessagesLoader = false
				this.previousScrollTopValue = scrollTop
			}

			this.debounceUpdateReadMarkerPosition()
		},

		/**
		 * Finds the last message that is fully visible in the scroller viewport
		 *
		 * Starts searching forward after the given message element until we reach
		 * the bottom of the viewport.
		 *
		 * @param {object} messageEl message element after which to start searching
		 * @return {object} DOM element for the last visible message
		 */
		findFirstVisibleMessage(messageEl) {
			let el = messageEl
			let previousEl = el
			const scrollTop = this.scroller.scrollTop
			while (el) {
				// is the message element fully visible with no intersection with the bottom border ?
				if (el.offsetTop - scrollTop >= 0) {
					// this means that the previous message we had was fully visible,
					// so we return that
					return previousEl
				}

				previousEl = el
				el = document.getElementById('message_' + el.getAttribute('data-next-message-id'))
			}

			return previousEl
		},

		/**
		 * Sync the visual marker position with what is currently in the store.
		 * This separation exists to avoid jumpy marker while scrolling.
		 *
		 * Also see updateReadMarkerPosition() for the backend update.
		 */
		refreshReadMarkerPosition() {
			if (!this.conversation) {
				return
			}
			console.debug('setVisualLastReadMessageId token=' + this.token + ' id=' + this.conversation.lastReadMessage)
			this.$store.dispatch('setVisualLastReadMessageId', {
				token: this.token,
				id: this.conversation.lastReadMessage,
			})
		},

		debounceUpdateReadMarkerPosition: debounce(function() {
			this.updateReadMarkerPosition()
		}, 1000),

		/**
		 * Recalculates the current read marker position based on the first visible element,
		 * but only do so if the previous marker was already seen.
		 *
		 * The new marker position will be sent to the backend but not applied visually.
		 * Visually, the marker will only move the next time the user is focussing back to this
		 * conversation in refreshReadMarkerPosition()
		 */
		updateReadMarkerPosition() {
			if (!this.conversation) {
				return
			}

			// to fix issues, this scenario should not happen
			if (this.conversation.lastReadMessage === 0) {
				console.debug('clearLastReadMessage because lastReadMessage was 0 token=' + this.token)
				this.$store.dispatch('clearLastReadMessage', { token: this.token, updateVisually: true })
				return
			}

			if (this.conversation.lastReadMessage === this.conversation.lastMessage?.id) {
				// already at bottom, nothing to do
				return
			}

			// first unread message has not been seen yet, so don't move it
			if (!this.unreadMessageElement || this.unreadMessageElement.getAttribute('data-seen') !== 'true') {
				return
			}

			// if we're at bottom of the chat with no more new messages to load, then simply clear the marker
			if (this.isSticky && !this.$store.getters.hasMoreMessagesToLoad(this.token)) {
				console.debug('clearLastReadMessage because of isSticky token=' + this.token)
				this.$store.dispatch('clearLastReadMessage', { token: this.token })
				return
			}

			if (this.unreadMessageElement.offsetTop - this.scroller.scrollTop > 0) {
				// still visible, hasn't disappeared at the top yet
				return
			}

			const firstVisibleMessage = this.findFirstVisibleMessage(this.unreadMessageElement)
			if (!firstVisibleMessage) {
				console.warn('First visible message not found: ', firstVisibleMessage)
				return
			}

			const messageId = parseInt(firstVisibleMessage.getAttribute('data-message-id'), 10)
			if (messageId <= this.conversation.lastReadMessage) {
				// it was probably a scroll up, don't update
				return
			}

			// we don't update visually here, it will update the next time the
			// user focusses back on the conversation. See refreshReadMarkerPosition().
			console.debug('updateLastReadMessage token=' + this.token + ' messageId=' + messageId)
			this.$store.dispatch('updateLastReadMessage', { token: this.token, id: messageId, updateVisually: false })
		},

		/**
		 * @param {object} options Event options
		 * @param {boolean} options.force Set to true, if the chat should be scrolled to the bottom even when it was not before
		 */
		handleScrollChatToBottomEvent(options) {
			if ((options && options.force) || this.isChatScrolledToBottom) {
				this.scrollToBottom()
				this.setChatScrolledToBottom(true)
			}
		},

		/**
		 * Scrolls to the bottom of the list smoothly.
		 */
		smoothScrollToBottom() {
			this.$nextTick(function() {
				if (this.isWindowVisible && document.hasFocus()) {
					// scrollTo is used when the user is watching
					this.scroller.scrollTo({
						top: this.scroller.scrollHeight,
						behavior: 'smooth',
					})
					this.setChatScrolledToBottom(true)
				} else {
					// Otherwise we jump half a message and stop autoscrolling, so the user can read up
					if (this.scroller.scrollHeight - this.scroller.scrollTop - this.scroller.offsetHeight < 40) {
						// Single new line from the previous author is 35px so scroll half a line
						this.scroller.scrollTop += 10
					} else {
						// Single new line from the new author is 75px so scroll half an avatar
						this.scroller.scrollTop += 40
					}
					this.setChatScrolledToBottom(false)
				}
			})
		},
		/**
		 * Scrolls to the bottom of the list.
		 */
		scrollToBottom() {
			this.$nextTick(function() {
				this.scroller.scrollTop = this.scroller.scrollHeight
				this.setChatScrolledToBottom(true)
			})

		},

		/**
		 * Temporarily highlight the given message id with a fade out effect.
		 *
		 * @param {string} messageId message id
		 * @param {boolean} smooth true to smooth scroll, false to jump directly
		 * @param {boolean} highlightAnimation true to highlight and set focus to the message
		 * @return {boolean} true if element was found, false otherwise
		 */
		focusMessage(messageId, smooth = true, highlightAnimation = true) {
			const element = document.getElementById(`message_${messageId}`)
			if (!element) {
				// TODO: in some cases might need to trigger a scroll up if this is an older message
				console.warn('Message to focus not found in DOM', messageId)
				return false
			}

			this.$nextTick(async () => {
				// FIXME: this doesn't wait for the smooth scroll to end
				await element.scrollIntoView({
					behavior: smooth ? 'smooth' : 'auto',
					block: 'center',
					inline: 'nearest',
				})
				if (!smooth) {
					// scroll the viewport slightly further to make sure the element is about 1/3 from the top
					this.scroller.scrollTop += this.scroller.offsetHeight / 4
				}
				if (highlightAnimation) {
					element.focus()
					element.highlightAnimation()
				}
			})

			return true
		},

		/**
		 * gets the last known message id.
		 *
		 * @return {string} The last known message id.
		 */
		getLastKnownMessageId() {
			let i = this.messagesList.length - 1

			while (i >= 0) {
				if (!this.messagesList[i].id.toString().startsWith('temp-')) {
					return this.messagesList[i].id
				}
				i--
			}
			return '0'
		},
		/**
		 * gets the first message's id.
		 *
		 * @return {string}
		 */
		getFirstKnownMessageId() {
			return this.messagesList[0].id.toString()
		},

		handleNetworkOffline() {
			console.debug('Canceling message request as we are offline')
			if (this.cancelLookForNewMessages) {
				this.$store.dispatch('cancelLookForNewMessages', { requestId: this.chatIdentifier })
			}
		},

		handleNetworkOnline() {
			console.debug('Restarting polling of new chat messages')
			this.getNewMessages()
		},

		onRouteChange({ from, to }) {
			if (from.name === 'conversation'
				&& to.name === 'conversation'
				&& from.token === to.token
				&& from.hash !== to.hash) {

				// the hash changed, need to focus/highlight another message
				if (to.hash && to.hash.startsWith('#message_')) {
					// need some delay (next tick is too short) to be able to run
					// after the browser's native "scroll to anchor" from
					// the hash
					window.setTimeout(() => {
						// scroll to message in URL anchor
						this.focusMessage(to.hash.substr(9), true)
					}, 2)
				}
			}
		},

		setChatScrolledToBottom(boolean) {
			this.$emit('set-chat-scrolled-to-bottom', boolean)
			if (boolean) {
				// mark as read if marker was seen
				// we have to do this early because unfocussing the window will remove the stickiness
				this.debounceUpdateReadMarkerPosition()
			}
		},

		onWindowFocus() {
			this.refreshReadMarkerPosition()
		},
	},
}
</script>

<style lang="scss" scoped>
@import '../../assets/variables.scss';

.scroller {
	flex: 1 0;
	overflow-y: auto;
	overflow-x: hidden;
	&__loading {
		height: 50px;
		display: flex;
		justify-content: center;
	}
}

.scroll-to-bottom {
	position: absolute;
	width: 44px;
	height: 44px;
	bottom: 76px;
	right: 24px;
	z-index: 2;
	padding: 0;
	margin: 0;
	display: flex;
	align-items: center;
	justify-content: center;
}

</style>


Link time spent values to a person aggregate

Now when browsing time spents, it's possible to click on a time spent to
see the aggregate for this person for the related period.

<template>

            {{ $t("people.list.name") }}

            {{ monthToString(month) }}

            {{ week }}

            {{ day }}

          <people-name-cell class="name" :entry="person" />

            <router-link
              :to="{
                name: 'timesheets-month-person',
                params: {
                  person_id: person.id,
                  year: year,
                  month: month
                }
              }"
              v-if="monthDuration(month, person.id) > 0"
            >
              {{ monthDuration(month, person.id) }}
            </router-link>

            -

            <router-link
              :to="{
                name: 'timesheets-week-person',
                params: {
                  person_id: person.id,
                  year: year,
                  week: week
                }
              }"
              v-if="weekDuration(week, person.id) > 0"
            >
              {{ weekDuration(week, person.id) }}
            </router-link>

            -

            <router-link
              :to="{
                name: 'timesheets-day-person',
                params: {
                  person_id: person.id,
                  year: year,
                  month: month,
                  day: day
                }
              }"
              v-if="dayDuration(day, person.id) > 0"
            >
              {{ dayDuration(day, person.id) }}
            </router-link>

            -

    {{ people.length }} {{ $tc('people.persons', people.length) }}

</template>

<template>

            {{ $t("people.list.name") }}

            {{ monthToString(month) }}

            {{ week }}

            {{ day }}

          <people-name-cell class="name" :entry="person" />

            {{ monthDuration(month, person.id) }}

            {{ weekDuration(week, person.id) }}

            {{ dayDuration(day, person.id) }}

    {{ people.length }} {{ $tc('people.persons', people.length) }}

</template>


Remove rogue console log upon opening combat

<template>

Cantrips:

{{cantrip}}

 0">

                                    Level {{ category }}

                                    {{ details.slots.expended }}/{{ details.slots.total }} slots used

{{ spell.name }}

</template>


refactored styles for AlsoInThis per feedback

<template>

{{ title }}

      <KRouterLink
        v-for="content in contentNodes"
        :key="content.id"
        :to="genContentLink(content.id, content.is_leaf, backRoute, context)"
        class="item"
        :class="windowIsSmall && 'small'"
        :style="linkStyles"
      >
        <LearningActivityIcon
          v-if="content.is_leaf"
          class="activity-icon"
          :kind="content.learning_activities"
        />
        <KIcon v-else class="topic-icon" icon="topic" />

            <TextTruncator
              class="content-title"
              :text="content.title"
              :maxHeight="72"
            />
            <TimeDuration
              v-if="content.duration"
              class="time-duration"
              :style="{
                color: $themeTokens.annotation,
              }"
              :seconds="content.duration"
            />

            <KIcon
              v-if="content.progress === 1"
              icon="star"
              class="mastered-icon"
              :style="{ fill: $themeTokens.mastered }"
            />
            <ProgressBar v-else :contentNode="content" class="bar" />

      </KRouterLink>

      {{ emptyMessage }}

    <KRouterLink
      v-if="nextContent"
      :to="genContentLink(nextContent.id, nextContent.is_leaf, backRoute, context)"
      class="next-content-link"
      :style="{ borderTop: '1px solid ' + $themeTokens.fineLine, ...linkStyles }"
    >
      <KIcon class="folder-icon" icon="topic" />

        Next folder

        {{ nextContent.title }}

      <KIcon class="forward-icon" icon="forward" />
    </KRouterLink>

</template>

<template>

{{ title }}

      <KRouterLink
        v-for="content in contentNodes"
        :key="content.id"
        :to="genContentLink(content.id, content.is_leaf, backRoute, context)"
        class="item"
        :class="windowIsSmall && 'small'"
        :style="linkStyles"
      >
        <LearningActivityIcon
          v-if="content.is_leaf"
          class="activity-icon"
          :kind="content.learning_activities"
        />
        <KIcon v-else class="topic-icon" icon="topic" />

          <TextTruncator
            :text="content.title"
            :maxHeight="24"
            :style="{ marginTop: (content.duration ? '8px' : '0px') }"
          />
          <TimeDuration
            v-if="content.duration"
            :style="{
              color: $themeTokens.annotation,
              top: '16px',
              position: 'relative'
            }"
            :seconds="content.duration"
          />

          <KIcon
            v-if="content.progress === 1"
            icon="star"
            class="mastered-icon"
            :style="{ fill: $themeTokens.mastered }"
          />
          <ProgressBar v-else :contentNode="content" class="bar" />

      </KRouterLink>

      {{ emptyMessage }}

    <KRouterLink
      v-if="nextContent"
      :to="genContentLink(nextContent.id, nextContent.is_leaf, backRoute, context)"
      class="next-content-link"
      :style="{ borderTop: '1px solid ' + $themeTokens.fineLine, ...linkStyles }"
    >
      <KIcon class="folder-icon" icon="topic" />

        Next folder

        {{ nextContent.title }}

      <KIcon class="forward-icon" icon="forward" />
    </KRouterLink>

</template>


fix(docs): QTable > Pagination slot example

<template>
  <div class="q-pa-md">
    <q-table
      title="Treats"
      :rows="rows"
      :columns="columns"
      row-key="name"
      v-model:pagination="pagination"
    >
      <template v-slot:pagination="scope">
        <q-btn
          v-if="scope.pagesNumber > 2"
          icon="first_page"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isFirstPage"
          @click="scope.firstPage"
        />

        <q-btn
          icon="chevron_left"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isFirstPage"
          @click="scope.prevPage"
        />

        <q-btn
          icon="chevron_right"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isLastPage"
          @click="scope.nextPage"
        />

        <q-btn
          v-if="scope.pagesNumber > 2"
          icon="last_page"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isLastPage"
          @click="scope.lastPage"
        />
      </template>
    </q-table>
  </div>
</template>

<script>
import { ref, computed } from 'vue'

const columns = [
  {
    name: 'desc',
    required: true,
    label: 'Dessert (100g serving)',
    align: 'left',
    field: row => row.name,
    format: val => `${val}`,
    sortable: true
  },
  { name: 'calories', align: 'center', label: 'Calories', field: 'calories', sortable: true },
  { name: 'fat', label: 'Fat (g)', field: 'fat', sortable: true },
  { name: 'carbs', label: 'Carbs (g)', field: 'carbs' },
  { name: 'protein', label: 'Protein (g)', field: 'protein' },
  { name: 'sodium', label: 'Sodium (mg)', field: 'sodium' },
  { name: 'calcium', label: 'Calcium (%)', field: 'calcium', sortable: true, sort: (a, b) => parseInt(a, 10) - parseInt(b, 10) },
  { name: 'iron', label: 'Iron (%)', field: 'iron', sortable: true, sort: (a, b) => parseInt(a, 10) - parseInt(b, 10) }
]

const rows = [
  {
    name: 'Frozen Yogurt',
    calories: 159,
    fat: 6.0,
    carbs: 24,
    protein: 4.0,
    sodium: 87,
    calcium: '14%',
    iron: '1%'
  },
  {
    name: 'Ice cream sandwich',
    calories: 237,
    fat: 9.0,
    carbs: 37,
    protein: 4.3,
    sodium: 129,
    calcium: '8%',
    iron: '1%'
  },
  {
    name: 'Eclair',
    calories: 262,
    fat: 16.0,
    carbs: 23,
    protein: 6.0,
    sodium: 337,
    calcium: '6%',
    iron: '7%'
  },
  {
    name: 'Cupcake',
    calories: 305,
    fat: 3.7,
    carbs: 67,
    protein: 4.3,
    sodium: 413,
    calcium: '3%',
    iron: '8%'
  },
  {
    name: 'Gingerbread',
    calories: 356,
    fat: 16.0,
    carbs: 49,
    protein: 3.9,
    sodium: 327,
    calcium: '7%',
    iron: '16%'
  },
  {
    name: 'Jelly bean',
    calories: 375,
    fat: 0.0,
    carbs: 94,
    protein: 0.0,
    sodium: 50,
    calcium: '0%',
    iron: '0%'
  },
  {
    name: 'Lollipop',
    calories: 392,
    fat: 0.2,
    carbs: 98,
    protein: 0,
    sodium: 38,
    calcium: '0%',
    iron: '2%'
  },
  {
    name: 'Honeycomb',
    calories: 408,
    fat: 3.2,
    carbs: 87,
    protein: 6.5,
    sodium: 562,
    calcium: '0%',
    iron: '45%'
  },
  {
    name: 'Donut',
    calories: 452,
    fat: 25.0,
    carbs: 51,
    protein: 4.9,
    sodium: 326,
    calcium: '2%',
    iron: '22%'
  },
  {
    name: 'KitKat',
    calories: 518,
    fat: 26.0,
    carbs: 65,
    protein: 7,
    sodium: 54,
    calcium: '12%',
    iron: '6%'
  }
]

export default {
  setup () {
    const pagination = ref({
      sortBy: 'desc',
      descending: false,
      page: 2,
      rowsPerPage: 3
      // rowsNumber: xx if getting data from a server
    })

    return {
      pagination,

      columns,
      rows,

      pagesNumber: computed(() => {
        return Math.ceil(rows.length / pagination.value.rowsPerPage)
      })
    }
  }
}
</script>

<template>
  <div class="q-pa-md">
    <q-table
      title="Treats"
      :rows="rows"
      :columns="columns"
      row-key="name"
      v-model:pagination="pagination"
    >
      <template v-slot:pagination="scope">
        <q-btn
          v-if="scope.pagesNumber > 2"
          icon="first_page"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isFirstPage"
          @click="scope.firstPage"
        />

        <q-btn
          icon="chevron_left"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isFirstPage"
          @click="scope.prevPage"
        />

        <q-btn
          icon="chevron_right"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isLastPage"
          @click="scope.nextPage"
        />

        <q-btn
          v-if="scope.pagesNumber > 2"
          icon="last_page"
          color="grey-8"
          round
          dense
          flat
          :disable="scope.isLastPage"
          @click="scope.lastPage"
        />
      </template>
    </q-table>
  </div>
</template>

<script>
import { computed } from 'vue'

const columns = [
  {
    name: 'desc',
    required: true,
    label: 'Dessert (100g serving)',
    align: 'left',
    field: row => row.name,
    format: val => `${val}`,
    sortable: true
  },
  { name: 'calories', align: 'center', label: 'Calories', field: 'calories', sortable: true },
  { name: 'fat', label: 'Fat (g)', field: 'fat', sortable: true },
  { name: 'carbs', label: 'Carbs (g)', field: 'carbs' },
  { name: 'protein', label: 'Protein (g)', field: 'protein' },
  { name: 'sodium', label: 'Sodium (mg)', field: 'sodium' },
  { name: 'calcium', label: 'Calcium (%)', field: 'calcium', sortable: true, sort: (a, b) => parseInt(a, 10) - parseInt(b, 10) },
  { name: 'iron', label: 'Iron (%)', field: 'iron', sortable: true, sort: (a, b) => parseInt(a, 10) - parseInt(b, 10) }
]

const rows = [
  {
    name: 'Frozen Yogurt',
    calories: 159,
    fat: 6.0,
    carbs: 24,
    protein: 4.0,
    sodium: 87,
    calcium: '14%',
    iron: '1%'
  },
  {
    name: 'Ice cream sandwich',
    calories: 237,
    fat: 9.0,
    carbs: 37,
    protein: 4.3,
    sodium: 129,
    calcium: '8%',
    iron: '1%'
  },
  {
    name: 'Eclair',
    calories: 262,
    fat: 16.0,
    carbs: 23,
    protein: 6.0,
    sodium: 337,
    calcium: '6%',
    iron: '7%'
  },
  {
    name: 'Cupcake',
    calories: 305,
    fat: 3.7,
    carbs: 67,
    protein: 4.3,
    sodium: 413,
    calcium: '3%',
    iron: '8%'
  },
  {
    name: 'Gingerbread',
    calories: 356,
    fat: 16.0,
    carbs: 49,
    protein: 3.9,
    sodium: 327,
    calcium: '7%',
    iron: '16%'
  },
  {
    name: 'Jelly bean',
    calories: 375,
    fat: 0.0,
    carbs: 94,
    protein: 0.0,
    sodium: 50,
    calcium: '0%',
    iron: '0%'
  },
  {
    name: 'Lollipop',
    calories: 392,
    fat: 0.2,
    carbs: 98,
    protein: 0,
    sodium: 38,
    calcium: '0%',
    iron: '2%'
  },
  {
    name: 'Honeycomb',
    calories: 408,
    fat: 3.2,
    carbs: 87,
    protein: 6.5,
    sodium: 562,
    calcium: '0%',
    iron: '45%'
  },
  {
    name: 'Donut',
    calories: 452,
    fat: 25.0,
    carbs: 51,
    protein: 4.9,
    sodium: 326,
    calcium: '2%',
    iron: '22%'
  },
  {
    name: 'KitKat',
    calories: 518,
    fat: 26.0,
    carbs: 65,
    protein: 7,
    sodium: 54,
    calcium: '12%',
    iron: '6%'
  }
]

export default {
  setup () {
    const pagination = {
      sortBy: 'desc',
      descending: false,
      page: 2,
      rowsPerPage: 3
      // rowsNumber: xx if getting data from a server
    }

    return {
      pagination,

      columns,
      rows,

      pagesNumber: computed(() => {
        return Math.ceil(rows.length / pagination.rowsPerPage)
      })
    }
  }
}
</script>


Expand script description

Expand script description

<template>

                            <template v-if="basePath !== ''"
                                      v-for="(each, index) in basePath.split('/')"><router-link :to="breadcrumbPathUrl.slice(0, index + 2).join('/')"
                                                v-text="each"></router-link>/</template>

                        <template v-if="scriptHandler && scriptHandler.validatable">
                            <button class="btn btn-success" @click="save(false)">

                            </button>
                            <button class="btn btn-success" @click="save(true)">

                            </button>
                            <button class="btn btn-primary" @click="validate">

                            </button>
                        </template>
                        <template v-else>
                            <button class="btn btn-success" @click="save(false)">

                            </button>
                        </template>

                        <textarea class="form-control" id="description"
                                  name="description" v-model="file.description">
                        </textarea>

                        <button class="btn btn-info float-right add-host-btn" @click.prevent="$refs.addHostModal.show">

                        </button>

            Script Samples
            <div class="float-right pointer-cursor tip"  data-toggle="popover" title="Tip" data-html="true"
                 data-placement="left" data-trigger="hover" :data-content="
            'Ctrl-F / Cmd-F :' + i18n('script.editor.tip.startSearching') + '
' +
            'Ctrl-G / Cmd-G : ' + i18n('script.editor.tip.findNext') + '
' +
            'Shift-Ctrl-G / Shift-Cmd-G : ' + i18n('script.editor.tip.findPrev') + '
' +
            'Shift-Ctrl-F / Cmd-Option-F : ' + i18n('script.editor.tip.replace') + '
' +
            'Shift-Ctrl-R / Shift-Cmd-Option-F : ' + i18n('script.editor.tip.replaceAll') + '
' +
            'F11 : ' + i18n('script.editor.tip.fullScreen') + '
' +
            'ESC : ' + i18n('script.editor.tip.back') ">
                Tip

        <host-modal ref="addHostModal" @add-host="addHost"></host-modal>
        <target-host-info-modal ref="targetHostInfoModal" :ip="targetHostIp"></target-host-info-modal>

</template>


fixed event propagation bug

fixed event propagation bug

<template>

      <icon :name="icon"></icon>

      Empty

</template>


Use globally-available reCAPTCHA source

Use globally-available reCAPTCHA source

As google.com over HTTPS is not available in China and other countries
with large blocks, use reCAPTCHA from recaptcha.net per this FAQ:

https://developers.google.com/recaptcha/docs/faq#can-i-use-recaptcha-globally

<template>
  <deprecated-modal
    :hide-footer="true"
    :title="__('Please solve the reCAPTCHA')"
    kind="warning"
    class="recaptcha-modal js-recaptcha-modal"
    @cancel="close"
  >

{{ __('We want to be sure it is you, please confirm you are not a robot.') }}

  </deprecated-modal>
</template>


Client - fix close button position

Client - fix close button position

<template>

FitTrackee

FitTrackee

          <router-link class="nav-item" to="/">{{
            t('dashboard.DASHBOARD')
          }}</router-link>

{{ t('workouts.WORKOUTS') }}

{{ t('statistics.STATISTICS') }}

{{ t('administration.ADMIN') }}

{{ t('workouts.ADD_WORKOUT') }}

User

{{ t('user.LOGOUT') }}

          <Dropdown
            v-if="availableLanguages && language"
            class="nav-item"
            :options="availableLanguages"
            :selected="language"
            @selected="updateLanguage"
          >

          </Dropdown>

</template>


fix SLN link in teaching course header

fix SLN link in teaching course header

<template>

          {{ section.curriculum_abbr }}
          {{ section.course_number }}
          {{ section.section_id }}

{{ section.course_title }}

          SLN
          <a
          :href="getTimeScheHref(section)"
          :title="`Time Schedule for SLN ${section.sln}`"
          :data-linklabel="getTimeScheLinkLable(section)"
          target="_blank"
        >
          {{ section.sln }}

            {{ ucfirst(section.section_type) }}

        <b-button v-if="section.mini_card"
            variant="dark" size="sm"
            :aria-label="`Remove ${section.id} mini-card`"
            title="Click to remove this mini-card"
            @click="toggleMini(section)">

        Term

        Summer
        {{
          section.summer_term
            .split('-')
            .map(ucfirst)
            .join('-')
        }}

        Dates

        {{ sectionFormattedDates(section) }}

        Your Status

        On Standby

</template>

<template>

          {{ section.curriculum_abbr }}
          {{ section.course_number }}
          {{ section.section_id }}

{{ section.course_title }}

          SLN {{ section.sln }}

            {{ ucfirst(section.section_type) }}

        <b-button v-if="section.mini_card"
            variant="dark" size="sm"
            :aria-label="`Remove ${section.id} mini-card`"
            title="Click to remove this mini-card"
            @click="toggleMini(section)">

        Term

        Summer
        {{
          section.summer_term
            .split('-')
            .map(ucfirst)
            .join('-')
        }}

        Dates

        {{ sectionFormattedDates(section) }}

        Your Status

        On Standby

</template>


实现向下拖动歌手歌曲列表时，歌手图片随之变大效果

实现向下拖动歌手歌曲列表时，歌手图片随之变大效果

<template>

    <scroll class="list" :data="songs" ref="list"
            @scroll="scroll"
            :probe-type="probeType"
            :listen-scroll="listenScroll">

        <song-list :songs="songs"></song-list>

    </scroll>

</template>


Fix media query to responsiveWindowMixin

<template>

  <nav :aria-label="ariaLabel">
    <ul
      ref="navbarUl"
      class="items"
      tabindex="-1"
      :style="mediumAndSmallStyleOverrides"
    >
      <!-- Contains NavbarLink components -->
      <slot></slot>
    </ul>
  </nav>

</template>

<script>

  import responsiveWindowMixin from 'kolibri.coreVue.mixins.responsiveWindowMixin';
  /**
   * Used for navigation between sub-pages of a top-level Kolibri section
   */
  export default {
    name: 'Navbar',
    mixins: [responsiveWindowMixin],
    computed: {
      mediumAndSmallStyleOverrides() {
        return !this.windowIsLarge ? { marginTop: 0 } : {};
      },
    },
  };

</script>

<style lang="scss" scoped>

  @import '~kolibri-design-system/lib/styles/definitions';

  .items {
    display: flex;
    padding: 0;
    margin-bottom: 4px;
    margin-left: 16px;
    overflow-x: hidden;
    overflow-y: hidden;
    white-space: nowrap;
  }

</style>

<template>

  <nav :aria-label="ariaLabel">
    <ul
      ref="navbarUl"
      class="items"
      tabindex="-1"
    >
      <!-- Contains NavbarLink components -->
      <slot></slot>
    </ul>
  </nav>

</template>

<script>

  /**
   * Used for navigation between sub-pages of a top-level Kolibri section
   */
  export default {
    name: 'Navbar',
  };

</script>

<style lang="scss" scoped>

  @import '~kolibri-design-system/lib/styles/definitions';

  .items {
    display: flex;
    padding: 0;
    margin-bottom: 4px;
    margin-left: 16px;
    overflow-x: hidden;
    overflow-y: hidden;
    white-space: nowrap;
  }

  @media (max-width: 840px) {
    .items {
      margin-top: 0;
    }
  }

</style>


Added div height and docu height

Added div height and docu height

<template>

  <core-base>
    <side-nav class='nav'></side-nav>

      <search-button class='search-btn'></search-button>

      <error-page v-show='error'></error-page>

        <explore-page v-if='showExplorePage'></explore-page>
        <content-page v-if='showContentPage'></content-page>
        <learn-page  v-if='showLearnPage'></learn-page>
        <scratchpad-page v-if='showScratchpadPage'></scratchpad-page>

          <search-widget
            :show-topics="exploreMode">
          </search-widget>

    <router-view></router-view>

  </core-base>

</template>


docs: style tables and improve snippets in light mode

<template>
  <nuxt-content :document="document" />
</template>

<script>
export default {
  props: ['document'],
};
</script>

<style lang="postcss" scoped>
* >>> {
  h1 {
    @apply text-4xl mb-8;
  }

  h2 {
    @apply text-2xl;
  }

  h3 {
    @apply text-xl;
  }

  h4 {
    @apply text-lg;
  }

  h1,
  h2,
  h3 {
    @apply font-display;
  }

  h2,
  h3,
  h4 {
    @apply font-semibold my-8 relative;
    transform: translateX(2ch);
    &::before {
      @apply absolute text-accent-800;
      margin-left: -2ch;
      content: '#';
    }

    @screen lg {
      transform: none;
    }
  }

  p + p {
    @apply mt-4;
  }

  ul {
    @apply px-8 my-4 list-disc;

    li + li {
      @apply mt-2;
    }
  }

  .features {
    ul {
      li {
        @apply relative flex items-center;
        &:before {
          @apply w-5 h-5 absolute rounded-full flex text-accent-900 bg-accent-100 items-center justify-center;
          content: '✓';
          left: -2rem;
        }
      }
    }
  }

  .nuxt-content-highlight {
    @apply my-4;
  }

  blockquote {
    @apply py-4 rounded-r-lg pl-4 bg-black border-l-4 border-accent-800 italic my-8 text-lg;
  }

  pre[class*='language-'] {
    @apply rounded-lg my-4 block border border-gray-100;
  }

  *:not(pre) > code:not([class]) {
    @apply text-sm px-1 rounded bg-gray-100 border border-gray-200 text-gray-900;
  }

  details {
    @apply my-10 border-l-4 border-accent-900 pl-3;

    summary {
      @apply mb-8;
    }
  }

  p {
    a[href] {
      @apply text-accent-800;

      &:hover {
        @apply text-accent-900;
      }
    }
  }

  code[class*='language-'],
  pre[class*='language-'] {
    color: #403f53;
    font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
    text-align: left;
    white-space: pre;
    word-spacing: normal;
    word-break: normal;
    word-wrap: normal;
    line-height: 1.5;

    -moz-tab-size: 4;
    -o-tab-size: 4;
    tab-size: 4;

    -webkit-hyphens: none;
    -moz-hyphens: none;
    -ms-hyphens: none;
    hyphens: none;
  }

  pre[class*='language-']::-moz-selection,
  pre[class*='language-'] ::-moz-selection,
  code[class*='language-']::-moz-selection,
  code[class*='language-'] ::-moz-selection {
    text-shadow: none;
    background: #fbfbfb;
  }

  pre[class*='language-']::selection,
  pre[class*='language-'] ::selection,
  code[class*='language-']::selection,
  code[class*='language-'] ::selection {
    text-shadow: none;
    background: #fbfbfb;
  }

  @media print {
    code[class*='language-'],
    pre[class*='language-'] {
      text-shadow: none;
    }
  }

  /* Code blocks */
  pre[class*='language-'] {
    padding: 1em;
    margin: 0.5em 0;
    overflow: auto;
  }

  :not(pre) > code[class*='language-'],
  pre[class*='language-'] {
    color: #000;
    background: #fbfbfb;
  }

  :not(pre) > code[class*='language-'] {
    padding: 0.1em;
    border-radius: 0.3em;
    white-space: normal;
  }

  .token.comment,
  .token.prolog,
  .token.cdata {
    color: rgb(152, 159, 177);
    font-style: italic;
  }

  .token.punctuation {
    color: rgb(153, 76, 195);
  }

  .namespace {
    color: rgb(12, 150, 155);
  }

  .token.deleted {
    color: rgba(239, 83, 80, 0.56);
    font-style: italic;
  }

  .token.symbol,
  .token.operator,
  .token.keyword,
  .token.property {
    color: rgb(12, 150, 155);
  }

  .token.tag {
    color: rgb(153, 76, 195);
  }

  .token.boolean {
    color: rgb(188, 84, 84);
  }

  .token.number {
    color: rgb(170, 9, 130);
  }

  .token.constant,
  .token.builtin,
  .token.string,
  .token.url,
  .token.entity,
  .language-css .token.string,
  .style .token.string,
  .token.char {
    color: rgb(72, 118, 214);
  }

  .token.selector,
  .token.function,
  .token.doctype {
    color: rgb(153, 76, 195);
    font-style: italic;
  }

  .token.attr-name,
  .token.inserted {
    color: rgb(72, 118, 214);
    font-style: italic;
  }

  .token.class-name,
  .token.atrule,
  .token.attr-value {
    color: rgb(17, 17, 17);
  }

  .token.regex,
  .token.important,
  .token.variable {
    color: rgb(201, 103, 101);
  }

  .token.important,
  .token.bold {
    font-weight: bold;
  }

  .token.italic {
    font-style: italic;
  }

  table {
    @apply w-full my-4;
  }

  table,
  th,
  td {
    @apply border border-gray-100;
  }

  th,
  td {
    @apply p-3;
  }
}

.is-dark {
  * >>> {
    *:not(pre) > code:not([class]) {
      @apply px-1 bg-black border border-gray-900 text-gray-200;
    }

    code[class*='language-'],
    pre[class*='language-'] {
      background-color: #070707;
      text-shadow: none;
      color: #d6deeb;
      font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
      text-align: left;
      white-space: pre;
      word-spacing: normal;
      word-break: normal;
      word-wrap: normal;
      line-height: 1.5;

      -moz-tab-size: 4;
      -o-tab-size: 4;
      tab-size: 4;

      -webkit-hyphens: none;
      -moz-hyphens: none;
      -ms-hyphens: none;
      hyphens: none;
    }

    pre[class*='language-']::-moz-selection,
    pre[class*='language-'] ::-moz-selection,
    code[class*='language-']::-moz-selection,
    code[class*='language-'] ::-moz-selection {
      text-shadow: none;
      background: rgba(29, 59, 83, 0.99);
    }

    pre[class*='language-']::selection,
    pre[class*='language-'] ::selection,
    code[class*='language-']::selection,
    code[class*='language-'] ::selection {
      text-shadow: none;
      background: rgba(29, 59, 83, 0.99);
    }

    @media print {
      code[class*='language-'],
      pre[class*='language-'] {
        text-shadow: none;
      }
    }

    /* Code blocks */
    pre {
      padding: 1em;
      margin: 0.5em 0;
      overflow: auto;
    }

    :not(pre) > code,
    pre {
      color: white;
      background: #011627;
    }

    :not(pre) > code {
      padding: 0.1em;
      border-radius: 0.3em;
      white-space: normal;
    }

    .token.comment,
    .token.prolog,
    .token.cdata {
      color: rgb(99, 119, 119);
      font-style: italic;
    }

    .token.punctuation {
      color: rgb(199, 146, 234);
    }

    .namespace {
      color: rgb(178, 204, 214);
    }

    .token.deleted {
      color: rgba(239, 83, 80, 0.56);
      font-style: italic;
    }

    .token.symbol,
    .token.property {
      color: rgb(128, 203, 196);
    }

    .token.tag,
    .token.operator,
    .token.keyword {
      color: rgb(127, 219, 202);
      background: transparent;
    }

    .token.boolean {
      color: rgb(255, 88, 116);
    }

    .token.number {
      color: rgb(247, 140, 108);
    }

    .token.constant,
    .token.function,
    .token.builtin,
    .token.char {
      color: rgb(130, 170, 255);
    }

    .token.selector,
    .token.doctype {
      color: rgb(199, 146, 234);
      font-style: italic;
    }

    .token.attr-name,
    .token.inserted {
      color: rgb(173, 219, 103);
      font-style: italic;
    }

    .token.string,
    .token.url,
    .token.entity,
    .language-css .token.string,
    .style .token.string {
      color: rgb(173, 219, 103);
    }

    .token.class-name,
    .token.atrule,
    .token.attr-value {
      color: rgb(255, 203, 139);
    }

    .token.regex,
    .token.important,
    .token.variable {
      color: rgb(214, 222, 235);
    }

    .token.important,
    .token.bold {
      font-weight: bold;
    }

    .token.italic {
      font-style: italic;
    }

    pre[class*='language-'] {
      @apply border-carbon;
    }

    table,
    th,
    td {
      @apply border border-carbon;
    }
  }
}
</style>

<template>
  <nuxt-content :document="document" />
</template>

<script>
export default {
  props: ['document'],
};
</script>

<style lang="postcss" scoped>
* >>> {
  h1 {
    @apply text-4xl mb-8;
  }

  h2 {
    @apply text-2xl;
  }

  h3 {
    @apply text-xl;
  }

  h4 {
    @apply text-lg;
  }

  h1,
  h2,
  h3 {
    @apply font-display;
  }

  h2,
  h3,
  h4 {
    @apply font-semibold my-8 relative;
    transform: translateX(2ch);
    &::before {
      @apply absolute text-accent-800;
      margin-left: -2ch;
      content: '#';
    }

    @screen lg {
      transform: none;
    }
  }

  p + p {
    @apply mt-4;
  }

  ul {
    @apply px-8 my-4 list-disc;

    li + li {
      @apply mt-2;
    }
  }

  .features {
    ul {
      li {
        @apply relative flex items-center;
        &:before {
          @apply w-5 h-5 absolute rounded-full flex text-accent-900 bg-accent-100 items-center justify-center;
          content: '✓';
          left: -2rem;
        }
      }
    }
  }

  blockquote {
    @apply py-4 rounded-r-lg pl-4 bg-black border-l-4 border-accent-800 italic my-8 text-lg;
  }

  pre[class*='language-'] {
    @apply rounded-lg my-4 block shadow-lg;
  }

  *:not(pre) > code:not([class]) {
    @apply text-sm px-1 rounded bg-gray-100 border border-gray-200 text-gray-900;
  }

  details {
    @apply my-10 border-l-4 border-accent-900 pl-3;

    summary {
      @apply mb-8;
    }
  }

  p {
    a[href] {
      @apply text-accent-800;

      &:hover {
        @apply text-accent-900;
      }
    }
  }

  code[class*='language-'],
  pre[class*='language-'] {
    color: #403f53;
    font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
    text-align: left;
    white-space: pre;
    word-spacing: normal;
    word-break: normal;
    word-wrap: normal;
    line-height: 1.5;

    -moz-tab-size: 4;
    -o-tab-size: 4;
    tab-size: 4;

    -webkit-hyphens: none;
    -moz-hyphens: none;
    -ms-hyphens: none;
    hyphens: none;
  }

  pre[class*='language-']::-moz-selection,
  pre[class*='language-'] ::-moz-selection,
  code[class*='language-']::-moz-selection,
  code[class*='language-'] ::-moz-selection {
    text-shadow: none;
    background: #fbfbfb;
  }

  pre[class*='language-']::selection,
  pre[class*='language-'] ::selection,
  code[class*='language-']::selection,
  code[class*='language-'] ::selection {
    text-shadow: none;
    background: #fbfbfb;
  }

  @media print {
    code[class*='language-'],
    pre[class*='language-'] {
      text-shadow: none;
    }
  }

  /* Code blocks */
  pre[class*='language-'] {
    padding: 1em;
    margin: 0.5em 0;
    overflow: auto;
  }

  :not(pre) > code[class*='language-'],
  pre[class*='language-'] {
    color: #000;
    background: #fbfbfb;
  }

  :not(pre) > code[class*='language-'] {
    padding: 0.1em;
    border-radius: 0.3em;
    white-space: normal;
  }

  .token.comment,
  .token.prolog,
  .token.cdata {
    color: rgb(152, 159, 177);
    font-style: italic;
  }

  .token.punctuation {
    color: rgb(153, 76, 195);
  }

  .namespace {
    color: rgb(12, 150, 155);
  }

  .token.deleted {
    color: rgba(239, 83, 80, 0.56);
    font-style: italic;
  }

  .token.symbol,
  .token.operator,
  .token.keyword,
  .token.property {
    color: rgb(12, 150, 155);
  }

  .token.tag {
    color: rgb(153, 76, 195);
  }

  .token.boolean {
    color: rgb(188, 84, 84);
  }

  .token.number {
    color: rgb(170, 9, 130);
  }

  .token.constant,
  .token.builtin,
  .token.string,
  .token.url,
  .token.entity,
  .language-css .token.string,
  .style .token.string,
  .token.char {
    color: rgb(72, 118, 214);
  }

  .token.selector,
  .token.function,
  .token.doctype {
    color: rgb(153, 76, 195);
    font-style: italic;
  }

  .token.attr-name,
  .token.inserted {
    color: rgb(72, 118, 214);
    font-style: italic;
  }

  .token.class-name,
  .token.atrule,
  .token.attr-value {
    color: rgb(17, 17, 17);
  }

  .token.regex,
  .token.important,
  .token.variable {
    color: rgb(201, 103, 101);
  }

  .token.important,
  .token.bold {
    font-weight: bold;
  }

  .token.italic {
    font-style: italic;
  }
}

.is-dark {
  * >>> {
    *:not(pre) > code:not([class]) {
      @apply px-1 bg-black border border-gray-900 text-gray-200;
    }

    code[class*='language-'],
    pre[class*='language-'] {
      background-color: #070707;
      text-shadow: none;
      color: #d6deeb;
      font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
      text-align: left;
      white-space: pre;
      word-spacing: normal;
      word-break: normal;
      word-wrap: normal;
      line-height: 1.5;

      -moz-tab-size: 4;
      -o-tab-size: 4;
      tab-size: 4;

      -webkit-hyphens: none;
      -moz-hyphens: none;
      -ms-hyphens: none;
      hyphens: none;
    }

    pre[class*='language-']::-moz-selection,
    pre[class*='language-'] ::-moz-selection,
    code[class*='language-']::-moz-selection,
    code[class*='language-'] ::-moz-selection {
      text-shadow: none;
      background: rgba(29, 59, 83, 0.99);
    }

    pre[class*='language-']::selection,
    pre[class*='language-'] ::selection,
    code[class*='language-']::selection,
    code[class*='language-'] ::selection {
      text-shadow: none;
      background: rgba(29, 59, 83, 0.99);
    }

    @media print {
      code[class*='language-'],
      pre[class*='language-'] {
        text-shadow: none;
      }
    }

    /* Code blocks */
    pre {
      padding: 1em;
      margin: 0.5em 0;
      overflow: auto;
    }

    :not(pre) > code,
    pre {
      color: white;
      background: #011627;
    }

    :not(pre) > code {
      padding: 0.1em;
      border-radius: 0.3em;
      white-space: normal;
    }

    .token.comment,
    .token.prolog,
    .token.cdata {
      color: rgb(99, 119, 119);
      font-style: italic;
    }

    .token.punctuation {
      color: rgb(199, 146, 234);
    }

    .namespace {
      color: rgb(178, 204, 214);
    }

    .token.deleted {
      color: rgba(239, 83, 80, 0.56);
      font-style: italic;
    }

    .token.symbol,
    .token.property {
      color: rgb(128, 203, 196);
    }

    .token.tag,
    .token.operator,
    .token.keyword {
      color: rgb(127, 219, 202);
      background: transparent;
    }

    .token.boolean {
      color: rgb(255, 88, 116);
    }

    .token.number {
      color: rgb(247, 140, 108);
    }

    .token.constant,
    .token.function,
    .token.builtin,
    .token.char {
      color: rgb(130, 170, 255);
    }

    .token.selector,
    .token.doctype {
      color: rgb(199, 146, 234);
      font-style: italic;
    }

    .token.attr-name,
    .token.inserted {
      color: rgb(173, 219, 103);
      font-style: italic;
    }

    .token.string,
    .token.url,
    .token.entity,
    .language-css .token.string,
    .style .token.string {
      color: rgb(173, 219, 103);
    }

    .token.class-name,
    .token.atrule,
    .token.attr-value {
      color: rgb(255, 203, 139);
    }

    .token.regex,
    .token.important,
    .token.variable {
      color: rgb(214, 222, 235);
    }

    .token.important,
    .token.bold {
      font-weight: bold;
    }

    .token.italic {
      font-style: italic;
    }
  }
}
</style>