/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {i18n} from '@lingui/core'; import {makeAutoObservable} from 'mobx'; import {makePersistent} from '~/lib/MobXPersistence'; import type {UnicodeEmoji} from '~/lib/UnicodeEmojis'; import UnicodeEmojis from '~/lib/UnicodeEmojis'; import type {ChannelRecord} from '~/records/ChannelRecord'; import {type GuildEmoji, GuildEmojiRecord} from '~/records/GuildEmojiRecord'; import type {GuildMember} from '~/records/GuildMemberRecord'; import type {Guild, GuildReadyData} from '~/records/GuildRecord'; import EmojiPickerStore from '~/stores/EmojiPickerStore'; import {patchGuildEmojiCacheFromGateway} from '~/stores/GuildExpressionTabCache'; import GuildListStore from '~/stores/GuildListStore'; import GuildMemberStore from '~/stores/GuildMemberStore'; import UserStore from '~/stores/UserStore'; import {filterEmojisForAutocomplete} from '~/utils/ExpressionPermissionUtils'; import * as RegexUtils from '~/utils/RegexUtils'; import {sortBySnowflakeDesc} from '~/utils/SnowflakeUtils'; export type Emoji = Readonly< Partial & Partial & { name: string; allNamesString: string; uniqueName: string; useSpriteSheet?: boolean; index?: number; diversityIndex?: number; hasDiversity?: boolean; } >; type GuildEmojiContext = Readonly<{ emojis: ReadonlyArray; usableEmojis: ReadonlyArray; }>; export function normalizeEmojiSearchQuery(query: string): string { return query.trim().replace(/^:+/, '').replace(/:+$/, ''); } class EmojiDisambiguations { private static _lastInstance: EmojiDisambiguations | null = null; private readonly guildId: string | null; private disambiguatedEmoji: ReadonlyArray | null = null; private customEmojis: ReadonlyMap | null = null; private emojisByName: ReadonlyMap | null = null; private emojisById: ReadonlyMap | null = null; private constructor(guildId?: string | null) { this.guildId = guildId ?? null; } static getInstance(guildId?: string | null): EmojiDisambiguations { if (!EmojiDisambiguations._lastInstance || EmojiDisambiguations._lastInstance.guildId !== guildId) { EmojiDisambiguations._lastInstance = new EmojiDisambiguations(guildId); } return EmojiDisambiguations._lastInstance; } static reset(): void { EmojiDisambiguations._lastInstance = null; } static clear(guildId?: string | null): void { if (EmojiDisambiguations._lastInstance?.guildId === guildId) { EmojiDisambiguations._lastInstance = null; } } getDisambiguatedEmoji(): ReadonlyArray { this.ensureDisambiguated(); return this.disambiguatedEmoji ?? []; } getCustomEmoji(): ReadonlyMap { this.ensureDisambiguated(); return this.customEmojis ?? new Map(); } getByName(disambiguatedEmojiName: string): Emoji | undefined { this.ensureDisambiguated(); return this.emojisByName?.get(disambiguatedEmojiName); } getById(emojiId: string): Emoji | undefined { this.ensureDisambiguated(); return this.emojisById?.get(emojiId); } nameMatchesChain(testName: (name: string) => boolean): ReadonlyArray { return this.getDisambiguatedEmoji().filter(({names, name}) => (names ? names.some(testName) : testName(name))); } private ensureDisambiguated(): void { if (!this.disambiguatedEmoji) { const result = this.buildDisambiguatedCustomEmoji(); this.disambiguatedEmoji = result.disambiguatedEmoji; this.customEmojis = result.customEmojis; this.emojisByName = result.emojisByName; this.emojisById = result.emojisById; } } private buildDisambiguatedCustomEmoji() { const emojiCountByName = new Map(); const disambiguatedEmoji: Array = []; const customEmojis = new Map(); const emojisByName = new Map(); const emojisById = new Map(); const disambiguateEmoji = (emoji: Emoji): void => { const uniqueName = emoji.name; const existingCount = emojiCountByName.get(uniqueName) ?? 0; emojiCountByName.set(uniqueName, existingCount + 1); const finalEmoji = existingCount > 0 ? { ...emoji, name: `${uniqueName}~${existingCount}`, uniqueName, allNamesString: `:${uniqueName}~${existingCount}:`, } : emoji; emojisByName.set(finalEmoji.name, finalEmoji); if (finalEmoji.id) { emojisById.set(finalEmoji.id, finalEmoji); customEmojis.set(finalEmoji.name, finalEmoji); } disambiguatedEmoji.push(finalEmoji); }; UnicodeEmojis.forEachEmoji((unicodeEmoji) => { const compatibleEmoji: Emoji = { ...unicodeEmoji, name: unicodeEmoji.uniqueName, url: unicodeEmoji.url || undefined, useSpriteSheet: unicodeEmoji.useSpriteSheet, index: unicodeEmoji.index, diversityIndex: unicodeEmoji.diversityIndex, hasDiversity: unicodeEmoji.hasDiversity, }; disambiguateEmoji(compatibleEmoji); }); const processGuildEmojis = (guildId: string) => { const guildEmoji = emojiGuildRegistry.get(guildId); if (!guildEmoji) return; guildEmoji.usableEmojis.forEach((emoji) => { const emojiForDisambiguation: Emoji = { ...emoji, name: emoji.name, uniqueName: emoji.name, allNamesString: emoji.allNamesString, url: emoji.url, useSpriteSheet: false, }; disambiguateEmoji(emojiForDisambiguation); }); }; if (this.guildId) { processGuildEmojis(this.guildId); } for (const guild of GuildListStore.guilds.filter((guild) => guild.id !== this.guildId)) { processGuildEmojis(guild.id); } return { disambiguatedEmoji: Object.freeze(disambiguatedEmoji), customEmojis: new Map(customEmojis), emojisByName: new Map(emojisByName), emojisById: new Map(emojisById), }; } } class EmojiGuildRegistry { private guilds = new Map(); private customEmojisById = new Map(); reset(): void { this.guilds.clear(); this.customEmojisById.clear(); EmojiDisambiguations.reset(); } deleteGuild(guildId: string): void { this.guilds.delete(guildId); } get(guildId: string): GuildEmojiContext | undefined { return this.guilds.get(guildId); } rebuildRegistry(): void { this.customEmojisById.clear(); for (const guild of this.guilds.values()) { for (const emoji of guild.usableEmojis) { this.customEmojisById.set(emoji.id, emoji); } } EmojiDisambiguations.reset(); } updateGuild(guildId: string, guildEmojis?: ReadonlyArray): void { this.deleteGuild(guildId); EmojiDisambiguations.clear(guildId); if (!guildEmojis) return; const currentUser = UserStore.getCurrentUser(); if (!currentUser) return; const localUser = GuildMemberStore.getMember(guildId, currentUser.id); if (!localUser) return; const emojiRecords = guildEmojis.map((emoji) => new GuildEmojiRecord(guildId, emoji)); const sortedEmojis = sortBySnowflakeDesc(emojiRecords); const frozenEmojis = Object.freeze(sortedEmojis); this.guilds.set(guildId, { emojis: frozenEmojis, usableEmojis: frozenEmojis, }); } getGuildEmojis(guildId: string): ReadonlyArray { return this.guilds.get(guildId)?.usableEmojis ?? []; } } const emojiGuildRegistry = new EmojiGuildRegistry(); class EmojiStore { skinTone = ''; constructor() { makeAutoObservable(this, {}, {autoBind: true}); this.initPersistence(); } private async initPersistence(): Promise { await makePersistent(this, 'EmojiStore', ['skinTone']); UnicodeEmojis.setDefaultSkinTone(this.skinTone); } get categories(): ReadonlyArray { return Object.freeze(['custom', ...UnicodeEmojis.getCategories()]); } getGuildEmoji(guildId: string): ReadonlyArray { return emojiGuildRegistry.getGuildEmojis(guildId); } getEmojiById(emojiId: string): Emoji | undefined { return this.getDisambiguatedEmojiContext(null).getById(emojiId); } getDisambiguatedEmojiContext(guildId?: string | null): EmojiDisambiguations { return EmojiDisambiguations.getInstance(guildId); } getEmojiMarkdown(emoji: Emoji): string { return emoji.id ? `<${emoji.animated ? 'a' : ''}:${emoji.uniqueName}:${emoji.id}>` : `:${emoji.uniqueName}:`; } filterExternal( channel: ChannelRecord | null, nameTest: (name: string) => boolean, count: number, ): ReadonlyArray { const results = EmojiDisambiguations.getInstance(channel?.guildId).nameMatchesChain(nameTest); const filtered = filterEmojisForAutocomplete(i18n, results, channel); return count > 0 ? filtered.slice(0, count) : filtered; } getAllEmojis(channel: ChannelRecord | null): ReadonlyArray { return this.getDisambiguatedEmojiContext(channel?.guildId).getDisambiguatedEmoji(); } search(channel: ChannelRecord | null, query: string, count = 0): ReadonlyArray { const normalizedQuery = normalizeEmojiSearchQuery(query); const lowerCasedQuery = normalizedQuery.toLowerCase(); if (!lowerCasedQuery) { const allEmojis = this.getAllEmojis(channel); const sorted = [...allEmojis].sort( (a, b) => EmojiPickerStore.getFrecencyScoreForEmoji(b) - EmojiPickerStore.getFrecencyScoreForEmoji(a), ); return count > 0 ? sorted.slice(0, count) : sorted; } const escapedQuery = RegexUtils.escapeRegex(lowerCasedQuery); const containsRegex = new RegExp(escapedQuery, 'i'); const startsWithRegex = new RegExp(`^${escapedQuery}`, 'i'); const boundaryRegex = new RegExp(`(^|_|[A-Z])${escapedQuery}s?([A-Z]|_|$)`); const searchResults = this.filterExternal(channel, containsRegex.test.bind(containsRegex), 0); if (searchResults.length === 0) return searchResults; const getScore = (name: string): number => { const nameLower = name.toLowerCase(); return ( 1 + (nameLower === lowerCasedQuery ? 4 : 0) + (boundaryRegex.test(nameLower) || boundaryRegex.test(name) ? 2 : 0) + (startsWithRegex.test(name) ? 1 : 0) ); }; const sortedResults = [...searchResults].sort((a, b) => { const frecencyDiff = EmojiPickerStore.getFrecencyScoreForEmoji(b) - EmojiPickerStore.getFrecencyScoreForEmoji(a); if (frecencyDiff !== 0) { return frecencyDiff; } const aName = a.names?.[0] ?? a.name; const bName = b.names?.[0] ?? b.name; const scoreDiff = getScore(bName) - getScore(aName); return scoreDiff || aName.localeCompare(bName); }); return count > 0 ? sortedResults.slice(0, count) : sortedResults; } setSkinTone(skinTone: string): void { this.skinTone = skinTone; UnicodeEmojis.setDefaultSkinTone(skinTone); } handleConnectionOpen({guilds}: {guilds: ReadonlyArray}): void { emojiGuildRegistry.reset(); for (const guild of guilds) { emojiGuildRegistry.updateGuild(guild.id, guild.emojis); } emojiGuildRegistry.rebuildRegistry(); } handleGuildUpdate({guild}: {guild: Guild | GuildReadyData}): void { emojiGuildRegistry.updateGuild(guild.id, 'emojis' in guild ? guild.emojis : undefined); emojiGuildRegistry.rebuildRegistry(); } handleGuildEmojiUpdated({guildId, emojis}: {guildId: string; emojis: ReadonlyArray}): void { emojiGuildRegistry.updateGuild(guildId, emojis); emojiGuildRegistry.rebuildRegistry(); patchGuildEmojiCacheFromGateway(guildId, emojis); } handleGuildDelete({guildId}: {guildId: string}): void { emojiGuildRegistry.deleteGuild(guildId); emojiGuildRegistry.rebuildRegistry(); } handleGuildMemberUpdate({guildId, member}: {guildId: string; member: GuildMember}): void { if (member.user.id !== UserStore.getCurrentUser()?.id) { return; } const currentGuildEmojis = emojiGuildRegistry.getGuildEmojis(guildId).map((emoji) => ({ ...emoji.toJSON(), guild_id: guildId, })); emojiGuildRegistry.updateGuild(guildId, currentGuildEmojis); emojiGuildRegistry.rebuildRegistry(); } } export default new EmojiStore();