// gomuks - A Matrix client written in Go. // Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { useMemo, useRef } from "react" import { ContentURI, EventID, ImagePack, ImagePackUsage, ReactionEventContent } from "@/api/types" import data from "./data.json" export interface EmojiMetadata { c: number | string // Category number or custom emoji pack name t: string // Emoji title n: string // Primary shortcode s: string[] // Shortcodes without underscores } export interface EmojiText { u: string // Unicode codepoint or custom emoji mxc:// URI } export type PartialEmoji = EmojiText & Partial export type Emoji = EmojiText & EmojiMetadata export const emojis: Emoji[] = data.e export const emojiMap = new Map() export const categories = data.c export const CATEGORY_FREQUENTLY_USED = "Frequently Used" for (const emoji of emojis) { emojiMap.set(emoji.u, emoji) } function filter(emojis: Emoji[], query: string): Emoji[] { return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query))) } function filterAndSort( emojis: Emoji[], query: string, frequentlyUsed?: Map, customEmojis?: CustomEmojiPack[], ): Emoji[] { const filteredStandardEmojis = 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 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) => e1.matchIndex === e2.matchIndex ? (frequentlyUsed?.get(e2.emoji.u) ?? 0) - (frequentlyUsed?.get(e1.emoji.u) ?? 0) : e1.matchIndex - e2.matchIndex) .map(({ emoji }) => emoji) } export function emojiToMarkdown(emoji: PartialEmoji): string { if (emoji.u.startsWith("mxc://")) { return `:${emoji.n}:` } return emoji.u } export function emojiToReactionContent(emoji: PartialEmoji, evtID: EventID): ReactionEventContent { const content: ReactionEventContent = { "m.relates_to": { rel_type: "m.annotation", event_id: evtID, key: emoji.u, }, } if (emoji.u?.startsWith("mxc://") && emoji.n) { content["com.beeper.reaction.shortcode"] = emoji.n } return content } export interface CustomEmojiPack { id: string name: string icon?: ContentURI emojis: Emoji[] emojiMap: Map } 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() 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("_", "").replaceAll(" ", "")) } else { converted = { c: id, u: image.url, n: shortcode, s: [shortcode.toLowerCase().replaceAll("_", "").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 { query: string result: Emoji[][] } interface filteredAndSortedEmojiCache { query: string result: Emoji[] | null } interface useEmojisParams { frequentlyUsed?: Map customEmojiPacks?: CustomEmojiPack[] } export function useFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[][] { query = query.toLowerCase().replaceAll("_", "").replaceAll(" ", "") const frequentlyUsedCategory: Emoji[] = useMemo(() => { if (!params.frequentlyUsed?.size) { return [] } return 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 => emoji !== undefined)) .filter((_emoji, index) => index < 24) }, [params.frequentlyUsed]) const allPacks = [frequentlyUsedCategory, emojis, ...(params.customEmojiPacks?.map(pack => pack.emojis) ?? [])] const prev = useRef({ query: "", result: allPacks }) if (!query) { prev.current.query = "" prev.current.result = allPacks } else if (prev.current.query !== query) { if (query.startsWith(prev.current.query) && allPacks.length === prev.current.result.length) { prev.current.result = prev.current.result.map(pack => filter(pack, query)) } else { prev.current.result = allPacks.map(pack => filter(pack, query)) } prev.current.query = query } 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({ 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 ?? [] }