Equicord/src/plugins/favEmojiFirst/index.tsx
2026-04-23 08:06:25 -04:00

1256 lines
43 KiB
TypeScript

/*
* 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 { Devs, 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 { Emoji, 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<string, StoredEmojiRef>;
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<string, string | null>();
const aliasResultCache = new Map<string, EmojiResult | null>();
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<StoredEmojiRef>;
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(`<a?:(\\w+)(?:~\\d+)?:${id}>`))?.[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(`<a?:(\\w+)(?:~\\d+)?:${id}>`))?.[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<string, unknown>;
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 => (
<SetAliasModal
modalProps={props}
emojiDisplayName={getEmojiDisplayName(ref)}
initialAlias={getExistingAliasForEmoji(ref)}
getValidationError={getAliasValidationError}
isDuplicateAlias={input => {
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<React.ReactElement<any> | null>, emojiRef: StoredEmojiRef) {
const alreadyAdded = children.some(child => child?.props?.id === "vc-emoji-alias-set");
if (alreadyAdded) return;
const menuItem = (
<Menu.MenuItem
id="vc-emoji-alias-set"
label={getAliasMenuLabel(emojiRef)}
action={() => 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 => <ClearAliasesConfirmModal modalProps={props} onConfirm={clearAliases} />);
}
function EmojiPreview({ emojiRef }: { emojiRef: StoredEmojiRef; }) {
if (emojiRef.kind === "custom" && emojiRef.id) {
return <img src={`https://${window.GLOBAL_ENV.CDN_HOST}/emojis/${emojiRef.id}.${emojiRef.animated ? "gif" : "png"}?size=32`} width="20" height="20" alt={emojiRef.name} />;
}
const previewUrl = getUnicodePreviewUrl(emojiRef);
if (previewUrl) {
return <img src={previewUrl} width="20" height="20" alt={getEmojiDisplayName(emojiRef)} />;
}
return <BaseText>{getEmojiDisplayName(emojiRef)}</BaseText>;
}
function AliasRow({ alias, emojiRef }: { alias: string; emojiRef: StoredEmojiRef; }) {
const [isEditing, setIsEditing] = useState(false);
const [draftAlias, setDraftAlias] = useState(alias);
const [error, setError] = useState<string | null>(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 (
<div className={cl("row")}>
<div className={cl("row-editing")}>
{isEditing ? (
<TextInput
className={cl("row-editing-text-input")}
value={draftAlias}
autoFocus
onChange={value => {
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);
}
}}
/>
) : (
<BaseText weight="semibold">:{alias}:</BaseText>
)}
<BaseText className={cl("row-arrow")}>→</BaseText>
<EmojiPreview emojiRef={emojiRef} />
<BaseText>{`:${normalizeEmojiName(emojiRef.name)}:`}</BaseText>
{error && <BaseText className={cl("row-error")}>{error}</BaseText>}
</div>
<div className={cl("row-button")}>
<Button
variant="secondary"
size="iconOnly"
onClick={() => {
setDraftAlias(alias);
setError(null);
setIsEditing(true);
}}
>
<PencilIcon width={14} height={14} />
</Button>
<Button variant="dangerSecondary" size="small" onClick={() => removeAlias(alias)}>Remove</Button>
</div>
</div>
);
}
function AliasListSetting() {
const [, setVersion] = useState(0);
useEffect(() => subscribeAliases(() => setVersion(version => version + 1)), []);
const entries = getAliasMapEntries();
if (!entries.length) {
return <Paragraph>No aliases set yet.</Paragraph>;
}
return (
<div className={cl("settings")}>
{entries.map(([alias, ref]) => (
<AliasRow key={alias} alias={alias} emojiRef={ref} />
))}
</div>
);
}
function ClearAllAliasesSetting() {
const [, setVersion] = useState(0);
useEffect(() => subscribeAliases(() => setVersion(version => version + 1)), []);
const disabled = !Object.keys(aliasMap).length;
return (
<Button variant="dangerPrimary" size="small" disabled={disabled} onClick={openClearAliasesConfirmModal}>
Delete all aliases
</Button>
);
}
const expressionPickerPatch: NavContextMenuPatchCallback = (children, { target }: { target?: ExpressionPickerTarget; }) => {
const emojiRef = toEmojiRefFromExpressionTarget(target);
if (!emojiRef) return;
children.push(
<Menu.MenuItem
id="vc-emoji-alias-set"
label={getAliasMenuLabel(emojiRef)}
action={() => 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);
};
const messageSendListener = (_channelId: string, messageObj: { content: string; }) => {
if (!messageObj.content || !Object.keys(aliasMap).length) return;
messageObj.content = messageObj.content.replace(/:([a-z0-9_]{2,32}):/gi, (match, aliasName) => {
const ref = aliasMap[aliasName.toLowerCase()];
if (!ref) return match;
if (ref.kind === "custom" && ref.id) {
return `<${ref.animated ? "a" : ""}:${ref.name}:${ref.id}>`;
}
return getUnicodeSurrogate(ref) ?? resolveUnicodeSurrogateByName(ref.name) ?? match;
});
};
export default definePlugin({
name: "FavoriteEmojiFirst",
authors: [Devs.Aria, Devs.Ven, EquicordDevs.justjxke],
tags: ["Emotes", "Customisation"],
description: "Puts your favorite emoji first in the emoji autocomplete and also has emoji alias.",
dependencies: ["MessagePopoverAPI"],
settings,
contextMenus: {
"expression-picker": expressionPickerPatch,
"message": messageContextMenuPatch,
"message-actions": messageContextMenuPatch,
"textarea-context": messageContextMenuPatch
},
isModified: true,
onBeforeMessageSend: messageSendListener,
patches: [
{
find: "renderResults({results:",
replacement: [
{
// https://regex101.com/r/N7kpLM/1
match: /let \i=.{1,100}renderResults\({results:(\i)\.query\.results,/,
replace: "$self.sortEmojis($1);$&"
},
],
},
{
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\(\d+?,\i(?:-\i\.length){2}\))\)/,
replace: "($1.sliceTo = $2, $1)"
}
]
}
],
injectAliasResults,
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 emojiContext = EmojiStore.getDisambiguatedEmojiContext();
const priorities = new Map<string, number>();
const refsByIdentity = new Map<string, StoredEmojiRef>();
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);
}
}
const exact: EmojiResult[] = [];
const prefix: EmojiResult[] = [];
const rest: EmojiResult[] = [];
const seen = new Set<string>();
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;
const isFavorite = emojiContext.isFavoriteEmojiWithoutFetchingLatest(emoji as unknown as Emoji);
if (priority === 2) {
exact.push(emoji);
} else if (priority === 1) {
prefix.push(emoji);
} else if (isFavorite) {
exact.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 || 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; };
}
});