From 8b9c97745f2a5b0a06a1d3fd0d789beeb3c885f3 Mon Sep 17 00:00:00 2001 From: Xavi Ferrer Date: Wed, 7 Oct 2020 18:51:11 +0200 Subject: [PATCH] Allow selected characters to precede a mention --- CHANGES.md | 1 + spec/autocomplete.js | 54 +++++++++++++++++++++++++++++ src/converse-autocomplete.js | 16 ++++++--- src/headless/converse-muc.js | 4 ++- src/headless/utils/core.js | 14 +++++--- src/headless/utils/parse-helpers.js | 2 +- 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index faafe2c33..3671710fd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 8.0.0 (Unreleased) - #1083: Add support for XEP-0393 Message Styling +- #2275: Allow selected characters to precede a mention - Bugfix: `null` inserted by emoji picker and can't switch between skintones - New configuration setting: [show_tab_notifications](https://conversejs.org/docs/html/configuration.html#show-tab-notifications) diff --git a/spec/autocomplete.js b/spec/autocomplete.js index 7a0806a89..9e3b397cb 100644 --- a/spec/autocomplete.js +++ b/spec/autocomplete.js @@ -114,6 +114,60 @@ describe("The nickname autocomplete feature", function () { done(); })); + it("shows all autocompletion options when the user presses @ right after an allowed character", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], {'opening_mention_characters':['(']}, + async function (done, _converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + + // Test that pressing @ brings up all options + const textarea = view.el.querySelector('textarea.chat-textarea'); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + textarea.value = '(' + view.onKeyDown(at_event); + textarea.value = '(@'; + view.onKeyUp(at_event); + + await u.waitUntil(() => view.el.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.el.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.el.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.el.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.el.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + done(); + })); + it("should order by query index position and length", mock.initConverse( ['rosterGroupsFetched', 'chatBoxesFetched'], {}, async function (done, _converse) { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); diff --git a/src/converse-autocomplete.js b/src/converse-autocomplete.js index 05554d5e4..5dd5e5941 100644 --- a/src/converse-autocomplete.js +++ b/src/converse-autocomplete.js @@ -9,6 +9,7 @@ import { Events } from '@converse/skeletor/src/events.js'; import { converse } from "@converse/headless/converse-core"; +converse.MENTION_BOUNDARIES = ['"', '(', '<', '#', '!', '\\', '/', '+', '~', '[', '{', '^', '>']; const u = converse.env.utils; @@ -93,6 +94,11 @@ const helpers = { regExpEscape (s) { return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); + }, + + isMention (word, ac_triggers, mention_boundaries) { + return (ac_triggers.includes(word[0]) || + (mention_boundaries.includes(word[0]) && ac_triggers.includes(word[1]))); } } @@ -245,7 +251,7 @@ export class AutoComplete { insertValue (suggestion) { if (this.match_current_word) { - u.replaceCurrentWord(this.input, suggestion.value); + u.replaceCurrentWord(this.input, suggestion.value, converse.MENTION_BOUNDARIES); } else { this.input.value = suggestion.value; } @@ -365,7 +371,7 @@ export class AutoComplete { this.auto_completing = true; } else if (ev.key === "Backspace") { const word = u.getCurrentWord(ev.target, ev.target.selectionEnd-1); - if (this.ac_triggers.includes(word[0])) { + if (helpers.isMention(word, this.ac_triggers, converse.MENTION_BOUNDARIES)) { this.auto_completing = true; } } @@ -387,11 +393,13 @@ export class AutoComplete { } let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value; - const contains_trigger = this.ac_triggers.includes(value[0]); + const contains_trigger = helpers.isMention(value, this.ac_triggers, converse.MENTION_BOUNDARIES); if (contains_trigger) { this.auto_completing = true; if (!this.include_triggers.includes(ev.key)) { - value = value.slice('1'); + value = converse.MENTION_BOUNDARIES.includes(value[0]) + ? value.slice('2') + : value.slice('1'); } } diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 3498af033..7e3b78800 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -963,7 +963,9 @@ converse.plugins.add('converse-muc', { getAllKnownNicknamesRegex () { const longNickString = this.getAllKnownNicknames().join('|'); const escapedLongNickString = p.escapeRegexString(longNickString) - return RegExp(`(?:\\s|^)@(${escapedLongNickString})(?![\\w@-])`, 'ig'); + const mention_boundaries = converse.MENTION_BOUNDARIES.join('|'); + const escaped_mention_boundaries = p.escapeRegexString(mention_boundaries); + return RegExp(`(?:\\s|^)[${escaped_mention_boundaries}]?@(${escapedLongNickString})(?![\\w@-])`, 'ig'); }, getOccupantByJID (jid) { diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index aaea838c1..d3b6c9d19 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -425,12 +425,16 @@ u.getCurrentWord = function (input, index, delineator) { return word; }; -u.replaceCurrentWord = function (input, new_value) { +u.replaceCurrentWord = function (input, new_value, mention_boundaries=[]) { const caret = input.selectionEnd || undefined, - current_word = last(input.value.slice(0, caret).split(' ')), - value = input.value; - input.value = value.slice(0, caret - current_word.length) + `${new_value} ` + value.slice(caret); - input.selectionEnd = caret - current_word.length + new_value.length + 1; + current_word = last(input.value.slice(0, caret).split(/\s/)), + value = input.value, + mention_boundary = mention_boundaries.includes(current_word[0]) + ? current_word[0] + : ''; + input.value = value.slice(0, caret - current_word.length) + mention_boundary + `${new_value} ` + value.slice(caret); + const selection_end = caret - current_word.length + new_value.length + 1; + input.selectionEnd = mention_boundary ? selection_end + 1 : selection_end; }; u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true) { diff --git a/src/headless/utils/parse-helpers.js b/src/headless/utils/parse-helpers.js index a0765fa28..341c3a08e 100644 --- a/src/headless/utils/parse-helpers.js +++ b/src/headless/utils/parse-helpers.js @@ -7,7 +7,7 @@ const helpers = {}; // Captures all mentions, but includes a space before the @ -helpers.mention_regex = /(?:\s|^)([@][\w_-]+(?:\.\w+)*)/ig; +helpers.mention_regex = /(?:\s|^)([@][\w_-]+(?:\.\w+)*)/gi; helpers.matchRegexInText = text => regex => text.matchAll(regex);