Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/plugins/kaomojiPicker/README.md
Original file line number Diff line number Diff line change
@@ -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`.

![kaomoji tab in the expression picker](https://github.com/user-attachments/assets/0051f55d-0bb3-4ef7-970d-5a4830354552)
9 changes: 9 additions & 0 deletions src/plugins/kaomojiPicker/cl.ts
Original file line number Diff line number Diff line change
@@ -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-");
178 changes: 178 additions & 0 deletions src/plugins/kaomojiPicker/components/AddKaomojiModal.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<AddKaomojiModal modalProps={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<string>("");
const [pendingCategories, setPendingCategories] = useState<string[]>([]);
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 (
<Modal
{...modalProps}
title={isAdvanced ? "Import Kaomojis" : "Add Kaomoji"}
actions={[
{
text: "Cancel",
variant: "secondary",
onClick: modalProps.onClose
},
{
text: isAdvanced ? "Import" : "Add",
variant: "primary",
disabled: !canSubmit,
onClick: handleSubmit
}
]}
>
<Flex flexDirection="column" gap={12}>
{!isAdvanced ? (
<>
<section>
<Flex alignItems="center" justifyContent="space-between" className={cl("section-header-wrap")}>
<HeadingSecondary>ID</HeadingSecondary>
<TextButton
variant="link"
onClick={() => setIsAdvanced(true)}
>
Advanced addition(s)
</TextButton>
</Flex>
<TextInput
value={id}
onChange={setId}
placeholder="meow"
autoFocus
error={isDuplicateId ? "This ID already exists" : undefined} />
</section>
<section>
<HeadingSecondary>Kaomoji</HeadingSecondary>
<TextInput
value={value}
onChange={setValue}
placeholder="/ᐠ - ˕ -マ Ⳋ"
error={isDuplicateValue ? "This Kaomoji already exists" : undefined} />
</section>
<section>
<HeadingSecondary>Category</HeadingSecondary>
<SearchableSelect
options={categories.map(c => ({ label: c.charAt(0).toUpperCase() + c.slice(1), value: c }))}
value={category}
multi={false}
onChange={setCategory}
placeholder="Select category"
/>
<div className={cl("create-category-wrap")}>
<TextButton
variant="link"
onClick={() => {
openCreateCategoryModal(name => {
const tag = name.trim().toLowerCase();
if (tag) {
if (!categories.includes(tag)) {
setPendingCategories([...pendingCategories, tag]);
}
setCategory(tag);
}
});
}}
>
Create new category
</TextButton>
</div>
</section>
</>
) : (
<>
<section>
<Flex alignItems="center" justifyContent="space-between" className={cl("section-header-wrap")}>
<HeadingSecondary>Import String / JSON</HeadingSecondary>
<TextButton
variant="link"
onClick={() => setIsAdvanced(false)}
>
Simple addition
</TextButton>
</Flex>
<TextArea
value={rawInput}
onChange={setRawInput}
placeholder="(✿◠‿◠), (=^・ω・^=)"
autoFocus
/>
{errorMessage && (
<div className={cl("create-category-wrap")}>
<BaseText size="xs" className={cl("error-message")}>
{errorMessage}
</BaseText>
</div>
)}
</section>
</>
)}
</Flex>
</Modal>
);
}
64 changes: 64 additions & 0 deletions src/plugins/kaomojiPicker/components/CreateCategoryModal.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<CreateCategoryModal modalProps={modalProps} onConfirm={onConfirm} />
));
}

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 (
<Modal
{...modalProps}
title="Create new Category"
actions={[
{
text: "Cancel",
variant: "secondary",
onClick: modalProps.onClose
},
{
text: "Create",
variant: "primary",
disabled: !canSubmit,
onClick: handleConfirm
}
]}
>
<form
onSubmit={e => {
e.preventDefault();
handleConfirm();
}}
>
<TextInput
value={name}
onChange={setName}
placeholder="Cats"
autoFocus
error={isDuplicate ? "This category already exists" : undefined}
/>
</form>
</Modal>
);
}
38 changes: 38 additions & 0 deletions src/plugins/kaomojiPicker/components/ExportKaomoji.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex flexDirection="column" gap={8}>
<BaseText size="md" weight="medium">
Export Kaomoji
</BaseText>

<Flex gap={8}>
<TextInput
readOnly
value={exportString}
onClick={e => (e.target as HTMLInputElement).select()}
/>
<Button
variant="secondary"
onClick={() => copyWithToast(exportString, "Kaomoji JSON copied to clipboard !")}
>
Copy
</Button>
</Flex>
</Flex>
);
}
31 changes: 31 additions & 0 deletions src/plugins/kaomojiPicker/components/GridItem.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>, item: Kaomoji, sectionTitle: string) => void;
}

export function GridItem({ item, sectionTitle, onInsert, onHover, onContextMenu }: GridItemProps) {
return (
<Clickable
className={cl("item")}
onClick={() => onInsert(item)}
onMouseEnter={() => onHover(item)}
onContextMenu={e => onContextMenu(e, item, sectionTitle)}
>
<span className={cl("item-value")}>{item.value}</span>
</Clickable>
);
}
Loading