diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 940aa98347..570f2350f1 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -96,6 +96,8 @@ export class PollService { const note = await this.notesRepository.findOneBy({ id: noteId }); if (note == null) throw new Error('note not found'); + if (note.localOnly) return; + const user = await this.usersRepository.findOneBy({ id: note.userId }); if (user == null) throw new Error('note not found'); diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index dbc0b04e8c..f64ab286e1 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -17,6 +17,7 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { DebounceLoader } from '@/misc/loader.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -29,6 +30,7 @@ export class NoteEntityService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private customEmojiService: CustomEmojiService; private reactionService: ReactionService; + private noteLoader = new DebounceLoader(this.findNoteOrFail); constructor( private moduleRef: ModuleRef, @@ -289,7 +291,7 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); + const note = typeof src === 'object' ? src : await this.noteLoader.load(src); const host = note.userHost; let text = note.text; @@ -307,6 +309,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis([note])); const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ @@ -472,4 +475,12 @@ export class NoteEntityService implements OnModuleInit { return await query.getCount(); } + + @bindThis + private async findNoteOrFail(id: string): Promise { + return await this.notesRepository.findOneOrFail({ + where: { id }, + relations: ["user"], + }); + } } diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index c235871931..742dad0857 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -149,7 +149,10 @@ export class RedisSingleCache { const cached = await this.redisClient.get(`singlecache:${this.name}`); if (cached == null) return undefined; - return this.fromRedisConverter(cached); + const parsed = this.fromRedisConverter(cached); + if (parsed == null) return undefined; + this.memoryCache.set(parsed); + return parsed; } @bindThis diff --git a/packages/backend/src/misc/loader.ts b/packages/backend/src/misc/loader.ts new file mode 100644 index 0000000000..49aac5d883 --- /dev/null +++ b/packages/backend/src/misc/loader.ts @@ -0,0 +1,49 @@ +export type FetchFunction = (key: K) => Promise; +type ResolveReject = Parameters>[0]>; +type ResolverPair = { + resolve: ResolveReject[0]; + reject: ResolveReject[1]; +}; +export class DebounceLoader { + private resolverMap = new Map>(); + private promiseMap = new Map>(); + private resolvedPromise = Promise.resolve(); + constructor(private loadFn: FetchFunction) {} + + public load(key: K): Promise { + const promise = this.promiseMap.get(key); + if (typeof promise !== 'undefined') { + return promise; + } + + const isFirst = this.promiseMap.size === 0; + const newPromise = new Promise((resolve, reject) => { + this.resolverMap.set(key, { resolve, reject }); + }); + this.promiseMap.set(key, newPromise); + + if (isFirst) { + this.enqueueDebouncedLoadJob(); + } + + return newPromise; + } + + private runDebouncedLoad(): void { + const resolvers = [...this.resolverMap]; + this.resolverMap.clear(); + this.promiseMap.clear(); + + for (const [key, { resolve, reject }] of resolvers) { + this.loadFn(key).then(resolve, reject); + } + } + + private enqueueDebouncedLoadJob(): void { + this.resolvedPromise.then(() => { + process.nextTick(() => { + this.runDebouncedLoad(); + }); + }); + } +} diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 1c2a2ee3cb..a06fd5c841 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -240,7 +240,7 @@ export function createPostgresDataSource(config: Config) { } : false, logging: log, logger: log ? new MyCustomLogger() : undefined, - maxQueryExecutionTime: 300, + maxQueryExecutionTime: 10000, // 10s entities: entities, migrations: ['../../migration/*.js'], }); diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 022dfab83c..3e31b5d5fa 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -34,17 +34,9 @@ //#region Detect language & fetch translations if (!localStorage.hasOwnProperty('locale')) { - const supportedLangs = LANGS; let lang = localStorage.getItem('lang'); - if (lang == null || !supportedLangs.includes(lang)) { - if (supportedLangs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - - // Fallback - if (lang == null) lang = 'en-US'; - } + if (lang == null || lang.toString == null || lang.toString() === 'null') { + lang = 'ja-JP'; } const metaRes = await window.fetch('/api/meta', { @@ -67,12 +59,6 @@ return; } - // for https://github.com/misskey-dev/misskey/issues/10202 - if (lang == null || lang.toString == null || lang.toString() === 'null') { - console.error('invalid lang value detected!!!', typeof lang, lang); - lang = 'en-US'; - } - const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); if (localRes.status === 200) { localStorage.setItem('lang', lang); diff --git a/packages/backend/test/unit/misc/loader.ts b/packages/backend/test/unit/misc/loader.ts new file mode 100644 index 0000000000..fa37950951 --- /dev/null +++ b/packages/backend/test/unit/misc/loader.ts @@ -0,0 +1,88 @@ +import { DebounceLoader } from '@/misc/loader.js'; + +class Mock { + loadCountByKey = new Map(); + load = async (key: number): Promise => { + const count = this.loadCountByKey.get(key); + if (typeof count === 'undefined') { + this.loadCountByKey.set(key, 1); + } else { + this.loadCountByKey.set(key, count + 1); + } + return key * 2; + }; + reset() { + this.loadCountByKey.clear(); + } +} + +describe(DebounceLoader, () => { + describe('single request', () => { + it('loads once', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + }); + }); + + describe('two duplicated requests at same time', () => { + it('loads once', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + const [v1, v2] = await Promise.all([ + loader.load(7), + loader.load(7), + ]); + expect(v1).toBe(14); + expect(v2).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + }); + }); + + describe('two different requests at same time', () => { + it('loads twice', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + const [v1, v2] = await Promise.all([ + loader.load(7), + loader.load(13), + ]); + expect(v1).toBe(14); + expect(v2).toBe(26); + expect(mock.loadCountByKey.size).toBe(2); + expect(mock.loadCountByKey.get(7)).toBe(1); + expect(mock.loadCountByKey.get(13)).toBe(1); + }); + }); + + describe('non-continuous same two requests', () => { + it('loads twice', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + mock.reset(); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + }); + }); + + describe('non-continuous different two requests', () => { + it('loads twice', async () => { + const mock = new Mock(); + const loader = new DebounceLoader(mock.load); + expect(await loader.load(7)).toBe(14); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(7)).toBe(1); + mock.reset(); + expect(await loader.load(13)).toBe(26); + expect(mock.loadCountByKey.size).toBe(1); + expect(mock.loadCountByKey.get(13)).toBe(1); + }); + }); +}); diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts index 60fc8c9d34..4899217d9a 100644 --- a/packages/frontend/src/config.ts +++ b/packages/frontend/src/config.ts @@ -13,7 +13,7 @@ export const hostname = address.hostname; export const url = address.origin; export const apiUrl = url + '/api'; export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; -export const lang = miLocalStorage.getItem('lang') ?? 'en-US'; +export const lang = miLocalStorage.getItem('lang') ?? 'ja-JP'; export const langs = _LANGS_; const preParseLocale = miLocalStorage.getItem('locale'); export let locale = preParseLocale ? JSON.parse(preParseLocale) : null; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index e715088d03..4016e1f81c 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -223,11 +223,11 @@ export const defaultStore = markRaw(new Storage('base', { }, useBlurEffectForModal: { where: 'device', - default: !/mobile|iphone|android/.test(navigator.userAgent.toLowerCase()), // 循環参照するのでdevice-kind.tsは参照できない + default: false, }, useBlurEffect: { where: 'device', - default: !/mobile|iphone|android/.test(navigator.userAgent.toLowerCase()), // 循環参照するのでdevice-kind.tsは参照できない + default: false, }, showFixedPostForm: { where: 'device', diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue index b09221f5d2..3752a6df75 100644 --- a/packages/frontend/src/ui/_common_/stream-indicator.vue +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -24,12 +24,17 @@ import { defaultStore } from '@/store.js'; const zIndex = os.claimZIndex('high'); let hasDisconnected = $ref(false); +let timeoutId = $ref(); function onDisconnected() { - hasDisconnected = true; + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + hasDisconnected = true; + }, 1000 * 10); } function resetDisconnected() { + window.clearTimeout(timeoutId); hasDisconnected = false; } @@ -37,9 +42,12 @@ function reload() { location.reload(); } +useStream().on('_connected_', resetDisconnected); useStream().on('_disconnected_', onDisconnected); onUnmounted(() => { + window.clearTimeout(timeoutId); + useStream().off('_connected_', resetDisconnected); useStream().off('_disconnected_', onDisconnected); }); diff --git a/packages/sw/src/scripts/lang.ts b/packages/sw/src/scripts/lang.ts index a2b99ff6b1..724e810489 100644 --- a/packages/sw/src/scripts/lang.ts +++ b/packages/sw/src/scripts/lang.ts @@ -13,7 +13,7 @@ class SwLang { public cacheName = `mk-cache-${_VERSION_}`; public lang: Promise = get('lang').then(async prelang => { - if (!prelang) return 'en-US'; + if (!prelang) return 'ja-JP'; return prelang; });