aboutsummaryrefslogtreecommitdiffstats
path: root/apps/user_status/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/user_status/src')
-rw-r--r--apps/user_status/src/UserStatus.vue184
-rw-r--r--apps/user_status/src/components/ClearAtSelect.vue85
-rw-r--r--apps/user_status/src/components/CustomMessageInput.vue106
-rw-r--r--apps/user_status/src/components/OnlineStatusSelect.vue110
-rw-r--r--apps/user_status/src/components/PredefinedStatus.vue128
-rw-r--r--apps/user_status/src/components/PredefinedStatusesList.vue84
-rw-r--r--apps/user_status/src/components/PreviousStatus.vue106
-rw-r--r--apps/user_status/src/components/SetStatusModal.vue391
-rw-r--r--apps/user_status/src/filters/clearAtFilter.js52
-rw-r--r--apps/user_status/src/menu.js52
-rw-r--r--apps/user_status/src/mixins/OnlineStatusMixin.js71
-rw-r--r--apps/user_status/src/services/clearAtOptionsService.js52
-rw-r--r--apps/user_status/src/services/clearAtService.js47
-rw-r--r--apps/user_status/src/services/dateService.js12
-rw-r--r--apps/user_status/src/services/heartbeatService.js25
-rw-r--r--apps/user_status/src/services/predefinedStatusService.js23
-rw-r--r--apps/user_status/src/services/statusOptionsService.js36
-rw-r--r--apps/user_status/src/services/statusService.js110
-rw-r--r--apps/user_status/src/store/index.js21
-rw-r--r--apps/user_status/src/store/predefinedStatuses.js53
-rw-r--r--apps/user_status/src/store/userBackupStatus.js102
-rw-r--r--apps/user_status/src/store/userStatus.js295
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 }