Sort short/exact emoji matches before longer incomplete matches (#10212)
* apply sort for exact match * add tests for emoji provider * apply filter in the emoji picker * add tests * revert cypress version * put correct copyright * fix eslint * fix eslint * add type * fix cypress test * fix tsc types issues * add forgotten space... --------- Co-authored-by: grimhilt <grimhilt@users.noreply.github.com> Co-authored-by: David Baker <dbkr@users.noreply.github.com>
This commit is contained in:
parent
b9f61da7e6
commit
0546a11fd9
4 changed files with 97 additions and 17 deletions
|
@ -19,7 +19,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { uniq, sortBy, ListIteratee } from "lodash";
|
import { uniq, sortBy, uniqBy, ListIteratee } from "lodash";
|
||||||
import EMOTICON_REGEX from "emojibase-regex/emoticon";
|
import EMOTICON_REGEX from "emojibase-regex/emoticon";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
|
||||||
|
@ -118,7 +118,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
||||||
// Do second match with shouldMatchWordsOnly in order to match against 'name'
|
// Do second match with shouldMatchWordsOnly in order to match against 'name'
|
||||||
completions = completions.concat(this.nameMatcher.match(matchedString));
|
completions = completions.concat(this.nameMatcher.match(matchedString));
|
||||||
|
|
||||||
let sorters: ListIteratee<ISortedEmoji>[] = [];
|
const sorters: ListIteratee<ISortedEmoji>[] = [];
|
||||||
// make sure that emoticons come first
|
// make sure that emoticons come first
|
||||||
sorters.push((c) => score(matchedString, c.emoji.emoticon || ""));
|
sorters.push((c) => score(matchedString, c.emoji.emoticon || ""));
|
||||||
|
|
||||||
|
@ -140,11 +140,27 @@ export default class EmojiProvider extends AutocompleteProvider {
|
||||||
completions = completions.slice(0, LIMIT);
|
completions = completions.slice(0, LIMIT);
|
||||||
|
|
||||||
// Do a second sort to place emoji matching with frequently used one on top
|
// Do a second sort to place emoji matching with frequently used one on top
|
||||||
sorters = [];
|
const recentlyUsedAutocomplete: ISortedEmoji[] = [];
|
||||||
this.recentlyUsed.forEach((emoji) => {
|
this.recentlyUsed.forEach((emoji) => {
|
||||||
sorters.push((c) => score(emoji.shortcodes[0], c.emoji.shortcodes[0]));
|
if (emoji.shortcodes[0].indexOf(trimmedMatch) === 0) {
|
||||||
|
recentlyUsedAutocomplete.push({ emoji: emoji, _orderBy: 0 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
completions = sortBy<ISortedEmoji>(uniq(completions), sorters);
|
|
||||||
|
//if there is an exact shortcode match in the frequently used emojis, it goes before everything
|
||||||
|
for (let i = 0; i < recentlyUsedAutocomplete.length; i++) {
|
||||||
|
if (recentlyUsedAutocomplete[i].emoji.shortcodes[0] === trimmedMatch) {
|
||||||
|
const exactMatchEmoji = recentlyUsedAutocomplete[i];
|
||||||
|
for (let j = i; j > 0; j--) {
|
||||||
|
recentlyUsedAutocomplete[j] = recentlyUsedAutocomplete[j - 1];
|
||||||
|
}
|
||||||
|
recentlyUsedAutocomplete[0] = exactMatchEmoji;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completions = recentlyUsedAutocomplete.concat(completions);
|
||||||
|
completions = uniqBy(completions, "emoji");
|
||||||
|
|
||||||
return completions.map((c) => ({
|
return completions.map((c) => ({
|
||||||
completion: c.emoji.unicode,
|
completion: c.emoji.unicode,
|
||||||
|
|
|
@ -194,10 +194,30 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||||
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
|
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id];
|
||||||
}
|
}
|
||||||
emojis = emojis.filter((emoji) => this.emojiMatchesFilter(emoji, lcFilter));
|
emojis = emojis.filter((emoji) => this.emojiMatchesFilter(emoji, lcFilter));
|
||||||
|
emojis = emojis.sort((a, b) => {
|
||||||
|
const indexA = a.shortcodes[0].indexOf(lcFilter);
|
||||||
|
const indexB = b.shortcodes[0].indexOf(lcFilter);
|
||||||
|
|
||||||
|
// Prioritize emojis containing the filter in its shortcode
|
||||||
|
if (indexA == -1 || indexB == -1) {
|
||||||
|
return indexB - indexA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both emojis start with the filter
|
||||||
|
// put the shorter emoji first
|
||||||
|
if (indexA == 0 && indexB == 0) {
|
||||||
|
return a.shortcodes[0].length - b.shortcodes[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioritize emojis starting with the filter
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
this.memoizedDataByCategory[cat.id] = emojis;
|
this.memoizedDataByCategory[cat.id] = emojis;
|
||||||
cat.enabled = emojis.length > 0;
|
cat.enabled = emojis.length > 0;
|
||||||
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
|
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so...
|
||||||
cat.ref.current.disabled = !cat.enabled;
|
if (cat.ref.current) {
|
||||||
|
cat.ref.current.disabled = !cat.enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.setState({ filter });
|
this.setState({ filter });
|
||||||
// Header underlines need to be updated, but updating requires knowing
|
// Header underlines need to be updated, but updating requires knowing
|
||||||
|
|
|
@ -69,20 +69,30 @@ describe("EmojiProvider", function () {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("Returns correct autocompletion based on recently used emoji", async function () {
|
it("Recently used emojis are correctly sorted", async function () {
|
||||||
add("😘"); //kissing_heart
|
add("😘"); //kissing_heart
|
||||||
add("😘");
|
add("💗"); //heartpulse
|
||||||
add("😚"); //kissing_closed_eyes
|
add("💗"); //heartpulse
|
||||||
const emojiProvider = new EmojiProvider(null!);
|
add("😍"); //heart_eyes
|
||||||
|
|
||||||
let completionsList = await emojiProvider.getCompletions(":kis", { beginning: true, end: 3, start: 3 });
|
const ep = new EmojiProvider(testRoom);
|
||||||
expect(completionsList[0].component!.props.title).toEqual(":kissing_heart:");
|
const completionsList = await ep.getCompletions(":heart", { beginning: true, start: 0, end: 6 });
|
||||||
expect(completionsList[1].component!.props.title).toEqual(":kissing_closed_eyes:");
|
expect(completionsList[0]?.component?.props.title).toEqual(":heartpulse:");
|
||||||
|
expect(completionsList[1]?.component?.props.title).toEqual(":heart_eyes:");
|
||||||
|
});
|
||||||
|
|
||||||
completionsList = await emojiProvider.getCompletions(":kissing_c", { beginning: true, end: 3, start: 3 });
|
it("Exact match in recently used takes the lead", async function () {
|
||||||
expect(completionsList[0].component!.props.title).toEqual(":kissing_closed_eyes:");
|
add("😘"); //kissing_heart
|
||||||
|
add("💗"); //heartpulse
|
||||||
|
add("💗"); //heartpulse
|
||||||
|
add("😍"); //heart_eyes
|
||||||
|
|
||||||
completionsList = await emojiProvider.getCompletions(":so", { beginning: true, end: 2, start: 2 });
|
add("❤️"); //heart
|
||||||
expect(completionsList[0].component!.props.title).toEqual(":sob:");
|
const ep = new EmojiProvider(testRoom);
|
||||||
|
const completionsList = await ep.getCompletions(":heart", { beginning: true, start: 0, end: 6 });
|
||||||
|
|
||||||
|
expect(completionsList[0]?.component?.props.title).toEqual(":heart:");
|
||||||
|
expect(completionsList[1]?.component?.props.title).toEqual(":heartpulse:");
|
||||||
|
expect(completionsList[2]?.component?.props.title).toEqual(":heart_eyes:");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
34
test/components/views/emojipicker/EmojiPicker-test.tsx
Normal file
34
test/components/views/emojipicker/EmojiPicker-test.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker";
|
||||||
|
import { stubClient } from "../../../test-utils";
|
||||||
|
|
||||||
|
describe("EmojiPicker", function () {
|
||||||
|
stubClient();
|
||||||
|
|
||||||
|
it("sort emojis by shortcode and size", function () {
|
||||||
|
const ep = new EmojiPicker({ onChoose: (str: String) => false });
|
||||||
|
|
||||||
|
//@ts-ignore private access
|
||||||
|
ep.onChangeFilter("heart");
|
||||||
|
|
||||||
|
//@ts-ignore private access
|
||||||
|
expect(ep.memoizedDataByCategory["people"][0].shortcodes[0]).toEqual("heart");
|
||||||
|
//@ts-ignore private access
|
||||||
|
expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat");
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue