diff options
Diffstat (limited to 'apps/user_status/src')
22 files changed, 2145 insertions, 0 deletions
diff --git a/apps/user_status/src/UserStatus.vue b/apps/user_status/src/UserStatus.vue new file mode 100644 index 00000000000..07d81aad95c --- /dev/null +++ b/apps/user_status/src/UserStatus.vue @@ -0,0 +1,184 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Fragment> + <NcListItem v-if="!inline" + class="user-status-menu-item" + compact + :name="visibleMessage" + @click.stop="openModal"> + <template #icon> + <NcUserStatusIcon class="user-status-icon" + :status="statusType" + aria-hidden="true" /> + </template> + </NcListItem> + + <div v-else> + <!-- Dashboard Status --> + <NcButton @click.stop="openModal"> + <template #icon> + <NcUserStatusIcon class="user-status-icon" + :status="statusType" + aria-hidden="true" /> + </template> + {{ visibleMessage }} + </NcButton> + </div> + <!-- Status management modal --> + <SetStatusModal v-if="isModalOpen" + :inline="inline" + @close="closeModal" /> + </Fragment> +</template> + +<script> +import { getCurrentUser } from '@nextcloud/auth' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Fragment } from 'vue-frag' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcListItem from '@nextcloud/vue/components/NcListItem' +import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon' +import debounce from 'debounce' + +import { sendHeartbeat } from './services/heartbeatService.js' +import OnlineStatusMixin from './mixins/OnlineStatusMixin.js' + +export default { + name: 'UserStatus', + + components: { + Fragment, + NcButton, + NcListItem, + NcUserStatusIcon, + SetStatusModal: () => import(/* webpackChunkName: 'user-status-modal' */'./components/SetStatusModal.vue'), + }, + mixins: [OnlineStatusMixin], + + props: { + /** + * Whether the component should be rendered as a Dashboard Status or a User Menu Entries + * true = Dashboard Status + * false = User Menu Entries + */ + inline: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + heartbeatInterval: null, + isAway: false, + isModalOpen: false, + mouseMoveListener: null, + setAwayTimeout: null, + } + }, + + /** + * Loads the current user's status from initial state + * and stores it in Vuex + */ + mounted() { + this.$store.dispatch('loadStatusFromInitialState') + + if (OC.config.session_keepalive) { + // Send the latest status to the server every 5 minutes + this.heartbeatInterval = setInterval(this._backgroundHeartbeat.bind(this), 1000 * 60 * 5) + this.setAwayTimeout = () => { + this.isAway = true + } + // Catch mouse movements, but debounce to once every 30 seconds + this.mouseMoveListener = debounce(() => { + const wasAway = this.isAway + this.isAway = false + // Reset the two minute counter + clearTimeout(this.setAwayTimeout) + // If the user did not move the mouse within two minutes, + // mark them as away + setTimeout(this.setAwayTimeout, 1000 * 60 * 2) + + if (wasAway) { + this._backgroundHeartbeat() + } + }, 1000 * 2, true) + window.addEventListener('mousemove', this.mouseMoveListener, { + capture: true, + passive: true, + }) + + this._backgroundHeartbeat() + } + subscribe('user_status:status.updated', this.handleUserStatusUpdated) + }, + + /** + * Some housekeeping before destroying the component + */ + beforeDestroy() { + window.removeEventListener('mouseMove', this.mouseMoveListener) + clearInterval(this.heartbeatInterval) + unsubscribe('user_status:status.updated', this.handleUserStatusUpdated) + }, + + methods: { + /** + * Opens the modal to set a custom status + */ + openModal() { + this.isModalOpen = true + }, + /** + * Closes the modal + */ + closeModal() { + this.isModalOpen = false + }, + + /** + * Sends the status heartbeat to the server + * + * @return {Promise<void>} + * @private + */ + async _backgroundHeartbeat() { + try { + const status = await sendHeartbeat(this.isAway) + if (status?.userId) { + this.$store.dispatch('setStatusFromHeartbeat', status) + } else { + await this.$store.dispatch('reFetchStatusFromServer') + } + } catch (error) { + console.debug('Failed sending heartbeat, got: ' + error.response?.status) + } + }, + handleUserStatusUpdated(state) { + if (getCurrentUser()?.uid === state.userId) { + this.$store.dispatch('setStatusFromObject', { + status: state.status, + icon: state.icon, + message: state.message, + }) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.user-status-icon { + width: 20px; + height: 20px; + margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size + opacity: 1 !important; + background-size: 20px; + vertical-align: middle !important; +} +</style> diff --git a/apps/user_status/src/components/ClearAtSelect.vue b/apps/user_status/src/components/ClearAtSelect.vue new file mode 100644 index 00000000000..91b816dc04a --- /dev/null +++ b/apps/user_status/src/components/ClearAtSelect.vue @@ -0,0 +1,85 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="clear-at-select"> + <label class="clear-at-select__label" for="clearStatus"> + {{ $t('user_status', 'Clear status after') }} + </label> + <NcSelect input-id="clearStatus" + class="clear-at-select__select" + :options="options" + :value="option" + :clearable="false" + placement="top" + label-outside + @option:selected="select" /> + </div> +</template> + +<script> +import NcSelect from '@nextcloud/vue/components/NcSelect' +import { getAllClearAtOptions } from '../services/clearAtOptionsService.js' +import { clearAtFilter } from '../filters/clearAtFilter.js' + +export default { + name: 'ClearAtSelect', + components: { + NcSelect, + }, + props: { + clearAt: { + type: Object, + default: null, + }, + }, + data() { + return { + options: getAllClearAtOptions(), + } + }, + computed: { + /** + * Returns an object of the currently selected option + * + * @return {object} + */ + option() { + return { + clearAt: this.clearAt, + label: clearAtFilter(this.clearAt), + } + }, + }, + methods: { + /** + * Triggered when the user selects a new option. + * + * @param {object=} option The new selected option + */ + select(option) { + if (!option) { + return + } + + this.$emit('select-clear-at', option.clearAt) + }, + }, +} +</script> + +<style lang="scss" scoped> +.clear-at-select { + display: flex; + gap: calc(2 * var(--default-grid-baseline)); + align-items: center; + margin-block: 0 calc(2 * var(--default-grid-baseline)); + + &__select { + flex-grow: 1; + min-width: 215px; + } +} +</style> diff --git a/apps/user_status/src/components/CustomMessageInput.vue b/apps/user_status/src/components/CustomMessageInput.vue new file mode 100644 index 00000000000..fb129281430 --- /dev/null +++ b/apps/user_status/src/components/CustomMessageInput.vue @@ -0,0 +1,106 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="custom-input" role="group"> + <NcEmojiPicker container=".custom-input" @select="setIcon"> + <NcButton type="tertiary" + :aria-label="t('user_status', 'Emoji for your status message')"> + <template #icon> + {{ visibleIcon }} + </template> + </NcButton> + </NcEmojiPicker> + <div class="custom-input__container"> + <NcTextField ref="input" + maxlength="80" + :disabled="disabled" + :placeholder="t('user_status', 'What is your status?')" + :value="message" + type="text" + :label="t('user_status', 'What is your status?')" + @input="onChange" /> + </div> + </div> +</template> + +<script> +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +export default { + name: 'CustomMessageInput', + + components: { + NcTextField, + NcButton, + NcEmojiPicker, + }, + + props: { + icon: { + type: String, + default: '😀', + }, + message: { + type: String, + required: true, + default: () => '', + }, + disabled: { + type: Boolean, + default: false, + }, + }, + + emits: [ + 'change', + 'select-icon', + ], + + computed: { + /** + * Returns the user-set icon or a smiley in case no icon is set + * + * @return {string} + */ + visibleIcon() { + return this.icon || '😀' + }, + }, + + methods: { + focus() { + this.$refs.input.focus() + }, + + /** + * Notifies the parent component about a changed input + * + * @param {Event} event The Change Event + */ + onChange(event) { + this.$emit('change', event.target.value) + }, + + setIcon(icon) { + this.$emit('select-icon', icon) + }, + }, +} +</script> + +<style lang="scss" scoped> +.custom-input { + display: flex; + align-items: flex-end; + gap: var(--default-grid-baseline); + width: 100%; + + &__container { + width: 100%; + } +} +</style> diff --git a/apps/user_status/src/components/OnlineStatusSelect.vue b/apps/user_status/src/components/OnlineStatusSelect.vue new file mode 100644 index 00000000000..0abcc8d68e6 --- /dev/null +++ b/apps/user_status/src/components/OnlineStatusSelect.vue @@ -0,0 +1,110 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="user-status-online-select"> + <input :id="id" + :checked="checked" + class="hidden-visually user-status-online-select__input" + type="radio" + name="user-status-online" + @change="onChange"> + <label :for="id" class="user-status-online-select__label"> + <NcUserStatusIcon :status="type" + class="user-status-online-select__icon" + aria-hidden="true" /> + {{ label }} + <em class="user-status-online-select__subline">{{ subline }}</em> + </label> + </div> +</template> + +<script> +import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon' + +export default { + name: 'OnlineStatusSelect', + + components: { + NcUserStatusIcon, + }, + + props: { + checked: { + type: Boolean, + default: false, + }, + type: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + subline: { + type: String, + default: null, + }, + }, + + computed: { + id() { + return `user-status-online-status-${this.type}` + }, + }, + + methods: { + onChange() { + this.$emit('select', this.type) + }, + }, +} +</script> + +<style lang="scss" scoped> +.user-status-online-select { + &__label { + box-sizing: inherit; + display: grid; + grid-template-columns: var(--default-clickable-area) 1fr 2fr; + align-items: center; + gap: var(--default-grid-baseline); + min-height: var(--default-clickable-area); + padding: var(--default-grid-baseline); + border-radius: var(--border-radius-large); + background-color: var(--color-background-hover); + + &, & * { + cursor: pointer; + } + + &:hover { + background-color: var(--color-background-dark); + } + } + + &__icon { + flex-shrink: 0; + max-width: 34px; + max-height: 100%; + } + + &__input:checked + &__label { + outline: 2px solid var(--color-main-text); + background-color: var(--color-background-dark); + box-shadow: 0 0 0 4px var(--color-main-background); + } + + &__input:focus-visible + &__label { + outline: 2px solid var(--color-primary-element) !important; + background-color: var(--color-background-dark); + } + + &__subline { + display: block; + color: var(--color-text-lighter); + } +} +</style> diff --git a/apps/user_status/src/components/PredefinedStatus.vue b/apps/user_status/src/components/PredefinedStatus.vue new file mode 100644 index 00000000000..b12892d4add --- /dev/null +++ b/apps/user_status/src/components/PredefinedStatus.vue @@ -0,0 +1,128 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <li class="predefined-status"> + <input :id="id" + class="hidden-visually predefined-status__input" + type="radio" + name="predefined-status" + :checked="selected" + @change="select"> + <label class="predefined-status__label" :for="id"> + <span aria-hidden="true" class="predefined-status__label--icon"> + {{ icon }} + </span> + <span class="predefined-status__label--message"> + {{ message }} + </span> + <span class="predefined-status__label--clear-at"> + {{ clearAt | clearAtFilter }} + </span> + </label> + </li> +</template> + +<script> +import { clearAtFilter } from '../filters/clearAtFilter.js' + +export default { + name: 'PredefinedStatus', + filters: { + clearAtFilter, + }, + props: { + messageId: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + clearAt: { + type: Object, + required: false, + default: null, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + id() { + return `user-status-predefined-status-${this.messageId}` + }, + }, + methods: { + /** + * Emits an event when the user clicks the row + */ + select() { + this.$emit('select') + }, + }, +} +</script> + +<style lang="scss" scoped> +.predefined-status { + &__label { + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + flex-basis: 100%; + border-radius: var(--border-radius); + align-items: center; + min-height: var(--default-clickable-area); + padding-inline: var(--default-grid-baseline); + + &, & * { + cursor: pointer; + } + + &:hover { + background-color: var(--color-background-dark); + } + + &--icon { + flex-basis: var(--default-clickable-area); + text-align: center; + } + + &--message { + font-weight: bold; + padding: 0 6px; + } + + &--clear-at { + color: var(--color-text-maxcontrast); + + &::before { + content: ' – '; + } + } + } + + &__input:checked + &__label, + &__label:active { + outline: 2px solid var(--color-main-text); + box-shadow: 0 0 0 4px var(--color-main-background); + background-color: var(--color-background-dark); + border-radius: var(--border-radius-large); + } + + &__input:focus-visible + &__label { + outline: 2px solid var(--color-primary-element) !important; + background-color: var(--color-background-dark); + border-radius: var(--border-radius-large); + } +} +</style> diff --git a/apps/user_status/src/components/PredefinedStatusesList.vue b/apps/user_status/src/components/PredefinedStatusesList.vue new file mode 100644 index 00000000000..cdf359dce76 --- /dev/null +++ b/apps/user_status/src/components/PredefinedStatusesList.vue @@ -0,0 +1,84 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <ul v-if="statusesHaveLoaded" + class="predefined-statuses-list" + :aria-label="t('user_status', 'Predefined statuses')"> + <PredefinedStatus v-for="status in predefinedStatuses" + :key="status.id" + :message-id="status.id" + :icon="status.icon" + :message="status.message" + :clear-at="status.clearAt" + :selected="lastSelected === status.id" + @select="selectStatus(status)" /> + </ul> + <div v-else + class="predefined-statuses-list"> + <div class="icon icon-loading-small" /> + </div> +</template> + +<script> +import PredefinedStatus from './PredefinedStatus.vue' +import { mapGetters, mapState } from 'vuex' + +export default { + name: 'PredefinedStatusesList', + components: { + PredefinedStatus, + }, + data() { + return { + lastSelected: null, + } + }, + computed: { + ...mapState({ + predefinedStatuses: state => state.predefinedStatuses.predefinedStatuses, + messageId: state => state.userStatus.messageId, + }), + ...mapGetters(['statusesHaveLoaded']), + }, + + watch: { + messageId: { + immediate: true, + handler() { + this.lastSelected = this.messageId + }, + }, + }, + + /** + * Loads all predefined statuses from the server + * when this component is mounted + */ + created() { + this.$store.dispatch('loadAllPredefinedStatuses') + }, + methods: { + /** + * Emits an event when the user selects a status + * + * @param {object} status The selected status + */ + selectStatus(status) { + this.lastSelected = status.id + this.$emit('select-status', status) + }, + }, +} +</script> + +<style lang="scss" scoped> +.predefined-statuses-list { + display: flex; + flex-direction: column; + gap: var(--default-grid-baseline); + margin-block: 0 calc(2 * var(--default-grid-baseline)); +} +</style> diff --git a/apps/user_status/src/components/PreviousStatus.vue b/apps/user_status/src/components/PreviousStatus.vue new file mode 100644 index 00000000000..58d6ebd294b --- /dev/null +++ b/apps/user_status/src/components/PreviousStatus.vue @@ -0,0 +1,106 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="predefined-status backup-status" + tabindex="0" + @keyup.enter="select" + @keyup.space="select" + @click="select"> + <span class="predefined-status__icon"> + {{ icon }} + </span> + <span class="predefined-status__message"> + {{ message }} + </span> + <span class="predefined-status__clear-at"> + {{ $t('user_status', 'Previously set') }} + </span> + + <div class="backup-status__reset-button"> + <NcButton @click="select"> + {{ $t('user_status', 'Reset status') }} + </NcButton> + </div> + </div> +</template> + +<script> +import NcButton from '@nextcloud/vue/components/NcButton' + +export default { + name: 'PreviousStatus', + + components: { + NcButton, + }, + + props: { + icon: { + type: [String, null], + required: true, + }, + message: { + type: String, + required: true, + }, + }, + methods: { + /** + * Emits an event when the user clicks the row + */ + select() { + this.$emit('select') + }, + }, +} +</script> + +<style lang="scss" scoped> +.predefined-status { + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + flex-basis: 100%; + border-radius: var(--border-radius); + align-items: center; + min-height: var(--default-clickable-area); + padding-inline: var(--default-grid-baseline); + + &:hover, + &:focus { + background-color: var(--color-background-hover); + } + + &:active{ + background-color: var(--color-background-dark); + } + + &__icon { + flex-basis: var(--default-clickable-area); + text-align: center; + } + + &__message { + font-weight: bold; + padding: 0 6px; + } + + &__clear-at { + color: var(--color-text-maxcontrast); + + &::before { + content: ' – '; + } + } +} + +.backup-status { + &__reset-button { + justify-content: flex-end; + display: flex; + flex-grow: 1; + } +} +</style> diff --git a/apps/user_status/src/components/SetStatusModal.vue b/apps/user_status/src/components/SetStatusModal.vue new file mode 100644 index 00000000000..8624ed19e94 --- /dev/null +++ b/apps/user_status/src/components/SetStatusModal.vue @@ -0,0 +1,391 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcModal size="normal" + label-id="user_status-set-dialog" + dark + :set-return-focus="setReturnFocus" + @close="closeModal"> + <div class="set-status-modal"> + <!-- Status selector --> + <h2 id="user_status-set-dialog" class="set-status-modal__header"> + {{ $t('user_status', 'Online status') }} + </h2> + <div class="set-status-modal__online-status" + role="radiogroup" + :aria-label="$t('user_status', 'Online status')"> + <OnlineStatusSelect v-for="status in statuses" + :key="status.type" + v-bind="status" + :checked="status.type === statusType" + @select="changeStatus" /> + </div> + + <!-- Status message form --> + <form @submit.prevent="saveStatus" @reset="clearStatus"> + <h3 class="set-status-modal__header"> + {{ $t('user_status', 'Status message') }} + </h3> + <div class="set-status-modal__custom-input"> + <CustomMessageInput ref="customMessageInput" + :icon="icon" + :message="editedMessage" + @change="setMessage" + @select-icon="setIcon" /> + <NcButton v-if="messageId === 'vacationing'" + :href="absencePageUrl" + target="_blank" + type="secondary" + :aria-label="$t('user_status', 'Set absence period')"> + {{ $t('user_status', 'Set absence period and replacement') + ' ↗' }} + </NcButton> + </div> + <div v-if="hasBackupStatus" + class="set-status-modal__automation-hint"> + {{ $t('user_status', 'Your status was set automatically') }} + </div> + <PreviousStatus v-if="hasBackupStatus" + :icon="backupIcon" + :message="backupMessage" + @select="revertBackupFromServer" /> + <PredefinedStatusesList @select-status="selectPredefinedMessage" /> + <ClearAtSelect :clear-at="clearAt" + @select-clear-at="setClearAt" /> + <div class="status-buttons"> + <NcButton :wide="true" + type="tertiary" + native-type="reset" + :aria-label="$t('user_status', 'Clear status message')" + :disabled="isSavingStatus"> + {{ $t('user_status', 'Clear status message') }} + </NcButton> + <NcButton :wide="true" + type="primary" + native-type="submit" + :aria-label="$t('user_status', 'Set status message')" + :disabled="isSavingStatus"> + {{ $t('user_status', 'Set status message') }} + </NcButton> + </div> + </form> + </div> + </NcModal> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { generateUrl } from '@nextcloud/router' +import NcModal from '@nextcloud/vue/components/NcModal' +import NcButton from '@nextcloud/vue/components/NcButton' +import { getAllStatusOptions } from '../services/statusOptionsService.js' +import OnlineStatusMixin from '../mixins/OnlineStatusMixin.js' +import PredefinedStatusesList from './PredefinedStatusesList.vue' +import PreviousStatus from './PreviousStatus.vue' +import CustomMessageInput from './CustomMessageInput.vue' +import ClearAtSelect from './ClearAtSelect.vue' +import OnlineStatusSelect from './OnlineStatusSelect.vue' + +export default { + name: 'SetStatusModal', + + components: { + ClearAtSelect, + CustomMessageInput, + NcModal, + OnlineStatusSelect, + PredefinedStatusesList, + PreviousStatus, + NcButton, + }, + mixins: [OnlineStatusMixin], + + props: { + /** + * Whether the component should be rendered as a Dashboard Status or a User Menu Entries + * true = Dashboard Status + * false = User Menu Entries + */ + inline: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + clearAt: null, + editedMessage: '', + predefinedMessageId: null, + isSavingStatus: false, + statuses: getAllStatusOptions(), + } + }, + + computed: { + messageId() { + return this.$store.state.userStatus.messageId + }, + icon() { + return this.$store.state.userStatus.icon + }, + message() { + return this.$store.state.userStatus.message || '' + }, + hasBackupStatus() { + return this.messageId && (this.backupIcon || this.backupMessage) + }, + backupIcon() { + return this.$store.state.userBackupStatus.icon || '' + }, + backupMessage() { + return this.$store.state.userBackupStatus.message || '' + }, + + absencePageUrl() { + return generateUrl('settings/user/availability#absence') + }, + + resetButtonText() { + if (this.backupIcon && this.backupMessage) { + return this.$t('user_status', 'Reset status to "{icon} {message}"', { + icon: this.backupIcon, + message: this.backupMessage, + }) + } else if (this.backupMessage) { + return this.$t('user_status', 'Reset status to "{message}"', { + message: this.backupMessage, + }) + } else if (this.backupIcon) { + return this.$t('user_status', 'Reset status to "{icon}"', { + icon: this.backupIcon, + }) + } + + return this.$t('user_status', 'Reset status') + }, + + setReturnFocus() { + if (this.inline) { + return undefined + } + return document.querySelector('[aria-controls="header-menu-user-menu"]') ?? undefined + }, + }, + + watch: { + message: { + immediate: true, + handler(newValue) { + this.editedMessage = newValue + }, + }, + }, + + /** + * Loads the current status when a user opens dialog + */ + mounted() { + this.$store.dispatch('fetchBackupFromServer') + + this.predefinedMessageId = this.$store.state.userStatus.messageId + if (this.$store.state.userStatus.clearAt !== null) { + this.clearAt = { + type: '_time', + time: this.$store.state.userStatus.clearAt, + } + } + }, + methods: { + /** + * Closes the Set Status modal + */ + closeModal() { + this.$emit('close') + }, + /** + * Sets a new icon + * + * @param {string} icon The new icon + */ + setIcon(icon) { + this.predefinedMessageId = null + this.$store.dispatch('setCustomMessage', { + message: this.message, + icon, + clearAt: this.clearAt, + }) + this.$nextTick(() => { + this.$refs.customMessageInput.focus() + }) + }, + /** + * Sets a new message + * + * @param {string} message The new message + */ + setMessage(message) { + this.predefinedMessageId = null + this.editedMessage = message + }, + /** + * Sets a new clearAt value + * + * @param {object} clearAt The new clearAt object + */ + setClearAt(clearAt) { + this.clearAt = clearAt + }, + /** + * Sets new icon/message/clearAt based on a predefined message + * + * @param {object} status The predefined status object + */ + selectPredefinedMessage(status) { + this.predefinedMessageId = status.id + this.clearAt = status.clearAt + this.$store.dispatch('setPredefinedMessage', { + messageId: status.id, + clearAt: status.clearAt, + }) + }, + /** + * Saves the status and closes the + * + * @return {Promise<void>} + */ + async saveStatus() { + if (this.isSavingStatus) { + return + } + + try { + this.isSavingStatus = true + + if (this.predefinedMessageId === null) { + await this.$store.dispatch('setCustomMessage', { + message: this.editedMessage, + icon: this.icon, + clearAt: this.clearAt, + }) + } else { + this.$store.dispatch('setPredefinedMessage', { + messageId: this.predefinedMessageId, + clearAt: this.clearAt, + }) + } + } catch (err) { + showError(this.$t('user_status', 'There was an error saving the status')) + console.debug(err) + this.isSavingStatus = false + return + } + + this.isSavingStatus = false + this.closeModal() + }, + /** + * + * @return {Promise<void>} + */ + async clearStatus() { + try { + this.isSavingStatus = true + + await this.$store.dispatch('clearMessage') + } catch (err) { + showError(this.$t('user_status', 'There was an error clearing the status')) + console.debug(err) + this.isSavingStatus = false + return + } + + this.isSavingStatus = false + this.predefinedMessageId = null + this.closeModal() + }, + /** + * + * @return {Promise<void>} + */ + async revertBackupFromServer() { + try { + this.isSavingStatus = true + + await this.$store.dispatch('revertBackupFromServer', { + messageId: this.messageId, + }) + } catch (err) { + showError(this.$t('user_status', 'There was an error reverting the status')) + console.debug(err) + this.isSavingStatus = false + return + } + + this.isSavingStatus = false + this.predefinedMessageId = this.$store.state.userStatus?.messageId + }, + }, +} +</script> + +<style lang="scss" scoped> + +.set-status-modal { + padding: 8px 20px 20px 20px; + + &, & * { + box-sizing: border-box; + } + + &__header { + font-size: 21px; + text-align: center; + height: fit-content; + min-height: var(--default-clickable-area); + line-height: var(--default-clickable-area); + overflow-wrap: break-word; + margin-block: 0 calc(2 * var(--default-grid-baseline)); + } + + &__online-status { + display: flex; + flex-direction: column; + gap: calc(2 * var(--default-grid-baseline)); + margin-block: 0 calc(2 * var(--default-grid-baseline)); + } + + &__custom-input { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--default-grid-baseline); + width: 100%; + padding-inline-start: var(--default-grid-baseline); + margin-block: 0 calc(2 * var(--default-grid-baseline)); + } + + &__automation-hint { + display: flex; + width: 100%; + margin-block: 0 calc(2 * var(--default-grid-baseline)); + color: var(--color-text-maxcontrast); + } + + .status-buttons { + display: flex; + padding: 3px; + padding-inline-start:0; + gap: 3px; + } +} + +@media only screen and (max-width: 500px) { + .set-status-modal__online-status { + grid-template-columns: none !important; + } +} + +</style> diff --git a/apps/user_status/src/filters/clearAtFilter.js b/apps/user_status/src/filters/clearAtFilter.js new file mode 100644 index 00000000000..5f62385a978 --- /dev/null +++ b/apps/user_status/src/filters/clearAtFilter.js @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { translate as t } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' +import { dateFactory } from '../services/dateService.js' + +/** + * Formats a clearAt object to be human readable + * + * @param {object} clearAt The clearAt object + * @return {string|null} + */ +const clearAtFilter = (clearAt) => { + if (clearAt === null) { + return t('user_status', 'Don\'t clear') + } + + if (clearAt.type === 'end-of') { + switch (clearAt.time) { + case 'day': + return t('user_status', 'Today') + case 'week': + return t('user_status', 'This week') + + default: + return null + } + } + + if (clearAt.type === 'period') { + return moment.duration(clearAt.time * 1000).humanize() + } + + // This is not an officially supported type + // but only used internally to show the remaining time + // in the Set Status Modal + if (clearAt.type === '_time') { + const momentNow = moment(dateFactory()) + const momentClearAt = moment(clearAt.time, 'X') + + return moment.duration(momentNow.diff(momentClearAt)).humanize() + } + + return null +} + +export { + clearAtFilter, +} diff --git a/apps/user_status/src/menu.js b/apps/user_status/src/menu.js new file mode 100644 index 00000000000..34e5e6eabb1 --- /dev/null +++ b/apps/user_status/src/menu.js @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' +import { subscribe } from '@nextcloud/event-bus' +import Vue from 'vue' + +import UserStatus from './UserStatus.vue' +import store from './store/index.js' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() + +Vue.prototype.t = t +Vue.prototype.$t = t + +const mountPoint = document.getElementById('user_status-menu-entry') + +const mountMenuEntry = () => { + const mountPoint = document.getElementById('user_status-menu-entry') + // eslint-disable-next-line no-new + new Vue({ + el: mountPoint, + render: h => h(UserStatus), + store, + }) +} + +if (mountPoint) { + mountMenuEntry() +} else { + subscribe('core:user-menu:mounted', mountMenuEntry) +} + +// Register dashboard status +document.addEventListener('DOMContentLoaded', function() { + if (!OCA.Dashboard) { + return + } + + OCA.Dashboard.registerStatus('status', (el) => { + const Dashboard = Vue.extend(UserStatus) + return new Dashboard({ + propsData: { + inline: true, + }, + store, + }).$mount(el) + }) +}) diff --git a/apps/user_status/src/mixins/OnlineStatusMixin.js b/apps/user_status/src/mixins/OnlineStatusMixin.js new file mode 100644 index 00000000000..5670eb4dc06 --- /dev/null +++ b/apps/user_status/src/mixins/OnlineStatusMixin.js @@ -0,0 +1,71 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mapState } from 'vuex' +import { showError } from '@nextcloud/dialogs' + +export default { + computed: { + ...mapState({ + statusType: state => state.userStatus.status, + statusIsUserDefined: state => state.userStatus.statusIsUserDefined, + customIcon: state => state.userStatus.icon, + customMessage: state => state.userStatus.message, + }), + + /** + * The message displayed in the top right corner + * + * @return {string} + */ + visibleMessage() { + if (this.customIcon && this.customMessage) { + return `${this.customIcon} ${this.customMessage}` + } + + if (this.customMessage) { + return this.customMessage + } + + if (this.statusIsUserDefined) { + switch (this.statusType) { + case 'online': + return this.$t('user_status', 'Online') + + case 'away': + case 'busy': + return this.$t('user_status', 'Away') + + case 'dnd': + return this.$t('user_status', 'Do not disturb') + + case 'invisible': + return this.$t('user_status', 'Invisible') + + case 'offline': + return this.$t('user_status', 'Offline') + } + } + + return this.$t('user_status', 'Set status') + }, + }, + + methods: { + /** + * Changes the user-status + * + * @param {string} statusType (online / away / dnd / invisible) + */ + async changeStatus(statusType) { + try { + await this.$store.dispatch('setStatus', { statusType }) + } catch (err) { + showError(this.$t('user_status', 'There was an error saving the new status')) + console.debug(err) + } + }, + }, +} diff --git a/apps/user_status/src/services/clearAtOptionsService.js b/apps/user_status/src/services/clearAtOptionsService.js new file mode 100644 index 00000000000..af0059bfb7f --- /dev/null +++ b/apps/user_status/src/services/clearAtOptionsService.js @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { translate as t } from '@nextcloud/l10n' + +/** + * Returns an array + * + * @return {object[]} + */ +const getAllClearAtOptions = () => { + return [{ + label: t('user_status', 'Don\'t clear'), + clearAt: null, + }, { + label: t('user_status', '30 minutes'), + clearAt: { + type: 'period', + time: 1800, + }, + }, { + label: t('user_status', '1 hour'), + clearAt: { + type: 'period', + time: 3600, + }, + }, { + label: t('user_status', '4 hours'), + clearAt: { + type: 'period', + time: 14400, + }, + }, { + label: t('user_status', 'Today'), + clearAt: { + type: 'end-of', + time: 'day', + }, + }, { + label: t('user_status', 'This week'), + clearAt: { + type: 'end-of', + time: 'week', + }, + }] +} + +export { + getAllClearAtOptions, +} diff --git a/apps/user_status/src/services/clearAtService.js b/apps/user_status/src/services/clearAtService.js new file mode 100644 index 00000000000..f23d267ad02 --- /dev/null +++ b/apps/user_status/src/services/clearAtService.js @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + dateFactory, +} from './dateService.js' +import moment from '@nextcloud/moment' + +/** + * Calculates the actual clearAt timestamp + * + * @param {object | null} clearAt The clear-at config + * @return {number | null} + */ +const getTimestampForClearAt = (clearAt) => { + if (clearAt === null) { + return null + } + + const date = dateFactory() + + if (clearAt.type === 'period') { + date.setSeconds(date.getSeconds() + clearAt.time) + return Math.floor(date.getTime() / 1000) + } + if (clearAt.type === 'end-of') { + switch (clearAt.time) { + case 'day': + case 'week': + return Number(moment(date).endOf(clearAt.time).format('X')) + } + } + // This is not an officially supported type + // but only used internally to show the remaining time + // in the Set Status Modal + if (clearAt.type === '_time') { + return clearAt.time + } + + return null +} + +export { + getTimestampForClearAt, +} diff --git a/apps/user_status/src/services/dateService.js b/apps/user_status/src/services/dateService.js new file mode 100644 index 00000000000..26a61d4a3e2 --- /dev/null +++ b/apps/user_status/src/services/dateService.js @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const dateFactory = () => { + return new Date() +} + +export { + dateFactory, +} diff --git a/apps/user_status/src/services/heartbeatService.js b/apps/user_status/src/services/heartbeatService.js new file mode 100644 index 00000000000..fda1a1ffc9f --- /dev/null +++ b/apps/user_status/src/services/heartbeatService.js @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import HttpClient from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Sends a heartbeat + * + * @param {boolean} isAway Whether or not the user is active + * @return {Promise<void>} + */ +const sendHeartbeat = async (isAway) => { + const url = generateOcsUrl('apps/user_status/api/v1/heartbeat?format=json') + const response = await HttpClient.put(url, { + status: isAway ? 'away' : 'online', + }) + return response.data.ocs.data +} + +export { + sendHeartbeat, +} diff --git a/apps/user_status/src/services/predefinedStatusService.js b/apps/user_status/src/services/predefinedStatusService.js new file mode 100644 index 00000000000..b423c6e0cc4 --- /dev/null +++ b/apps/user_status/src/services/predefinedStatusService.js @@ -0,0 +1,23 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import HttpClient from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetches all predefined statuses from the server + * + * @return {Promise<void>} + */ +const fetchAllPredefinedStatuses = async () => { + const url = generateOcsUrl('apps/user_status/api/v1/predefined_statuses?format=json') + const response = await HttpClient.get(url) + + return response.data.ocs.data +} + +export { + fetchAllPredefinedStatuses, +} diff --git a/apps/user_status/src/services/statusOptionsService.js b/apps/user_status/src/services/statusOptionsService.js new file mode 100644 index 00000000000..6c23645e5be --- /dev/null +++ b/apps/user_status/src/services/statusOptionsService.js @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { translate as t } from '@nextcloud/l10n' + +/** + * Returns a list of all user-definable statuses + * + * @return {object[]} + */ +const getAllStatusOptions = () => { + return [{ + type: 'online', + label: t('user_status', 'Online'), + }, { + type: 'away', + label: t('user_status', 'Away'), + }, { + type: 'busy', + label: t('user_status', 'Busy'), + }, { + type: 'dnd', + label: t('user_status', 'Do not disturb'), + subline: t('user_status', 'Mute all notifications'), + }, { + type: 'invisible', + label: t('user_status', 'Invisible'), + subline: t('user_status', 'Appear offline'), + }] +} + +export { + getAllStatusOptions, +} diff --git a/apps/user_status/src/services/statusService.js b/apps/user_status/src/services/statusService.js new file mode 100644 index 00000000000..6504411c996 --- /dev/null +++ b/apps/user_status/src/services/statusService.js @@ -0,0 +1,110 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import HttpClient from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Fetches the current user-status + * + * @return {Promise<object>} + */ +const fetchCurrentStatus = async () => { + const url = generateOcsUrl('apps/user_status/api/v1/user_status') + const response = await HttpClient.get(url) + + return response.data.ocs.data +} + +/** + * Fetches the current user-status + * + * @param {string} userId Id of the user to fetch the status + * @return {Promise<object>} + */ +const fetchBackupStatus = async (userId) => { + const url = generateOcsUrl('apps/user_status/api/v1/statuses/{userId}', { userId: '_' + userId }) + const response = await HttpClient.get(url) + + return response.data.ocs.data +} + +/** + * Sets the status + * + * @param {string} statusType The status (online / away / dnd / invisible) + * @return {Promise<void>} + */ +const setStatus = async (statusType) => { + const url = generateOcsUrl('apps/user_status/api/v1/user_status/status') + await HttpClient.put(url, { + statusType, + }) +} + +/** + * Sets a message based on our predefined statuses + * + * @param {string} messageId The id of the message, taken from predefined status service + * @param {number | null} clearAt When to automatically clean the status + * @return {Promise<void>} + */ +const setPredefinedMessage = async (messageId, clearAt = null) => { + const url = generateOcsUrl('apps/user_status/api/v1/user_status/message/predefined?format=json') + await HttpClient.put(url, { + messageId, + clearAt, + }) +} + +/** + * Sets a custom message + * + * @param {string} message The user-defined message + * @param {string | null} statusIcon The user-defined icon + * @param {number | null} clearAt When to automatically clean the status + * @return {Promise<void>} + */ +const setCustomMessage = async (message, statusIcon = null, clearAt = null) => { + const url = generateOcsUrl('apps/user_status/api/v1/user_status/message/custom?format=json') + await HttpClient.put(url, { + message, + statusIcon, + clearAt, + }) +} + +/** + * Clears the current status of the user + * + * @return {Promise<void>} + */ +const clearMessage = async () => { + const url = generateOcsUrl('apps/user_status/api/v1/user_status/message?format=json') + await HttpClient.delete(url) +} + +/** + * Revert the automated status + * + * @param {string} messageId ID of the message to revert + * @return {Promise<object>} + */ +const revertToBackupStatus = async (messageId) => { + const url = generateOcsUrl('apps/user_status/api/v1/user_status/revert/{messageId}', { messageId }) + const response = await HttpClient.delete(url) + + return response.data.ocs.data +} + +export { + fetchCurrentStatus, + fetchBackupStatus, + setStatus, + setCustomMessage, + setPredefinedMessage, + clearMessage, + revertToBackupStatus, +} diff --git a/apps/user_status/src/store/index.js b/apps/user_status/src/store/index.js new file mode 100644 index 00000000000..d9cfe674165 --- /dev/null +++ b/apps/user_status/src/store/index.js @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import Vuex, { Store } from 'vuex' +import predefinedStatuses from './predefinedStatuses.js' +import userStatus from './userStatus.js' +import userBackupStatus from './userBackupStatus.js' + +Vue.use(Vuex) + +export default new Store({ + modules: { + predefinedStatuses, + userStatus, + userBackupStatus, + }, + strict: true, +}) diff --git a/apps/user_status/src/store/predefinedStatuses.js b/apps/user_status/src/store/predefinedStatuses.js new file mode 100644 index 00000000000..6d592ca627e --- /dev/null +++ b/apps/user_status/src/store/predefinedStatuses.js @@ -0,0 +1,53 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { fetchAllPredefinedStatuses } from '../services/predefinedStatusService.js' + +const state = { + predefinedStatuses: [], +} + +const mutations = { + + /** + * Adds a predefined status to the state + * + * @param {object} state The Vuex state + * @param {object} status The status to add + */ + addPredefinedStatus(state, status) { + state.predefinedStatuses = [...state.predefinedStatuses, status] + }, +} + +const getters = { + statusesHaveLoaded(state) { + return state.predefinedStatuses.length > 0 + }, +} + +const actions = { + + /** + * Loads all predefined statuses from the server + * + * @param {object} vuex The Vuex components + * @param {Function} vuex.commit The Vuex commit function + * @param {object} vuex.state - + */ + async loadAllPredefinedStatuses({ state, commit }) { + if (state.predefinedStatuses.length > 0) { + return + } + + const statuses = await fetchAllPredefinedStatuses() + for (const status of statuses) { + commit('addPredefinedStatus', status) + } + }, + +} + +export default { state, mutations, getters, actions } diff --git a/apps/user_status/src/store/userBackupStatus.js b/apps/user_status/src/store/userBackupStatus.js new file mode 100644 index 00000000000..78e5318de9d --- /dev/null +++ b/apps/user_status/src/store/userBackupStatus.js @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + fetchBackupStatus, + revertToBackupStatus, +} from '../services/statusService.js' +import { getCurrentUser } from '@nextcloud/auth' +import { emit } from '@nextcloud/event-bus' + +const state = { + // Status (online / away / dnd / invisible / offline) + status: null, + // Whether the status is user-defined + statusIsUserDefined: null, + // A custom message set by the user + message: null, + // The icon selected by the user + icon: null, + // When to automatically clean the status + clearAt: null, + // Whether the message is predefined + // (and can automatically be translated by Nextcloud) + messageIsPredefined: null, + // The id of the message in case it's predefined + messageId: null, +} + +const mutations = { + /** + * Loads the status from initial state + * + * @param {object} state The Vuex state + * @param {object} data The destructuring object + * @param {string} data.status The status type + * @param {boolean} data.statusIsUserDefined Whether or not this status is user-defined + * @param {string} data.message The message + * @param {string} data.icon The icon + * @param {number} data.clearAt When to automatically clear the status + * @param {boolean} data.messageIsPredefined Whether or not the message is predefined + * @param {string} data.messageId The id of the predefined message + */ + loadBackupStatusFromServer(state, { status, statusIsUserDefined, message, icon, clearAt, messageIsPredefined, messageId }) { + state.status = status + state.message = message + state.icon = icon + + // Don't overwrite certain values if the refreshing comes in via short updates + // E.g. from talk participant list which only has the status, message and icon + if (typeof statusIsUserDefined !== 'undefined') { + state.statusIsUserDefined = statusIsUserDefined + } + if (typeof clearAt !== 'undefined') { + state.clearAt = clearAt + } + if (typeof messageIsPredefined !== 'undefined') { + state.messageIsPredefined = messageIsPredefined + } + if (typeof messageId !== 'undefined') { + state.messageId = messageId + } + }, +} + +const getters = {} + +const actions = { + /** + * Re-fetches the status from the server + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @return {Promise<void>} + */ + async fetchBackupFromServer({ commit }) { + try { + const status = await fetchBackupStatus(getCurrentUser()?.uid) + commit('loadBackupStatusFromServer', status) + } catch (e) { + // Ignore missing user backup status + } + }, + + async revertBackupFromServer({ commit }, { messageId }) { + const status = await revertToBackupStatus(messageId) + if (status) { + commit('loadBackupStatusFromServer', {}) + commit('loadStatusFromServer', status) + emit('user_status:status.updated', { + status: status.status, + message: status.message, + icon: status.icon, + clearAt: status.clearAt, + userId: getCurrentUser()?.uid, + }) + } + }, +} + +export default { state, mutations, getters, actions } diff --git a/apps/user_status/src/store/userStatus.js b/apps/user_status/src/store/userStatus.js new file mode 100644 index 00000000000..9bc86ab5062 --- /dev/null +++ b/apps/user_status/src/store/userStatus.js @@ -0,0 +1,295 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + fetchCurrentStatus, + setStatus, + setPredefinedMessage, + setCustomMessage, + clearMessage, +} from '../services/statusService.js' +import { loadState } from '@nextcloud/initial-state' +import { getCurrentUser } from '@nextcloud/auth' +import { getTimestampForClearAt } from '../services/clearAtService.js' +import { emit } from '@nextcloud/event-bus' + +const state = { + // Status (online / away / dnd / invisible / offline) + status: null, + // Whether the status is user-defined + statusIsUserDefined: null, + // A custom message set by the user + message: null, + // The icon selected by the user + icon: null, + // When to automatically clean the status + clearAt: null, + // Whether the message is predefined + // (and can automatically be translated by Nextcloud) + messageIsPredefined: null, + // The id of the message in case it's predefined + messageId: null, +} + +const mutations = { + + /** + * Sets a new status + * + * @param {object} state The Vuex state + * @param {object} data The destructuring object + * @param {string} data.statusType The new status type + */ + setStatus(state, { statusType }) { + state.status = statusType + state.statusIsUserDefined = true + }, + + /** + * Sets a message using a predefined message + * + * @param {object} state The Vuex state + * @param {object} data The destructuring object + * @param {string} data.messageId The messageId + * @param {number | null} data.clearAt When to automatically clear the status + * @param {string} data.message The message + * @param {string} data.icon The icon + */ + setPredefinedMessage(state, { messageId, clearAt, message, icon }) { + state.messageId = messageId + state.messageIsPredefined = true + + state.message = message + state.icon = icon + state.clearAt = clearAt + }, + + /** + * Sets a custom message + * + * @param {object} state The Vuex state + * @param {object} data The destructuring object + * @param {string} data.message The message + * @param {string} data.icon The icon + * @param {number} data.clearAt When to automatically clear the status + */ + setCustomMessage(state, { message, icon, clearAt }) { + state.messageId = null + state.messageIsPredefined = false + + state.message = message + state.icon = icon + state.clearAt = clearAt + }, + + /** + * Clears the status + * + * @param {object} state The Vuex state + */ + clearMessage(state) { + state.messageId = null + state.messageIsPredefined = false + + state.message = null + state.icon = null + state.clearAt = null + }, + + /** + * Loads the status from initial state + * + * @param {object} state The Vuex state + * @param {object} data The destructuring object + * @param {string} data.status The status type + * @param {boolean} data.statusIsUserDefined Whether or not this status is user-defined + * @param {string} data.message The message + * @param {string} data.icon The icon + * @param {number} data.clearAt When to automatically clear the status + * @param {boolean} data.messageIsPredefined Whether or not the message is predefined + * @param {string} data.messageId The id of the predefined message + */ + loadStatusFromServer(state, { status, statusIsUserDefined, message, icon, clearAt, messageIsPredefined, messageId }) { + state.status = status + state.message = message + state.icon = icon + + // Don't overwrite certain values if the refreshing comes in via short updates + // E.g. from talk participant list which only has the status, message and icon + if (typeof statusIsUserDefined !== 'undefined') { + state.statusIsUserDefined = statusIsUserDefined + } + if (typeof clearAt !== 'undefined') { + state.clearAt = clearAt + } + if (typeof messageIsPredefined !== 'undefined') { + state.messageIsPredefined = messageIsPredefined + } + if (typeof messageId !== 'undefined') { + state.messageId = messageId + } + }, +} + +const getters = {} + +const actions = { + + /** + * Sets a new status + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @param {object} vuex.state The Vuex state object + * @param {object} data The data destructuring object + * @param {string} data.statusType The new status type + * @return {Promise<void>} + */ + async setStatus({ commit, state }, { statusType }) { + await setStatus(statusType) + commit('setStatus', { statusType }) + emit('user_status:status.updated', { + status: state.status, + message: state.message, + icon: state.icon, + clearAt: state.clearAt, + userId: getCurrentUser()?.uid, + }) + }, + + /** + * Update status from 'user_status:status.updated' update. + * This doesn't trigger another 'user_status:status.updated' + * event. + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @param {object} vuex.state The Vuex state object + * @param {string} status The new status + * @return {Promise<void>} + */ + async setStatusFromObject({ commit, state }, status) { + commit('loadStatusFromServer', status) + }, + + /** + * Sets a message using a predefined message + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @param {object} vuex.state The Vuex state object + * @param {object} vuex.rootState The Vuex root state + * @param {object} data The data destructuring object + * @param {string} data.messageId The messageId + * @param {object | null} data.clearAt When to automatically clear the status + * @return {Promise<void>} + */ + async setPredefinedMessage({ commit, rootState, state }, { messageId, clearAt }) { + const resolvedClearAt = getTimestampForClearAt(clearAt) + + await setPredefinedMessage(messageId, resolvedClearAt) + const status = rootState.predefinedStatuses.predefinedStatuses.find((status) => status.id === messageId) + const { message, icon } = status + + commit('setPredefinedMessage', { messageId, clearAt: resolvedClearAt, message, icon }) + emit('user_status:status.updated', { + status: state.status, + message: state.message, + icon: state.icon, + clearAt: state.clearAt, + userId: getCurrentUser()?.uid, + }) + }, + + /** + * Sets a custom message + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @param {object} vuex.state The Vuex state object + * @param {object} data The data destructuring object + * @param {string} data.message The message + * @param {string} data.icon The icon + * @param {object | null} data.clearAt When to automatically clear the status + * @return {Promise<void>} + */ + async setCustomMessage({ commit, state }, { message, icon, clearAt }) { + const resolvedClearAt = getTimestampForClearAt(clearAt) + + await setCustomMessage(message, icon, resolvedClearAt) + commit('setCustomMessage', { message, icon, clearAt: resolvedClearAt }) + emit('user_status:status.updated', { + status: state.status, + message: state.message, + icon: state.icon, + clearAt: state.clearAt, + userId: getCurrentUser()?.uid, + }) + }, + + /** + * Clears the status + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @param {object} vuex.state The Vuex state object + * @return {Promise<void>} + */ + async clearMessage({ commit, state }) { + await clearMessage() + commit('clearMessage') + emit('user_status:status.updated', { + status: state.status, + message: state.message, + icon: state.icon, + clearAt: state.clearAt, + userId: getCurrentUser()?.uid, + }) + }, + + /** + * Re-fetches the status from the server + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @return {Promise<void>} + */ + async reFetchStatusFromServer({ commit }) { + const status = await fetchCurrentStatus() + commit('loadStatusFromServer', status) + }, + + /** + * Stores the status we got in the reply of the heartbeat + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + * @param {object} status The data destructuring object + * @param {string} status.status The status type + * @param {boolean} status.statusIsUserDefined Whether or not this status is user-defined + * @param {string} status.message The message + * @param {string} status.icon The icon + * @param {number} status.clearAt When to automatically clear the status + * @param {boolean} status.messageIsPredefined Whether or not the message is predefined + * @param {string} status.messageId The id of the predefined message + * @return {Promise<void>} + */ + async setStatusFromHeartbeat({ commit }, status) { + commit('loadStatusFromServer', status) + }, + + /** + * Loads the server from the initial state + * + * @param {object} vuex The Vuex destructuring object + * @param {Function} vuex.commit The Vuex commit function + */ + loadStatusFromInitialState({ commit }) { + const status = loadState('user_status', 'status') + commit('loadStatusFromServer', status) + }, +} + +export default { state, mutations, getters, actions } |