From 3cc87370c3b4495edf9f31a9f96e2b6f34cbd35d Mon Sep 17 00:00:00 2001
From: Yarden Shoham <git@yardenshoham.com>
Date: Sat, 22 Apr 2023 18:32:34 +0300
Subject: [PATCH] Improve emoji and mention matching (#24255)

Prioritize matches that start with the given text, then matches that
contain the given text.

I wanted to add a heart emoji on a pull request comment so I started
writing `:`, `h`, `e`, `a`, `r` (at this point I still couldn't find the
heart), `t`... The heart was not on the list, that's weird - it feels
like I made a typo or a mistake. This fixes that.

This also feels more like GitHub's emoji auto-complete.

# Before

![image](https://user-images.githubusercontent.com/20454870/233630750-bd0a1b76-33d0-41d4-9218-a37b670c42b0.png)

# After

![image](https://user-images.githubusercontent.com/20454870/233775128-05e67fc1-e092-4025-b6f7-1fd8e5f71e87.png)

---------

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
Co-authored-by: silverwind <me@silverwind.io>
---
 .../js/features/comp/ComboMarkdownEditor.js   | 22 ++-------
 web_src/js/test/setup.js                      |  9 ++++
 web_src/js/utils/match.js                     | 43 +++++++++++++++++
 web_src/js/utils/match.test.js                | 47 +++++++++++++++++++
 4 files changed, 103 insertions(+), 18 deletions(-)
 create mode 100644 web_src/js/utils/match.js
 create mode 100644 web_src/js/utils/match.test.js

diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index eb73b0914d..9995033e89 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -5,11 +5,11 @@ import {attachTribute} from '../tribute.js';
 import {hideElem, showElem, autosize} from '../../utils/dom.js';
 import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
 import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
-import {emojiKeys, emojiString} from '../emoji.js';
+import {emojiString} from '../emoji.js';
 import {renderPreviewPanelContent} from '../repo-editor.js';
+import {matchEmoji, matchMention} from '../../utils/match.js';
 
 let elementIdCounter = 0;
-const maxExpanderMatches = 6;
 
 /**
  * validate if the given textarea is non-empty.
@@ -106,14 +106,7 @@ class ComboMarkdownEditor {
     const expander = this.container.querySelector('text-expander');
     expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
       if (key === ':') {
-        const matches = [];
-        const textLowerCase = text.toLowerCase();
-        for (const name of emojiKeys) {
-          if (name.toLowerCase().includes(textLowerCase)) {
-            matches.push(name);
-            if (matches.length >= maxExpanderMatches) break;
-          }
-        }
+        const matches = matchEmoji(text);
         if (!matches.length) return provide({matched: false});
 
         const ul = document.createElement('ul');
@@ -129,14 +122,7 @@ class ComboMarkdownEditor {
 
         provide({matched: true, fragment: ul});
       } else if (key === '@') {
-        const matches = [];
-        const textLowerCase = text.toLowerCase();
-        for (const obj of window.config.tributeValues) {
-          if (obj.key.toLowerCase().includes(textLowerCase)) {
-            matches.push(obj);
-            if (matches.length >= maxExpanderMatches) break;
-          }
-        }
+        const matches = matchMention(text);
         if (!matches.length) return provide({matched: false});
 
         const ul = document.createElement('ul');
diff --git a/web_src/js/test/setup.js b/web_src/js/test/setup.js
index e0e2c71e29..d9f0b8b547 100644
--- a/web_src/js/test/setup.js
+++ b/web_src/js/test/setup.js
@@ -3,4 +3,13 @@ window.config = {
   pageData: {},
   i18n: {},
   appSubUrl: '',
+  tributeValues: [
+    {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
+    {key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
+    {key: 'user3 User 3', value: 'user3', name: 'user3', fullname: 'User 3', avatar: 'https://avatar3.com'},
+    {key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
+    {key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
+    {key: 'user6 User 6', value: 'user6', name: 'user6', fullname: 'User 6', avatar: 'https://avatar6.com'},
+    {key: 'user7 User 7', value: 'user7', name: 'user7', fullname: 'User 7', avatar: 'https://avatar7.com'},
+  ],
 };
diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js
new file mode 100644
index 0000000000..0d20ca336f
--- /dev/null
+++ b/web_src/js/utils/match.js
@@ -0,0 +1,43 @@
+import emojis from '../../../assets/emoji.json';
+
+const maxMatches = 6;
+
+function sortAndReduce(map) {
+  const sortedMap = new Map([...map.entries()].sort((a, b) => a[1] - b[1]));
+  return Array.from(sortedMap.keys()).slice(0, maxMatches);
+}
+
+export function matchEmoji(queryText) {
+  const query = queryText.toLowerCase().replaceAll('_', ' ');
+  if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]);
+
+  // results is a map of weights, lower is better
+  const results = new Map();
+  for (const {aliases} of emojis) {
+    const mainAlias = aliases[0];
+    for (const [aliasIndex, alias] of aliases.entries()) {
+      const index = alias.replaceAll('_', ' ').indexOf(query);
+      if (index === -1) continue;
+      const existing = results.get(mainAlias);
+      const rankedIndex = index + aliasIndex;
+      results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex);
+    }
+  }
+
+  return sortAndReduce(results);
+}
+
+export function matchMention(queryText) {
+  const query = queryText.toLowerCase();
+
+  // results is a map of weights, lower is better
+  const results = new Map();
+  for (const obj of window.config.tributeValues) {
+    const index = obj.key.toLowerCase().indexOf(query);
+    if (index === -1) continue;
+    const existing = results.get(obj);
+    results.set(obj, existing ? existing - index : index);
+  }
+
+  return sortAndReduce(results);
+}
diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.js
new file mode 100644
index 0000000000..78710f2a5f
--- /dev/null
+++ b/web_src/js/utils/match.test.js
@@ -0,0 +1,47 @@
+import {test, expect} from 'vitest';
+import {matchEmoji, matchMention} from './match.js';
+
+test('matchEmoji', () => {
+  expect(matchEmoji('')).toEqual([
+    '+1',
+    '-1',
+    '100',
+    '1234',
+    '1st_place_medal',
+    '2nd_place_medal',
+  ]);
+
+  expect(matchEmoji('hea')).toEqual([
+    'headphones',
+    'headstone',
+    'health_worker',
+    'hear_no_evil',
+    'heard_mcdonald_islands',
+    'heart',
+  ]);
+
+  expect(matchEmoji('hear')).toEqual([
+    'hear_no_evil',
+    'heard_mcdonald_islands',
+    'heart',
+    'heart_decoration',
+    'heart_eyes',
+    'heart_eyes_cat',
+  ]);
+
+  expect(matchEmoji('poo')).toEqual([
+    'poodle',
+    'hankey',
+    'spoon',
+    'bowl_with_spoon',
+  ]);
+
+  expect(matchEmoji('1st_')).toEqual([
+    '1st_place_medal',
+  ]);
+});
+
+test('matchMention', () => {
+  expect(matchMention('')).toEqual(window.config.tributeValues.slice(0, 6));
+  expect(matchMention('user4')).toEqual([window.config.tributeValues[3]]);
+});