diff options
author | Joas Schilling <213943+nickvergessen@users.noreply.github.com> | 2020-01-09 23:38:18 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-01-09 23:38:18 +0300 |
commit | 19ed07e749a9aa7a789225413cedebdff74c5ccc (patch) | |
tree | dc9251a94003907192cb9a025039dee571518382 /src | |
parent | f0b1297060f10d50365b03ebe9eb5810cc980eef (diff) | |
parent | 003207b68eba4f52b471bb9654b1a9685002d829 (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.vue | 10 | ||||
-rw-r--r-- | src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue | 197 | ||||
-rw-r--r-- | src/components/NewMessageForm/NewMessageForm.vue | 43 | ||||
-rw-r--r-- | src/main.js | 2 | ||||
-rw-r--r-- | src/mainChatTab.js | 2 | ||||
-rw-r--r-- | src/mixins/vueAtReparenter.js | 127 | ||||
-rw-r--r-- | src/services/mentionsService.js | 42 |
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" /> + + <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 line<br>second 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(/ /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, +} |