Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/spreed.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoas Schilling <213943+nickvergessen@users.noreply.github.com>2020-12-18 15:09:13 +0300
committerGitHub <noreply@github.com>2020-12-18 15:09:13 +0300
commit8c7d09b6f6571f414bd85a8b72e58453b1d49682 (patch)
treeb3fa7d905108f3e8088afaf2ea715981374079ab
parentcdfac518632dd588c4f2bec71a9fe31440561c58 (diff)
parent77853824a3ab601ed8d29f0aefabfd2aff5317d9 (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.json6
-rw-r--r--package.json1
-rw-r--r--src/components/RightSidebar/Description/Description.vue423
-rw-r--r--src/components/RightSidebar/RightSidebar.vue62
-rw-r--r--src/services/conversationsService.js8
-rw-r--r--src/store/conversationsStore.js11
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) {