diff options
author | Joas Schilling <213943+nickvergessen@users.noreply.github.com> | 2020-12-18 15:09:13 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-12-18 15:09:13 +0300 |
commit | 8c7d09b6f6571f414bd85a8b72e58453b1d49682 (patch) | |
tree | b3fa7d905108f3e8088afaf2ea715981374079ab | |
parent | cdfac518632dd588c4f2bec71a9fe31440561c58 (diff) | |
parent | 77853824a3ab601ed8d29f0aefabfd2aff5317d9 (diff) |
Merge pull request #4546 from nextcloud/feature/3432/conversation-description-frontendv11.0.0-alpha.1
Conversation description - front-end
-rw-r--r-- | package-lock.json | 6 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/components/RightSidebar/Description/Description.vue | 423 | ||||
-rw-r--r-- | src/components/RightSidebar/RightSidebar.vue | 62 | ||||
-rw-r--r-- | src/services/conversationsService.js | 8 | ||||
-rw-r--r-- | src/store/conversationsStore.js | 11 |
6 files changed, 507 insertions, 4 deletions
diff --git a/package-lock.json b/package-lock.json index e02c89845..259765747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20832,9 +20832,9 @@ "dev": true }, "v-click-outside": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v-click-outside/-/v-click-outside-3.0.1.tgz", - "integrity": "sha512-FITcAM0R3JEPUSGiO7hfhKDODZHkOQTk/FyI9mwxNcz6LbMbJhABhjevLI5VsU00PRksloQx8vmpFIqlAfX6nw==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/v-click-outside/-/v-click-outside-3.1.2.tgz", + "integrity": "sha512-gMdRqfRE6m6XU6SiFi3dyBlFB2MWogiXpof8Aa3LQysrl9pzTndqp/iEaAphLoadaQUFnQ0ec6fLLaxr7LiY6A==" }, "v-tooltip": { "version": "2.0.3", diff --git a/package.json b/package.json index 4a90255f7..3d233d394 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "ua-parser-js": "^0.7.23", "url-parse": "^1.4.7", "util": "^0.12.3", + "v-click-outside": "^3.1.2", "vue": "^2.6.12", "vue-at": "^2.5.0-beta.2", "vue-clipboard2": "^0.3.1", diff --git a/src/components/RightSidebar/Description/Description.vue b/src/components/RightSidebar/Description/Description.vue new file mode 100644 index 000000000..63afe5f49 --- /dev/null +++ b/src/components/RightSidebar/Description/Description.vue @@ -0,0 +1,423 @@ +<!-- + - @copyright Copyright (c) 2020 Marco Ambrosini <marcoambrosini@pm.me> + - + - @author Marco Ambrosini <marcoambrosini@pm.me> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. +--> + +<template> + <div ref="description" + v-click-outside="handleClickOutside" + class="description" + :class="{'description--editing': editing, 'description--expanded': expanded}"> + <RichContentEditable + ref="contenteditable" + :key="forceReRenderKey" + :value.sync="descriptionText" + class="description__contenteditable" + :auto-complete="()=>{}" + :maxlength="maxLength" + :contenteditable="editing && !loading" + :placeholder="placeholder" + @submit="handleSubmitDescription" + @keydown.esc="handleCancelEditing" /> + <template v-if="!loading"> + <template v-if="editing"> + <button + class="description__button" + :aria-label="t('spreed','Cancel editing description')" + @click="handleCancelEditing"> + <Close + decorative + title="" + :size="20" /> + </button> + <button + class="description__button primary" + :aria-label="t('spreed','Submit conversation description')" + :disabled="!canSubmit" + @click="handleSubmitDescription"> + <Check + decorative + title="" + :size="20" /> + </button> + <div v-if="showCountDown" + v-tooltip.auto="countDownWarningText" + class="counter" + tabindex="0" + aria-label="countDownWarningText"> + <span>{{ charactersCountDown }}</span> + </div> + </template> + <button v-if="!editing && editable" + class="description__button" + :aria-label="t('spreed','Edit conversation description')" + @click="handleEditDescription"> + <Pencil + decorative + :size="20" /> + </button> + </template> + <div v-if="loading" class="icon-loading-small spinner" /> + <button v-if="!editing && overflows && expanded" class="expand-indicator description__button" @click="handleClick"> + <ChevronDown /> + </button> + <div v-if="showOverlay" + cursor="pointer" + class="overlay" + @click="handleClick" /> + </div> +</template> + +<script> +import Pencil from 'vue-material-design-icons/Pencil' +import Check from 'vue-material-design-icons/Check' +import Close from 'vue-material-design-icons/Close' +import ChevronDown from 'vue-material-design-icons/ChevronDown' +import RichContentEditable from '@nextcloud/vue/dist/Components/RichContenteditable' +import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +import { directive as ClickOutside } from 'v-click-outside' + +export default { + name: 'Description', + components: { + Pencil, + Check, + Close, + RichContentEditable, + ChevronDown, + }, + + directives: { + Tooltip, + ClickOutside, + }, + + props: { + /** + * The description (An editable paragraph just above the sidebar tabs) + */ + descriptionTitle: { + type: String, + default: t('spreed', 'Description'), + }, + + /** + * A paragraph below the title. + */ + description: { + type: String, + default: '', + }, + + /** + * Shows or hides the editing buttons. + */ + editable: { + type: Boolean, + default: false, + }, + + /** + * Toggles the description editing state on and off. + */ + editing: { + type: Boolean, + default: false, + }, + + /** + * Placeholder for the contenteditable element. + */ + placeholder: { + type: String, + default: '', + }, + + /** + * Toggles the loading state on and off. + */ + loading: { + type: Boolean, + default: false, + }, + + /** + * Maximum description length in characters + */ + maxLength: { + type: Number, + default: 500, + }, + }, + + data() { + return { + descriptionText: '', + forceReRenderKey: 0, + expanded: false, + overflows: null, + } + }, + + computed: { + + canSubmit() { + return this.charactersCount <= this.maxLength && this.descriptionText !== this.description + }, + + charactersCount() { + return this.descriptionText.length + }, + + charactersCountDown() { + return this.maxLength - this.charactersCount + }, + + showCountDown() { + return this.charactersCount >= this.maxLength - 20 + }, + + countDownWarningText() { + return t('spreed', 'The description must be less than or equal to {maxLength} characters long. Your current text is {charactersCount} characters long.', { + maxLength: this.maxLength, + charactersCount: this.charactersCount, + }) + }, + + showCollapseButton() { + return this.overflows && !this.editing && !this.loading && this.expanded + }, + + showOverlay() { + return this.overflows && !this.editing && !this.loading && !this.expanded + }, + }, + + watch: { + // Each time the prop changes, reflect the changes in the value stored in this component + description() { + this.descriptionText = this.description + if (!this.editing) { + this.checkOverflow() + } + }, + editing(newValue) { + if (!newValue) { + this.descriptionText = this.description + } + }, + }, + + methods: { + handleEditDescription() { + const contenteditable = this.$refs.contenteditable.$refs.contenteditable + this.$emit('update:editing', true) + this.$nextTick(() => { + // Focus and select the text in the description + contenteditable.focus() + document.execCommand('selectAll', false, null) + }) + }, + + handleSubmitDescription() { + if (!this.canSubmit) { + return + } + // Remove newlines and whitespaces. + this.descriptionText = this.descriptionText.replace(/\r\n|\n|\r/gm, '').trim() + // Submit description + this.$emit('submit:description', this.descriptionText) + /** + * Change the richcontenteditable key in order to trigger a re-render + * without this all the trimmed new lines and whitespaces would + * still be present in the contenteditable element. + */ + this.forceReRenderKey += 1 + }, + + handleCancelEditing() { + this.descriptionText = this.description + this.$emit('update:editing', false) + // Deselect all the text that's been selected in `handleEditDescription` + window.getSelection().removeAllRanges() + }, + + // Expand the description + handleClick() { + if (this.editing || this.loading) { + return + } if (this.overflows) { + this.expanded = !this.expanded + } + }, + + // Collapse the description or dismiss editing + handleClickOutside() { + this.expanded = false + this.$emit('update:editing', false) + }, + + checkOverflow() { + const descriptionScrollHeight = this.$refs.description.offsetHeight + const descriptionOffsetHeight = this.$refs.contenteditable.$refs.contenteditable.offsetHeight + this.overflows = descriptionScrollHeight > descriptionOffsetHeight + }, + }, +} +</script> + +<style lang="scss" scoped> +@import '../../../assets/variables.scss'; + +.description { + margin: -20px 0 8px 8px; + display: flex; + width: 100%; + overflow: hidden; + position: relative; + max-height: calc(var(--default-line-height) * 3 + 28px); + &--editing { + box-shadow: 0 2px var(--color-primary-element); + transition: all 150ms ease-in-out; + max-height: unset; + align-items: flex-end; + } + &--expanded { + max-height: unset; + min-height: $clickable-area * 2; + align-items: flex-end; + } + &__header { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + } + &__title { + color: var(--color-primary); + font-weight: bold; + font-size: var(--default-font-size); + line-height: var(----default-line-height); + } + + &__contenteditable { + width: 100%; + &--empty:before { + position: absolute; + content: attr(placeholder); + color: var(--color-text-maxcontrast); + } + } + &__buttons{ + display: flex; + margin-top: 8px; + justify-content: flex-end; + } + &__button { + width: $clickable-area; + height: $clickable-area; + flex-shrink: 0; + border: 0; + padding: 0; + margin: 0 0 4px 4px; + z-index: 1; + &:not(.primary) { + background-color: transparent; + } + + &:hover, + &:focus { + background-color: var(--color-background-hover); + } + &:disabled { + &:hover { + background-color: var(--color-primary-element); + } + } + } +} + +.spinner { + width: $clickable-area; + height: $clickable-area; + margin: 0 0 4px 0; +} + +.expand-indicator { + width: $clickable-area; + height: $clickable-area; + margin: 0 0 4px 0; + position: absolute; + top: 0; + right: 0; +} + +.counter { + background-color: var(--color-background-dark); + height: 44px; + width: 44px; + border-radius: var(--border-radius-pill); + position: absolute; + top: 0; + right: 0px; + display: flex; + align-items: center; + justify-content: center; +} + +.overlay { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(255, 255, 255, 0.5) 75%, #FFFFFF 100%); + width: 100%; + height: 100%; + position: absolute; + top: 0; + right: 0; + padding-right: $clickable-area; + cursor: pointer; +} +// Restyle richContentEditable component from our library. +::v-deep .rich-contenteditable__input { + min-height: var(--default-line-height); + border-radius: 0; + overflow-x: hidden; + padding: 0 0 4px 0; + overflow: visible; + width: 100%; + background-color: transparent; + border: none; + color: var(--color-main-text); + font-size: var(--default-font-size); + line-height: var(--default-line-height); + margin-bottom: 4px; + max-height: unset; + align-self: flex-start; + margin-top: 12px; + &::before { + position: relative; + } + &[contenteditable='false'] { + background-color: transparent; + color: var(--color-main-text); + border: 0; + opacity: 1; + border-radius: 0; + } +} + +</style> diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue index a7414abb3..b8bb06b9e 100644 --- a/src/components/RightSidebar/RightSidebar.vue +++ b/src/components/RightSidebar/RightSidebar.vue @@ -35,6 +35,16 @@ @submit-title="handleSubmitTitle" @dismiss-editing="dismissEditing" @close="handleClose"> + <Description + v-if="showDescription" + slot="description" + :editable="canFullModerate" + :description="description" + :editing="isEditingDescription" + :loading="isDescriptionLoading" + :placeholder="t('spreed', 'Add a description for this conversation')" + @submit:description="handleUpdateDescription" + @update:editing="handleEditDescription" /> <AppSidebarTab v-if="showChatInSidebar" id="chat" @@ -95,6 +105,9 @@ import MatterbridgeSettings from './Matterbridge/MatterbridgeSettings' import isInLobby from '../../mixins/isInLobby' import SetGuestUsername from '../SetGuestUsername' import SipSettings from './SipSettings' +import Description from './Description/Description' +import { EventBus } from '../../services/EventBus' +import { showError } from '@nextcloud/dialogs' export default { name: 'RightSidebar', @@ -107,6 +120,7 @@ export default { SetGuestUsername, SipSettings, MatterbridgeSettings, + Description, }, mixins: [ @@ -126,9 +140,12 @@ export default { contactsLoading: false, // The conversation name (while editing) conversationName: '', + isEditingDescription: false, + isDescriptionLoading: false, // Sidebar status before starting editing operation sidebarOpenBeforeEditingName: '', matterbridgeEnabled: loadState('talk', 'enable_matterbridge'), + } }, @@ -191,6 +208,7 @@ export default { return this.conversation.displayName } }, + isRenamingConversation() { return this.$store.getters.isRenamingConversation }, @@ -199,6 +217,18 @@ export default { return this.conversation.sipEnabled === WEBINAR.SIP.ENABLED && this.conversation.attendeePin }, + + description() { + return this.conversation.description + }, + + showDescription() { + if (this.canFullModerate) { + return this.conversation.type !== CONVERSATION.TYPE.ONE_TO_ONE + } else { + return this.description !== '' + } + }, }, watch: { @@ -209,6 +239,14 @@ export default { }, }, + mounted() { + EventBus.$on('routeChange', this.handleRouteChange) + }, + + beforeDestroy() { + EventBus.$off('routeChange', this.handleRouteChange) + }, + methods: { handleClose() { this.dismissEditing() @@ -254,6 +292,30 @@ export default { emit('show-settings') }, + async handleUpdateDescription(description) { + this.isDescriptionLoading = true + try { + await this.$store.dispatch('setConversationDescription', { + token: this.token, + description, + }) + this.isEditingDescription = false + } catch (error) { + console.error('Error while setting conversation description', error) + showError(t('spreed', 'Error while updating conversation description')) + } + this.isDescriptionLoading = false + }, + + handleEditDescription(payload) { + this.isEditingDescription = payload + }, + + handleRouteChange() { + // Reset description data on route change + this.isEditingDescription = false + this.isDescriptionLoading = false + }, }, } </script> diff --git a/src/services/conversationsService.js b/src/services/conversationsService.js index d592dfd53..f71470092 100644 --- a/src/services/conversationsService.js +++ b/src/services/conversationsService.js @@ -346,6 +346,13 @@ const changeListable = async function(token, listable) { return response } +const setConversationDescription = async function(token, description) { + const response = await axios.put(generateOcsUrl('apps/spreed/api/v3', 2) + `room/${token}/description`, { + description, + }) + return response +} + export { fetchConversations, fetchConversation, @@ -367,4 +374,5 @@ export { changeListable, setConversationPassword, setConversationName, + setConversationDescription, } diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 00a15d4a3..8369787f6 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -30,7 +30,7 @@ import { addToFavorites, removeFromFavorites, setConversationName, -} from '../services/conversationsService' + setConversationDescription } from '../services/conversationsService' import { getCurrentUser } from '@nextcloud/auth' import { CONVERSATION, WEBINAR, PARTICIPANT } from '../constants' @@ -102,6 +102,10 @@ const mutations = { purgeConversationsStore(state) { Object.assign(state, getDefaultState()) }, + + setConversationDescription(state, { token, description }) { + Vue.set(state.conversations[token], 'description', description) + }, } const actions = { @@ -231,6 +235,11 @@ const actions = { commit('addConversation', conversation) }, + async setConversationDescription({ commit }, { token, description }) { + await setConversationDescription(token, description) + commit('setConversationDescription', { token, description }) + }, + async setReadOnlyState({ commit, getters }, { token, readOnly }) { const conversation = Object.assign({}, getters.conversations[token]) if (!conversation) { |