loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Yarden Shoham
silverwind
and committed by
GitHub
3cc87370 ce9c1ddc

+103 -18
+4 -18
web_src/js/features/comp/ComboMarkdownEditor.js
··· 5 5 import {hideElem, showElem, autosize} from '../../utils/dom.js'; 6 6 import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js'; 7 7 import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; 8 - import {emojiKeys, emojiString} from '../emoji.js'; 8 + import {emojiString} from '../emoji.js'; 9 9 import {renderPreviewPanelContent} from '../repo-editor.js'; 10 + import {matchEmoji, matchMention} from '../../utils/match.js'; 10 11 11 12 let elementIdCounter = 0; 12 - const maxExpanderMatches = 6; 13 13 14 14 /** 15 15 * validate if the given textarea is non-empty. ··· 106 106 const expander = this.container.querySelector('text-expander'); 107 107 expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { 108 108 if (key === ':') { 109 - const matches = []; 110 - const textLowerCase = text.toLowerCase(); 111 - for (const name of emojiKeys) { 112 - if (name.toLowerCase().includes(textLowerCase)) { 113 - matches.push(name); 114 - if (matches.length >= maxExpanderMatches) break; 115 - } 116 - } 109 + const matches = matchEmoji(text); 117 110 if (!matches.length) return provide({matched: false}); 118 111 119 112 const ul = document.createElement('ul'); ··· 129 122 130 123 provide({matched: true, fragment: ul}); 131 124 } else if (key === '@') { 132 - const matches = []; 133 - const textLowerCase = text.toLowerCase(); 134 - for (const obj of window.config.tributeValues) { 135 - if (obj.key.toLowerCase().includes(textLowerCase)) { 136 - matches.push(obj); 137 - if (matches.length >= maxExpanderMatches) break; 138 - } 139 - } 125 + const matches = matchMention(text); 140 126 if (!matches.length) return provide({matched: false}); 141 127 142 128 const ul = document.createElement('ul');
+9
web_src/js/test/setup.js
··· 3 3 pageData: {}, 4 4 i18n: {}, 5 5 appSubUrl: '', 6 + tributeValues: [ 7 + {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'}, 8 + {key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'}, 9 + {key: 'user3 User 3', value: 'user3', name: 'user3', fullname: 'User 3', avatar: 'https://avatar3.com'}, 10 + {key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'}, 11 + {key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'}, 12 + {key: 'user6 User 6', value: 'user6', name: 'user6', fullname: 'User 6', avatar: 'https://avatar6.com'}, 13 + {key: 'user7 User 7', value: 'user7', name: 'user7', fullname: 'User 7', avatar: 'https://avatar7.com'}, 14 + ], 6 15 };
+43
web_src/js/utils/match.js
··· 1 + import emojis from '../../../assets/emoji.json'; 2 + 3 + const maxMatches = 6; 4 + 5 + function sortAndReduce(map) { 6 + const sortedMap = new Map([...map.entries()].sort((a, b) => a[1] - b[1])); 7 + return Array.from(sortedMap.keys()).slice(0, maxMatches); 8 + } 9 + 10 + export function matchEmoji(queryText) { 11 + const query = queryText.toLowerCase().replaceAll('_', ' '); 12 + if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]); 13 + 14 + // results is a map of weights, lower is better 15 + const results = new Map(); 16 + for (const {aliases} of emojis) { 17 + const mainAlias = aliases[0]; 18 + for (const [aliasIndex, alias] of aliases.entries()) { 19 + const index = alias.replaceAll('_', ' ').indexOf(query); 20 + if (index === -1) continue; 21 + const existing = results.get(mainAlias); 22 + const rankedIndex = index + aliasIndex; 23 + results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex); 24 + } 25 + } 26 + 27 + return sortAndReduce(results); 28 + } 29 + 30 + export function matchMention(queryText) { 31 + const query = queryText.toLowerCase(); 32 + 33 + // results is a map of weights, lower is better 34 + const results = new Map(); 35 + for (const obj of window.config.tributeValues) { 36 + const index = obj.key.toLowerCase().indexOf(query); 37 + if (index === -1) continue; 38 + const existing = results.get(obj); 39 + results.set(obj, existing ? existing - index : index); 40 + } 41 + 42 + return sortAndReduce(results); 43 + }
+47
web_src/js/utils/match.test.js
··· 1 + import {test, expect} from 'vitest'; 2 + import {matchEmoji, matchMention} from './match.js'; 3 + 4 + test('matchEmoji', () => { 5 + expect(matchEmoji('')).toEqual([ 6 + '+1', 7 + '-1', 8 + '100', 9 + '1234', 10 + '1st_place_medal', 11 + '2nd_place_medal', 12 + ]); 13 + 14 + expect(matchEmoji('hea')).toEqual([ 15 + 'headphones', 16 + 'headstone', 17 + 'health_worker', 18 + 'hear_no_evil', 19 + 'heard_mcdonald_islands', 20 + 'heart', 21 + ]); 22 + 23 + expect(matchEmoji('hear')).toEqual([ 24 + 'hear_no_evil', 25 + 'heard_mcdonald_islands', 26 + 'heart', 27 + 'heart_decoration', 28 + 'heart_eyes', 29 + 'heart_eyes_cat', 30 + ]); 31 + 32 + expect(matchEmoji('poo')).toEqual([ 33 + 'poodle', 34 + 'hankey', 35 + 'spoon', 36 + 'bowl_with_spoon', 37 + ]); 38 + 39 + expect(matchEmoji('1st_')).toEqual([ 40 + '1st_place_medal', 41 + ]); 42 + }); 43 + 44 + test('matchMention', () => { 45 + expect(matchMention('')).toEqual(window.config.tributeValues.slice(0, 6)); 46 + expect(matchMention('user4')).toEqual([window.config.tributeValues[3]]); 47 + });