Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions resources/js/components/chat/message-reactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { emojiName, searchEmojiByUse } from '@/lib/emoji';
import { emojiName, searchEmoji } from '@/lib/emoji';
import { formatCount, formatExactCount } from '@/lib/numbers';
import type { ReactionUsage, Tap } from '@/lib/reactions';
import {
Expand Down Expand Up @@ -529,7 +529,7 @@ export function EmojiPicker({
return [
{
name: 'Results',
emoji: searchEmojiByUse(query, uses, 200).map(
emoji: searchEmoji(query, uses).map(
(result) => result.emoji,
),
},
Expand Down
31 changes: 5 additions & 26 deletions resources/js/lib/emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,33 +66,13 @@ function matchRank(name: string, partial: string): number {
}

/**
* The shortcodes that start with the query, then the ones with a later word starting with it, then the ones that only
* contain it, each shortest first.
* Each emoji once, under its best matching shortcode: the ones starting with the query, then the ones with a later
* word starting with it, then the ones that only contain it. Within each, the emoji the user uses most come first,
* then the shortest shortcodes. Colons around the query and its case are ignored.
*/
export function searchEmoji(query: string, limit = 8): EmojiSuggestion[] {
const partial = query.toLowerCase();

return Object.keys(shortcodeEmoji)
.filter((name) => name.includes(partial))
.map((name) => ({ name, rank: matchRank(name, partial) }))
.sort(
(left, right) =>
left.rank - right.rank ||
left.name.length - right.name.length ||
left.name.localeCompare(right.name),
)
.slice(0, limit)
.map(({ name }) => ({ name, emoji: shortcodeEmoji[name] }));
}

/**
* Each emoji once, under its best matching shortcode, ranked as searchEmoji does but with the emoji the user uses
* most first within each kind of match. Colons around the query are ignored.
*/
export function searchEmojiByUse(
export function searchEmoji(
query: string,
uses: ReadonlyMap<string, number>,
limit = 80,
uses: ReadonlyMap<string, number> = new Map(),
): EmojiSuggestion[] {
const partial = query
.trim()
Expand Down Expand Up @@ -124,6 +104,5 @@ export function searchEmojiByUse(
left.name.length - right.name.length ||
left.name.localeCompare(right.name),
)
.slice(0, limit)
.map(([emoji, { name }]) => ({ name, emoji }));
}
31 changes: 15 additions & 16 deletions tests/Unit/EmojiSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
/// <reference types="node" />
import assert from 'node:assert/strict';
import { test } from 'vite-plus/test';
import {
emojiName,
searchEmoji,
searchEmojiByUse,
} from '../../resources/js/lib/emoji';
import { emojiName, searchEmoji } from '../../resources/js/lib/emoji';

test('suggests the shortcodes starting with the query first, shortest first', () => {
const names = searchEmoji('smi').map((emoji) => emoji.name);

assert.deepEqual(names.slice(0, 3), ['smile', 'smirk', 'smiley']);
assert.ok(names.every((name) => name.startsWith('smi')));
const prefixed = names.filter((name) => name.startsWith('smi')).length;

assert.ok(names.slice(0, prefixed).every((name) => name.startsWith('smi')));
});

test('falls back to the shortcodes with a word starting with the query, ignoring case', () => {
Expand All @@ -28,27 +26,28 @@ test('lists the shortcodes containing the query inside a word last', () => {
assert.deepEqual(names, ['apple', 'green_apple', 'pineapple']);
});

test('limits the suggestions and pairs each shortcode with its emoji', () => {
const suggestions = searchEmoji('a', 5);

assert.equal(suggestions.length, 5);
assert.deepEqual(searchEmoji('+1', 1), [{ name: '+1', emoji: 'πŸ‘' }]);
test('suggests every matching shortcode and pairs each with its emoji', () => {
assert.equal(searchEmoji('heart').length, 34);
assert.deepEqual(
searchEmoji('boat').filter((result) => result.emoji === 'β›΅'),
[{ name: 'boat', emoji: 'β›΅' }],
);
assert.equal(searchEmoji('ar').length, 253);
assert.deepEqual(searchEmoji('+1'), [{ name: '+1', emoji: 'πŸ‘' }]);
assert.deepEqual(searchEmoji('no_such_shortcode'), []);
});

test('searches each emoji once, putting the ones the user reacts with most first within a match', () => {
const results = searchEmojiByUse('heart', new Map([['😍', 3]]));
const results = searchEmoji('heart', new Map([['😍', 3]]));

assert.deepEqual(
results.slice(0, 2).map((result) => result.emoji),
['😍', '❀️'],
);
assert.equal(
searchEmojiByUse('thumbs', new Map()).filter(
(result) => result.emoji === 'πŸ‘',
).length,
searchEmoji('thumbs').filter((result) => result.emoji === 'πŸ‘').length,
1,
);
assert.equal(searchEmojiByUse(':rock', new Map())[0].emoji, 'πŸͺ¨');
assert.equal(searchEmoji(':rock')[0].emoji, 'πŸͺ¨');
assert.equal(emojiName('πŸ‘'), '+1');
});
Loading