From ea93bfdbde07692fa3580d36f9a96d5fc0d5e5e9 Mon Sep 17 00:00:00 2001 From: Hafiz Date: Tue, 17 Feb 2026 13:42:03 -0600 Subject: [PATCH] emoji autocomplete to chat, messages, tasks, and profile add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper) --- .../src/components/chat/emojiAutoComplete.vue | 191 ++++++++++++++++++ website/client/src/components/groups/chat.vue | 10 + .../client/src/components/tasks/taskModal.vue | 64 +++++- .../src/components/userMenu/profile.vue | 37 +++- .../client/src/mixins/autoCompleteHelper.js | 42 ++-- .../src/pages/private-messages/index.vue | 29 ++- 6 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 website/client/src/components/chat/emojiAutoComplete.vue diff --git a/website/client/src/components/chat/emojiAutoComplete.vue b/website/client/src/components/chat/emojiAutoComplete.vue new file mode 100644 index 0000000000..5b41c44811 --- /dev/null +++ b/website/client/src/components/chat/emojiAutoComplete.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/website/client/src/components/groups/chat.vue b/website/client/src/components/groups/chat.vue index d1b0e7ae4d..2d242863bc 100644 --- a/website/client/src/components/groups/chat.vue +++ b/website/client/src/components/groups/chat.vue @@ -41,6 +41,14 @@ :chat="group.chat" @select="selectedAutocomplete" /> +
@@ -90,6 +98,7 @@ import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants'; import externalLinks from '../../mixins/externalLinks'; import autocomplete from '../chat/autoComplete'; +import emojiAutoComplete from '../chat/emojiAutoComplete'; import communityGuidelines from './communityGuidelines'; import chatMessages from '../chat/chatMessages'; import { mapState } from '@/libs/store'; @@ -102,6 +111,7 @@ export default { }, components: { autocomplete, + emojiAutoComplete, communityGuidelines, chatMessages, }, diff --git a/website/client/src/components/tasks/taskModal.vue b/website/client/src/components/tasks/taskModal.vue index b2f17bf5bb..7117cbf927 100644 --- a/website/client/src/components/tasks/taskModal.vue +++ b/website/client/src/components/tasks/taskModal.vue @@ -70,6 +70,13 @@ spellcheck="true" :disabled="challengeAccessRequired" :placeholder="$t('addATitle')" + @focus="setActiveField('title')" + @keydown="autoCompleteMixinUpdateCarretPosition" + @keydown.tab="autoCompleteMixinHandleTab($event)" + @keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)" + @keydown.down="autoCompleteMixinSelectNextAutocomplete($event)" + @keypress.enter="titleEnterHandler($event)" + @keydown.esc="autoCompleteMixinHandleEscape($event)" >
+
{ + this.textbox.setSelectionRange(newCaret, newCaret); + this.textbox.focus(); + }); }, async addTag (name) { const tagResult = await this.createTag({ name }); diff --git a/website/client/src/components/userMenu/profile.vue b/website/client/src/components/userMenu/profile.vue index d42713d4a0..ac979bca98 100644 --- a/website/client/src/components/userMenu/profile.vue +++ b/website/client/src/components/userMenu/profile.vue @@ -398,14 +398,29 @@ :placeholder="$t('imageUrl')" >
-
+
+
@@ -1001,6 +1016,8 @@ import mute from '@/assets/svg/mute.svg?raw'; import shadowMute from '@/assets/svg/shadow-mute.svg?raw'; import externalLinks from '../../mixins/externalLinks'; import { userCustomStateMixin } from '../../mixins/userState'; +import emojiAutoComplete from '@/components/chat/emojiAutoComplete'; +import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper'; // @TODO: EMAILS.COMMUNITY_MANAGER_EMAIL const COMMUNITY_MANAGER_EMAIL = 'admin@habitica.com'; @@ -1012,8 +1029,9 @@ export default { MemberDetails, profileStats, toggleSwitch, + emojiAutoComplete, }, - mixins: [externalLinks, userCustomStateMixin('userLoggedIn')], + mixins: [externalLinks, userCustomStateMixin('userLoggedIn'), autoCompleteHelperMixin], props: ['userId', 'startingPage'], data () { return { @@ -1033,6 +1051,7 @@ export default { mute, shadowMute, }), + textbox: null, userIdToMessage: '', editing: false, editingProfile: { @@ -1121,6 +1140,13 @@ export default { userLoggedIn () { this.loadUser(); }, + editing (val) { + if (val) { + this.$nextTick(() => { + this.textbox = this.$refs.blurbTextarea; + }); + } + }, }, mounted () { this.loadUser(); @@ -1331,6 +1357,13 @@ export default { this.$emit('toggled', this.isOpened); }, + selectedAutocomplete (newText, newCaret) { + this.editingProfile.blurb = newText; + this.$nextTick(() => { + this.textbox.setSelectionRange(newCaret, newCaret); + this.textbox.focus(); + }); + }, reportPlayer () { this.$root.$emit('habitica::report-profile', { memberId: this.user._id, diff --git a/website/client/src/mixins/autoCompleteHelper.js b/website/client/src/mixins/autoCompleteHelper.js index d2651df88d..e2ad31c27d 100644 --- a/website/client/src/mixins/autoCompleteHelper.js +++ b/website/client/src/mixins/autoCompleteHelper.js @@ -15,46 +15,60 @@ export const autoCompleteHelperMixin = { }; }, methods: { + _getActiveAutocomplete () { + if (this.$refs.autocomplete && this.$refs.autocomplete.searchActive) { + return this.$refs.autocomplete; + } + if (this.$refs.emojiAutocomplete && this.$refs.emojiAutocomplete.searchActive) { + return this.$refs.emojiAutocomplete; + } + return null; + }, + autoCompleteMixinHandleTab (e) { - if (this.$refs.autocomplete.searchActive) { + const ac = this._getActiveAutocomplete(); + if (ac) { e.preventDefault(); if (e.shiftKey) { - this.$refs.autocomplete.selectPrevious(); + ac.selectPrevious(); } else { - this.$refs.autocomplete.selectNext(); + ac.selectNext(); } } }, autoCompleteMixinHandleEscape (e) { - if (this.$refs.autocomplete.searchActive) { + const ac = this._getActiveAutocomplete(); + if (ac) { e.preventDefault(); - this.$refs.autocomplete.cancel(); + ac.cancel(); } }, autoCompleteMixinSelectNextAutocomplete (e) { - if (this.$refs.autocomplete.searchActive) { + const ac = this._getActiveAutocomplete(); + if (ac) { e.preventDefault(); - this.$refs.autocomplete.selectNext(); + ac.selectNext(); } }, autoCompleteMixinSelectPreviousAutocomplete (e) { - if (this.$refs.autocomplete.searchActive) { + const ac = this._getActiveAutocomplete(); + if (ac) { e.preventDefault(); - this.$refs.autocomplete.selectPrevious(); + ac.selectPrevious(); } }, autoCompleteMixinSelectAutocomplete (e) { - if (this.$refs.autocomplete.searchActive) { - if (this.$refs.autocomplete.selected !== null) { + const ac = this._getActiveAutocomplete(); + if (ac) { + if (ac.selected !== null) { e.preventDefault(); - this.$refs.autocomplete.makeSelection(); + ac.makeSelection(); } else { - // no autocomplete selected, newline instead - this.$refs.autocomplete.cancel(); + ac.cancel(); } } }, diff --git a/website/client/src/pages/private-messages/index.vue b/website/client/src/pages/private-messages/index.vue index 4e347ce870..f3dd2ee699 100644 --- a/website/client/src/pages/private-messages/index.vue +++ b/website/client/src/pages/private-messages/index.vue @@ -153,9 +153,23 @@ :placeholder="$t('needsTextPlaceholder')" :maxlength="MAX_MESSAGE_LENGTH" :class="{'has-content': newMessage.trim() !== '', 'disabled': newMessageDisabled}" + @keydown="autoCompleteMixinUpdateCarretPosition" @keyup.ctrl.enter="sendPrivateMessage()" + @keydown.tab="autoCompleteMixinHandleTab($event)" + @keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)" + @keydown.down="autoCompleteMixinSelectNextAutocomplete($event)" + @keypress.enter="autoCompleteMixinSelectAutocomplete($event)" + @keydown.esc="autoCompleteMixinHandleEscape($event)" > +
{ const data = vm.$store.state.privateMessageOptions; @@ -751,6 +769,7 @@ export default defineComponent({ /** @type {Record} */ messagesByConversation: {}, // cache {uuid: []} + textbox: null, newMessage: '', messages: [], messagesLoading: false, @@ -964,6 +983,7 @@ export default defineComponent({ }, }, async mounted () { + this.textbox = this.$refs.textarea; this.$store.dispatch('common:setTitle', { section: this.$t('messages'), }); @@ -1224,6 +1244,13 @@ export default defineComponent({ triggerStartNewConversationState () { this.showStartNewConversationInput = true; }, + selectedAutocomplete (newText, newCaret) { + this.newMessage = newText; + this.$nextTick(() => { + this.textbox.setSelectionRange(newCaret, newCaret); + this.textbox.focus(); + }); + }, async startConversationByUsername (targetUserName) { // check if the target user exists in current conversations, select that conversation /** @type {PrivateMessages.ConversationSummaryMessageEntry} */