diff --git a/src/plugins/kaomojiPicker/README.md b/src/plugins/kaomojiPicker/README.md new file mode 100644 index 00000000000..eb587918875 --- /dev/null +++ b/src/plugins/kaomojiPicker/README.md @@ -0,0 +1,8 @@ +# KaomojiPicker (๑>ᴗ<๑) + +Adds a Kaomoji Tab to bring back the removed Kaomoji experiments + +### Small tips +You can quickly open the Kaomoji Tab by pressing `Ctrl + E` followed by `Ctrl + F`. + + diff --git a/src/plugins/kaomojiPicker/cl.ts b/src/plugins/kaomojiPicker/cl.ts new file mode 100644 index 00000000000..a428f89018d --- /dev/null +++ b/src/plugins/kaomojiPicker/cl.ts @@ -0,0 +1,9 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@utils/css"; + +export const cl = classNameFactory("vc-kaomoji-"); diff --git a/src/plugins/kaomojiPicker/components/AddKaomojiModal.tsx b/src/plugins/kaomojiPicker/components/AddKaomojiModal.tsx new file mode 100644 index 00000000000..73f5c2b042f --- /dev/null +++ b/src/plugins/kaomojiPicker/components/AddKaomojiModal.tsx @@ -0,0 +1,178 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { TextButton } from "@components/Button"; +import { Flex } from "@components/Flex"; +import { HeadingSecondary } from "@components/Heading"; +import { BaseText } from "@components/index"; +import { cl } from "@plugins/kaomojiPicker/cl"; +import { getAllKaomoji, getCategories, Kaomoji, parseUserSetting } from "@plugins/kaomojiPicker/data/kaomoji"; +import { saveUserKaomoji, useKaomojiStore } from "@plugins/kaomojiPicker/store"; +import { RenderModalProps } from "@vencord/discord-types"; +import { Modal, openModal, SearchableSelect, TextArea, TextInput, useState } from "@webpack/common"; + +import { openCreateCategoryModal } from "./CreateCategoryModal"; + +export function openAddKaomojiModal() { + openModal(modalProps => ( + + )); +} + +function AddKaomojiModal({ modalProps }: { modalProps: RenderModalProps; }) { + const { userKaomoji } = useKaomojiStore(); + const [isAdvanced, setIsAdvanced] = useState(false); + + const [id, setId] = useState(""); + const [value, setValue] = useState(""); + const [category, setCategory] = useState(""); + const [pendingCategories, setPendingCategories] = useState([]); + const [rawInput, setRawInput] = useState(""); + + const categories = Array.from(new Set([...getCategories(), ...pendingCategories])); + + const idTrimmed = id.trim(); + const valueTrimmed = value.trim(); + const isDuplicateId = Boolean(idTrimmed) && getAllKaomoji().some(e => e.id.toLowerCase() === idTrimmed.toLowerCase()); + const isDuplicateValue = Boolean(valueTrimmed) && getAllKaomoji().some(e => e.value === valueTrimmed); + const canSubmitSimple = Boolean(idTrimmed && valueTrimmed && !isDuplicateId && !isDuplicateValue); + + const parsedItems = parseUserSetting(rawInput); + const validItems = parsedItems.filter(item => !getAllKaomoji().some(exist => exist.value === item.value)); + const duplicate = parsedItems.length - validItems.length; + const canSubmitAdvanced = validItems.length > 0; + + const canSubmit = isAdvanced ? canSubmitAdvanced : canSubmitSimple; + + const errorMessage = parsedItems.length > 0 && validItems.length === 0 + ? "All entered kaomojis already exists" + : duplicate > 0 + ? `${duplicate} duplicate ${duplicate === 1 ? "kaomoji" : "kaomojis"} will be skipped` + : undefined; + + function handleSubmit() { + if (!canSubmit) return; + + if (isAdvanced) { + saveUserKaomoji([...userKaomoji, ...validItems]); + } else { + const kaomoji: Kaomoji = { + id: idTrimmed, + value: valueTrimmed, + tags: [category], + }; + saveUserKaomoji([...userKaomoji, kaomoji]); + } + modalProps.onClose(); + } + + return ( + + + {!isAdvanced ? ( + <> + + + ID + setIsAdvanced(true)} + > + Advanced addition(s) + + + + + + Kaomoji + + + + Category + ({ label: c.charAt(0).toUpperCase() + c.slice(1), value: c }))} + value={category} + multi={false} + onChange={setCategory} + placeholder="Select category" + /> + + { + openCreateCategoryModal(name => { + const tag = name.trim().toLowerCase(); + if (tag) { + if (!categories.includes(tag)) { + setPendingCategories([...pendingCategories, tag]); + } + setCategory(tag); + } + }); + }} + > + Create new category + + + + > + ) : ( + <> + + + Import String / JSON + setIsAdvanced(false)} + > + Simple addition + + + + {errorMessage && ( + + + {errorMessage} + + + )} + + > + )} + + + ); +} diff --git a/src/plugins/kaomojiPicker/components/CreateCategoryModal.tsx b/src/plugins/kaomojiPicker/components/CreateCategoryModal.tsx new file mode 100644 index 00000000000..2694f878cc8 --- /dev/null +++ b/src/plugins/kaomojiPicker/components/CreateCategoryModal.tsx @@ -0,0 +1,64 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { getCategories } from "@plugins/kaomojiPicker/data/kaomoji"; +import { RenderModalProps } from "@vencord/discord-types"; +import { Modal, openModal, TextInput, useState } from "@webpack/common"; + +export function openCreateCategoryModal(onConfirm: (name: string) => void) { + openModal(modalProps => ( + + )); +} + +function CreateCategoryModal({ modalProps, onConfirm }: { modalProps: RenderModalProps; onConfirm: (name: string) => void; }) { + const [name, setName] = useState(""); + + const trimmed = name.trim(); + const isDuplicate = getCategories().some(c => c.toLowerCase() === trimmed.toLowerCase()); + const canSubmit = Boolean(trimmed && !isDuplicate); + + function handleConfirm() { + if (!canSubmit) return; + onConfirm(trimmed); + modalProps.onClose(); + } + + return ( + + { + e.preventDefault(); + handleConfirm(); + }} + > + + + + ); +} diff --git a/src/plugins/kaomojiPicker/components/ExportKaomoji.tsx b/src/plugins/kaomojiPicker/components/ExportKaomoji.tsx new file mode 100644 index 00000000000..6cf216b56f6 --- /dev/null +++ b/src/plugins/kaomojiPicker/components/ExportKaomoji.tsx @@ -0,0 +1,38 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import { Flex } from "@components/Flex"; +import { getExportString } from "@plugins/kaomojiPicker/store"; +import { copyWithToast } from "@utils/discord"; +import { TextInput } from "@webpack/common"; + +export function ExportKaomoji() { + const exportString = getExportString(); + + return ( + + + Export Kaomoji + + + + (e.target as HTMLInputElement).select()} + /> + copyWithToast(exportString, "Kaomoji JSON copied to clipboard !")} + > + Copy + + + + ); +} diff --git a/src/plugins/kaomojiPicker/components/GridItem.tsx b/src/plugins/kaomojiPicker/components/GridItem.tsx new file mode 100644 index 00000000000..ad63bf9de91 --- /dev/null +++ b/src/plugins/kaomojiPicker/components/GridItem.tsx @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { cl } from "@plugins/kaomojiPicker/cl"; +import { Kaomoji } from "@plugins/kaomojiPicker/data/kaomoji"; +import { Clickable } from "@webpack/common"; +import type { MouseEvent } from "react"; + +interface GridItemProps { + item: Kaomoji; + sectionTitle: string; + onInsert: (item: Kaomoji) => void; + onHover: (item: Kaomoji) => void; + onContextMenu: (e: MouseEvent, item: Kaomoji, sectionTitle: string) => void; +} + +export function GridItem({ item, sectionTitle, onInsert, onHover, onContextMenu }: GridItemProps) { + return ( + onInsert(item)} + onMouseEnter={() => onHover(item)} + onContextMenu={e => onContextMenu(e, item, sectionTitle)} + > + {item.value} + + ); +} diff --git a/src/plugins/kaomojiPicker/components/KaomojiPicker.tsx b/src/plugins/kaomojiPicker/components/KaomojiPicker.tsx new file mode 100644 index 00000000000..b45486e9c7b --- /dev/null +++ b/src/plugins/kaomojiPicker/components/KaomojiPicker.tsx @@ -0,0 +1,247 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button } from "@components/Button"; +import { Flex } from "@components/Flex"; +import { ClockIcon, DeleteIcon, SearchIcon, StarFilled, StarOutlined } from "@components/Icons"; +import { cl } from "@plugins/kaomojiPicker/cl"; +import { getAllKaomoji, getCategories, Kaomoji } from "@plugins/kaomojiPicker/data/kaomoji"; +import { addRecent, deleteUserKaomoji, isFavorite, isFolded, removeRecent, toggleFavorite, toggleFolded, useKaomojiStore } from "@plugins/kaomojiPicker/store"; +import { insertTextIntoChatInputBox } from "@utils/discord"; +import { findComponentByCodeLazy } from "@webpack"; +import { ContextMenuApi, ExpressionPickerStore, Menu, ScrollerThin, TextInput, useCallback, useMemo, useState } from "@webpack/common"; +import type { ComponentProps, MouseEvent, ReactNode } from "react"; + +import { settings } from ".."; +import { GridItem } from "./GridItem"; +import { openManageKaomojiModal } from "./ManageKaomojiModal"; + +interface Section { + title: string; + icon?: ReactNode; + items: Kaomoji[]; +} + +interface SectionHeaderProps { + children?: ReactNode; + icon?: ReactNode; + isCollapsed?: boolean; + onClick?: () => void; +} + +const SectionHeader = findComponentByCodeLazy( + "isCollapsed:", + "trailing:" +); + +interface ExpressionPickerInspectorProps { + className?: string; + graphicPrimary?: ReactNode; + graphicSecondary?: ReactNode; + titlePrimary?: ReactNode; + titleSecondary?: ReactNode; + isFavorite?: boolean; +} + +const ExpressionPickerInspector = findComponentByCodeLazy( + "graphicPrimary", + 'variant:"text-md/semibold"', + 'variant:"text-xs/normal"' +); + +const SearchAccessory = (props: ComponentProps) => ; + +export function KaomojiPicker() { + const { favorites, recent, userKaomoji, version } = useKaomojiStore(); + + const [search, setSearch] = useState(""); + const [hoveredItem, setHoveredItem] = useState(null); + + const query = search.trim().toLowerCase(); + + const groupedSections = useMemo(() => { + const lookup = (v: string): Kaomoji => + getAllKaomoji().find(e => e.value === v) ?? { id: v, value: v, tags: [] }; + + const categories = getCategories(); + + const _sections: Section[] = []; + + if (favorites.length) + _sections.push({ title: "Favorites", icon: , items: favorites.map(lookup) }); + + if (settings.store.showRecent && recent.length) + _sections.push({ title: "Recent", icon: , items: recent.map(lookup) }); + + const grouped = new Map(); + for (const cat of categories) grouped.set(cat, []); + + for (const e of getAllKaomoji()) { + for (const tag of e.tags) { + const lowerTag = tag.toLowerCase(); + if (grouped.has(lowerTag)) { + grouped.get(lowerTag)!.push(e); + break; + } + } + } + + for (const cat of categories) { + const items = grouped.get(cat)!; + if (items.length) _sections.push({ title: cat, items }); + } + + return _sections; + }, [version, settings.store.showRecent]); + + const visible = useMemo(() => { + if (!query) return groupedSections; + + return [{ + title: "Search Results", + items: Array.from( + new Map( + groupedSections + .flatMap(s => s.items) + .filter(e => + e.id.toLowerCase().includes(query) + || e.value.toLowerCase().includes(query) + || e.tags.some(t => t.toLowerCase().includes(query)) + ) + .map(e => [e.id + e.value, e] as const) + ).values() + ) + }].filter(s => s.items.length > 0); + }, [query, groupedSections]); + + const handleInsert = useCallback((item: Kaomoji) => { + insertTextIntoChatInputBox(item.value + " "); + addRecent(item.value); + ExpressionPickerStore.closeExpressionPicker(); + }, []); + + const handleHover = useCallback((item: Kaomoji) => { + setHoveredItem(item); + }, []); + + const handleContextMenu = useCallback((event: MouseEvent, item: Kaomoji, sectionTitle: string) => { + const fav = isFavorite(item.value); + const isCustomItem = userKaomoji.some(e => e.value === item.value || e.id === item.id); + + ContextMenuApi.openContextMenu(event, () => ( + + { toggleFavorite(item.value); }} + /> + {sectionTitle === "Recent" && ( + { removeRecent(item.value); }} + /> + )} + {isCustomItem && sectionTitle !== "Recent" && ( + deleteUserKaomoji(item.value)} + /> + )} + + )); + }, [userKaomoji]); + + const displayItem = hoveredItem ?? visible[0]?.items[0]; + + return ( + + + + + + openManageKaomojiModal()} + > + Manage + + + + + + {visible.map(s => ( + + {!query && ( + toggleFolded(s.title)} + > + {s.title.charAt(0).toUpperCase() + s.title.slice(1)} + + )} + {(query || !isFolded(s.title)) && ( + + {s.items.map((item, idx) => ( + + + + ))} + + )} + + ))} + + {visible.length === 0 && ( + No kaomoji match your search + )} + + + + {displayItem.value} + )} + titlePrimary={displayItem?.id} + titleSecondary={displayItem?.tags.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(", ")} + isFavorite={displayItem ? isFavorite(displayItem.value) : false} + /> + + ); +} diff --git a/src/plugins/kaomojiPicker/components/ManageKaomojiModal.tsx b/src/plugins/kaomojiPicker/components/ManageKaomojiModal.tsx new file mode 100644 index 00000000000..f81e3277061 --- /dev/null +++ b/src/plugins/kaomojiPicker/components/ManageKaomojiModal.tsx @@ -0,0 +1,139 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { BaseText } from "@components/BaseText"; +import { Button, TextButton } from "@components/Button"; +import { Card } from "@components/Card"; +import { Flex } from "@components/Flex"; +import { DeleteIcon, SearchIcon } from "@components/Icons"; +import { cl } from "@plugins/kaomojiPicker/cl"; +import { deleteUserKaomoji, useKaomojiStore } from "@plugins/kaomojiPicker/store"; +import { RenderModalProps } from "@vencord/discord-types"; +import { ConfirmModal, Modal, openModal, ScrollerThin, TextInput, useMemo, useState } from "@webpack/common"; +import { ComponentProps } from "react"; + +import { openAddKaomojiModal } from "./AddKaomojiModal"; +import { ExportKaomoji } from "./ExportKaomoji"; + +const SearchAccessory = (props: ComponentProps) => ; + +export function openManageKaomojiModal() { + openModal(modalProps => ( + + )); +} + +function ManageKaomojiModal({ modalProps }: { modalProps: RenderModalProps; }) { + const { userKaomoji } = useKaomojiStore(); + const [search, setSearch] = useState(""); + const [exports, setExport] = useState(false); + + const query = search.trim().toLowerCase(); + + const visibleKaomoji = useMemo(() => { + if (!query) return userKaomoji; + + return userKaomoji.filter(item => + item.id.toLowerCase().includes(query) || + item.value.toLowerCase().includes(query) || + item.tags.some(tag => tag.toLowerCase().includes(query)) + ); + }, [userKaomoji, query]); + + return ( + + + + + + + openAddKaomojiModal()} + > + Add Kaomoji + + + {userKaomoji.length === 0 ? ( + + No kaomojis added yet. + + ) : visibleKaomoji.length === 0 ? ( + + No kaomojis match your search. + + ) : ( + + + {visibleKaomoji.map(item => ( + + + + + {item.value} + + + + + {item.id} + + + {item.tags.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(", ")} + + + + { + openModal(props => ( + { + deleteUserKaomoji(item.value); + }} + > + + Are you sure you want to delete the {item.value} kaomoji? + + + )); + }} + > + + + + ))} + + + )} + {!exports ? ( + + setExport(true)} + > + Export ? + + + ) : undefined} + {exports && } + + + ); +} diff --git a/src/plugins/kaomojiPicker/data/builtin.json b/src/plugins/kaomojiPicker/data/builtin.json new file mode 100644 index 00000000000..4f4c96e78cd --- /dev/null +++ b/src/plugins/kaomojiPicker/data/builtin.json @@ -0,0 +1,102 @@ +{ + + "wave": [ + { "id": "hello", "value": "( ̄▽ ̄)ノ" }, + { "id": "shy-wave", "value": "( *⌒ヮ⌒*)ゞ" }, + { "id": "happy-wave", "value": "ヽ(o´∀`o)ノ" }, + { "id": "hello-flower", "value": "ヽ(✿゚▽゚)ノ" }, + { "id": "waving", "value": "ヾ(\\*’O’\\*)/" } + ], + + "happy": [ + { "id": "relax", "value": "(^▽^)" }, + { "id": "so-happy", "value": "(*´▽`*)" }, + { "id": "grin", "value": "(^v^)" }, + { "id": "joy", "value": "ヽ(´▽`)/" }, + { "id": "sparkle", "value": "✧(≖ ◡ ≖✿)" }, + { "id": "cheer", "value": "\(^o^)/" }, + { "id": "yay", "value": "\\(^o^)/" }, + { "id": "agree", "value": "ദ്ദി ( ᵔ ᗜ ᵔ )" }, + { "id": "happy", "value": "(๑ᵔ⤙ᵔ๑)" }, + { "id": "very-happy", "value": "(˶˃ ᵕ ˂˶)" }, + { "id": "hug", "value": "(っ˶ ˘ ᵕ˘)ˆᵕ ˆ˶ς)" } + ], + + "excited": [ + { "id": "cute-joy", "value": "(๑>ᴗ<๑)" }, + { "id": "excited", "value": "o(>ω<)o" }, + { "id": "very-excited", "value": "(˶˃ ᵕ ˂˶) .ᐟ.ᐟ" }, + { "id": "sparkle-excited", "value": "ヾ(。✪ω✪。)シ" }, + { "id": "cat-excited", "value": ">⩊<" }, + { "id": "flowercited", "value": "(๑´>᎑<)~*" } + ], + + "cute": [ + { "id": "hello-cat", "value": "ฅ(•˕ •マ⟆" }, + { "id": "cat-calm", "value": "=^..^=" }, + { "id": "happy-cat", "value": "ฅ^•ﻌ•^ฅ" }, + { "id": "excited-cat", "value": "ฅ^>⩊<^ฅ" }, + { "id": "sleepy-cat", "value": "/ᐠ - ˕ -マ ᶻ 𝗓 𐰁" }, + { "id": "sparkle-nya", "value": "(ฅ✧ω✧ฅ)ニャ✧" }, + { "id": "bear", "value": "ʕ•ᴥ•ʔ" }, + { "id": "happy-bear", "value": "ฅ՞•ﻌ•՞ฅ" }, + { "id": "sparkle-bunny", "value": "݁ ˖Ი𐑼⋆" }, + { "id": "ribbon-bunny", "value": "Ი⑅𐑼" }, + { "id": "patpat", "value": "ヾ(•ω•`)o" }, + { "id": "pat", "value": "(っ˘ω˘ς )" } + ], + + "love": [ + { "id": "heart", "value": "⸜(。˃ ᵕ ˂ )⸝♡" }, + { "id": "kiss", "value": "(˶ ˘ ³˘)ˆᵕ ˆ˶) ❤︎.ᐟ" }, + { "id": "kiss-calm", "value": "(˶˘ ³˘(´͈ ᵕ `͈˶)" }, + { "id": "kiss-happy", "value": "( ˶˘ ³˘(ˊᗜˋ*)!♡" }, + { "id": "in-love", "value": "(⸝⸝ ♡﹏♡⸝⸝)" }, + { "id": "flustered", "value": "(⸝⸝๑﹏๑⸝⸝)" }, + { "id": "blush", "value": "(⸝⸝>⸝⸝<⸝⸝)" }, + { "id": "shy", "value": "(⁄ ⁄•⁄ω⁄•⁄ ⁄)" }, + { "id": "happy-cry", "value": "(╥‸╥)♡" }, + { "id": "calm-shy", "value": "(⸝⸝⩌⸝⸝⩌⸝⸝)" }, + { "id": "calm-shy-1", "value": "(,,¬﹏¬,,)" }, + { "id": "hug", "value": "(っ˶ ˘ ᵕ˘)ˆᵕ ˆ˶ς)" }, + { "id": "excited-love", "value": "(≧ヮ≦) 💕" } + ], + + "angry": [ + { "id": "jii", "value": "(¬_¬;)" }, + { "id": "pout", "value": "(˶˃⤙˂˶)" }, + { "id": "fume", "value": "(¬_¬)" }, + { "id": "mad", "value": "( ,,⩌'︿'⩌ꐦ,,)" }, + { "id": "are-u-kidding-me", "value": "(`Д´)" }, + { "id": "yandere", "value": "ヾ(๑╹◡╹)ノ🔪" }, + { "id": "so-mad", "value": "ヽ(`Д´#)ノ ムキー!!" } + ], + + "sad": [ + { "id": "cry", "value": "(╥﹏╥)" }, + { "id": "worried-cry", "value": "(;′⌒`)" }, + { "id": "huhuu-cry", "value": "(╥ᆺ╥;)" }, + { "id": "tears", "value": "(ಥ﹏ಥ)" }, + { "id": "sulk", "value": "(´-ω-`)" }, + { "id": "worried", "value": "(,,•᷄﹏•᷅,,)" }, + { "id": "ehh-i-see", "value": "(|lI.‸.)" }, + { "id": "meh", "value": "(´_`)" } + ], + + "surprised": [ + { "id": "wa", "value": "( ˶°ㅁ°) !!" }, + { "id": "gasp", "value": "Σ(°△°|||)" }, + { "id": "wow", "value": "w(°o°)w" }, + { "id": "shock", "value": "(⊙_⊙)" }, + { "id": "oops", "value": "(・◇・)" }, + { "id": "what", "value": "Σ(゚Д゚)" } + ], + + "misc": [ + { "id": "its-fine-pat", "value": "( ´・・)ノ(._.`)" }, + { "id": "smug", "value": "(¬‿¬)" }, + { "id": "shrug", "value": "¯\\_(ツ)_/¯" }, + { "id": "tableflip", "value": "(╯°□°)╯︵ ┻━┻" }, + { "id": "sleepy", "value": "(−_−) …zzz" } + ] +} diff --git a/src/plugins/kaomojiPicker/data/kaomoji.ts b/src/plugins/kaomojiPicker/data/kaomoji.ts new file mode 100644 index 00000000000..33d74677a36 --- /dev/null +++ b/src/plugins/kaomojiPicker/data/kaomoji.ts @@ -0,0 +1,72 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { userKaomoji } from "@plugins/kaomojiPicker/store"; + +import builtin from "./builtin.json"; + +export type Kaomoji = { + id: string; + value: string; + tags: string[]; +}; + +export function parseCategory(obj: Record): Kaomoji[] { + const results: Kaomoji[] = []; + + for (const [cat, items] of Object.entries(obj)) { + if (!Array.isArray(items)) continue; + items.forEach((item, idx) => { + const categoryId = idx !== 0 ? `${cat}-${idx}` : cat; + if (typeof item === "string") { + results.push({ id: categoryId, value: item, tags: [cat] }); + } else if (item && typeof item === "object") { + const vals = (Array.isArray(item) ? item : Object.values(item)) + .filter((v): v is string => typeof v === "string" && Boolean(v)); + if (vals.length >= 2) { + results.push({ id: vals[0], value: vals[1], tags: [cat] }); + } else if (vals.length === 1) { + results.push({ id: categoryId, value: vals[0], tags: [cat] }); + } + } + }); + } + return results; +} + +export function parseUserSetting(raw: string | undefined): Kaomoji[] { + const trim = raw?.trim(); + if (!trim) return []; + + if (trim.startsWith("{") || trim.startsWith("[")) { + try { + const parsed = JSON.parse(trim); + return parseCategory(Array.isArray(parsed) ? { custom: parsed } : parsed); + } catch { + try { + const sanitized = trim.replace(/,\s*([}\]])/g, "$1"); + const parsed = JSON.parse(sanitized); + return parseCategory(Array.isArray(parsed) ? { custom: parsed } : parsed); + } catch { } + } + } + + const items = trim.split(/[\n,]/).map(s => s.trim()).filter(Boolean); + return parseCategory({ custom: items }); +} + +export const BUILTIN_KAOMOJI: Kaomoji[] = parseCategory(builtin); + +export function getAllKaomoji(): Kaomoji[] { + return [...BUILTIN_KAOMOJI, ...userKaomoji]; +} + +export function getCategories(): string[] { + return Array.from(new Set([ + ...userKaomoji.flatMap(k => k.tags), + ...BUILTIN_KAOMOJI.flatMap(k => k.tags) + ])); +} diff --git a/src/plugins/kaomojiPicker/index.tsx b/src/plugins/kaomojiPicker/index.tsx new file mode 100644 index 00000000000..bb77e851833 --- /dev/null +++ b/src/plugins/kaomojiPicker/index.tsx @@ -0,0 +1,235 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import { definePluginSettings } from "@api/Settings"; +import { Devs, IS_MAC } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { ActiveView } from "@vencord/discord-types"; +import { findByPropsLazy } from "@webpack"; +import { ExpressionPickerStore } from "@webpack/common"; +import { ComponentType } from "react"; + +import { cl } from "./cl"; +import { ExportKaomoji } from "./components/ExportKaomoji"; +import { KaomojiPicker } from "./components/KaomojiPicker"; +import { getAllKaomoji, Kaomoji } from "./data/kaomoji"; +import { loadUserData } from "./store"; + +const Autocomplete = findByPropsLazy("Generic", "Title", "Divider"); + +export const settings = definePluginSettings({ + showRecent: { + type: OptionType.BOOLEAN, + description: "Show the Recent section", + default: true + }, + recentCap: { + type: OptionType.SLIDER, + description: "How many recently used kaomoji to keep", + markers: [4, 8, 12, 16, 20], + default: 16, + stickToMarkers: true + }, + userKaomoji: { + type: OptionType.COMPONENT, + component: () => + } +}); + +export default definePlugin({ + name: "KaomojiPicker", + description: "Adds a Kaomoji Tab", + tags: ["Emotes", "Chat"], + authors: [Devs.Shiro], + settings, + + patches: [ + { + find: /onlyEmojis[\s\S]*?role:"tablist"/, + group: true, + replacement: [ + { + // https://regex101.com/r/3sNGIb/3 + match: /(role:"tablist"[^>}]*?children:\s*\[(?:\i\s*,\s*)*(\i),)/, + replace: "$1$self.renderKaomojiTab($2.type,$self.useActiveView()===\"vc-kaomoji-picker-tab\")," + }, + { + // https://regex101.com/r/yjxYE4/2 + match: /(\i===\i\.\i\.EMOJI\|\|(?:\i\??\.)+onlyEmojis\s*===?\s*(?:!0|true)\?)/, + replace: "$self.useActiveView()===\"vc-kaomoji-picker-tab\"?$self.renderKaomojiGrid():$1" + } + ] + }, + { + find: "numEmojiResults:", + group: true, + replacement: [ + { + // https://regex101.com/r/QHX6nu/2 + match: /return\{results:\{emojis:(\i),stickers:(\i),soundmoji:(\i)\},metadata:/, + replace: "return{results:{emojis:$1,stickers:$2,soundmoji:$3,kaomoji:$self.getKaomoji(n)},metadata:" + }, + { + // https://regex101.com/r/WOheSy/1 + match: /(key:"emoji"\}\),)/, + replace: "$1...$self.renderKaomojiAutoComplete(arguments[0])," + }, + { + // https://regex101.com/r/IhJKa2/2 + match: /(key:"(?:stickers|soundmoji)",indexOffset:)(\i)\.length/g, + replace: "$1$2.length+($self.getKaomojiCount(arguments[0]))" + }, + { + // https://regex101.com/r/0aVxg5/2 + match: /if\(\(i-=(\i)\.length\)<(\i)\.length\)\{/, + replace: "let _km=$self.onSelectKaomoji(e,i-$1.length);if(_km)return _km;if((i-=$1.length+($self.getKaomojiCount(e)))<$2.length){" + } + ] + } + ], + + start() { + loadUserData(); + document.addEventListener("keydown", onKeyDown); + }, + + stop() { + document.removeEventListener("keydown", onKeyDown); + if (chordTimeout) clearTimeout(chordTimeout); + chordArmed = false; + }, + + renderKaomojiTab(Tab: ComponentType, active: boolean) { + return ( + + (^▽^) + + ); + }, + + renderKaomojiGrid() { + return ( + + + + ); + }, + + getKaomojiCount(e: any): number { + return e.results.kaomoji.length; + }, + + getKaomoji(search: string): Kaomoji[] { + const query = search.toLowerCase().trim(); + if (!query || query.length < 2) return []; + + return getAllKaomoji() + .filter(e => + e.id.toLowerCase().includes(query) + || e.value.toLowerCase().includes(query) + || e.tags.some(t => t.toLowerCase().includes(query)) + ) + .slice(0, 6); + }, + + renderKaomojiAutoComplete(args: any) { + const { results, selectedIndex, onClick, onHover, query } = args; + const kaomojiList: Kaomoji[] = results.kaomoji; + if (!kaomojiList.length) return []; + + const offset = results.emojis.length; + const hasEmojis = offset > 0; + + return [ + hasEmojis && ( + + ), + , + ...kaomojiList.map((item, idx) => { + const itemIndex = offset + idx; + return ( + onClick?.(itemIndex)} + onHover={() => onHover?.(itemIndex)} + /> + ); + }) + ]; + }, + + onSelectKaomoji(e: any, kaomojiIndex: number) { + const kaomojiList: Kaomoji[] = e.results.kaomoji; + if (!kaomojiList.length) return null; + + if (kaomojiIndex >= 0 && kaomojiIndex < kaomojiList.length) { + const selected = kaomojiList[kaomojiIndex]; + + e.options.insertText(selected.value); + return { type: "KAOMOJI" }; + } + + return null; + }, + + useActiveView(): ActiveView | null { + return ExpressionPickerStore.useExpressionPickerStore(s => s.activeView) ?? null; + }, + + openKaomojiView +}); + +export function openKaomojiView() { + ExpressionPickerStore.setExpressionPickerView(cl("picker-tab")); +} + +let chordArmed = false; +let chordTimeout: ReturnType | null = null; + +const isCtrl = (e: KeyboardEvent) => (IS_MAC ? e.metaKey : e.ctrlKey); + +function onKeyDown(e: KeyboardEvent) { + if (isCtrl(e) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "e") { + chordArmed = true; + if (chordTimeout) clearTimeout(chordTimeout); + chordTimeout = setTimeout(() => { + chordArmed = false; + }, 2000); + return; + } + + if (chordArmed && isCtrl(e) && e.key.toLowerCase() === "f") { + e.preventDefault(); + e.stopPropagation(); + chordArmed = false; + if (chordTimeout) clearTimeout(chordTimeout); + openKaomojiView(); + } +} diff --git a/src/plugins/kaomojiPicker/store.ts b/src/plugins/kaomojiPicker/store.ts new file mode 100644 index 00000000000..739a988aadb --- /dev/null +++ b/src/plugins/kaomojiPicker/store.ts @@ -0,0 +1,144 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { get, set } from "@api/DataStore"; +import { React } from "@webpack/common"; + +import { settings } from "."; +import { Kaomoji } from "./data/kaomoji"; + +const FAVORITES = "KaomojiPicker_Favorites"; +const RECENT = "KaomojiPicker_Recent"; +const FOLDED = "KaomojiPicker_FoldedSections"; +const KAOMOJI = "KaomojiPicker_UserKaomoji"; + +export let favorites: string[] = []; +export let recent: string[] = []; +export let foldedSections: string[] = []; +export let userKaomoji: Kaomoji[] = []; + +const listeners = new Set<() => void>(); + +export function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +let storeVersion = 0; +export function notify() { + storeVersion++; + for (const listener of listeners) { + listener(); + } +} + +export function useKaomojiStore() { + const version = React.useSyncExternalStore(subscribe, () => storeVersion); + return { + version, + favorites, + recent, + userKaomoji + }; +} + +export async function loadUserData() { + favorites = (await get(FAVORITES)) ?? []; + recent = (await get(RECENT)) ?? []; + foldedSections = (await get(FOLDED)) ?? []; + userKaomoji = (await get(KAOMOJI)) ?? []; + notify(); +} + +function saveFavorites() { + set(FAVORITES, favorites); + notify(); +} + +function saveRecent() { + set(RECENT, recent); + notify(); +} + +function saveFolded() { + set(FOLDED, foldedSections); + notify(); +} + +export async function saveUserKaomoji(kaomoji: Kaomoji[]) { + userKaomoji = kaomoji; + await set(KAOMOJI, userKaomoji); + notify(); +} + +export function getExportString() { + const cats: Record = {}; + + for (const item of userKaomoji) { + const cat = item.tags[0] || "custom"; + cats[cat] ??= []; + cats[cat].push({ + id: item.id, + value: item.value + }); + } + + return JSON.stringify(cats); +} + +export function isFavorite(value: string) { + return favorites.includes(value); +} + +export function toggleFavorite(value: string) { + const i = favorites.indexOf(value); + if (i === -1) favorites.push(value); + else favorites.splice(i, 1); + saveFavorites(); +} + +export function addRecent(value: string) { + recent = [value, ...recent.filter(v => v !== value)] + .slice(0, Math.max(1, settings.store.recentCap)); + saveRecent(); +} + +export function removeRecent(value: string) { + recent = recent.filter(v => v !== value); + saveRecent(); +} + +export function isFolded(sectionTitle: string) { + return foldedSections.includes(sectionTitle); +} + +export function toggleFolded(sectionTitle: string) { + const i = foldedSections.indexOf(sectionTitle); + if (i === -1) foldedSections.push(sectionTitle); + else foldedSections.splice(i, 1); + saveFolded(); +} + +export function addUserKaomoji(value: string, id?: string, category = "custom") { + const kaomoji: Kaomoji = { + id: id || category, + value: value.trim(), + tags: [category] + }; + saveUserKaomoji([...userKaomoji, kaomoji]); +} + +export function deleteUserKaomoji(value: string) { + userKaomoji = userKaomoji.filter(k => k.value !== value && k.id !== value); + favorites = favorites.filter(v => v !== value && v.trim() !== value.trim()); + recent = recent.filter(v => v !== value && v.trim() !== value.trim()); + + saveFavorites(); + saveRecent(); + saveUserKaomoji([...userKaomoji]); +} diff --git a/src/plugins/kaomojiPicker/style.css b/src/plugins/kaomojiPicker/style.css new file mode 100644 index 00000000000..636f23e3261 --- /dev/null +++ b/src/plugins/kaomojiPicker/style.css @@ -0,0 +1,187 @@ +.vc-kaomoji-wrapper { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; +} + +.vc-kaomoji-header { + flex-shrink: 0; + padding: var(--custom-gif-picker-gutter-size); + border-bottom: 1px solid var(--border-subtle); +} + +.vc-kaomoji-search-bar { + flex: 1; +} + +.vc-kaomoji-body-wrap { + display: flex; + flex: 1; + overflow: hidden; +} + +.vc-kaomoji-body { + display: flex; + flex: 1; + flex-direction: column; + padding: 0 4px; +} + +.vc-kaomoji-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + padding: 0 4px 12px 8px; +} + +.vc-kaomoji-item { + display: flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 2px 4px; + background: var(--background-secondary-alt); + border-radius: var(--radius-sm); + color: var(--text-default); + font-size: 13px; + word-break: break-all; + user-select: none; + cursor: pointer; + transition: background 0.1s; + + &:hover { + background: var(--background-mod-strong); + } +} + +.vc-kaomoji-empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + height: 100%; + min-height: 240px; + padding: 32px 16px; + color: var(--text-muted); + font-size: 14px; + text-align: center; +} + +.vc-kaomoji-inspector > div:first-child { + display: flex; + align-items: center; + justify-content: center; + width: max-content; + max-width: 260px; + height: 100%; + flex-shrink: 0; +} + +.vc-kaomoji-inspector-preview { + display: inline-flex; + align-items: center; + justify-content: center; + width: max-content; + max-width: 260px; + font-size: 20px; + line-height: normal; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-default); +} + +.vc-kaomoji-panel { + display: grid; + overflow: hidden; + min-height: 0; + border-radius: var(--custom-emoji-picker-border-radius); +} + +.vc-kaomoji-section > .vc-kaomoji-grid:first-child { + padding-top: 8px; +} + +.vc-kaomoji-autocomplete-divider { + padding-bottom: 0; +} + +.vc-kaomoji-autocomplete-title { + padding-top: 0; +} + +.vc-kaomoji-section-header-wrap { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; + + & > * { + margin: 0; + } +} + +.vc-kaomoji-create-category-wrap { + margin-top: 8px; +} + +.vc-kaomoji-manage-header { + flex-shrink: 0; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-subtle); +} + +.vc-kaomoji-manage-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + height: 56px; + box-sizing: border-box; + background-color: var(--background-secondary-alt); +} + +.vc-kaomoji-manage-info { + overflow: hidden; +} + +.vc-kaomoji-manage-empty { + color: var(--text-muted); + text-align: center; + padding-bottom: var(--space-8); +} + +.vc-kaomoji-manage-list { + max-height: 262px; + padding-right: 4px; +} + +.vc-kaomoji-manage-preview-wrap { + display: flex; + align-items: center; + justify-content: center; + min-width: 60px; + height: 100%; +} + +.vc-kaomoji-error-message { + color: var(--text-feedback-critical); + animation: slide-down .3s ease-in-out; +} + +@keyframes slide-down { + 0% { + height: 0; + width: 0; + transform: translateY(-4px); + } + + 100% { + height: auto; + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 160408c5385..c7599fd8401 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -673,6 +673,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ Kaede: { name: "Kaede", id: 1492642701320126504n + }, + Shiro: { + name: "Shiro", + id: 397211459651633166n } } satisfies Record);