diff --git a/src/equicordplugins/emojiAlias/components/modals/ClearAliasesConfirmModal.tsx b/src/equicordplugins/emojiAlias/components/modals/ClearAliasesConfirmModal.tsx new file mode 100644 index 000000000..dac9f4022 --- /dev/null +++ b/src/equicordplugins/emojiAlias/components/modals/ClearAliasesConfirmModal.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 { Button } from "@components/Button"; +import { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize } from "@utils/modal"; + +import { ClearAliasesConfirmModalProps } from "./types"; + +export function ClearAliasesConfirmModal({ modalProps, onConfirm }: ClearAliasesConfirmModalProps) { + return ( + + + Delete all aliases + + + + This will remove every emoji alias you saved. + + + + + + ); +} diff --git a/src/equicordplugins/emojiAlias/components/modals/SetAliasModal.tsx b/src/equicordplugins/emojiAlias/components/modals/SetAliasModal.tsx new file mode 100644 index 000000000..19e0fddd6 --- /dev/null +++ b/src/equicordplugins/emojiAlias/components/modals/SetAliasModal.tsx @@ -0,0 +1,74 @@ +/* + * 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 { Heading } from "@components/Heading"; +import { Paragraph } from "@components/Paragraph"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize } from "@utils/modal"; +import { TextInput, useState } from "@webpack/common"; + +import { SetAliasModalProps } from "./types"; + +export function SetAliasModal({ + modalProps, + emojiDisplayName, + initialAlias, + getValidationError, + isDuplicateAlias, + onSave +}: SetAliasModalProps) { + const [input, setInput] = useState(initialAlias); + const [error, setError] = useState(null); + + const validationError = getValidationError(input); + const duplicateAlias = isDuplicateAlias(input); + const finalError = duplicateAlias ? "Duplicate alias" : error ?? validationError; + const canSave = !validationError && !duplicateAlias; + + return ( + + + Set alias + + + + + Set an alias for {emojiDisplayName} + { + setInput(value); + setError(null); + }} + placeholder='Alias, e.g. "sob"' + /> + {finalError && ( + + {finalError} + + )} + + + + + + + ); +} diff --git a/src/equicordplugins/emojiAlias/components/modals/types.ts b/src/equicordplugins/emojiAlias/components/modals/types.ts new file mode 100644 index 000000000..7295b38a7 --- /dev/null +++ b/src/equicordplugins/emojiAlias/components/modals/types.ts @@ -0,0 +1,23 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ModalProps } from "@utils/modal"; + +export type SetAliasSaveResult = { ok: true; } | { ok: false; error: string; }; + +export interface SetAliasModalProps { + modalProps: ModalProps; + emojiDisplayName: string; + initialAlias: string; + getValidationError: (input: string) => string | null; + isDuplicateAlias: (input: string) => boolean; + onSave: (input: string) => Promise; +} + +export interface ClearAliasesConfirmModalProps { + modalProps: ModalProps; + onConfirm: () => Promise; +} diff --git a/src/equicordplugins/emojiAlias/index.tsx b/src/equicordplugins/emojiAlias/index.tsx new file mode 100644 index 000000000..03c2157cb --- /dev/null +++ b/src/equicordplugins/emojiAlias/index.tsx @@ -0,0 +1,1231 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { addGlobalContextMenuPatch, findGroupChildrenByChildId, GlobalContextMenuPatchCallback, NavContextMenuPatchCallback, removeGlobalContextMenuPatch } from "@api/ContextMenu"; +import { DataStore } from "@api/index"; +import { definePluginSettings } from "@api/Settings"; +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import { Paragraph } from "@components/Paragraph"; +import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { Logger } from "@utils/Logger"; +import { openModal } from "@utils/modal"; +import definePlugin, { OptionType } from "@utils/types"; +import { Message } from "@vencord/discord-types"; +import { findByPropsLazy, findExportedComponentLazy } from "@webpack"; +import { EmojiStore, Menu, TextInput, Toasts, useEffect, useState } from "@webpack/common"; + +import { ClearAliasesConfirmModal } from "./components/modals/ClearAliasesConfirmModal"; +import { SetAliasModal } from "./components/modals/SetAliasModal"; + +type EmojiKind = "custom" | "unicode"; + +interface StoredEmojiRef { + kind: EmojiKind; + id?: string; + name: string; + surrogates?: string; + animated?: boolean; +} + +type AliasMap = Record; + +interface EmojiResult { + id?: string; + name?: string; + uniqueName?: string; + surrogates?: string; + type?: string; + originalName?: string; + guildId?: string; + index?: number; + useSpriteSheet?: boolean; + names?: string[]; + emojiObject?: { + id?: string; + name?: string; + surrogates?: string; + guildId?: string; + animated?: boolean; + }; +} + +interface EmojiAutocompleteState { + query?: { + type?: string; + query?: string; + text?: string; + queryText?: string; + typeInfo?: { + sentinel?: string; + }; + results?: { + emojis?: (EmojiResult[] & { sliceTo?: number; }); + }; + }; +} + +interface EmojiDataset { + id?: string; + name?: string; + surrogates?: string; + type?: string; +} + +interface ExpressionPickerTarget { + dataset: EmojiDataset; + firstChild: HTMLImageElement | null; +} + +interface MessageContextMenuArgs { + favoriteableId?: string; + favoriteableName?: string; + favoriteableType?: string; + itemHref?: string; + itemSrc?: string; + itemTextContent?: string; + message?: Message; + target?: ExpressionPickerTarget | HTMLElement | Node; + rawTarget?: Node; + props?: MessageContextMenuArgs; +} + +const DATA_KEY = "emoji-aliases"; +const logger = new Logger("EmojiAlias"); +const EmojiQueryService = findByPropsLazy("queryEmojiResults"); +const PencilIcon = findExportedComponentLazy("PencilIcon"); +const cl = classNameFactory("vc-emoji-alias-"); + +let aliasMap: AliasMap = {}; +const aliasListeners = new Set<() => void>(); +let globalContextPatch: GlobalContextMenuPatchCallback | null = null; +const unicodeSurrogateCache = new Map(); +const aliasResultCache = new Map(); + +const settings = definePluginSettings({ + aliases: { + type: OptionType.COMPONENT, + description: "Manage your emoji aliases.", + component: AliasListSetting + }, + clearAll: { + type: OptionType.COMPONENT, + description: "Delete all aliases.", + component: ClearAllAliasesSetting + } +}); + +function subscribeAliases(listener: () => void) { + aliasListeners.add(listener); + return () => { + aliasListeners.delete(listener); + }; +} + +function notifyAliasesChanged() { + for (const listener of aliasListeners) { + listener(); + } +} + +function normalizeAlias(input: string): string { + if (typeof input !== "string") return ""; + return input.trim().replace(/^:+|:+$/g, "").toLowerCase(); +} + +function normalizeEmojiName(input: string): string { + return input.trim().replace(/^:+|:+$/g, ""); +} + +function normalizeEmojiNameForCompare(input: string | undefined): string { + return normalizeEmojiName(input ?? "").toLowerCase(); +} + +function getAliasValidationError(input: string): string | null { + const normalized = normalizeAlias(input); + if (!normalized.length) return "Alias is required."; + if (!/^[a-z0-9_]{2,32}$/.test(normalized)) return "Alias must be 2-32 characters and only use lowercase letters, numbers, or underscores."; + return null; +} + +function isStoredEmojiRef(value: unknown): value is StoredEmojiRef { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + if (candidate.kind !== "custom" && candidate.kind !== "unicode") return false; + if (typeof candidate.name !== "string" || candidate.name.length === 0) return false; + if (candidate.kind === "custom" && (!candidate.id || typeof candidate.id !== "string")) return false; + if (candidate.id != null && typeof candidate.id !== "string") return false; + if (candidate.surrogates != null && typeof candidate.surrogates !== "string") return false; + if (candidate.animated != null && typeof candidate.animated !== "boolean") return false; + return true; +} + +function parseAliasMap(value: unknown): AliasMap { + if (!value || typeof value !== "object" || Array.isArray(value)) { + logger.warn("Alias store was invalid and has been reset."); + return {}; + } + + const parsed: AliasMap = {}; + + for (const [alias, ref] of Object.entries(value)) { + if (!/^[a-z0-9_]{2,32}$/.test(alias) || !isStoredEmojiRef(ref)) { + logger.warn(`Ignoring invalid alias entry: ${alias}`); + continue; + } + if (ref.kind === "unicode") { + const surrogate = getUnicodeSurrogate(ref) ?? resolveUnicodeSurrogateByName(ref.name); + parsed[alias] = { + ...ref, + name: normalizeEmojiName(ref.name), + surrogates: surrogate + }; + continue; + } + + parsed[alias] = ref; + } + + return parsed; +} + +async function loadAliases() { + try { + aliasMap = parseAliasMap(await DataStore.get(DATA_KEY)); + } catch (error) { + aliasMap = {}; + logger.error("Failed to load emoji aliases.", error); + } + unicodeSurrogateCache.clear(); + aliasResultCache.clear(); + notifyAliasesChanged(); +} + +async function persistAliases(nextMap: AliasMap) { + aliasMap = nextMap; + await DataStore.set(DATA_KEY, aliasMap); + aliasResultCache.clear(); + notifyAliasesChanged(); +} + +function emojiIdentityFromRef(ref: StoredEmojiRef): string { + if (ref.kind === "custom" && ref.id) return `custom:${ref.id}`; + + const surrogate = getUnicodeSurrogate(ref)?.trim(); + if (surrogate) return `unicode:${surrogate}`; + + return `unicode-name:${ref.name.toLowerCase()}`; +} + +function emojiIdentityFromResult(emoji: EmojiResult): string { + const customId = emoji.id ?? emoji.emojiObject?.id; + if (customId) return `custom:${customId}`; + + const surrogate = (emoji.surrogates ?? emoji.emojiObject?.surrogates)?.trim(); + if (surrogate) return `unicode:${surrogate}`; + + const normalizedName = (emoji.uniqueName ?? emoji.name ?? emoji.emojiObject?.name ?? "").toLowerCase(); + return `unicode-name:${normalizedName}`; +} + +function isSameEmoji(left: StoredEmojiRef, right: StoredEmojiRef): boolean { + return emojiIdentityFromRef(left) === emojiIdentityFromRef(right); +} + +function getEmojiDisplayName(ref: StoredEmojiRef): string { + return ref.kind === "custom" + ? `:${ref.name}:` + : getUnicodeSurrogate(ref) ?? resolveUnicodeSurrogateByName(ref.name) ?? `:${normalizeEmojiName(ref.name)}:`; +} + +function isGifUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.pathname.endsWith(".gif") || parsed.searchParams.get("animated") === "true"; + } catch { + return false; + } +} + +function extractUnicodeEmoji(text: string | undefined | null): string | null { + if (!text) return null; + const match = text.match(/\p{Extended_Pictographic}(?:\uFE0F|\u200D\p{Extended_Pictographic})*/u); + return match?.[0] ?? null; +} + +function getUnicodeSurrogate(ref: StoredEmojiRef): string | undefined { + return ref.surrogates ?? extractUnicodeEmoji(ref.name) ?? undefined; +} + +function resolveUnicodeSurrogateByName(name: string): string | undefined { + const normalizedName = normalizeEmojiName(name); + if (!normalizedName.length) return undefined; + const cached = unicodeSurrogateCache.get(normalizedName); + if (cached !== undefined) return cached ?? undefined; + + const queryCandidates = [normalizedName, `:${normalizedName}:`]; + for (const query of queryCandidates) { + const queried = EmojiQueryService.queryEmojiResults({ + query, + channel: undefined, + intention: undefined, + maxCount: 100 + }); + + const unlocked = queried?.emojis?.unlocked as EmojiResult[] | undefined; + if (!unlocked?.length) continue; + + for (const result of unlocked) { + const surrogate = result.surrogates ?? result.emojiObject?.surrogates; + if (!surrogate) continue; + + const resultName = normalizeEmojiNameForCompare(result.uniqueName ?? result.name ?? result.originalName ?? ""); + if (resultName === normalizedName.toLowerCase()) { + unicodeSurrogateCache.set(normalizedName, surrogate); + return surrogate; + } + } + } + + unicodeSurrogateCache.set(normalizedName, null); + return undefined; +} + +function getUnicodePreviewUrl(ref: StoredEmojiRef): string | null { + const surrogate = getUnicodeSurrogate(ref) ?? resolveUnicodeSurrogateByName(ref.name); + if (!surrogate) return null; + + const codepoints = Array.from(surrogate) + .map(char => char.codePointAt(0)?.toString(16)) + .filter((value): value is string => !!value) + .join("-"); + + if (!codepoints.length) return null; + return `https://twemoji.maxcdn.com/v/latest/72x72/${codepoints}.png`; +} + +function isDirectUnicodeTarget(target: HTMLElement): string | null { + const text = target.textContent?.trim(); + if (!text) return null; + + const unicode = extractUnicodeEmoji(text); + if (!unicode) return null; + + const nonEmoji = text.replace(unicode, "").trim(); + if (nonEmoji.length) return null; + + return unicode; +} + +function getUnicodeFromNodeTarget(target: Node | null | undefined): string | null { + if (!target) return null; + if (target.nodeType === Node.TEXT_NODE) { + const text = target.textContent?.trim(); + if (!text) return null; + const unicode = extractUnicodeEmoji(text); + if (!unicode) return null; + const nonEmoji = text.replace(unicode, "").trim(); + return nonEmoji.length ? null : unicode; + } + + if (!(target instanceof HTMLElement)) return null; + const roleImg = target.closest("[role='img']") as HTMLElement | null; + const roleText = roleImg?.getAttribute("aria-label") ?? roleImg?.textContent; + const roleUnicode = extractUnicodeEmoji(roleText); + if (roleUnicode) return roleUnicode; + + return isDirectUnicodeTarget(target); +} + +function toEmojiRefFromExpressionTarget(target: ExpressionPickerTarget | undefined): StoredEmojiRef | null { + const dataset = target?.dataset; + if (!dataset || dataset.type !== "emoji" || !dataset.name) return null; + + const baseName = dataset.name.replace(/~\d+$/, ""); + + if (dataset.id) { + return { + kind: "custom", + id: dataset.id, + name: baseName, + animated: !!target.firstChild?.src && isGifUrl(target.firstChild.src) + }; + } + + return { + kind: "unicode", + name: baseName, + surrogates: dataset.surrogates + }; +} + +function toEmojiRefFromElementTarget(target: unknown): StoredEmojiRef | null { + const htmlTarget = target instanceof HTMLElement + ? target + : target instanceof Node + ? target.parentElement + : null; + if (!htmlTarget) return null; + + const emojiElement = htmlTarget.closest("[data-type='emoji']") as HTMLElement | null; + if (emojiElement) { + const datasetTarget = { + dataset: { + id: emojiElement.dataset.id, + name: emojiElement.dataset.name, + surrogates: emojiElement.dataset.surrogates, + type: emojiElement.dataset.type + }, + firstChild: emojiElement.firstChild as HTMLImageElement | null + } satisfies ExpressionPickerTarget; + + const fromDataset = toEmojiRefFromExpressionTarget(datasetTarget); + if (fromDataset) return fromDataset; + } + + const imageElement = htmlTarget.closest("img") as HTMLImageElement | null; + const imageSource = imageElement?.src; + if (!imageSource) { + const unicode = getUnicodeFromNodeTarget(htmlTarget) ?? extractUnicodeEmoji(imageElement?.alt); + if (!unicode) return null; + return { + kind: "unicode", + name: unicode, + surrogates: unicode + }; + } + + const idMatch = imageSource.match(/\/emojis\/(\d+)\.(?:png|gif|webp|jpe?g)/); + if (!idMatch) return null; + + const id = idMatch[1]; + const fromStore = EmojiStore.getCustomEmojiById(id); + const resolvedName = fromStore?.name + ?? imageElement.alt?.replace(/^:|:$/g, "") + ?? "emoji"; + + return { + kind: "custom", + id, + name: resolvedName, + animated: !!fromStore?.animated || isGifUrl(imageSource) + }; +} + +function toEmojiRefFromHrefContext(args: MessageContextMenuArgs): StoredEmojiRef | null { + const source = args.itemHref ?? args.itemSrc; + if (!source) { + const unicode = extractUnicodeEmoji(args.itemTextContent); + if (!unicode) return null; + return { + kind: "unicode", + name: unicode, + surrogates: unicode + }; + } + + const match = source.match(/\/emojis\/(\d+)\.(?:png|gif|webp|jpe?g)/); + if (!match) return null; + + const id = match[1]; + const fromStore = EmojiStore.getCustomEmojiById(id); + if (fromStore) { + return { + kind: "custom", + id, + name: fromStore.name, + animated: !!fromStore.animated || isGifUrl(source) + }; + } + + const contentName = args.favoriteableName + ?? args.itemTextContent?.match(/:([a-zA-Z0-9_]+):/)?.[1] + ?? args.message?.content.match(RegExp(``))?.[1] + ?? "emoji"; + + return { + kind: "custom", + id, + name: contentName, + animated: isGifUrl(source) + }; +} + +function toEmojiRefFromFavoriteableContext(args: MessageContextMenuArgs): StoredEmojiRef | null { + if (args.favoriteableType !== "emoji") return null; + if (!args.favoriteableId) { + const unicode = getUnicodeFromNodeTarget(args.rawTarget) + ?? getUnicodeFromNodeTarget(args.target instanceof Node ? args.target : null) + ?? extractUnicodeEmoji(args.itemTextContent); + if (unicode) { + return { + kind: "unicode", + name: normalizeEmojiName(args.favoriteableName ?? unicode), + surrogates: unicode + }; + } + + if (!args.favoriteableName) return null; + return { + kind: "unicode", + name: normalizeEmojiName(args.favoriteableName) + }; + } + + const id = args.favoriteableId; + const fromStore = EmojiStore.getCustomEmojiById(id); + if (fromStore) { + return { + kind: "custom", + id, + name: fromStore.name, + animated: !!fromStore.animated || isGifUrl(args.itemHref ?? args.itemSrc ?? "") + }; + } + + const contentName = args.favoriteableName + ?? args.itemTextContent?.match(/:([a-zA-Z0-9_]+):/)?.[1] + ?? args.message?.content.match(RegExp(``))?.[1] + ?? "emoji"; + + return { + kind: "custom", + id, + name: contentName, + animated: isGifUrl(args.itemHref ?? args.itemSrc ?? "") + }; +} + +function toEmojiRefFromStrictTarget(args: MessageContextMenuArgs): StoredEmojiRef | null { + if (!(args.target instanceof HTMLElement)) return null; + + const elementTargetEmojiRef = toEmojiRefFromElementTarget(args.target); + if (elementTargetEmojiRef) return elementTargetEmojiRef; + + return toEmojiRefFromHrefContext(args); +} + +function toEmojiRefFromMessageContext(args: MessageContextMenuArgs): StoredEmojiRef | null { + const favoriteableEmojiRef = toEmojiRefFromFavoriteableContext(args); + if (favoriteableEmojiRef) return favoriteableEmojiRef; + + return toEmojiRefFromStrictTarget(args); +} + +function hasExplicitEmojiContext(args: MessageContextMenuArgs): boolean { + if (args.favoriteableType === "emoji") return true; + if (args.favoriteableId) return true; + + const source = args.itemHref ?? args.itemSrc; + if (source?.includes("/emojis/")) return true; + + const target = args.target instanceof HTMLElement + ? args.target + : args.target instanceof Node + ? args.target.parentElement + : null; + if (!target) return false; + + if (target.closest("[data-type='emoji']")) return true; + const roleImg = target.closest("[role='img']") as HTMLElement | null; + if (roleImg) { + const roleText = roleImg.getAttribute("aria-label") ?? roleImg.textContent; + if (extractUnicodeEmoji(roleText)) return true; + } + const img = target.closest("img") as HTMLImageElement | null; + if (img?.src?.includes("/emojis/")) return true; + if (extractUnicodeEmoji(img?.alt)) return true; + + return false; +} + +function normalizeContextKey(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value !== "object") return typeof value; + + const record = value as Record; + const priorityValues = [record.id, record.channelId, record.guildId, record.type, record.emojiIntention, record.name] + .filter((candidate): candidate is string | number => typeof candidate === "string" || typeof candidate === "number"); + if (priorityValues.length) return priorityValues.map(String).join(":"); + + const entries = Object.keys(record) + .sort() + .slice(0, 6) + .map(key => { + const candidate = record[key]; + if (typeof candidate === "string" || typeof candidate === "number" || typeof candidate === "boolean") { + return `${key}:${String(candidate)}`; + } + return `${key}:${typeof candidate}`; + }); + return entries.join("|"); +} + +function resolveTargetFromArgs(args: unknown[]): HTMLElement | null { + for (const arg of args) { + if (arg instanceof HTMLElement) return arg; + if (arg instanceof Node) return arg.parentElement; + if (!arg || typeof arg !== "object") continue; + + const maybeTarget = (arg as { target?: unknown; }).target; + if (maybeTarget instanceof HTMLElement) return maybeTarget; + if (maybeTarget instanceof Node) return maybeTarget.parentElement; + + const nativeTarget = (arg as { nativeEvent?: { target?: unknown; }; }).nativeEvent?.target; + if (nativeTarget instanceof HTMLElement) return nativeTarget; + if (nativeTarget instanceof Node) return nativeTarget.parentElement; + } + + return null; +} + +function resolveRawTargetFromArgs(args: unknown[]): Node | null { + for (const arg of args) { + if (arg instanceof Node) return arg; + if (!arg || typeof arg !== "object") continue; + + const maybeTarget = (arg as { target?: unknown; }).target; + if (maybeTarget instanceof Node) return maybeTarget; + + const nativeTarget = (arg as { nativeEvent?: { target?: unknown; }; }).nativeEvent?.target; + if (nativeTarget instanceof Node) return nativeTarget; + } + + return null; +} + +function resolveContextPropsFromArgs(args: unknown[]): MessageContextMenuArgs | null { + const merged: MessageContextMenuArgs = {}; + + for (const arg of args) { + if (!arg || typeof arg !== "object") continue; + const candidate = arg as MessageContextMenuArgs; + const nestedProps = candidate.props; + + if (candidate.favoriteableId) merged.favoriteableId = candidate.favoriteableId; + if (candidate.favoriteableName) merged.favoriteableName = candidate.favoriteableName; + if (candidate.favoriteableType) merged.favoriteableType = candidate.favoriteableType; + if (candidate.itemHref) merged.itemHref = candidate.itemHref; + if (candidate.itemSrc) merged.itemSrc = candidate.itemSrc; + if (candidate.itemTextContent) merged.itemTextContent = candidate.itemTextContent; + if (candidate.message) merged.message = candidate.message; + if (candidate.target instanceof HTMLElement) merged.target = candidate.target; + + if (nestedProps) { + if (nestedProps.favoriteableId) merged.favoriteableId = nestedProps.favoriteableId; + if (nestedProps.favoriteableName) merged.favoriteableName = nestedProps.favoriteableName; + if (nestedProps.favoriteableType) merged.favoriteableType = nestedProps.favoriteableType; + if (nestedProps.itemHref) merged.itemHref = nestedProps.itemHref; + if (nestedProps.itemSrc) merged.itemSrc = nestedProps.itemSrc; + if (nestedProps.itemTextContent) merged.itemTextContent = nestedProps.itemTextContent; + if (nestedProps.message) merged.message = nestedProps.message; + if (nestedProps.target instanceof HTMLElement) merged.target = nestedProps.target; + } + } + + const strictTarget = resolveTargetFromArgs(args); + if (strictTarget) merged.target = strictTarget; + const rawTarget = resolveRawTargetFromArgs(args); + if (rawTarget) merged.rawTarget = rawTarget; + + if (!merged.target && !merged.favoriteableId && !merged.itemHref && !merged.itemSrc) { + return null; + } + + return merged; +} + +function getAliasMapEntries(): Array<[string, StoredEmojiRef]> { + return Object.entries(aliasMap).sort(([left], [right]) => left.localeCompare(right)); +} + +function getExistingAliasForEmoji(ref: StoredEmojiRef): string { + for (const [alias, current] of Object.entries(aliasMap)) { + if (isSameEmoji(current, ref)) return alias; + } + return ""; +} + +async function saveAlias(aliasInput: string, ref: StoredEmojiRef): Promise<{ ok: true; } | { ok: false; error: string; }> { + const validationError = getAliasValidationError(aliasInput); + if (validationError) return { ok: false, error: validationError }; + + const alias = normalizeAlias(aliasInput); + const existing = aliasMap[alias]; + + if (existing && !isSameEmoji(existing, ref)) { + return { ok: false, error: "Duplicate alias" }; + } + + const normalizedRef = ref.kind === "unicode" + ? { + ...ref, + name: normalizeEmojiName(ref.name), + surrogates: getUnicodeSurrogate(ref) ?? resolveUnicodeSurrogateByName(ref.name) + } + : ref; + + const nextMap = { ...aliasMap }; + for (const [existingAlias, existingRef] of Object.entries(nextMap)) { + if (existingAlias === alias) continue; + if (!isSameEmoji(existingRef, normalizedRef)) continue; + delete nextMap[existingAlias]; + } + nextMap[alias] = normalizedRef; + + try { + await persistAliases(nextMap); + return { ok: true }; + } catch (error) { + logger.error("Failed to save emoji alias.", error); + return { ok: false, error: "Failed to save alias." }; + } +} + +function getAliasMenuLabel(ref: StoredEmojiRef): string { + return getExistingAliasForEmoji(ref) ? "Edit alias" : "Set alias"; +} + +async function removeAlias(alias: string) { + if (!(alias in aliasMap)) return; + + const nextMap = { ...aliasMap }; + delete nextMap[alias]; + + try { + await persistAliases(nextMap); + Toasts.show({ + id: Toasts.genId(), + message: `Removed alias :${alias}:`, + type: Toasts.Type.SUCCESS + }); + } catch (error) { + logger.error("Failed to remove emoji alias.", error); + Toasts.show({ + id: Toasts.genId(), + message: "Failed to remove alias.", + type: Toasts.Type.FAILURE + }); + } +} + +async function clearAliases() { + if (!Object.keys(aliasMap).length) return; + + try { + await persistAliases({}); + Toasts.show({ + id: Toasts.genId(), + message: "Deleted all emoji aliases.", + type: Toasts.Type.SUCCESS + }); + } catch (error) { + logger.error("Failed to clear emoji aliases.", error); + Toasts.show({ + id: Toasts.genId(), + message: "Failed to delete aliases.", + type: Toasts.Type.FAILURE + }); + } +} + +function openSetAliasModal(ref: StoredEmojiRef) { + openModal(props => ( + { + const normalized = normalizeAlias(input); + const existing = normalized ? aliasMap[normalized] : null; + return !!existing && !isSameEmoji(existing, ref); + }} + onSave={async input => { + const result = await saveAlias(input, ref); + if (!result.ok) return result; + Toasts.show({ + id: Toasts.genId(), + message: `Alias set for ${getEmojiDisplayName(ref)}.`, + type: Toasts.Type.SUCCESS + }); + return result; + }} + /> + )); +} + +function queryAliasEmojiResult(aliasEmoji: StoredEmojiRef, channel: unknown, intention: unknown): EmojiResult | null { + const cacheKey = [ + aliasEmoji.kind, + aliasEmoji.id ?? "", + normalizeEmojiName(aliasEmoji.name), + getUnicodeSurrogate(aliasEmoji) ?? "", + normalizeContextKey(channel), + normalizeContextKey(intention) + ].join("|"); + const cached = aliasResultCache.get(cacheKey); + if (cached !== undefined) return cached; + + const baseQuery = getUnicodeSurrogate(aliasEmoji) ?? resolveUnicodeSurrogateByName(aliasEmoji.name) ?? aliasEmoji.name; + if (!baseQuery.length) { + aliasResultCache.set(cacheKey, null); + return null; + } + + const queryCandidates = aliasEmoji.kind === "custom" + ? [aliasEmoji.name, `:${aliasEmoji.name}:`, baseQuery] + : [ + getUnicodeSurrogate(aliasEmoji), + normalizeEmojiName(aliasEmoji.name), + `:${normalizeEmojiName(aliasEmoji.name)}:`, + baseQuery + ].filter((value): value is string => !!value && value.length > 0); + + for (const query of queryCandidates) { + const queried = EmojiQueryService.queryEmojiResults({ + query, + channel, + intention, + maxCount: 100 + }); + + const unlocked = queried?.emojis?.unlocked as EmojiResult[] | undefined; + if (!unlocked?.length) continue; + + const resolved = unlocked.find(result => { + if (aliasEmoji.kind === "custom") { + return emojiIdentityFromResult(result) === emojiIdentityFromRef(aliasEmoji); + } + + const refSurrogate = getUnicodeSurrogate(aliasEmoji) ?? resolveUnicodeSurrogateByName(aliasEmoji.name); + const resultSurrogate = (result.surrogates ?? result.emojiObject?.surrogates)?.trim(); + if (refSurrogate && resultSurrogate) return refSurrogate === resultSurrogate; + + const resultCustomId = result.id ?? result.emojiObject?.id; + if (resultCustomId) return false; + + const refName = normalizeEmojiNameForCompare(aliasEmoji.name); + const resultName = normalizeEmojiNameForCompare(result.uniqueName ?? result.name ?? result.originalName ?? ""); + return !!refName && refName === resultName; + }) ?? null; + if (resolved) { + aliasResultCache.set(cacheKey, resolved); + return resolved; + } + } + + aliasResultCache.set(cacheKey, null); + return null; +} + +function fallbackAliasEmojiResult(aliasEmoji: StoredEmojiRef, template: EmojiResult | undefined): EmojiResult | null { + if (aliasEmoji.kind === "unicode") { + return null; + } + + if (!aliasEmoji.id) return null; + + const storeEmoji = EmojiStore.getUsableCustomEmojiById(aliasEmoji.id) ?? EmojiStore.getCustomEmojiById(aliasEmoji.id); + if (!storeEmoji) return null; + + const hasWrapperShape = !!template?.emojiObject || "emojiObject" in (template ?? {}); + if (!hasWrapperShape) { + return { + id: storeEmoji.id, + name: storeEmoji.name, + uniqueName: storeEmoji.name + }; + } + + return { + emojiObject: { + id: storeEmoji.id, + name: storeEmoji.name, + guildId: storeEmoji.guildId, + animated: !!storeEmoji.animated + }, + id: storeEmoji.id, + name: storeEmoji.name, + uniqueName: storeEmoji.name, + originalName: storeEmoji.name, + guildId: storeEmoji.guildId, + surrogates: template?.surrogates, + type: template?.type ?? "EMOJI", + index: typeof template?.index === "number" ? template.index : 0, + useSpriteSheet: template?.useSpriteSheet ?? false, + names: [storeEmoji.name] + }; +} + +function injectAliasResults(emojis: EmojiResult[], queryText: string, channel: unknown, intention: unknown): EmojiResult[] { + const query = normalizeAlias(queryText); + if (!query) return emojis; + + const template = emojis[0]; + const seen = new Set(emojis.map(emojiIdentityFromResult)); + const exactMatches: EmojiResult[] = []; + const prefixMatches: EmojiResult[] = []; + + for (const [alias, ref] of Object.entries(aliasMap)) { + if (!alias.startsWith(query)) continue; + + const identity = emojiIdentityFromRef(ref); + if (seen.has(identity)) continue; + + const fromQuery = queryAliasEmojiResult(ref, channel, intention); + const resolved = ref.kind === "unicode" + ? fromQuery + : fromQuery ?? fallbackAliasEmojiResult(ref, template); + if (!resolved) continue; + + if (alias === query) { + exactMatches.push(resolved); + } else { + prefixMatches.push(resolved); + } + seen.add(identity); + } + + if (!exactMatches.length && !prefixMatches.length) return emojis; + return [...exactMatches, ...prefixMatches, ...emojis]; +} + +function getAutocompleteQuery(query: EmojiAutocompleteState["query"]): string { + if (!query) return ""; + if (typeof query.queryText === "string") return query.queryText; + if (typeof query.query === "string") return query.query; + if (typeof query.text === "string") return query.text; + return ""; +} + +function insertSetAliasMenuItem(children: Array | null>, emojiRef: StoredEmojiRef) { + const alreadyAdded = children.some(child => child?.props?.id === "vc-emoji-alias-set"); + if (alreadyAdded) return; + + const menuItem = ( + openSetAliasModal(emojiRef)} + /> + ); + + const favoriteGroup = findGroupChildrenByChildId( + ["favorite-emoji", "favourite-emoji", "unfavorite-emoji", "unfavourite-emoji"], + children, + true + ); + if (favoriteGroup) { + favoriteGroup.push(menuItem); + return; + } + + const group = findGroupChildrenByChildId(["copy-link", "copy-text", "copy-message-link"], children, true); + if (group) { + group.push(menuItem); + return; + } + + children.push(menuItem); +} + +function openClearAliasesConfirmModal() { + openModal(props => ); +} + +function EmojiPreview({ emojiRef }: { emojiRef: StoredEmojiRef; }) { + if (emojiRef.kind === "custom" && emojiRef.id) { + return {emojiRef.name}; + } + + const previewUrl = getUnicodePreviewUrl(emojiRef); + if (previewUrl) { + return {getEmojiDisplayName(emojiRef)}; + } + + return {getEmojiDisplayName(emojiRef)}; +} + +function AliasRow({ alias, emojiRef }: { alias: string; emojiRef: StoredEmojiRef; }) { + const [isEditing, setIsEditing] = useState(false); + const [draftAlias, setDraftAlias] = useState(alias); + const [error, setError] = useState(null); + + useEffect(() => { + setDraftAlias(alias); + }, [alias]); + + const saveEdit = async () => { + const result = await saveAlias(draftAlias, emojiRef); + if (!result.ok) { + setError(result.error); + return; + } + + setError(null); + setIsEditing(false); + }; + + return ( +
+
+ {isEditing ? ( + { + setDraftAlias(value); + setError(null); + }} + onKeyDown={async event => { + if (event.key === "Enter") { + event.preventDefault(); + await saveEdit(); + } + + if (event.key === "Escape") { + event.preventDefault(); + setDraftAlias(alias); + setError(null); + setIsEditing(false); + } + }} + /> + ) : ( + :{alias}: + )} + → + + {`:${normalizeEmojiName(emojiRef.name)}:`} + {error && {error}} +
+
+ + +
+
+ ); +} + +function AliasListSetting() { + const [, setVersion] = useState(0); + + useEffect(() => subscribeAliases(() => setVersion(version => version + 1)), []); + + const entries = getAliasMapEntries(); + + if (!entries.length) { + return No aliases set yet.; + } + + return ( +
+ {entries.map(([alias, ref]) => ( + + ))} +
+ ); +} + +function ClearAllAliasesSetting() { + const [, setVersion] = useState(0); + + useEffect(() => subscribeAliases(() => setVersion(version => version + 1)), []); + + const disabled = !Object.keys(aliasMap).length; + + return ( + + ); +} + +const expressionPickerPatch: NavContextMenuPatchCallback = (children, { target }: { target?: ExpressionPickerTarget; }) => { + const emojiRef = toEmojiRefFromExpressionTarget(target); + if (!emojiRef) return; + + children.push( + openSetAliasModal(emojiRef)} + /> + ); +}; + +const messageContextMenuPatch: NavContextMenuPatchCallback = (children, ...args: unknown[]) => { + const props = resolveContextPropsFromArgs(args); + if (!props) return; + if (!hasExplicitEmojiContext(props)) return; + + const emojiRef = toEmojiRefFromMessageContext(props); + if (!emojiRef) return; + + insertSetAliasMenuItem(children, emojiRef); +}; + +export default definePlugin({ + name: "EmojiAlias", + description: "Set custom aliases for emojis and prioritize them in autocomplete.", + authors: [EquicordDevs.justjxke], + settings, + contextMenus: { + "expression-picker": expressionPickerPatch, + "message": messageContextMenuPatch, + "message-actions": messageContextMenuPatch, + "textarea-context": messageContextMenuPatch + }, + patches: [ + { + find: "renderResults({results:", + replacement: { + match: /let \i=.{1,100}renderResults\({results:(\i)\.query\.results,/, + replace: "$self.sortEmojis($1);$&" + }, + }, + { + find: "numEmojiResults:", + replacement: [ + { + match: /\{emojis:\{unlocked:(\i)\}\}=.{0,25}\(\{query:(\i),channel:(\i),intention:(\i).{0,30}\}\);/, + replace: "$&$1=$self.injectAliasResults($1,$2,$3,$4.emojiIntention);" + }, + { + match: /(intention:\i\.emojiIntention,maxCount:)\i/, + replace: "$1Infinity" + }, + { + match: /(\i)\.slice\(0,(Math\.max\(\i,\i(?:-\i\.length){2}\))\)/, + replace: "($1.sliceTo = $2, $1)" + } + ] + } + ], + + injectAliasResults(emojis: EmojiResult[], queryText: string, channel: unknown, intention: unknown) { + return injectAliasResults(emojis, queryText, channel, intention); + }, + + async start() { + await loadAliases(); + + globalContextPatch = (_navId, children, ...args) => { + if (_navId === "expression-picker" || _navId === "message" || _navId === "message-actions" || _navId === "textarea-context") { + return; + } + const alreadyAdded = children.some(child => child?.props?.id === "vc-emoji-alias-set"); + if (alreadyAdded) return; + + const props = resolveContextPropsFromArgs(args); + if (!props) return; + if (!hasExplicitEmojiContext(props)) return; + + const emojiRef = toEmojiRefFromMessageContext(props); + if (!emojiRef) return; + + insertSetAliasMenuItem(children, emojiRef); + }; + + addGlobalContextMenuPatch(globalContextPatch); + }, + + stop() { + if (!globalContextPatch) return; + removeGlobalContextMenuPatch(globalContextPatch); + globalContextPatch = null; + unicodeSurrogateCache.clear(); + aliasResultCache.clear(); + }, + + sortEmojis(state: EmojiAutocompleteState) { + if (!state.query?.results?.emojis?.length) { + return; + } + + const query = normalizeAlias(getAutocompleteQuery(state.query)); + if (!query) return; + const { channel, intention } = state.query as { channel?: unknown; intention?: unknown; }; + const injectedResults = injectAliasResults(state.query.results.emojis, query, channel, intention); + + const priorities = new Map(); + const refsByIdentity = new Map(); + + for (const [alias, ref] of Object.entries(aliasMap)) { + if (!alias.startsWith(query)) continue; + + const priority = alias === query ? 2 : 1; + const identity = emojiIdentityFromRef(ref); + const existingPriority = priorities.get(identity) ?? 0; + if (priority > existingPriority) { + priorities.set(identity, priority); + refsByIdentity.set(identity, ref); + } + } + + if (!priorities.size) return; + + const exact: EmojiResult[] = []; + const prefix: EmojiResult[] = []; + const rest: EmojiResult[] = []; + const seen = new Set(); + const template = injectedResults[0]; + + const allResults = [...injectedResults]; + + for (const emoji of allResults) { + const identity = emojiIdentityFromResult(emoji); + if (seen.has(identity)) continue; + seen.add(identity); + + const priority = priorities.get(identity) ?? 0; + + if (priority === 2) { + exact.push(emoji); + } else if (priority === 1) { + prefix.push(emoji); + } else { + rest.push(emoji); + } + } + + for (const [identity, priority] of priorities.entries()) { + if (seen.has(identity)) continue; + const ref = refsByIdentity.get(identity); + if (!ref) continue; + + if (ref.kind === "unicode") continue; + + const fallback = fallbackAliasEmojiResult(ref, template); + if (!fallback) continue; + + seen.add(identity); + if (priority === 2) { + exact.push(fallback); + } else { + prefix.push(fallback); + } + } + + state.query.results.emojis = [...exact, ...prefix, ...rest].slice(0, state.query.results.emojis.sliceTo ?? Infinity) as EmojiResult[] & { sliceTo?: number; }; + } +}); diff --git a/src/equicordplugins/emojiAlias/styles.css b/src/equicordplugins/emojiAlias/styles.css new file mode 100644 index 000000000..c21a542c4 --- /dev/null +++ b/src/equicordplugins/emojiAlias/styles.css @@ -0,0 +1,39 @@ +.vc-emoji-alias-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px; + border-radius: 8px; + background: var(--background-secondary); +} + +.vc-emoji-alias-row-editing { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-emoji-alias-row-editing-text-input { + width: 180px; +} + +.vc-emoji-alias-row-arrow { + color: var(--text-muted); +} + +.vc-emoji-alias-row-error { + color: var(--text-feedback-critical); +} + +.vc-emoji-alias-row-button { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-emoji-alias-settings { + display: flex; + flex-direction: column; + gap: 8px; +}