397 lines
12 KiB
TypeScript
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();
|