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
path: root/src
diff options
context:
space:
mode:
authorJoas Schilling <213943+nickvergessen@users.noreply.github.com>2020-01-09 23:38:18 +0300
committerGitHub <noreply@github.com>2020-01-09 23:38:18 +0300
commit19ed07e749a9aa7a789225413cedebdff74c5ccc (patch)
treedc9251a94003907192cb9a025039dee571518382 /src
parentf0b1297060f10d50365b03ebe9eb5810cc980eef (diff)
parent003207b68eba4f52b471bb9654b1a9685002d829 (diff)
Merge pull request #2691 from nextcloud/add-autocompletion-for-mentions
Add autocompletion for mentions
Diffstat (limited to 'src')
-rw-r--r--src/components/MessagesList/MessagesGroup/Message/Message.vue10
-rw-r--r--src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue197
-rw-r--r--src/components/NewMessageForm/NewMessageForm.vue43
-rw-r--r--src/main.js2
-rw-r--r--src/mainChatTab.js2
-rw-r--r--src/mixins/vueAtReparenter.js127
-rw-r--r--src/services/mentionsService.js42
7 files changed, 404 insertions, 19 deletions
diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue
index c7567e7fa..5b2c317e4 100644
--- a/src/components/MessagesList/MessagesGroup/Message/Message.vue
+++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue
@@ -287,10 +287,12 @@ export default {
return PlainText
} else if (messagePartType === 'user') {
return Mention
- } else if (messagePartType === 'call') {
- return Mention
- } else if (messagePartType === 'guest') {
- return Mention
+ // FIXME Reenable once the UserBubble allows non-users
+ // FIXME Ref: https://github.com/nextcloud/nextcloud-vue/issues/796
+ // } else if (messagePartType === 'call') {
+ // return Mention
+ // } else if (messagePartType === 'guest') {
+ // return Mention
} else if (messagePartType === 'file') {
return FilePreview
}
diff --git a/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue b/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue
index 65e9d42e6..ecfe4271c 100644
--- a/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue
+++ b/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue
@@ -20,19 +20,69 @@
-->
<template>
- <div ref="contentEditable"
- v-contenteditable:text="active"
- :placeHolder="placeholderText"
- class="new-message-form__advancedinput"
- @keydown.enter="handleKeydown"
- @paste="onPaste" />
+ <At ref="at"
+ v-model="text"
+ name-key="label"
+ :members="autoCompleteMentionCandidates"
+ :filter-match="atFilter"
+ @at="handleAtEvent">
+ <template v-slot:item="scope">
+ <Avatar v-if="isMentionToAll(scope.item.id)"
+ :icon-class="'icon-group-forced-white'"
+ :disable-tooltip="true"
+ :disable-menu="true"
+ :is-no-user="true" />
+ <div v-else-if="isMentionToGuest(scope.item.id)"
+ class="avatar guest"
+ :style="getGuestAvatarStyle()">
+ {{ getFirstLetterOfGuestName(scope.item.label) }}
+ </div>
+ <Avatar v-else
+ :user="scope.item.id"
+ :display-name="scope.item.label"
+ :disable-tooltip="true"
+ :disable-menu="true" />
+ &nbsp;
+ <span>{{ scope.item.label }}</span>
+ </template>
+ <template v-slot:embeddedItem="scope">
+ <!-- The root element itself is ignored, only its contents are taken
+ into account. -->
+ <span>
+ <!-- vue-at seems to try to create an embedded item at some
+ strange times in which no item is selected and thus there
+ is no data, so do not use the Mention component in those
+ cases. -->
+ <Mention v-if="scope.current.id" :data="getDataForMentionComponent(scope.current)" :data-mention-id="scope.current.id" />
+ </span>
+ </template>
+ <div ref="contentEditable"
+ :contenteditable="activeInput"
+ :placeHolder="placeholderText"
+ class="new-message-form__advancedinput"
+ @keydown.enter="handleKeydown"
+ @paste="onPaste" />
+ </At>
</template>
<script>
+import At from 'vue-at'
+import VueAtReparenter from '../../../mixins/vueAtReparenter'
import { EventBus } from '../../../services/EventBus'
+import { searchPossibleMentions } from '../../../services/mentionsService'
+import Avatar from '@nextcloud/vue/dist/Components/Avatar'
+import Mention from '../../MessagesList/MessagesGroup/Message/MessagePart/Mention'
export default {
name: 'AdvancedInput',
+ components: {
+ At,
+ Avatar,
+ Mention,
+ },
+ mixins: [
+ VueAtReparenter,
+ ],
props: {
/**
* The placeholder for the input field
@@ -54,15 +104,25 @@ export default {
type: String,
required: true,
},
+
+ /**
+ * The token of the conversation to get candidate mentions for.
+ */
+ token: {
+ type: String,
+ required: true,
+ },
},
data: function() {
return {
- active: true,
text: '',
+ autoCompleteMentionCandidates: [],
}
},
watch: {
text(text) {
+ this.$emit('update:contentEditable', this.$refs.contentEditable.cloneNode(true))
+
this.$emit('update:value', text)
this.$emit('input', text)
this.$emit('change', text)
@@ -70,6 +130,14 @@ export default {
value(value) {
this.text = value
},
+ atwho(atwho) {
+ if (!atwho) {
+ // Clear mention candidates when closing the panel. Otherwise
+ // they would be shown when the panel is opened again until the
+ // new ones are received.
+ this.autoCompleteMentionCandidates = []
+ }
+ },
},
mounted() {
this.focusInput()
@@ -77,6 +145,8 @@ export default {
* Listen to routeChange global events and focus on the
*/
EventBus.$on('routeChange', this.focusInput)
+
+ this.addCustomAtWhoStyleSheet()
},
beforeDestroy() {
EventBus.$off('routeChange', this.focusInput)
@@ -89,6 +159,16 @@ export default {
},
/**
+ * The vue-at library only searches in the display name by default.
+ * But luckily our server responds already only with matching items,
+ * so we just filter none and show them all.
+ * @returns {boolean} True as we never filter anything out
+ */
+ atFilter() {
+ return true
+ },
+
+ /**
* Focuses the contenteditable div input
*/
focusInput() {
@@ -108,12 +188,111 @@ export default {
* @param {object} event the event object;
*/
handleKeydown(event) {
+ // Prevent submit event when vue-at panel is open, as that should
+ // just select the mention from the panel.
+ if (this.atwho) {
+ return
+ }
+
// TODO: add support for CTRL+ENTER new line
if (!(event.shiftKey)) {
event.preventDefault()
this.$emit('submit', event)
}
},
+
+ /**
+ * Sets the autocomplete mention candidates based on the matched text
+ * after the "@".
+ *
+ * @param {String} chunk the matched text to look candidate mentions for.
+ */
+ async handleAtEvent(chunk) {
+ const response = await searchPossibleMentions(this.token, chunk)
+ const possibleMentions = response.data.ocs.data
+
+ // Wrap mention ids with spaces in quotes.
+ possibleMentions.forEach(possibleMention => {
+ if (possibleMention.id.indexOf(' ') !== -1
+ || possibleMention.id.indexOf('guest/') === 0) {
+ possibleMention.id = '"' + possibleMention.id + '"'
+ }
+ })
+
+ this.autoCompleteMentionCandidates = possibleMentions
+ },
+
+ isMentionToAll(mentionId) {
+ return mentionId === 'all'
+ },
+
+ isMentionToGuest(mentionId) {
+ // Guest ids, like ids of users with spaces, are wrapped in quotes.
+ return mentionId.startsWith('"guest/')
+ },
+
+ getGuestAvatarStyle() {
+ return {
+ 'width': '32px',
+ 'height': '32px',
+ 'line-height': '32px',
+ 'background-color': '#b9b9b9',
+ 'text-align': 'center',
+ }
+ },
+
+ getFirstLetterOfGuestName(displayName) {
+ const customName = displayName !== t('spreed', 'Guest') ? displayName : '?'
+ return customName.charAt(0)
+ },
+
+ getDataForMentionComponent(candidate) {
+ let type = 'user'
+ if (this.isMentionToAll(candidate.id)) {
+ type = 'call'
+ } else if (this.isMentionToGuest(candidate.id)) {
+ type = 'guest'
+ }
+
+ return {
+ id: candidate.id,
+ name: candidate.label,
+ type: type,
+ }
+ },
+
+ /**
+ * Adds a special style sheet to customize atwho elements.
+ *
+ * The <style> section has no effect on the atwho elements, as the atwho
+ * panel is reparented to the body, and the rules added there are rooted
+ * on the AdvancedInput.
+ */
+ addCustomAtWhoStyleSheet() {
+ for (let i = 0; i < document.styleSheets.length; i++) {
+ const sheet = document.styleSheets[i]
+ if (sheet.title === 'at-who-custom') {
+ return
+ }
+ }
+
+ const style = document.createElement('style')
+ style.setAttribute('title', 'at-who-custom')
+
+ document.head.appendChild(style)
+
+ // Override "width: 180px", as that makes the autocompletion panel
+ // too narrow.
+ style.sheet.insertRule('.atwho-view { width: unset; }', 0)
+ // Override autocompletion panel items height, as they are too short
+ // for the avatars and also need some padding.
+ style.sheet.insertRule('.atwho-li { height: unset; padding-top: 6px; padding-bottom: 6px; }', 0)
+
+ // Although the height of its wrapper is 32px the height of the icon
+ // is the default 16px. This is a temporary fix until it is fixed
+ // in the avatar component.
+ style.sheet.insertRule('.atwho-li .icon-group-forced-white { width: 32px; height: 32px; }', 0)
+ },
},
}
</script>
@@ -126,10 +305,10 @@ export default {
margin: 0;
}
-//Support for the placehoder text in the div contenteditable
+// Support for the placeholder text in the div contenteditable
[contenteditable]:empty:before{
content: attr(placeholder);
display: block;
- color: gray;
+ color: var(--color-text-maxcontrast);
}
</style>
diff --git a/src/components/NewMessageForm/NewMessageForm.vue b/src/components/NewMessageForm/NewMessageForm.vue
index 73d6ec537..a80ef40ec 100644
--- a/src/components/NewMessageForm/NewMessageForm.vue
+++ b/src/components/NewMessageForm/NewMessageForm.vue
@@ -40,6 +40,8 @@
v-bind="messageToBeReplied" />
<AdvancedInput
v-model="text"
+ :token="token"
+ @update:contentEditable="contentEditableToParsed"
@submit="handleSubmit" />
</div>
<button
@@ -75,6 +77,7 @@ export default {
data: function() {
return {
text: '',
+ parsedText: '',
}
},
computed: {
@@ -94,6 +97,42 @@ export default {
},
},
methods: {
+ contentEditableToParsed(contentEditable) {
+ const mentions = contentEditable.querySelectorAll('span[data-at-embedded]')
+ mentions.forEach(mention => {
+ // FIXME Adding a space after the mention should be improved to
+ // do it or not based on the next element instead of always
+ // adding it.
+ mention.replaceWith('@' + mention.firstElementChild.attributes['data-mention-id'].value + ' ')
+ })
+
+ this.parsedText = this.rawToParsed(contentEditable.innerHTML)
+ },
+ /**
+ * Returns a parsed version of the given raw text of the content
+ * editable div.
+ *
+ * The given raw text contains a plain text representation of HTML
+ * content (like "first&nbsp;line<br>second&nbsp;line"). The returned
+ * parsed text replaces the (known) HTML content with the format
+ * expected by the server (like "first line\nsecond line").
+ *
+ * The parsed text is also trimmed.
+ *
+ * @param {String} text the raw text
+ * @returns {String} the parsed text
+ */
+ rawToParsed(text) {
+ text = text.replace(/<br>/g, '\n')
+ text = text.replace(/&nbsp;/g, ' ')
+
+ // Although the text is fully trimmed, at the very least the last
+ // "\n" occurrence should be always removed, as browsers add a
+ // "<br>" element as soon as some rich text is written in a content
+ // editable div (for example, if a new line is added the div content
+ // will be "<br><br>").
+ return text.trim()
+ },
/**
* Create a temporary message that will be used until the
* actual message object is retrieved from the server
@@ -109,7 +148,7 @@ export default {
timestamp: 0,
systemMessage: '',
messageType: '',
- message: this.text,
+ message: this.parsedText,
messageParameters: {},
token: this.token,
isReplyable: false,
@@ -161,7 +200,7 @@ export default {
* Sends the new message
*/
async handleSubmit() {
- if (this.text.trim() !== '') {
+ if (this.parsedText !== '') {
const temporaryMessage = this.createTemporaryMessage()
this.$store.dispatch('addTemporaryMessage', temporaryMessage)
this.text = ''
diff --git a/src/main.js b/src/main.js
index 7230aebf5..f3756d7fa 100644
--- a/src/main.js
+++ b/src/main.js
@@ -38,7 +38,6 @@ import { generateFilePath } from '@nextcloud/router'
import { getRequestToken } from '@nextcloud/auth'
// Directives
-import contenteditableDirective from 'vue-contenteditable-directive'
import VueClipboard from 'vue-clipboard2'
import { translate, translatePlural } from '@nextcloud/l10n'
import VueObserveVisibility from 'vue-observe-visibility'
@@ -60,7 +59,6 @@ Vue.prototype.n = translatePlural
Vue.prototype.OC = OC
Vue.prototype.OCA = OCA
-Vue.use(contenteditableDirective)
Vue.use(Vuex)
Vue.use(VueRouter)
Vue.use(VueClipboard)
diff --git a/src/mainChatTab.js b/src/mainChatTab.js
index bb523704b..6ac645fd9 100644
--- a/src/mainChatTab.js
+++ b/src/mainChatTab.js
@@ -34,7 +34,6 @@ import { generateFilePath } from '@nextcloud/router'
import { getRequestToken } from '@nextcloud/auth'
// Directives
-import contenteditableDirective from 'vue-contenteditable-directive'
import { translate, translatePlural } from '@nextcloud/l10n'
import vuescroll from 'vue-scroll'
@@ -54,7 +53,6 @@ Vue.prototype.n = translatePlural
Vue.prototype.OC = OC
Vue.prototype.OCA = OCA
-Vue.use(contenteditableDirective)
Vue.use(Vuex)
Vue.use(vuescroll, { debounce: 600 })
diff --git a/src/mixins/vueAtReparenter.js b/src/mixins/vueAtReparenter.js
new file mode 100644
index 000000000..3c36d0984
--- /dev/null
+++ b/src/mixins/vueAtReparenter.js
@@ -0,0 +1,127 @@
+/**
+ *
+ * @copyright Copyright (c) 2020, Daniel Calviño Sánchez <danxuliu@gmail.com>
+ *
+ * @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/>.
+ *
+ */
+
+import Vue from 'vue'
+
+/**
+ * Mixin to reparent the panel of the vue-at component to a specific element.
+ *
+ * By default the panel of the vue-at component is a child of the root element
+ * of the component. In some cases this may not be desirable (for example, if
+ * a parent element uses "overflow: hidden" and causes the panel to be
+ * partially hidden), so this mixin reparents the panel to a specific element
+ * when it is shown.
+ *
+ * Components using this mixin require a reference called "at" to the vue-at
+ * component. The desired parent element can be specified using the
+ * "atWhoPanelParentSelector" property.
+ */
+export default {
+
+ data: function() {
+ return {
+ /**
+ * The selector for the HTML element to reparent the vue-at panel to.
+ */
+ atWhoPanelParentSelector: 'body',
+ at: null,
+ atWhoPanelElement: null,
+ originalWrapElement: null,
+ }
+ },
+
+ computed: {
+ /**
+ * Returns the "atwho" property of the vue-at component.
+ *
+ * The "atwho" property is an object when the panel is open and null
+ * when the panel is closed.
+ *
+ * @returns {Object} the "atwho" property of the vue-at component.
+ */
+ atwho() {
+ if (!this.at) {
+ return null
+ }
+
+ return this.at.atwho
+ },
+ },
+
+ watch: {
+ /**
+ * Reparents the panel of the vue-at component when shown.
+ *
+ * Besides reparenting the panel its position needs to be adjusted to
+ * the new parent. The panel is initially a child of the "wrap" element
+ * of vue-at and vue-at calculates the position of the panel based on
+ * that element. Fortunately the reference to that element is not used
+ * for anything else, so it can be modified while the panel is open to
+ * point to the new parent.
+ *
+ * @param {Object} atwho current value of atwho
+ * @param {Object} atwhoOld previous value of atwho
+ */
+ atwho(atwho, atwhoOld) {
+ // Only check whether the object existed or not; its properties are
+ // not relevant.
+ if ((atwho && atwhoOld) || (!atwho && !atwhoOld)) {
+ return
+ }
+
+ if (atwho) {
+ // Panel will be opened in next tick; defer moving it to the
+ // proper parent until that happens
+ Vue.nextTick(function() {
+ this.atWhoPanelElement = this.at.$refs.wrap.querySelector('.atwho-panel')
+
+ this.originalWrapElement = this.at.$refs.wrap
+ this.at.$refs.wrap = window.document.querySelector(this.atWhoPanelParentSelector)
+
+ const atWhoPanelParentSelector = window.document.querySelector(this.atWhoPanelParentSelector)
+ atWhoPanelParentSelector.appendChild(this.atWhoPanelElement)
+
+ // The position of the panel will be automatically adjusted
+ // due to the reactivity, but that will happen in next tick.
+ // To prevent a flicker due to the change of the panel
+ // position the style is explicitly adjusted now.
+ const { top, left } = this.at._computedWatchers.style.get()
+ this.atWhoPanelElement.style.top = top
+ this.atWhoPanelElement.style.left = left
+ }.bind(this))
+ } else {
+ this.at.$refs.wrap = this.originalWrapElement
+ this.originalWrapElement = null
+
+ // Panel will be closed in next tick; move it back to the
+ // expected parent before that happens.
+ this.at.$refs.wrap.appendChild(this.atWhoPanelElement)
+ }
+ },
+ },
+
+ mounted() {
+ // $refs is not reactive and its contents are set after the initial
+ // render.
+ this.at = this.$refs.at
+ },
+
+}
diff --git a/src/services/mentionsService.js b/src/services/mentionsService.js
new file mode 100644
index 000000000..0c1b8db5c
--- /dev/null
+++ b/src/services/mentionsService.js
@@ -0,0 +1,42 @@
+/**
+ *
+ * @copyright Copyright (c) 2020, Daniel Calviño Sánchez <danxuliu@gmail.com>
+ *
+ * @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/>.
+ *
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+/**
+ * Fetch possible mentions
+ *
+ * @param {string} token The token of the conversation.
+ * @param {string} searchText The string that will be used in the search query.
+ */
+const searchPossibleMentions = async function(token, searchText) {
+ try {
+ const response = await axios.get(generateOcsUrl('apps/spreed/api/v1/chat', 2) + `${token}/mentions?search=${searchText}`)
+ return response
+ } catch (error) {
+ console.debug('Error while searching possible mentions: ', error)
+ }
+}
+
+export {
+ searchPossibleMentions,
+}