Add search for custom emojis

This commit is contained in:
Lim Chee Aun 2024-05-02 00:14:25 +08:00
parent 77bc06545c
commit 65a4c3441c
5 changed files with 411 additions and 172 deletions

9
package-lock.json generated
View file

@ -21,6 +21,7 @@
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1", "fast-equals": "~5.0.1",
"fuse.js": "~7.0.0",
"html-prettify": "^1.0.7", "html-prettify": "^1.0.7",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
@ -5130,6 +5131,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/fuse.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View file

@ -23,6 +23,7 @@
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1", "fast-equals": "~5.0.1",
"fuse.js": "~7.0.0",
"html-prettify": "^1.0.7", "html-prettify": "^1.0.7",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",

View file

@ -597,41 +597,123 @@
#custom-emojis-sheet { #custom-emojis-sheet {
max-height: 50vh; max-height: 50vh;
max-height: 50dvh; max-height: 50dvh;
}
#custom-emojis-sheet main { header {
mask-image: none; .loader-container {
} margin: 0;
#custom-emojis-sheet .custom-emojis-list .section-header { }
font-size: 80%;
text-transform: uppercase; form {
color: var(--text-insignificant-color); margin: 8px 0 0;
padding: 8px 0 4px;
position: sticky; input {
top: 0; width: 100%;
background-color: var(--bg-blur-color); min-width: 0;
backdrop-filter: blur(1px); font-size: 0.8em;
} }
#custom-emojis-sheet .custom-emojis-list section { }
display: flex; }
flex-wrap: wrap;
} main {
#custom-emojis-sheet .custom-emojis-list button { mask-image: none;
border-radius: 8px; min-height: 40vh;
background-image: radial-gradient( padding-bottom: 88px;
closest-side, }
var(--img-bg-color),
transparent .custom-emojis-matches {
); margin: 0;
} padding: 0;
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) { list-style: none;
filter: none; display: flex;
background-color: var(--bg-faded-color); flex-wrap: wrap;
} }
#custom-emojis-sheet .custom-emojis-list button img {
transition: transform 0.1s ease-out; .custom-emojis-list {
} .section-header {
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img { font-size: 80%;
transform: scale(1.5); text-transform: uppercase;
color: var(--text-insignificant-color);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-color);
z-index: 1;
}
section {
display: flex;
flex-wrap: wrap;
}
button {
color: var(--text-color);
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
text-shadow: 0 1px 0 var(--bg-color);
position: relative;
min-width: 44px;
min-height: 44px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
&[data-title]:after {
max-width: 50vw;
pointer-events: none;
position: absolute;
content: attr(data-title);
left: 50%;
top: 0;
background-color: var(--bg-color);
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--text-color);
transform: translate(-50%, -110%);
opacity: 0;
transition: opacity 0.1s ease-out 0.1s;
font-family: var(--monospace-font);
line-height: 1;
}
&.edge-left[data-title]:after {
left: 0;
transform: translate(0, -110%);
}
&.edge-right[data-title]:after {
left: 100%;
transform: translate(-100%, -110%);
}
&:is(:hover, :focus) {
z-index: 1;
filter: none;
background-color: var(--bg-faded-color);
&[data-title]:after {
opacity: 1;
}
}
img {
transition: transform 0.1s ease-out;
}
&:is(:hover, :focus) img {
transform: scale(2);
}
&.edge-left img {
transform-origin: left center;
}
&.edge-right img {
transform-origin: right center;
}
code {
font-size: 0.8em;
}
}
}
} }
.compose-field-container { .compose-field-container {

View file

@ -3,8 +3,16 @@ import './compose.css';
import '@github/text-expander-element'; import '@github/text-expander-element';
import { MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { deepEqual } from 'fast-equals'; import { deepEqual } from 'fast-equals';
import Fuse from 'fuse.js';
import { memo } from 'preact/compat';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import stringLength from 'string-length'; import stringLength from 'string-length';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
@ -21,6 +29,7 @@ import db from '../utils/db';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
import localeMatch from '../utils/locale-match'; import localeMatch from '../utils/locale-match';
import openCompose from '../utils/open-compose'; import openCompose from '../utils/open-compose';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
@ -181,6 +190,8 @@ function highlightText(text, { maxCharacters = Infinity }) {
const rtf = new Intl.RelativeTimeFormat(); const rtf = new Intl.RelativeTimeFormat();
const CUSTOM_EMOJIS_COUNT = 100;
function Compose({ function Compose({
onClose, onClose,
replyToStatus, replyToStatus,
@ -1423,25 +1434,40 @@ function autoResizeTextarea(textarea) {
} }
} }
async function _getCustomEmojis(instance, masto) {
const emojis = await masto.v1.customEmojis.list();
const visibleEmojis = emojis.filter((e) => e.visibleInPicker);
const searcher = new Fuse(visibleEmojis, {
keys: ['shortcode'],
findAllMatches: true,
});
return [visibleEmojis, searcher];
}
const getCustomEmojis = pmem(_getCustomEmojis, {
// Limit by time to reduce memory usage
// Cached by instance
matchesArg: (cacheKeyArg, keyArg) => cacheKeyArg.instance === keyArg.instance,
maxAge: 30 * 60 * 1000, // 30 minutes
});
const Textarea = forwardRef((props, ref) => { const Textarea = forwardRef((props, ref) => {
const { masto } = api(); const { masto, instance } = api();
const [text, setText] = useState(ref.current?.value || ''); const [text, setText] = useState(ref.current?.value || '');
const { maxCharacters, performSearch = () => {}, ...textareaProps } = props; const { maxCharacters, performSearch = () => {}, ...textareaProps } = props;
// const snapStates = useSnapshot(states); // const snapStates = useSnapshot(states);
// const charCount = snapStates.composerCharacterCount; // const charCount = snapStates.composerCharacterCount;
const customEmojis = useRef(); // const customEmojis = useRef();
const searcherRef = useRef();
useEffect(() => { useEffect(() => {
(async () => { getCustomEmojis(instance, masto)
try { .then((r) => {
const emojis = await masto.v1.customEmojis.list(); const [emojis, searcher] = r;
console.log({ emojis }); searcherRef.current = searcher;
customEmojis.current = emojis; })
} catch (e) { .catch((e) => {
// silent fail
console.error(e); console.error(e);
} });
})();
}, []); }, []);
const textExpanderRef = useRef(); const textExpanderRef = useRef();
@ -1467,23 +1493,26 @@ const Textarea = forwardRef((props, ref) => {
// const emojis = customEmojis.current.filter((emoji) => // const emojis = customEmojis.current.filter((emoji) =>
// emoji.shortcode.startsWith(text), // emoji.shortcode.startsWith(text),
// ); // );
const emojis = filterShortcodes(customEmojis.current, text); // const emojis = filterShortcodes(customEmojis.current, text);
const results = searcherRef.current?.search(text, {
limit: 5,
});
let html = ''; let html = '';
emojis.forEach((emoji) => { results.forEach(({ item: emoji }) => {
const { shortcode, url } = emoji; const { shortcode, url } = emoji;
html += ` html += `
<li role="option" data-value="${encodeHTML(shortcode)}"> <li role="option" data-value="${encodeHTML(shortcode)}">
<img src="${encodeHTML( <img src="${encodeHTML(
url, url,
)}" width="16" height="16" alt="" loading="lazy" /> )}" width="16" height="16" alt="" loading="lazy" />
:${encodeHTML(shortcode)}: ${encodeHTML(shortcode)}
</li>`; </li>`;
}); });
// console.log({ emojis, html }); // console.log({ emojis, html });
menu.innerHTML = html; menu.innerHTML = html;
provide( provide(
Promise.resolve({ Promise.resolve({
matched: emojis.length > 0, matched: results.length > 0,
fragment: menu, fragment: menu,
}), }),
); );
@ -2185,38 +2214,19 @@ function CustomEmojisModal({
}) { }) {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const customEmojisList = useRef([]); const customEmojisList = useRef([]);
const [customEmojis, setCustomEmojis] = useState({}); const [customEmojis, setCustomEmojis] = useState([]);
const recentlyUsedCustomEmojis = useMemo( const recentlyUsedCustomEmojis = useMemo(
() => store.account.get('recentlyUsedCustomEmojis') || [], () => store.account.get('recentlyUsedCustomEmojis') || [],
); );
const searcherRef = useRef();
useEffect(() => { useEffect(() => {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const emojis = await masto.v1.customEmojis.list(); const [emojis, searcher] = await getCustomEmojis(instance, masto);
// Group emojis by category console.log('emojis', emojis);
const emojisCat = { searcherRef.current = searcher;
'--recent--': recentlyUsedCustomEmojis.filter((emoji) => setCustomEmojis(emojis);
emojis.find((e) => e.shortcode === emoji.shortcode),
),
};
const othersCat = [];
emojis.forEach((emoji) => {
if (!emoji.visibleInPicker) return;
customEmojisList.current?.push?.(emoji);
if (!emoji.category) {
othersCat.push(emoji);
return;
}
if (!emojisCat[emoji.category]) {
emojisCat[emoji.category] = [];
}
emojisCat[emoji.category].push(emoji);
});
if (othersCat.length) {
emojisCat['--others--'] = othersCat;
}
setCustomEmojis(emojisCat);
setUIState('default'); setUIState('default');
} catch (e) { } catch (e) {
setUIState('error'); setUIState('error');
@ -2225,6 +2235,83 @@ function CustomEmojisModal({
})(); })();
}, []); }, []);
const customEmojisCatList = useMemo(() => {
// Group emojis by category
const emojisCat = {
'--recent--': recentlyUsedCustomEmojis.filter((emoji) =>
customEmojis.find((e) => e.shortcode === emoji.shortcode),
),
};
const othersCat = [];
customEmojis.forEach((emoji) => {
customEmojisList.current?.push?.(emoji);
if (!emoji.category) {
othersCat.push(emoji);
return;
}
if (!emojisCat[emoji.category]) {
emojisCat[emoji.category] = [];
}
emojisCat[emoji.category].push(emoji);
});
if (othersCat.length) {
emojisCat['--others--'] = othersCat;
}
return emojisCat;
}, [customEmojis]);
const scrollableRef = useRef();
const [matches, setMatches] = useState(null);
const onFind = useCallback(
(e) => {
const { value } = e.target;
if (value) {
const results = searcherRef.current?.search(value, {
limit: CUSTOM_EMOJIS_COUNT,
});
setMatches(results.map((r) => r.item));
scrollableRef.current?.scrollTo?.(0, 0);
} else {
setMatches(null);
}
},
[customEmojis],
);
const onSelectEmoji = useCallback(
(emoji) => {
onSelect?.(emoji);
onClose?.();
queueMicrotask(() => {
let recentlyUsedCustomEmojis =
store.account.get('recentlyUsedCustomEmojis') || [];
const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis.findIndex(
(e) => e.shortcode === emoji.shortcode,
);
if (recentlyUsedEmojiIndex !== -1) {
// Move emoji to index 0
recentlyUsedCustomEmojis.splice(recentlyUsedEmojiIndex, 1);
recentlyUsedCustomEmojis.unshift(emoji);
} else {
recentlyUsedCustomEmojis.unshift(emoji);
// Remove unavailable ones
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.filter((e) =>
customEmojisList.current?.find?.(
(emoji) => emoji.shortcode === e.shortcode,
),
);
// Limit to 10
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.slice(0, 10);
}
// Store back
store.account.set('recentlyUsedCustomEmojis', recentlyUsedCustomEmojis);
});
},
[onSelect],
);
return ( return (
<div id="custom-emojis-sheet" class="sheet"> <div id="custom-emojis-sheet" class="sheet">
{!!onClose && ( {!!onClose && (
@ -2233,107 +2320,167 @@ function CustomEmojisModal({
</button> </button>
)} )}
<header> <header>
<b>Custom emojis</b>{' '} <div>
{uiState === 'loading' ? ( <b>Custom emojis</b>{' '}
<Loader /> {uiState === 'loading' ? (
) : ( <Loader />
<small class="insignificant"> {instance}</small> ) : (
)} <small class="insignificant"> {instance}</small>
</header>
<main>
<div class="custom-emojis-list">
{uiState === 'error' && (
<div class="ui-state">
<p>Error loading custom emojis</p>
</div>
)} )}
{uiState === 'default' &&
Object.entries(customEmojis).map(
([category, emojis]) =>
!!emojis?.length && (
<>
<div class="section-header">
{{
'--recent--': 'Recently used',
'--others--': 'Others',
}[category] || category}
</div>
<section>
{emojis.map((emoji) => (
<button
key={emoji}
type="button"
class="plain4"
onClick={() => {
onClose();
requestAnimationFrame(() => {
onSelect(`:${emoji.shortcode}:`);
});
let recentlyUsedCustomEmojis =
store.account.get('recentlyUsedCustomEmojis') ||
[];
const recentlyUsedEmojiIndex =
recentlyUsedCustomEmojis.findIndex(
(e) => e.shortcode === emoji.shortcode,
);
if (recentlyUsedEmojiIndex !== -1) {
// Move emoji to index 0
recentlyUsedCustomEmojis.splice(
recentlyUsedEmojiIndex,
1,
);
recentlyUsedCustomEmojis.unshift(emoji);
} else {
recentlyUsedCustomEmojis.unshift(emoji);
// Remove unavailable ones
recentlyUsedCustomEmojis =
recentlyUsedCustomEmojis.filter((e) =>
customEmojisList.current?.find?.(
(emoji) => emoji.shortcode === e.shortcode,
),
);
// Limit to 10
recentlyUsedCustomEmojis =
recentlyUsedCustomEmojis.slice(0, 10);
}
// Store back
store.account.set(
'recentlyUsedCustomEmojis',
recentlyUsedCustomEmojis,
);
}}
title={`:${emoji.shortcode}:`}
>
<picture>
{!!emoji.staticUrl && (
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
/>
)}
<img
class="shortcode-emoji"
src={emoji.url || emoji.staticUrl}
alt={emoji.shortcode}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
</button>
))}
</section>
</>
),
)}
</div> </div>
<form
onSubmit={(e) => {
e.preventDefault();
const emoji = matches[0];
if (emoji) {
onSelectEmoji(`:${emoji.shortcode}:`);
}
}}
>
<input
type="search"
placeholder="Search emoji"
onInput={onFind}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellCheck="false"
dir="auto"
/>
</form>
</header>
<main ref={scrollableRef}>
{matches !== null ? (
<ul class="custom-emojis-matches custom-emojis-list">
{matches.map((emoji) => (
<li key={emoji.shortcode} class="custom-emojis-match">
<CustomEmojiButton
emoji={emoji}
onClick={() => {
onSelectEmoji(`:${emoji.shortcode}:`);
}}
showCode
/>
</li>
))}
</ul>
) : (
<div class="custom-emojis-list">
{uiState === 'error' && (
<div class="ui-state">
<p>Error loading custom emojis</p>
</div>
)}
{uiState === 'default' &&
Object.entries(customEmojisCatList).map(
([category, emojis]) =>
!!emojis?.length && (
<>
<div class="section-header">
{{
'--recent--': 'Recently used',
'--others--': 'Others',
}[category] || category}
</div>
<CustomEmojisList
emojis={emojis}
onSelect={onSelectEmoji}
/>
</>
),
)}
</div>
)}
</main> </main>
</div> </div>
); );
} }
const CustomEmojisList = memo(({ emojis, onSelect }) => {
const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT);
const showMore = emojis.length > max;
return (
<section>
{emojis.slice(0, max).map((emoji) => (
<CustomEmojiButton
key={emoji.shortcode}
emoji={emoji}
onClick={() => {
onSelect(`:${emoji.shortcode}:`);
}}
/>
))}
{showMore && (
<button
type="button"
class="plain small"
onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)}
>
{(emojis.length - max).toLocaleString()} more
</button>
)}
</section>
);
});
const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => {
const addEdges = (e) => {
// Add edge-left or edge-right class based on self position relative to scrollable parent
// If near left edge, add edge-left, if near right edge, add edge-right
const buffer = 88;
const parent = e.currentTarget.closest('main');
if (parent) {
const rect = parent.getBoundingClientRect();
const selfRect = e.currentTarget.getBoundingClientRect();
const targetClassList = e.currentTarget.classList;
if (selfRect.left < rect.left + buffer) {
targetClassList.add('edge-left');
targetClassList.remove('edge-right');
} else if (selfRect.right > rect.right - buffer) {
targetClassList.add('edge-right');
targetClassList.remove('edge-left');
} else {
targetClassList.remove('edge-left', 'edge-right');
}
}
};
return (
<button
type="button"
className="plain4"
onClick={onClick}
data-title={showCode ? undefined : emoji.shortcode}
onPointerEnter={addEdges}
onFocus={addEdges}
>
<picture>
{!!emoji.staticUrl && (
<source
srcSet={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
/>
)}
<img
className="shortcode-emoji"
src={emoji.url || emoji.staticUrl}
alt={emoji.shortcode}
width="24"
height="24"
loading="lazy"
decoding="async"
/>
</picture>
{showCode && (
<>
{' '}
<code>{emoji.shortcode}</code>
</>
)}
</button>
);
});
const GIFS_PER_PAGE = 20; const GIFS_PER_PAGE = 20;
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');

View file

@ -2386,8 +2386,8 @@ a.card:is(:hover, :focus):visited {
max-width: 100%; max-width: 100%;
height: 1.2em; height: 1.2em;
vertical-align: text-bottom; vertical-align: text-bottom;
object-fit: cover; object-fit: contain;
object-position: left; /* object-position: left; */
} }
/* EDIT HISTORY */ /* EDIT HISTORY */