diff --git a/package-lock.json b/package-lock.json index 8d3ce96f..ac84dd3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", "fast-equals": "~5.0.1", + "fuse.js": "~7.0.0", "html-prettify": "^1.0.7", "idb-keyval": "~6.2.1", "just-debounce-it": "~3.2.0", @@ -5130,6 +5131,14 @@ "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": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index c8485d51..907f901f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", "fast-equals": "~5.0.1", + "fuse.js": "~7.0.0", "html-prettify": "^1.0.7", "idb-keyval": "~6.2.1", "just-debounce-it": "~3.2.0", diff --git a/src/components/compose.css b/src/components/compose.css index 407f172f..220bcf4d 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -597,41 +597,123 @@ #custom-emojis-sheet { max-height: 50vh; max-height: 50dvh; -} -#custom-emojis-sheet main { - mask-image: none; -} -#custom-emojis-sheet .custom-emojis-list .section-header { - font-size: 80%; - text-transform: uppercase; - color: var(--text-insignificant-color); - padding: 8px 0 4px; - position: sticky; - top: 0; - background-color: var(--bg-blur-color); - backdrop-filter: blur(1px); -} -#custom-emojis-sheet .custom-emojis-list section { - display: flex; - flex-wrap: wrap; -} -#custom-emojis-sheet .custom-emojis-list button { - border-radius: 8px; - background-image: radial-gradient( - closest-side, - var(--img-bg-color), - transparent - ); -} -#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) { - filter: none; - background-color: var(--bg-faded-color); -} -#custom-emojis-sheet .custom-emojis-list button img { - transition: transform 0.1s ease-out; -} -#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img { - transform: scale(1.5); + + header { + .loader-container { + margin: 0; + } + + form { + margin: 8px 0 0; + + input { + width: 100%; + min-width: 0; + font-size: 0.8em; + } + } + } + + main { + mask-image: none; + min-height: 40vh; + padding-bottom: 88px; + } + + .custom-emojis-matches { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + } + + .custom-emojis-list { + .section-header { + font-size: 80%; + 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 { diff --git a/src/components/compose.jsx b/src/components/compose.jsx index 49f9a847..12045bd8 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -3,8 +3,16 @@ import './compose.css'; import '@github/text-expander-element'; import { MenuItem } from '@szhsin/react-menu'; import { deepEqual } from 'fast-equals'; +import Fuse from 'fuse.js'; +import { memo } 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 stringLength from 'string-length'; import { uid } from 'uid/single'; @@ -21,6 +29,7 @@ import db from '../utils/db'; import emojifyText from '../utils/emojify-text'; import localeMatch from '../utils/locale-match'; import openCompose from '../utils/open-compose'; +import pmem from '../utils/pmem'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import states, { saveStatus } from '../utils/states'; @@ -181,6 +190,8 @@ function highlightText(text, { maxCharacters = Infinity }) { const rtf = new Intl.RelativeTimeFormat(); +const CUSTOM_EMOJIS_COUNT = 100; + function Compose({ onClose, 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 { masto } = api(); + const { masto, instance } = api(); const [text, setText] = useState(ref.current?.value || ''); const { maxCharacters, performSearch = () => {}, ...textareaProps } = props; // const snapStates = useSnapshot(states); // const charCount = snapStates.composerCharacterCount; - const customEmojis = useRef(); + // const customEmojis = useRef(); + const searcherRef = useRef(); useEffect(() => { - (async () => { - try { - const emojis = await masto.v1.customEmojis.list(); - console.log({ emojis }); - customEmojis.current = emojis; - } catch (e) { - // silent fail + getCustomEmojis(instance, masto) + .then((r) => { + const [emojis, searcher] = r; + searcherRef.current = searcher; + }) + .catch((e) => { console.error(e); - } - })(); + }); }, []); const textExpanderRef = useRef(); @@ -1467,23 +1493,26 @@ const Textarea = forwardRef((props, ref) => { // const emojis = customEmojis.current.filter((emoji) => // 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 = ''; - emojis.forEach((emoji) => { + results.forEach(({ item: emoji }) => { const { shortcode, url } = emoji; html += `
  • - :${encodeHTML(shortcode)}: + ${encodeHTML(shortcode)}
  • `; }); // console.log({ emojis, html }); menu.innerHTML = html; provide( Promise.resolve({ - matched: emojis.length > 0, + matched: results.length > 0, fragment: menu, }), ); @@ -2185,38 +2214,19 @@ function CustomEmojisModal({ }) { const [uiState, setUIState] = useState('default'); const customEmojisList = useRef([]); - const [customEmojis, setCustomEmojis] = useState({}); + const [customEmojis, setCustomEmojis] = useState([]); const recentlyUsedCustomEmojis = useMemo( () => store.account.get('recentlyUsedCustomEmojis') || [], ); + const searcherRef = useRef(); useEffect(() => { setUIState('loading'); (async () => { try { - const emojis = await masto.v1.customEmojis.list(); - // Group emojis by category - const emojisCat = { - '--recent--': recentlyUsedCustomEmojis.filter((emoji) => - 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); + const [emojis, searcher] = await getCustomEmojis(instance, masto); + console.log('emojis', emojis); + searcherRef.current = searcher; + setCustomEmojis(emojis); setUIState('default'); } catch (e) { 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 (
    {!!onClose && ( @@ -2233,107 +2320,167 @@ function CustomEmojisModal({ )}
    - Custom emojis{' '} - {uiState === 'loading' ? ( - - ) : ( - • {instance} - )} -
    -
    -
    - {uiState === 'error' && ( -
    -

    Error loading custom emojis

    -
    +
    + Custom emojis{' '} + {uiState === 'loading' ? ( + + ) : ( + • {instance} )} - {uiState === 'default' && - Object.entries(customEmojis).map( - ([category, emojis]) => - !!emojis?.length && ( - <> -
    - {{ - '--recent--': 'Recently used', - '--others--': 'Others', - }[category] || category} -
    -
    - {emojis.map((emoji) => ( - - ))} -
    - - ), - )}
    +
    { + e.preventDefault(); + const emoji = matches[0]; + if (emoji) { + onSelectEmoji(`:${emoji.shortcode}:`); + } + }} + > + +
    + +
    + {matches !== null ? ( +
      + {matches.map((emoji) => ( +
    • + { + onSelectEmoji(`:${emoji.shortcode}:`); + }} + showCode + /> +
    • + ))} +
    + ) : ( +
    + {uiState === 'error' && ( +
    +

    Error loading custom emojis

    +
    + )} + {uiState === 'default' && + Object.entries(customEmojisCatList).map( + ([category, emojis]) => + !!emojis?.length && ( + <> +
    + {{ + '--recent--': 'Recently used', + '--others--': 'Others', + }[category] || category} +
    + + + ), + )} +
    + )}
    ); } +const CustomEmojisList = memo(({ emojis, onSelect }) => { + const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT); + const showMore = emojis.length > max; + return ( +
    + {emojis.slice(0, max).map((emoji) => ( + { + onSelect(`:${emoji.shortcode}:`); + }} + /> + ))} + {showMore && ( + + )} +
    + ); +}); + +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 ( + + ); +}); + const GIFS_PER_PAGE = 20; function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { const [uiState, setUIState] = useState('default'); diff --git a/src/components/status.css b/src/components/status.css index 247600cf..96ed5750 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -2386,8 +2386,8 @@ a.card:is(:hover, :focus):visited { max-width: 100%; height: 1.2em; vertical-align: text-bottom; - object-fit: cover; - object-position: left; + object-fit: contain; + /* object-position: left; */ } /* EDIT HISTORY */