Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
- Fix: クライアントを閉じている間に追加・変更されたカスタム絵文字が、クライアントのキャッシュを削除するか期限切れになるまで反映されない問題を修正
Comment thread
kakkokari-gtyih marked this conversation as resolved.

### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
Expand All @@ -38,6 +39,7 @@
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
- Fix: ローカルユーザーへのホスト付きメンションが本文に含まれる指名ノートの作成時、投稿フォームにて、当該ユーザーが宛先に含まれていても正しく認識されない問題を修正
- Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正
- Fix: セッション中にカスタム絵文字が追加・変更されても、既に描画済みの絵文字に反映されない問題を修正

### Server
- Feat: OpenTelemetryサポート
Expand Down
26 changes: 24 additions & 2 deletions packages/backend/src/core/CustomEmojiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { MemoryKVCache, MemorySingleCache, RedisSingleCache } from '@/misc/cache.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
import type { MiEmoji } from '@/models/Emoji.js';
Expand Down Expand Up @@ -57,9 +57,15 @@ export const fetchEmojisSortKeys = [
] as const;
export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number];

export type LocalEmojisStats = {
count: number;
lastUpdatedAt: Date | null;
};

@Injectable()
export class CustomEmojiService implements OnApplicationShutdown {
private emojisCache: MemoryKVCache<MiEmoji | null>;
private localEmojisStatsCache: MemorySingleCache<LocalEmojisStats>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;

constructor(
Expand All @@ -74,7 +80,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
) {
this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h

this.localEmojisStatsCache = new MemorySingleCache<LocalEmojisStats>(1000 * 60 * 3); // 3m
this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60 * 3, // 3m
Expand Down Expand Up @@ -601,6 +607,22 @@ export class CustomEmojiService implements OnApplicationShutdown {
};
}

@bindThis
public async getLocalEmojisStats(): Promise<LocalEmojisStats> {
return this.localEmojisStatsCache.fetch(async () => {
const raw = await this.emojisRepository.createQueryBuilder('emoji')
.select('COUNT(*)', 'count')
.addSelect('MAX(emoji.updatedAt)', 'lastUpdatedAt')
.where('emoji.host IS NULL')
.getRawOne<{ count: string; lastUpdatedAt: Date | null }>();

return {
count: raw ? Number(raw.count) : 0,
lastUpdatedAt: raw?.lastUpdatedAt ?? null,
};
});
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

現状、件数と最終更新日時ベースでは絵文字削除が追えないかと思います(物理削除なので)。

「有効な絵文字一覧」としてのバージョンをmetaあたりに持っておくのが良いかと思います。
addやdelete/deleteBulkなどの1操作につき1回更新されるイメージの

@kakkokari-gtyih kakkokari-gtyih Jul 24, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • カスタム絵文字削除した場合は件数が変わる→最終更新日時は変わらないけど件数が変わるのでキャッシュ更新
  • 削除した分と同じだけカスタム絵文字を追加する→件数は変わる前と同じになるけど最終更新日時が変わるのでキャッシュ更新

で問題なく更新される説がある

@samunohito samunohito Jul 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ありがとうございます。削除の追跡が漏れる懸念はなさそうです。

--

ただ、それでもやはり気になるのが、絵文字一覧の変更有無を表現するためだけに COUNT / MAX の集計を都度行っている点です。

  • キャッシュの有効期間が3分と短く、絵文字の追加・削除・更新時にも無効化される
  • 複数のHTTPプロセスを使っている環境ではプロセスごとに集計が走る
  • キュープロセスなどの別プロセスで行われた変更はHTTPプロセス側のキャッシュを更新できず、最大3分古い値を返す可能性がある
  • キャッシュミスが重なった場合、同一の集計クエリが並行して実行され得る

という点から、負荷と整合性の両面でウィークポイントになりやすいと考えています。

そのため再度、metaやRedisなどの共有領域に、集計不要な絵文字一覧のリビジョンを持たせ、論理的な変更操作ごとに更新する方式を提案させて頂きます


@bindThis
public dispose(): void {
this.emojisCache.dispose();
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/server/api/endpoint-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export * as 'drive/stream' from './endpoints/drive/stream.js';
export * as 'email-address/available' from './endpoints/email-address/available.js';
export * as 'emoji' from './endpoints/emoji.js';
export * as 'emojis' from './endpoints/emojis.js';
export * as 'emojis/stats' from './endpoints/emojis/stats.js';
export * as 'endpoint' from './endpoints/endpoint.js';
export * as 'endpoints' from './endpoints/endpoints.js';
export * as 'export-custom-emojis' from './endpoints/export-custom-emojis.js';
Expand Down
53 changes: 53 additions & 0 deletions packages/backend/src/server/api/endpoints/emojis/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';

export const meta = {
tags: ['meta'],

requireCredential: false,

res: {
type: 'object',
optional: false, nullable: false,
properties: {
count: {
type: 'number',
optional: false, nullable: false,
},
lastUpdatedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
},
},
} as const;

export const paramDef = {
type: 'object',
properties: {
},
required: [],
} as const;

@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private customEmojiService: CustomEmojiService,
) {
super(meta, paramDef, async (ps, me) => {
const stats = await this.customEmojiService.getLocalEmojisStats();

return {
count: stats.count,
lastUpdatedAt: stats.lastUpdatedAt ? stats.lastUpdatedAt.toISOString() : null,
};
});
}
}
12 changes: 8 additions & 4 deletions packages/frontend/src/boot/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { getAccountFromId } from '@/utility/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { analytics, initAnalytics } from '@/analytics.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { fetchCustomEmojis, isInitialLoading as isCustomEmojiCacheNotFound } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { $i } from '@/i.js';
import { launchPlugins } from '@/plugin.js';
Expand Down Expand Up @@ -242,9 +242,13 @@ export async function common(createVue: () => Promise<App<Element>>) {
}
//#endregion

try {
await fetchCustomEmojis();
} catch (err) { /* empty */ }
if (isCustomEmojiCacheNotFound) {
// まだキャッシュしたことがない場合は、取得を待つ
await fetchCustomEmojis(true).catch(() => { /* empty */ });
} else {
// 既にキャッシュがあるなら、バックグラウンドで更新を試みる(描画の方はリアクティビティにより更新される)
fetchCustomEmojis().catch(() => { /* empty */ });
}

// analytics
fetchInstanceMetaPromise.then(async () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/frontend/src/components/MkEmojiPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@ watch(q, () => {
}
}
} else {
if (customEmojisMap.has(newQ)) {
matches.add(customEmojisMap.get(newQ)!);
if (customEmojisMap.value.has(newQ)) {
matches.add(customEmojisMap.value.get(newQ)!);
Comment thread
kakkokari-gtyih marked this conversation as resolved.
}
if (matches.size >= max) return matches;

Expand Down Expand Up @@ -405,7 +405,7 @@ function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeE
// カスタム絵文字が存在する場合はその情報を持つオブジェクトを返し、
// サーバの管理画面から削除された等で情報が見つからない場合は名前の文字列をそのまま返しておく(undefinedを返すとエラーになるため)
const name = emoji.replaceAll(':', '');
return customEmojisMap.get(name) ?? emoji;
return customEmojisMap.value.get(name) ?? emoji;
} else {
return getUnicodeEmoji(emoji);
}
Expand Down Expand Up @@ -476,7 +476,7 @@ function done(query?: string): boolean | void {
if (query == null || typeof query !== 'string') return;

const q2 = query.replace(/:/g, '');
const exactMatchCustom = customEmojisMap.get(q2);
const exactMatchCustom = customEmojisMap.value.get(q2);
if (exactMatchCustom) {
chosen(exactMatchCustom);
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const buttonEl = useTemplateRef('buttonEl');
const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, ''));

const canToggle = computed(() => {
const emoji = customEmojisMap.get(emojiName.value) ?? getUnicodeEmojiOrNull(props.reaction);
const emoji = customEmojisMap.value.get(emojiName.value) ?? getUnicodeEmojiOrNull(props.reaction);

// TODO
//return !props.reaction.match(/@\w/) && $i && emoji && checkReactionPermissions($i, props.note, emoji);
Expand Down Expand Up @@ -106,7 +106,7 @@ async function toggleReaction() {
noteId: props.noteId,
reaction: props.reaction,
}).then(() => {
const emoji = customEmojisMap.get(emojiName.value);
const emoji = customEmojisMap.value.get(emojiName.value);
if (emoji == null && getUnicodeEmojiOrNull(props.reaction) == null) {
return;
}
Expand Down Expand Up @@ -140,7 +140,7 @@ async function toggleReaction() {
noteId: props.noteId,
reaction: props.reaction,
}).then(() => {
const emoji = customEmojisMap.get(emojiName.value);
const emoji = customEmojisMap.value.get(emojiName.value);
if (emoji == null && getUnicodeEmojiOrNull(props.reaction) == null) {
return;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/MkReactionsViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function onMockToggleReaction(emoji: string, count: number) {
function canReact(reaction: string) {
if (!$i) return false;
// TODO: CheckPermissions
return !reaction.match(/@\w/) && (customEmojisMap.has(reaction) || isSupportedEmoji(reaction));
return !reaction.match(/@\w/) && (customEmojisMap.value.has(reaction) || isSupportedEmoji(reaction));
}

watch([() => props.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/global/MkCustomEmoji.vue
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const rawUrl = computed(() => {
return props.url;
}
if (isLocal.value) {
return customEmojisMap.get(customEmojiName.value)?.url ?? null;
return customEmojisMap.value.get(customEmojiName.value)?.url ?? null;
}
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
});
Expand Down
59 changes: 32 additions & 27 deletions packages/frontend/src/custom-emojis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { shallowRef, computed, markRaw, watch } from 'vue';
import { shallowRef, computed, markRaw } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { get, set } from '@/utility/idb-proxy.js';

const storageCache = await get('emojis');
export const isInitialLoading = !Array.isArray(storageCache);
export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []);
export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
const categories = new Set<string>();
Expand All @@ -20,47 +21,51 @@ export const customEmojiCategories = computed<[ ...string[], null ]>(() => {
return markRaw([...Array.from(categories), null]);
});

export const customEmojisMap = new Map<string, Misskey.entities.EmojiSimple>();
watch(customEmojis, emojis => {
customEmojisMap.clear();
for (const emoji of emojis) {
customEmojisMap.set(emoji.name, emoji);
// customEmojis が shallowRef なので問題ないが、そうでなくなった場合は値の変化で computed が乱発される可能性があるので要注意
export const customEmojisMap = computed(() => {
const map = new Map<string, Misskey.entities.EmojiSimple>();
for (const emoji of customEmojis.value) {
map.set(emoji.name, emoji);
}
}, { immediate: true });
return markRaw(map);
});

let cachedTags: string[] | null = null;

function setCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) {
customEmojis.value = emojis;
cachedTags = null;
set('emojis', emojis);
}

export function addCustomEmoji(emoji: Misskey.entities.EmojiSimple) {
customEmojis.value = [emoji, ...customEmojis.value];
set('emojis', customEmojis.value);
setCustomEmojis([emoji, ...customEmojis.value]);
}

export function updateCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) {
customEmojis.value = customEmojis.value.map(item => emojis.find(search => search.name === item.name) ?? item);
set('emojis', customEmojis.value);
setCustomEmojis(customEmojis.value.map(item => emojis.find(search => search.name === item.name) ?? item));
}

export function removeCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) {
customEmojis.value = customEmojis.value.filter(item => !emojis.some(search => search.name === item.name));
set('emojis', customEmojis.value);
setCustomEmojis(customEmojis.value.filter(item => !emojis.some(search => search.name === item.name)));
}

function isSameStats(a: Misskey.entities.EmojisStatsResponse | null | undefined, b: Misskey.entities.EmojisStatsResponse) {
// lastUpdatedAt はサーバー時刻の巻き戻しや古い updatedAt のまま復元する操作で単調増加とは限らないため大小比較ではいけない
return a != null && a.count === b.count && a.lastUpdatedAt === b.lastUpdatedAt;
}

export async function fetchCustomEmojis(force = false) {
const now = Date.now();
const stats = await misskeyApi('emojis/stats');
if (!force && isSameStats(await get('emojisStats'), stats)) return;

let res;
if (force) {
res = await misskeyApi('emojis', {});
} else {
const lastFetchedAt = await get('lastEmojisFetchedAt');
if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return;
res = await misskeyApiGet('emojis', {});
}
// GETだとキャッシュで古いリストが返ってきうるのでPOST
const res = await misskeyApi('emojis');

customEmojis.value = res.emojis;
set('emojis', res.emojis);
set('lastEmojisFetchedAt', now);
setCustomEmojis(res.emojis);
set('emojisStats', stats);
Comment thread
kakkokari-gtyih marked this conversation as resolved.
Outdated
}

let cachedTags: string[] | null = null;
export function getCustomEmojiTags() {
if (cachedTags) return cachedTags;

Expand Down
4 changes: 4 additions & 0 deletions packages/misskey-js/etc/misskey-js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,9 @@ type EmojiSimple = components['schemas']['EmojiSimple'];
// @public (undocumented)
type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];

// @public (undocumented)
type EmojisStatsResponse = operations['emojis___stats']['responses']['200']['content']['application/json'];

// @public (undocumented)
type EmojiUpdated = {
emojis: EmojiDetailed[];
Expand Down Expand Up @@ -1858,6 +1861,7 @@ declare namespace entities {
EmojiRequest,
EmojiResponse,
EmojisResponse,
EmojisStatsResponse,
EndpointRequest,
EndpointResponse,
EndpointsResponse,
Expand Down
11 changes: 11 additions & 0 deletions packages/misskey-js/src/autogen/apiClientJSDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2337,6 +2337,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;

/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'emojis/stats', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;

/**
* No description provided.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/misskey-js/src/autogen/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ import type {
EmojiRequest,
EmojiResponse,
EmojisResponse,
EmojisStatsResponse,
EndpointRequest,
EndpointResponse,
EndpointsResponse,
Expand Down Expand Up @@ -879,6 +880,7 @@ export type Endpoints = {
'email-address/available': { req: EmailAddressAvailableRequest; res: EmailAddressAvailableResponse };
'emoji': { req: EmojiRequest; res: EmojiResponse };
'emojis': { req: EmptyRequest; res: EmojisResponse };
'emojis/stats': { req: EmptyRequest; res: EmojisStatsResponse };
'endpoint': { req: EndpointRequest; res: EndpointResponse };
'endpoints': { req: EmptyRequest; res: EndpointsResponse };
'export-custom-emojis': { req: EmptyRequest; res: EmptyResponse };
Expand Down
1 change: 1 addition & 0 deletions packages/misskey-js/src/autogen/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export type EmailAddressAvailableResponse = operations['email-address___availabl
export type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
export type EmojiResponse = operations['emoji']['responses']['200']['content']['application/json'];
export type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
export type EmojisStatsResponse = operations['emojis___stats']['responses']['200']['content']['application/json'];
export type EndpointRequest = operations['endpoint']['requestBody']['content']['application/json'];
export type EndpointResponse = operations['endpoint']['responses']['200']['content']['application/json'];
export type EndpointsResponse = operations['endpoints']['responses']['200']['content']['application/json'];
Expand Down
Loading
Loading