1
0
Fork 0
forked from Mirrors/gomuks

web/emoji: implement MSC2545

This commit is contained in:
Tulir Asokan 2024-10-26 15:46:36 +03:00
parent 96b11fca8e
commit ac3b906211
19 changed files with 435 additions and 109 deletions

View file

@ -145,7 +145,7 @@ func (gmx *Gomuks) SetupLog() {
} }
func (gmx *Gomuks) StartClient() { func (gmx *Gomuks) StartClient() {
hicli.HTMLSanitizerImgSrcTemplate = "_gomuks/media/%s/%s" hicli.HTMLSanitizerImgSrcTemplate = "_gomuks/media/%s/%s?encrypted=false"
rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{ rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{
PoolConfig: dbutil.PoolConfig{ PoolConfig: dbutil.PoolConfig{
Type: "sqlite3-fk-wal", Type: "sqlite3-fk-wal",

2
go.mod
View file

@ -24,7 +24,7 @@ require (
golang.org/x/text v0.19.0 golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.21.2-0.20241020164413-e17cb8385518 maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f
mvdan.cc/xurls/v2 v2.5.0 mvdan.cc/xurls/v2 v2.5.0
) )

4
go.sum
View file

@ -89,7 +89,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.21.2-0.20241020164413-e17cb8385518 h1:ROFV2DnfWw6wzNGXJP+SVSsd+cHoyeqHvVvaW5PQyk0= maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f h1:fZL9ASp9m4KaC0QUEDkv5ptPwVvRjigy9uPI6NYZAD0=
maunium.net/go/mautrix v0.21.2-0.20241020164413-e17cb8385518/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw= maunium.net/go/mautrix v0.21.2-0.20241026115159-a59d4d78677f/go.mod h1:sjCZR1R/3NET/WjkcXPL6WpAHlWKku9HjRsdOkbM8Qw=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -52,7 +52,7 @@ func (h *HiClient) SendMessage(
text = strings.TrimPrefix(text, "/html ") text = strings.TrimPrefix(text, "/html ")
content = format.RenderMarkdown(text, false, true) content = format.RenderMarkdown(text, false, true)
} else if text != "" { } else if text != "" {
content = format.RenderMarkdown(text, true, false) content = format.RenderMarkdown(text, true, true)
} }
if base != nil { if base != nil {
if text != "" { if text != "" {

View file

@ -81,7 +81,7 @@ export default tseslint.config(
"asyncArrow": "always", "asyncArrow": "always",
}], }],
"func-style": ["warn", "declaration", {"allowArrowFunctions": true}], "func-style": ["warn", "declaration", {"allowArrowFunctions": true}],
"id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}], "id-length": ["warn", {"min": 1, "max": 40, "exceptions": ["i", "j", "x", "y", "_"]}],
"new-cap": ["warn", { "new-cap": ["warn", {
"newIsCap": true, "newIsCap": true,
"capIsNew": true, "capIsNew": true,

View file

@ -24,6 +24,8 @@ export default class Client {
constructor(readonly rpc: RPCClient) { constructor(readonly rpc: RPCClient) {
this.rpc.event.listen(this.#handleEvent) this.rpc.event.listen(this.#handleEvent)
this.store.accountDataSubs.getSubscriber("im.ponies.emote_rooms")(() =>
queueMicrotask(() => this.#handleEmoteRoomsChange))
} }
get userID(): UserID { get userID(): UserID {
@ -102,6 +104,9 @@ export default class Client {
} }
async incrementFrequentlyUsedEmoji(targetEmoji: string) { async incrementFrequentlyUsedEmoji(targetEmoji: string) {
if (targetEmoji.startsWith("mxc://")) {
return
}
let recentEmoji = this.store.accountData.get("io.element.recent_emoji")?.recent_emoji as let recentEmoji = this.store.accountData.get("io.element.recent_emoji")?.recent_emoji as
[string, number][] | undefined [string, number][] | undefined
if (!Array.isArray(recentEmoji)) { if (!Array.isArray(recentEmoji)) {
@ -127,6 +132,14 @@ export default class Client {
await this.rpc.setAccountData("io.element.recent_emoji", newContent) await this.rpc.setAccountData("io.element.recent_emoji", newContent)
} }
#handleEmoteRoomsChange() {
this.store.invalidateEmojiPackKeyCache()
this.loadSpecificRoomState(this.store.getEmojiPackKeys()).then(
() => this.store.emojiRoomsSub.notify(),
err => console.error("Failed to load emote rooms", err),
)
}
async loadSpecificRoomState(keys: RoomStateGUID[]): Promise<void> { async loadSpecificRoomState(keys: RoomStateGUID[]): Promise<void> {
const missingKeys = keys.filter(key => { const missingKeys = keys.filter(key => {
const room = this.store.rooms.get(key.room_id) const room = this.store.rooms.get(key.room_id)

View file

@ -13,7 +13,8 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useSyncExternalStore } from "react" import { useMemo, useSyncExternalStore } from "react"
import { CustomEmojiPack } from "@/util/emoji"
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types" import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
import { StateStore } from "./main.ts" import { StateStore } from "./main.ts"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
@ -50,3 +51,27 @@ export function useAccountData(ss: StateStore, type: EventType): UnknownEventCon
() => ss.accountData.get(type) ?? null, () => ss.accountData.get(type) ?? null,
) )
} }
export function useCustomEmojis(
ss: StateStore, room: RoomStateStore,
): CustomEmojiPack[] {
const personalPack = useSyncExternalStore(
ss.accountDataSubs.getSubscriber("im.ponies.user_emotes"),
() => ss.getPersonalEmojiPack(),
)
const watchedRoomPacks = useSyncExternalStore(
ss.emojiRoomsSub.subscribe,
() => ss.getRoomEmojiPacks(),
)
const specialRoomPacks = useSyncExternalStore(
room.stateSubs.getSubscriber("im.ponies.room_emotes"),
() => room.getAllEmojiPacks(),
)
return useMemo(() => {
const allPacksObject = { ...watchedRoomPacks, ...specialRoomPacks }
if (personalPack) {
allPacksObject.personal = personalPack
}
return Object.values(allPacksObject)
}, [personalPack, watchedRoomPacks, specialRoomPacks])
}

View file

@ -14,21 +14,26 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import { focused } from "@/util/focus.ts" import { focused } from "@/util/focus.ts"
import toSearchableString from "@/util/searchablestring.ts" import toSearchableString from "@/util/searchablestring.ts"
import { MultiSubscribable } from "@/util/subscribable.ts" import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
import type { import {
ContentURI, ContentURI,
EventRowID, EventRowID,
EventsDecryptedData, EventsDecryptedData,
ImagePack,
ImagePackRooms,
MemDBEvent, MemDBEvent,
RoomID, RoomID,
RoomStateGUID,
SendCompleteData, SendCompleteData,
SyncCompleteData, SyncCompleteData,
SyncRoom, SyncRoom,
UnknownEventContent, UnknownEventContent,
UserID, UserID,
roomStateGUIDToString,
} from "../types" } from "../types"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
@ -51,7 +56,11 @@ export class StateStore {
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
readonly accountData: Map<string, UnknownEventContent> = new Map() readonly accountData: Map<string, UnknownEventContent> = new Map()
readonly accountDataSubs = new MultiSubscribable() readonly accountDataSubs = new MultiSubscribable()
readonly emojiRoomsSub = new Subscribable()
#frequentlyUsedEmoji: Map<string, number> | null = null #frequentlyUsedEmoji: Map<string, number> | null = null
#emojiPackKeys: RoomStateGUID[] | null = null
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
#personalEmojiPack: CustomEmojiPack | null = null
switchRoom?: (roomID: RoomID) => void switchRoom?: (roomID: RoomID) => void
imageAuthToken?: string imageAuthToken?: string
@ -116,7 +125,7 @@ export class StateStore {
let isNewRoom = false let isNewRoom = false
let room = this.rooms.get(roomID) let room = this.rooms.get(roomID)
if (!room) { if (!room) {
room = new RoomStateStore(data.meta) room = new RoomStateStore(data.meta, this)
this.rooms.set(roomID, room) this.rooms.set(roomID, room)
isNewRoom = true isNewRoom = true
} }
@ -184,6 +193,66 @@ export class StateStore {
} }
} }
invalidateEmojiPackKeyCache() {
this.#emojiPackKeys = null
}
invalidateEmojiPacksCache() {
this.#watchedRoomEmojiPacks = null
this.emojiRoomsSub.notify()
}
getPersonalEmojiPack(): CustomEmojiPack | null {
if (this.#personalEmojiPack === null) {
const pack = this.accountData.get("im.ponies.user_emotes")
if (!pack) {
return null
}
this.#personalEmojiPack = parseCustomEmojiPack(pack as ImagePack, "personal", "Personal pack")
}
return this.#personalEmojiPack
}
getEmojiPackKeys(): RoomStateGUID[] {
if (this.#emojiPackKeys === null) {
const emoteRooms = this.accountData.get("im.ponies.emote_rooms") as ImagePackRooms | undefined
try {
const emojiPacks: RoomStateGUID[] = []
for (const [roomID, packs] of Object.entries(emoteRooms?.rooms ?? {})) {
for (const pack of Object.keys(packs)) {
emojiPacks.push({ room_id: roomID, type: "im.ponies.room_emotes", state_key: pack })
}
}
this.#emojiPackKeys = emojiPacks
} catch (err) {
console.warn("Failed to parse emote rooms data", err, emoteRooms)
this.#emojiPackKeys = []
}
}
return this.#emojiPackKeys
}
getRoomEmojiPacks() {
if (this.#watchedRoomEmojiPacks === null) {
this.#watchedRoomEmojiPacks = Object.fromEntries(
this.getEmojiPackKeys()
.map(key => {
const room = this.rooms.get(key.room_id)
if (!room) {
return null
}
const pack = room.getEmojiPack(key.state_key)
if (!pack) {
return null
}
return [roomStateGUIDToString(key), pack]
})
.filter(pack => !!pack),
)
}
return this.#watchedRoomEmojiPacks ?? {}
}
get frequentlyUsedEmoji(): Map<string, number> { get frequentlyUsedEmoji(): Map<string, number> {
if (this.#frequentlyUsedEmoji === null) { if (this.#frequentlyUsedEmoji === null) {
const emojiData = this.accountData.get("io.element.recent_emoji") const emojiData = this.accountData.get("io.element.recent_emoji")

View file

@ -13,22 +13,26 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts" import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
import type { import {
DBRoom, DBRoom,
EncryptedEventContent, EncryptedEventContent,
EventID, EventID,
EventRowID, EventRowID,
EventType, EventType,
EventsDecryptedData, EventsDecryptedData,
ImagePack,
LazyLoadSummary, LazyLoadSummary,
MemDBEvent, MemDBEvent,
RawDBEvent, RawDBEvent,
RoomID, RoomID,
SyncRoom, SyncRoom,
TimelineRowTuple, TimelineRowTuple,
roomStateGUIDToString,
} from "../types" } from "../types"
import type { StateStore } from "./main.ts"
function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean { function arraysAreEqual<T>(arr1?: T[], arr2?: T[]): boolean {
if (!arr1 || !arr2) { if (!arr1 || !arr2) {
@ -75,12 +79,14 @@ export class RoomStateStore {
readonly eventSubs = new MultiSubscribable() readonly eventSubs = new MultiSubscribable()
readonly requestedEvents: Set<EventID> = new Set() readonly requestedEvents: Set<EventID> = new Set()
readonly openNotifications: Map<EventRowID, Notification> = new Map() readonly openNotifications: Map<EventRowID, Notification> = new Map()
readonly emojiPacks: Map<string, CustomEmojiPack | null> = new Map()
#allPacksCache: Record<string, CustomEmojiPack> | null = null
readonly pendingEvents: EventRowID[] = [] readonly pendingEvents: EventRowID[] = []
paginating = false paginating = false
paginationRequestedForRow = -1 paginationRequestedForRow = -1
readUpToRow = -1 readUpToRow = -1
constructor(meta: DBRoom) { constructor(meta: DBRoom, private parent: StateStore) {
this.roomID = meta.room_id this.roomID = meta.room_id
this.meta = new NonNullCachedEventDispatcher(meta) this.meta = new NonNullCachedEventDispatcher(meta)
} }
@ -111,6 +117,39 @@ export class RoomStateStore {
return this.eventsByRowID.get(rowID) return this.eventsByRowID.get(rowID)
} }
getEmojiPack(key: string): CustomEmojiPack | null {
if (!this.emojiPacks.has(key)) {
const pack = this.getStateEvent("im.ponies.room_emotes", key)?.content
if (!pack) {
this.emojiPacks.set(key, null)
return null
}
const fallbackName = key === ""
? this.meta.current.name : `${this.meta.current.name} - ${key}`
const packID = roomStateGUIDToString({
room_id: this.roomID,
type: "im.ponies.room_emotes",
state_key: key,
})
this.emojiPacks.set(key, parseCustomEmojiPack(pack as ImagePack, packID, fallbackName))
}
return this.emojiPacks.get(key) ?? null
}
getAllEmojiPacks(): Record<string, CustomEmojiPack> {
if (this.#allPacksCache === null) {
this.#allPacksCache = Object.fromEntries(
this.state.get("im.ponies.room_emotes")?.keys()
.map(stateKey => {
const pack = this.getEmojiPack(stateKey)
return pack ? [pack.id, pack] : null
})
.filter((res): res is [string, CustomEmojiPack] => !!res) ?? [],
)
}
return this.#allPacksCache
}
getPinnedEvents(): EventID[] { getPinnedEvents(): EventID[] {
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
if (Array.isArray(pinnedList)) { if (Array.isArray(pinnedList)) {
@ -185,6 +224,15 @@ export class RoomStateStore {
this.notifyTimelineSubscribers() this.notifyTimelineSubscribers()
} }
invalidateStateCaches(evtType: string, key: string) {
if (evtType === "im.ponies.room_emotes") {
this.emojiPacks.delete(key)
this.#allPacksCache = null
this.parent.invalidateEmojiPacksCache()
}
this.stateSubs.notify(this.stateSubKey(evtType, key))
}
applySync(sync: SyncRoom) { applySync(sync: SyncRoom) {
if (visibleMetaIsEqual(this.meta.current, sync.meta)) { if (visibleMetaIsEqual(this.meta.current, sync.meta)) {
this.meta.current = sync.meta this.meta.current = sync.meta
@ -202,8 +250,9 @@ export class RoomStateStore {
} }
for (const [key, rowID] of Object.entries(changedEvts)) { for (const [key, rowID] of Object.entries(changedEvts)) {
stateMap.set(key, rowID) stateMap.set(key, rowID)
this.stateSubs.notify(this.stateSubKey(evtType, key)) this.invalidateStateCaches(evtType, key)
} }
this.stateSubs.notify(evtType)
} }
if (sync.reset) { if (sync.reset) {
this.timeline = sync.timeline this.timeline = sync.timeline
@ -231,7 +280,8 @@ export class RoomStateStore {
this.state.set(evt.type, stateMap) this.state.set(evt.type, stateMap)
} }
stateMap.set(evt.state_key, evt.rowid) stateMap.set(evt.state_key, evt.rowid)
this.stateSubs.notify(this.stateSubKey(evt.type, evt.state_key)) this.invalidateStateCaches(evt.type, evt.state_key)
this.stateSubs.notify(evt.type)
} }
applyFullState(state: RawDBEvent[]) { applyFullState(state: RawDBEvent[]) {
@ -248,12 +298,15 @@ export class RoomStateStore {
} }
stateMap.set(evt.state_key, evt.rowid) stateMap.set(evt.state_key, evt.rowid)
} }
this.emojiPacks.clear()
this.#allPacksCache = null
this.state = newStateMap this.state = newStateMap
this.stateLoaded = true this.stateLoaded = true
for (const [evtType, stateMap] of newStateMap) { for (const [evtType, stateMap] of newStateMap) {
for (const [key] of stateMap) { for (const [key] of stateMap) {
this.stateSubs.notify(this.stateSubKey(evtType, key)) this.stateSubs.notify(this.stateSubKey(evtType, key))
} }
this.stateSubs.notify(evtType)
} }
} }

View file

@ -164,6 +164,10 @@ export interface ClientWellKnown {
} }
} }
export function roomStateGUIDToString(guid: RoomStateGUID): string {
return `${guid.room_id}/${guid.type}/${guid.state_key}`
}
export interface RoomStateGUID { export interface RoomStateGUID {
room_id: RoomID room_id: RoomID
type: EventType type: EventType

View file

@ -169,6 +169,7 @@ export type ImagePackUsage = "emoticon" | "sticker"
export interface ImagePackEntry { export interface ImagePackEntry {
url: ContentURI url: ContentURI
body?: string
info?: MediaInfo info?: MediaInfo
usage?: ImagePackUsage[] usage?: ImagePackUsage[]
} }

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m260-520 220-360 220 360H260ZM700-80q-75 0-127.5-52.5T520-260q0-75 52.5-127.5T700-440q75 0 127.5 52.5T880-260q0 75-52.5 127.5T700-80Zm-580-20v-320h320v320H120Zm580-60q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Zm-500-20h160v-160H200v160Zm202-420h156l-78-126-78 126Zm78 0ZM360-340Zm340 80Z"/></svg>

After

Width:  |  Height:  |  Size: 441 B

View file

@ -23,5 +23,10 @@ div.autocompletions {
&.selected, &:hover { &.selected, &:hover {
background-color: #f0f0f0; background-color: #f0f0f0;
} }
> img {
width: 1.5rem;
height: 1.5rem;
}
} }
} }

View file

@ -14,9 +14,9 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, use, useEffect } from "react" import { JSX, use, useEffect } from "react"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL, getMediaURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
import { Emoji, useFilteredEmojis } from "@/util/emoji" import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import type { ComposerState } from "./MessageComposer.tsx" import type { ComposerState } from "./MessageComposer.tsx"
@ -81,7 +81,7 @@ function useAutocompleter<T>({
onSelect(params.selected) onSelect(params.selected)
} }
}, [onSelect, params.selected]) }, [onSelect, params.selected])
const selected = params.selected !== undefined ? positiveMod(params.selected, items.length) : -1 const selected = params.selected !== undefined ? positiveMod(params.selected, items.length) : -1
return <div className="autocompletions"> return <div className="autocompletions">
{items.map((item, i) => <div {items.map((item, i) => <div
onClick={onClick} onClick={onClick}
@ -93,18 +93,22 @@ function useAutocompleter<T>({
} }
const emojiFuncs = { const emojiFuncs = {
getText: (emoji: Emoji) => emoji.u, getText: (emoji: Emoji) => emojiToMarkdown(emoji),
getKey: (emoji: Emoji) => emoji.u, getKey: (emoji: Emoji) => `${emoji.c}-${emoji.u}`,
render: (emoji: Emoji) => <>{emoji.u} :{emoji.n}:</>, render: (emoji: Emoji) => <>{emoji.u.startsWith("mxc://")
? <img loading="lazy" src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/>
: emoji.u
} :{emoji.n}:</>,
} }
export const EmojiAutocompleter = ({ params, ...rest }: AutocompleterProps) => { export const EmojiAutocompleter = ({ params, room, ...rest }: AutocompleterProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const items = useFilteredEmojis((params.frozenQuery ?? params.query).slice(1), { const customEmojiPacks = useCustomEmojis(client.store, room)
sorted: true, const items = useSortedAndFilteredEmojis((params.frozenQuery ?? params.query).slice(1), {
frequentlyUsed: client.store.frequentlyUsedEmoji, frequentlyUsed: client.store.frequentlyUsedEmoji,
customEmojiPacks,
}) })
return useAutocompleter({ params, ...rest, items, ...emojiFuncs }) return useAutocompleter({ params, room, ...rest, items, ...emojiFuncs })
} }
const escapeDisplayname = (input: string) => input const escapeDisplayname = (input: string) => input

View file

@ -25,7 +25,7 @@ import type {
RelatesTo, RelatesTo,
RoomID, RoomID,
} from "@/api/types" } from "@/api/types"
import { emojiToMarkdown } from "@/util/emoji" import { PartialEmoji, emojiToMarkdown } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
@ -320,17 +320,19 @@ const MessageComposer = () => {
evt.stopPropagation() evt.stopPropagation()
roomCtx.setEditing(null) roomCtx.setEditing(null)
}, [roomCtx]) }, [roomCtx])
const onSelectEmoji = useEvent((emoji: PartialEmoji) => {
setState({
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ emojiToMarkdown(emoji)
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
})
})
const openEmojiPicker = useEvent(() => { const openEmojiPicker = useEvent(() => {
openModal({ openModal({
content: <EmojiPicker content: <EmojiPicker
style={{ bottom: (composerRef.current?.clientHeight ?? 32) + 2, right: "1rem" }} style={{ bottom: (composerRef.current?.clientHeight ?? 32) + 2, right: "1rem" }}
onSelect={emoji => { room={roomCtx.store}
setState({ onSelect={onSelectEmoji}
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ emojiToMarkdown(emoji)
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
})
}}
/>, />,
onClose: () => textInput.current?.focus(), onClose: () => textInput.current?.focus(),
}) })

View file

@ -2,14 +2,14 @@ div.emoji-picker {
position: fixed; position: fixed;
background-color: white; background-color: white;
width: 22rem; width: 22rem;
height: 30rem; height: 34rem;
border-radius: 1rem; border-radius: 1rem;
border: 1px solid #ccc; border: 1px solid #ccc;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
div.emoji-category-bar { div.emoji-category-bar {
height: 2.5rem; /*height: 2.5rem;*/
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
@ -80,6 +80,11 @@ div.emoji-picker {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
> img {
width: 3rem;
height: 3rem;
}
} }
> div.emoji-name { > div.emoji-name {
@ -97,6 +102,7 @@ div.emoji-picker {
div.emoji-category { div.emoji-category {
width: 100%; width: 100%;
content-visibility: auto;
} }
div.emoji-category-list { div.emoji-category-list {
@ -109,6 +115,11 @@ div.emoji-picker {
margin: 0; margin: 0;
} }
button.emoji-category-icon > img, button.emoji > img {
width: 1.5rem;
height: 1.5rem;
}
button.emoji { button.emoji {
font-size: 1.25rem; font-size: 1.25rem;
padding: 0; padding: 0;

View file

@ -15,9 +15,12 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CSSProperties, JSX, use, useCallback, useState } from "react" import { CSSProperties, JSX, use, useCallback, useState } from "react"
import { getMediaURL } from "@/api/media.ts" import { getMediaURL } from "@/api/media.ts"
import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji" import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx" import { ModalCloseContext } from "../modal/Modal.tsx"
import FallbackPackIcon from "@/icons/category.svg?react"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react" import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react"
import AnimalsNatureIcon from "@/icons/emoji-categories/animals-nature.svg?react" import AnimalsNatureIcon from "@/icons/emoji-categories/animals-nature.svg?react"
@ -52,7 +55,7 @@ const sortedEmojiCategories: EmojiCategory[] = [
function renderEmoji(emoji: Emoji): JSX.Element | string { function renderEmoji(emoji: Emoji): JSX.Element | string {
if (emoji.u.startsWith("mxc://")) { if (emoji.u.startsWith("mxc://")) {
return <img src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/> return <img loading="lazy" src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/>
} }
return emoji.u return emoji.u
} }
@ -60,25 +63,27 @@ function renderEmoji(emoji: Emoji): JSX.Element | string {
interface EmojiPickerProps { interface EmojiPickerProps {
style: CSSProperties style: CSSProperties
onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void
room: RoomStateStore
allowFreeform?: boolean allowFreeform?: boolean
closeOnSelect?: boolean closeOnSelect?: boolean
selected?: string[] selected?: string[]
} }
export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnSelect }: EmojiPickerProps) => { export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const customEmojiPacks = useCustomEmojis(client.store, room)
const emojis = useFilteredEmojis(query, { const emojis = useFilteredEmojis(query, {
frequentlyUsed: client.store.frequentlyUsedEmoji, frequentlyUsed: client.store.frequentlyUsedEmoji,
frequentlyUsedAsCategory: true, customEmojiPacks,
}) })
const [previewEmoji, setPreviewEmoji] = useState<Emoji>() const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
const clearQuery = useCallback(() => setQuery(""), []) const clearQuery = useCallback(() => setQuery(""), [])
const cats: JSX.Element[] = []
let currentCat: JSX.Element[] = []
let currentCatNum: number | string = -1
const close = use(ModalCloseContext) const close = use(ModalCloseContext)
const onSelectWrapped = (emoji: PartialEmoji) => { const onSelectWrapped = (emoji?: PartialEmoji) => {
if (!emoji) {
return
}
onSelect(emoji, selected?.includes(emoji.u)) onSelect(emoji, selected?.includes(emoji.u))
if (emoji.c) { if (emoji.c) {
client.incrementFrequentlyUsedEmoji(emoji.u) client.incrementFrequentlyUsedEmoji(emoji.u)
@ -88,39 +93,72 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
close() close()
} }
} }
for (const emoji of emojis) { const getEmojiFromAttrs = (elem: HTMLButtonElement) => {
if (emoji.c === 2) { const groupIdx = elem.getAttribute("data-emoji-group-index")
continue if (!groupIdx) {
return
} }
if (emoji.c !== currentCatNum) { const idx = elem.getAttribute("data-emoji-index")
if (currentCat.length) { if (!idx) {
const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum return
cats.push(<div className="emoji-category" key={currentCatNum} id={`emoji-category-${categoryName}`}>
<h4 className="emoji-category-name">{categoryName}</h4>
<div className="emoji-category-list">
{currentCat}
</div>
</div>)
}
currentCatNum = emoji.c
currentCat = []
} }
currentCat.push(<button const emoji = emojis[+groupIdx]?.[+idx]
key={emoji.c === CATEGORY_FREQUENTLY_USED ? `freq-${emoji.u}` : emoji.u} if (!emoji) {
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`} return
onMouseOver={() => setPreviewEmoji(emoji)} }
onMouseOut={() => setPreviewEmoji(undefined)} return emoji
onClick={() => onSelectWrapped(emoji)}
>{renderEmoji(emoji)}</button>)
} }
if (currentCat.length) { const onClickEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
onSelectWrapped(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [])
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query }))
const renderedCats: JSX.Element[] = []
let currentCatRender: JSX.Element[] = []
let currentCatNum: number | string = -1
const renderCurrentCategory = () => {
if (!currentCatRender.length) {
return
}
const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum
cats.push(<div className="emoji-category" key={currentCatNum} id={`emoji-category-${categoryName}`}> renderedCats.push(<div
className="emoji-category"
key={currentCatNum}
id={`emoji-category-${categoryName}`}
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(currentCatRender.length / 8) * 2.5}rem` }}
>
<h4 className="emoji-category-name">{categoryName}</h4> <h4 className="emoji-category-name">{categoryName}</h4>
<div className="emoji-category-list"> <div className="emoji-category-list">
{currentCat} {currentCatRender}
</div> </div>
</div>) </div>)
currentCatRender = []
currentCatNum = -1
}
for (let catIdx = 0; catIdx < emojis.length; catIdx++) {
const cat = emojis[catIdx]
for (let emojiIdx = 0; emojiIdx < cat.length; emojiIdx++) {
const emoji = cat[emojiIdx]
if (emoji.c === 2) {
continue
}
if (emoji.c !== currentCatNum) {
renderCurrentCategory()
currentCatNum = emoji.c
}
currentCatRender.push(<button
key={`${emoji.c}-${emoji.u}`}
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
data-emoji-group-index={catIdx}
data-emoji-index={emojiIdx}
onMouseOver={onMouseOverEmoji}
onMouseOut={onMouseOutEmoji}
onClick={onClickEmoji}
>{renderEmoji(emoji)}</button>)
}
renderCurrentCategory()
} }
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), []) const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => { const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
@ -131,6 +169,7 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
<div className="emoji-category-bar"> <div className="emoji-category-bar">
<button <button
className="emoji-category-icon" className="emoji-category-icon"
data-category-id={CATEGORY_FREQUENTLY_USED}
title={CATEGORY_FREQUENTLY_USED} title={CATEGORY_FREQUENTLY_USED}
onClick={onClickCategoryButton} onClick={onClickCategoryButton}
>{<RecentIcon/>}</button> >{<RecentIcon/>}</button>
@ -138,10 +177,22 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
<button <button
key={cat.index} key={cat.index}
className="emoji-category-icon" className="emoji-category-icon"
data-category-id={cat.index}
title={cat.name ?? categories[cat.index]} title={cat.name ?? categories[cat.index]}
onClick={onClickCategoryButton} onClick={onClickCategoryButton}
>{cat.icon}</button>, >{cat.icon}</button>,
)} )}
{customEmojiPacks.map(customPack =>
<button
key={customPack.id}
className="emoji-category-icon custom-emoji"
data-category-id={customPack.id}
title={customPack.name}
onClick={onClickCategoryButton}
>
{customPack.icon ? <img src={getMediaURL(customPack.icon)} alt="" /> : <FallbackPackIcon/>}
</button>,
)}
</div> </div>
<div className="emoji-search"> <div className="emoji-search">
<input autoFocus onChange={onChangeQuery} value={query} type="search" placeholder="Search emojis"/> <input autoFocus onChange={onChangeQuery} value={query} type="search" placeholder="Search emojis"/>
@ -150,10 +201,10 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
</button> </button>
</div> </div>
<div className="emoji-list"> <div className="emoji-list">
{cats} {renderedCats}
{allowFreeform && query && <button {allowFreeform && query && <button
className="freeform-react" className="freeform-react"
onClick={() => onSelectWrapped({ u: query })} onClick={onClickFreeformReact}
>React with "{query}"</button>} >React with "{query}"</button>}
</div> </div>
{previewEmoji ? <div className="emoji-preview"> {previewEmoji ? <div className="emoji-preview">

View file

@ -71,12 +71,13 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id)) client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id))
.catch(err => window.alert(`Failed to send reaction: ${err}`)) .catch(err => window.alert(`Failed to send reaction: ${err}`))
}} }}
room={roomCtx.store}
closeOnSelect={true} closeOnSelect={true}
allowFreeform={true} allowFreeform={true}
/>, />,
onClose: () => setForceOpen(false), onClose: () => setForceOpen(false),
}) })
}, [client, evt, setForceOpen, openModal]) }, [client, roomCtx, evt, setForceOpen, openModal])
const onClickEdit = useCallback(() => { const onClickEdit = useCallback(() => {
roomCtx.setEditing(evt) roomCtx.setEditing(evt)
}, [roomCtx, evt]) }, [roomCtx, evt])

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useMemo, useRef } from "react" import { useMemo, useRef } from "react"
import { EventID, ReactionEventContent } from "@/api/types" import { ContentURI, EventID, ImagePack, ImagePackUsage, ReactionEventContent } from "@/api/types"
import data from "./data.json" import data from "./data.json"
export interface EmojiMetadata { export interface EmojiMetadata {
@ -45,8 +45,13 @@ function filter(emojis: Emoji[], query: string): Emoji[] {
return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query))) return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query)))
} }
function filterAndSort(emojis: Emoji[], query: string, frequentlyUsed?: Map<string, number>): Emoji[] { function filterAndSort(
return emojis emojis: Emoji[],
query: string,
frequentlyUsed?: Map<string, number>,
customEmojis?: CustomEmojiPack[],
): Emoji[] {
const filteredStandardEmojis = emojis
.map(emoji => { .map(emoji => {
const matchIndex = emoji.s.reduce((minIndex, shortcode) => { const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
const index = shortcode.indexOf(query) const index = shortcode.indexOf(query)
@ -55,6 +60,20 @@ function filterAndSort(emojis: Emoji[], query: string, frequentlyUsed?: Map<stri
return { emoji, matchIndex } return { emoji, matchIndex }
}) })
.filter(({ matchIndex }) => matchIndex !== -1) .filter(({ matchIndex }) => matchIndex !== -1)
const filteredCustomEmojis = customEmojis
?.flatMap(pack => pack.emojis
.map(emoji => {
const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
const index = shortcode.indexOf(query)
return index !== -1 && (minIndex === -1 || index < minIndex) ? index : minIndex
}, -1)
return { emoji, matchIndex }
})
.filter(({ matchIndex }) => matchIndex !== -1)) ?? []
const allEmojis = filteredCustomEmojis.length
? filteredStandardEmojis.concat(filteredCustomEmojis)
: filteredStandardEmojis
return allEmojis
.sort((e1, e2) => .sort((e1, e2) =>
e1.matchIndex === e2.matchIndex e1.matchIndex === e2.matchIndex
? (frequentlyUsed?.get(e2.emoji.u) ?? 0) - (frequentlyUsed?.get(e1.emoji.u) ?? 0) ? (frequentlyUsed?.get(e2.emoji.u) ?? 0) - (frequentlyUsed?.get(e1.emoji.u) ?? 0)
@ -62,15 +81,9 @@ function filterAndSort(emojis: Emoji[], query: string, frequentlyUsed?: Map<stri
.map(({ emoji }) => emoji) .map(({ emoji }) => emoji)
} }
export function search(query: string, sorted = false, prev?: Emoji[]): Emoji[] {
query = query.toLowerCase().replaceAll("_", "")
if (!query) return emojis
return (sorted ? filterAndSort : filter)(prev ?? emojis, query)
}
export function emojiToMarkdown(emoji: PartialEmoji): string { export function emojiToMarkdown(emoji: PartialEmoji): string {
if (emoji.u.startsWith("mxc://")) { if (emoji.u.startsWith("mxc://")) {
return `<img data-mx-emoticon src="${emoji.u}" alt=":${emoji.n}:" title=":${emoji.n}:"/>` return `<img data-mx-emoticon height="32" src="${emoji.u}" alt=":${emoji.n}:" title=":${emoji.n}:"/>`
} }
return emoji.u return emoji.u
} }
@ -89,48 +102,121 @@ export function emojiToReactionContent(emoji: PartialEmoji, evtID: EventID): Rea
return content return content
} }
export interface CustomEmojiPack {
id: string
name: string
icon?: ContentURI
emojis: Emoji[]
emojiMap: Map<string, Emoji>
}
export function parseCustomEmojiPack(
pack: ImagePack,
id: string,
fallbackName?: string,
usage: ImagePackUsage = "emoticon",
): CustomEmojiPack | null {
try {
if (pack.pack.usage && !pack.pack.usage.includes(usage)) {
return null
}
const name = pack.pack.display_name || fallbackName || "Unnamed pack"
const emojiMap = new Map<string, Emoji>()
for (const [shortcode, image] of Object.entries(pack.images)) {
if (!image.url || (image.usage && !image.usage.includes(usage))) {
continue
}
let converted = emojiMap.get(image.url)
if (converted) {
converted.s.push(shortcode.toLowerCase().replaceAll("_", ""))
} else {
converted = {
c: name,
u: image.url,
n: shortcode,
s: [shortcode.toLowerCase().replaceAll("_", "")],
t: image.body || shortcode,
}
emojiMap.set(image.url, converted)
}
}
const emojis = Array.from(emojiMap.values())
const icon = pack.pack.avatar_url || emojis[0]?.u
return {
id,
name,
icon,
emojis,
emojiMap,
}
} catch (err) {
console.warn("Failed to parse custom emoji pack", pack, err)
return null
}
}
interface filteredEmojiCache { interface filteredEmojiCache {
query: string query: string
result: Emoji[] result: Emoji[][]
} }
interface useFilteredEmojisParams { interface filteredAndSortedEmojiCache {
sorted?: boolean query: string
result: Emoji[] | null
}
interface useEmojisParams {
frequentlyUsed?: Map<string, number> frequentlyUsed?: Map<string, number>
frequentlyUsedAsCategory?: boolean customEmojiPacks?: CustomEmojiPack[]
} }
export function useFilteredEmojis(query: string, params: useFilteredEmojisParams = {}): Emoji[] { export function useFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[][] {
query = query.toLowerCase().replaceAll("_", "") query = query.toLowerCase().replaceAll("_", "")
const allEmojis: Emoji[] = useMemo(() => { const frequentlyUsedCategory: Emoji[] = useMemo(() => {
let output: Emoji[] = [] if (!params.frequentlyUsed?.size) {
if (params.frequentlyUsedAsCategory && params.frequentlyUsed) { return []
output = Array.from(params.frequentlyUsed.keys()
.map(key => {
const emoji = emojiMap.get(key)
if (!emoji) {
return undefined
}
return { ...emoji, c: CATEGORY_FREQUENTLY_USED } as Emoji
})
.filter((emoji, index): emoji is Emoji => emoji !== undefined && index < 24))
} }
if (output.length === 0) { return Array.from(params.frequentlyUsed.keys()
return emojis .map(key => {
} const emoji = emojiMap.get(key)
return output.concat(emojis) if (!emoji) {
}, [params.frequentlyUsed, params.frequentlyUsedAsCategory]) return undefined
const prev = useRef<filteredEmojiCache>({ query: "", result: allEmojis }) }
return { ...emoji, c: CATEGORY_FREQUENTLY_USED } as Emoji
})
.filter(emoji => emoji !== undefined))
.filter((_emoji, index) => index < 24)
}, [params.frequentlyUsed])
const allPacks = [frequentlyUsedCategory, emojis, ...(params.customEmojiPacks?.map(pack => pack.emojis) ?? [])]
const prev = useRef<filteredEmojiCache>({ query: "", result: allPacks })
if (!query) { if (!query) {
prev.current.query = "" prev.current.query = ""
prev.current.result = allEmojis prev.current.result = allPacks
} else if (prev.current.query !== query) { } else if (prev.current.query !== query) {
prev.current.result = (params.sorted ? filterAndSort : filter)( if (query.startsWith(prev.current.query) && allPacks.length === prev.current.result.length) {
query.startsWith(prev.current.query) ? prev.current.result : allEmojis, prev.current.result = prev.current.result.map(pack => filter(pack, query))
query, } else {
params.frequentlyUsed, prev.current.result = allPacks.map(pack => filter(pack, query))
) }
prev.current.query = query prev.current.query = query
} }
return prev.current.result return prev.current.result
} }
export function useSortedAndFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[] {
if (!query) {
throw new Error("useSortedAndFilteredEmojis requires a query")
}
query = query.toLowerCase().replaceAll("_", "")
const prev = useRef<filteredAndSortedEmojiCache>({ query: "", result: null })
if (prev.current.query !== query) {
if (prev.current.result != null && query.startsWith(prev.current.query)) {
prev.current.result = filterAndSort(prev.current.result, query, params.frequentlyUsed)
} else {
prev.current.result = filterAndSort(emojis, query, params.frequentlyUsed, params.customEmojiPacks)
}
prev.current.query = query
}
return prev.current.result ?? []
}