mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
222 lines
6.8 KiB
TypeScript
222 lines
6.8 KiB
TypeScript
// 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 <https://www.gnu.org/licenses/>.
|
|
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<EmojiMetadata>
|
|
export type Emoji = EmojiText & EmojiMetadata
|
|
|
|
export const emojis: Emoji[] = data.e
|
|
export const emojiMap = new Map<string, Emoji>()
|
|
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<string, number>,
|
|
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 `<img data-mx-emoticon height="32" src="${emoji.u}" alt=":${emoji.n}:" title=":${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.emoji.shortcode"] = emoji.n
|
|
}
|
|
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 {
|
|
query: string
|
|
result: Emoji[][]
|
|
}
|
|
|
|
interface filteredAndSortedEmojiCache {
|
|
query: string
|
|
result: Emoji[] | null
|
|
}
|
|
|
|
interface useEmojisParams {
|
|
frequentlyUsed?: Map<string, number>
|
|
customEmojiPacks?: CustomEmojiPack[]
|
|
}
|
|
|
|
export function useFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[][] {
|
|
query = query.toLowerCase().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<filteredEmojiCache>({ 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<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 ?? []
|
|
}
|