From ad541dfc402a1426769a517f589b9f23ff8af357 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:17:15 +0900 Subject: [PATCH 1/4] wip --- CHANGELOG.md | 2 + .../backend/src/core/CustomEmojiService.ts | 26 ++++++- .../backend/src/server/api/endpoint-list.ts | 1 + .../src/server/api/endpoints/emojis/stats.ts | 53 ++++++++++++++ packages/frontend/src/boot/common.ts | 4 +- .../frontend/src/components/MkEmojiPicker.vue | 8 +-- .../components/MkReactionsViewer.reaction.vue | 6 +- .../src/components/MkReactionsViewer.vue | 2 +- .../src/components/global/MkCustomEmoji.vue | 2 +- packages/frontend/src/custom-emojis.ts | 58 ++++++++------- packages/misskey-js/etc/misskey-js.api.md | 4 ++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 11 +++ packages/misskey-js/src/autogen/endpoint.ts | 2 + packages/misskey-js/src/autogen/entities.ts | 1 + packages/misskey-js/src/autogen/types.ts | 71 +++++++++++++++++++ 15 files changed, 210 insertions(+), 41 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/emojis/stats.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4787ea4efd6..9bda49762c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Feat: コントロールパネルから二要素認証を解除できるように - Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (Based on https://github.com/MisskeyIO/misskey/pull/214) +- Fix: クライアントを閉じている間に追加・変更されたカスタム絵文字が、クライアントのキャッシュを削除するか期限切れになるまで反映されない問題を修正 ### Client - 2025.4.0 以前の設定情報の移行処理が削除されました @@ -38,6 +39,7 @@ - Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正 - Fix: ローカルユーザーへのホスト付きメンションが本文に含まれる指名ノートの作成時、投稿フォームにて、当該ユーザーが宛先に含まれていても正しく認識されない問題を修正 - Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正 +- Fix: セッション中にカスタム絵文字が追加・変更されても、既に描画済みの絵文字に反映されない問題を修正 ### Server - Feat: OpenTelemetryサポート diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index eb752a8bbe0..1318052e9b4 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -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'; @@ -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; + private localEmojisStatsCache: MemorySingleCache; public localEmojisCache: RedisSingleCache>; constructor( @@ -74,7 +80,7 @@ export class CustomEmojiService implements OnApplicationShutdown { private globalEventService: GlobalEventService, ) { this.emojisCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h - + this.localEmojisStatsCache = new MemorySingleCache(1000 * 60 * 3); // 3m this.localEmojisCache = new RedisSingleCache>(this.redisClient, 'localEmojis', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60 * 3, // 3m @@ -601,6 +607,22 @@ export class CustomEmojiService implements OnApplicationShutdown { }; } + @bindThis + public async getLocalEmojisStats(): Promise { + 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, + }; + }); + } + @bindThis public dispose(): void { this.emojisCache.dispose(); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index b3cd0daf4a3..ca877c7c33e 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -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'; diff --git a/packages/backend/src/server/api/endpoints/emojis/stats.ts b/packages/backend/src/server/api/endpoints/emojis/stats.ts new file mode 100644 index 00000000000..de8cd84c355 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emojis/stats.ts @@ -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 { // 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, + }; + }); + } +} diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 4c5e601dae9..9005f278464 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -242,9 +242,7 @@ export async function common(createVue: () => Promise>) { } //#endregion - try { - await fetchCustomEmojis(); - } catch (err) { /* empty */ } + fetchCustomEmojis().catch(() => { /* empty */ }); // analytics fetchInstanceMetaPromise.then(async () => { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index bf0f9d01307..64ad89b619c 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -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)!); } if (matches.size >= max) return matches; @@ -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); } @@ -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; diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index db99080de55..656787f564a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -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); @@ -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; } @@ -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; } diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 67fd570b415..cc49affba34 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -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]) => { diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 39662fb7d45..7148bafe53a 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -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`; }); diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index e04d540c388..5b6545833d4 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -3,9 +3,9 @@ * 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'); @@ -20,47 +20,51 @@ export const customEmojiCategories = computed<[ ...string[], null ]>(() => { return markRaw([...Array.from(categories), null]); }); -export const customEmojisMap = new Map(); -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(); + 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); } -let cachedTags: string[] | null = null; export function getCustomEmojiTags() { if (cachedTags) return cachedTags; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 634ee1f02c2..c3bf869b5d5 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -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[]; @@ -1858,6 +1861,7 @@ declare namespace entities { EmojiRequest, EmojiResponse, EmojisResponse, + EmojisStatsResponse, EndpointRequest, EndpointResponse, EndpointsResponse, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index dd9642f5396..3eee0b337e3 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -2337,6 +2337,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *No* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 926b9f35525..273ab646481 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -322,6 +322,7 @@ import type { EmojiRequest, EmojiResponse, EmojisResponse, + EmojisStatsResponse, EndpointRequest, EndpointResponse, EndpointsResponse, @@ -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 }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index bd9e2b5c60d..c50a0d5ee00 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -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']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 0f5db3e205e..4d5fb5bbb3c 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1914,6 +1914,15 @@ export type paths = { */ post: operations['emojis']; }; + '/emojis/stats': { + /** + * emojis/stats + * @description No description provided. + * + * **Credential required**: *No* + */ + post: operations['emojis___stats']; + }; '/endpoint': { /** * endpoint @@ -20998,6 +21007,68 @@ export interface operations { }; }; }; + emojis___stats: { + responses: { + /** @description OK (with results) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + count: number; + /** Format: date-time */ + lastUpdatedAt: string | null; + }; + }; + }; + /** @description Client error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; endpoint: { requestBody: { content: { From 5092984d68c5d412bd783df36bc62386b23dc8bd Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:23:27 +0900 Subject: [PATCH 2/4] fix --- packages/frontend/src/boot/common.ts | 10 ++++++++-- packages/frontend/src/custom-emojis.ts | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 9005f278464..9a81dbdba6b 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -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'; @@ -242,7 +242,13 @@ export async function common(createVue: () => Promise>) { } //#endregion - fetchCustomEmojis().catch(() => { /* empty */ }); + if (isCustomEmojiCacheNotFound) { + // まだキャッシュしたことがない場合は、取得を待つ + await fetchCustomEmojis(true).catch(() => { /* empty */ }); + } else { + // 既にキャッシュがあるなら、バックグラウンドで更新を試みる(描画の方はリアクティビティにより更新される) + fetchCustomEmojis().catch(() => { /* empty */ }); + } // analytics fetchInstanceMetaPromise.then(async () => { diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 5b6545833d4..673fd6e6ae4 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -9,6 +9,7 @@ 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(Array.isArray(storageCache) ? storageCache : []); export const customEmojiCategories = computed<[ ...string[], null ]>(() => { const categories = new Set(); @@ -58,7 +59,7 @@ export async function fetchCustomEmojis(force = false) { const stats = await misskeyApi('emojis/stats'); if (!force && isSameStats(await get('emojisStats'), stats)) return; - // GETだとキャッシュで古いリストが返ってきうるのでPOST + // GETだとキャッシュで古いリストが返ってきうるのでPOST const res = await misskeyApi('emojis'); setCustomEmojis(res.emojis); From edcd70f4516245467cdfbbecf06ce9af221ee870 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:45:34 +0900 Subject: [PATCH 3/4] fix review --- .../backend/src/core/CustomEmojiService.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 1318052e9b4..91db1a1878e 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -95,6 +95,12 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } + @bindThis + private refreshLocalEmojiCaches(): void { + this.localEmojisCache.refresh(); + this.localEmojisStatsCache.delete(); + } + @bindThis public async add(data: { originalUrl: string; @@ -126,7 +132,7 @@ export class CustomEmojiService implements OnApplicationShutdown { }); if (data.host == null) { - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(emoji.id), @@ -188,7 +194,7 @@ export class CustomEmojiService implements OnApplicationShutdown { roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, }); - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); const packed = await this.emojiEntityService.packDetailed(emoji.id); @@ -230,7 +236,7 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ids), @@ -246,7 +252,7 @@ export class CustomEmojiService implements OnApplicationShutdown { aliases: aliases, }); - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ids), @@ -266,7 +272,7 @@ export class CustomEmojiService implements OnApplicationShutdown { }); } - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ids), @@ -282,7 +288,7 @@ export class CustomEmojiService implements OnApplicationShutdown { category: category, }); - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ids), @@ -298,7 +304,7 @@ export class CustomEmojiService implements OnApplicationShutdown { license: license, }); - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: await this.emojiEntityService.packDetailedMany(ids), @@ -311,7 +317,7 @@ export class CustomEmojiService implements OnApplicationShutdown { await this.emojisRepository.delete(emoji.id); - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiDeleted', { emojis: [await this.emojiEntityService.packDetailed(emoji)], @@ -342,7 +348,7 @@ export class CustomEmojiService implements OnApplicationShutdown { } } - this.localEmojisCache.refresh(); + this.refreshLocalEmojiCaches(); this.globalEventService.publishBroadcastStream('emojiDeleted', { emojis: await this.emojiEntityService.packDetailedMany(emojis), From 9f06d5f54aeaf58d65b8def5b95d94f6c47fc099 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:51:35 +0900 Subject: [PATCH 4/4] fix --- packages/frontend/src/custom-emojis.ts | 35 +++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 673fd6e6ae4..3716bed756c 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -38,16 +38,29 @@ function setCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { set('emojis', emojis); } +type CustomEmojisMutation = (emojis: Misskey.entities.EmojiSimple[]) => Misskey.entities.EmojiSimple[]; + +/** + * fetchCustomEmojis が絵文字リストを取得している間に届いたストリーム経由の更新を記録しておくバッファ + * 取得していない間は null。 + */ +let mutationsDuringFetch: CustomEmojisMutation[] | null = null; + +function mutateCustomEmojis(mutation: CustomEmojisMutation) { + mutationsDuringFetch?.push(mutation); + setCustomEmojis(mutation(customEmojis.value)); +} + export function addCustomEmoji(emoji: Misskey.entities.EmojiSimple) { - setCustomEmojis([emoji, ...customEmojis.value]); + mutateCustomEmojis(current => [emoji, ...current]); } export function updateCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { - setCustomEmojis(customEmojis.value.map(item => emojis.find(search => search.name === item.name) ?? item)); + mutateCustomEmojis(current => current.map(item => emojis.find(search => search.name === item.name) ?? item)); } export function removeCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { - setCustomEmojis(customEmojis.value.filter(item => !emojis.some(search => search.name === item.name))); + mutateCustomEmojis(current => current.filter(item => !emojis.some(search => search.name === item.name))); } function isSameStats(a: Misskey.entities.EmojisStatsResponse | null | undefined, b: Misskey.entities.EmojisStatsResponse) { @@ -59,11 +72,21 @@ export async function fetchCustomEmojis(force = false) { const stats = await misskeyApi('emojis/stats'); if (!force && isSameStats(await get('emojisStats'), stats)) return; + // 取得中にストリーム経由で届いた更新 + const mutations: CustomEmojisMutation[] = []; + mutationsDuringFetch = mutations; + + try { // GETだとキャッシュで古いリストが返ってきうるのでPOST - const res = await misskeyApi('emojis'); + const res = await misskeyApi('emojis'); - setCustomEmojis(res.emojis); - set('emojisStats', stats); + // 取得中に届いた更新は取得結果より新しいので、取得したリストに改めて適用する + // そのまま上書きしてしまうと、その間に追加された絵文字が次回起動まで消えてしまう + setCustomEmojis(mutations.reduce((emojis, mutation) => mutation(emojis), res.emojis)); + set('emojisStats', stats); + } finally { + mutationsDuringFetch = null; + } } export function getCustomEmojiTags() {