Files
fluxer/fluxer_app/src/stores/EmojiStore.tsx
2026-01-03 06:44:40 +01:00

397 lines
12 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<GuildEmojiRecord> &
Partial<UnicodeEmoji> & {
name: string;
allNamesString: string;
uniqueName: string;
useSpriteSheet?: boolean;
index?: number;
diversityIndex?: number;
hasDiversity?: boolean;
}
>;
type GuildEmojiContext = Readonly<{
emojis: ReadonlyArray<GuildEmojiRecord>;
usableEmojis: ReadonlyArray<GuildEmojiRecord>;
}>;
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<Emoji> | null = null;
private customEmojis: ReadonlyMap<string, Emoji> | null = null;
private emojisByName: ReadonlyMap<string, Emoji> | null = null;
private emojisById: ReadonlyMap<string, Emoji> | 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<Emoji> {
this.ensureDisambiguated();
return this.disambiguatedEmoji ?? [];
}
getCustomEmoji(): ReadonlyMap<string, Emoji> {
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<Emoji> {
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<string, number>();
const disambiguatedEmoji: Array<Emoji> = [];
const customEmojis = new Map<string, Emoji>();
const emojisByName = new Map<string, Emoji>();
const emojisById = new Map<string, Emoji>();
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<string, GuildEmojiContext>();
private customEmojisById = new Map<string, GuildEmojiRecord>();
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<GuildEmoji>): 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<GuildEmojiRecord> {
return this.guilds.get(guildId)?.usableEmojis ?? [];
}
}
const emojiGuildRegistry = new EmojiGuildRegistry();
class EmojiStore {
skinTone = '';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'EmojiStore', ['skinTone']);
UnicodeEmojis.setDefaultSkinTone(this.skinTone);
}
get categories(): ReadonlyArray<string> {
return Object.freeze(['custom', ...UnicodeEmojis.getCategories()]);
}
getGuildEmoji(guildId: string): ReadonlyArray<GuildEmojiRecord> {
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<Emoji> {
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<Emoji> {
return this.getDisambiguatedEmojiContext(channel?.guildId).getDisambiguatedEmoji();
}
search(channel: ChannelRecord | null, query: string, count = 0): ReadonlyArray<Emoji> {
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<GuildReadyData>}): 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<GuildEmoji>}): 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();