diff --git a/.config/example.yml b/.config/example.yml index ba8e818b5d..f781b72b91 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -341,9 +341,9 @@ checkActivityPubGetSignature: false # Upload or download file size limits (bytes) #maxFileSize: 262144000 -# timeout and maximum size for imports (e.g. note imports) +# timeout (in milliseconds) and maximum size for imports (e.g. note imports) #import: -# downloadTimeout: 30 +# downloadTimeout: 30000 # maxFileSize: 262144000 # PID File of master process diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4db8bda32e..d0f4d94948 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,7 +4,7 @@ stages: testCommit: stage: test - image: node:iron + image: node:jod services: - postgres:15 - redis diff --git a/locales/index.d.ts b/locales/index.d.ts index bcb78e4ee1..3a3b94b89d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9020,6 +9020,10 @@ export interface Locale extends ILocale { * Remove background */ "removeBackground": string; + /** + * ListenBrainz username + */ + "listenbrainz": string; }; "_exportOrImport": { /** @@ -10805,6 +10809,10 @@ export interface Locale extends ILocale { * Date */ "date": string; + /** + * Boost (hold Shift for visibility menu) + */ + "renoteShift": string; /** * Quoted. */ @@ -11525,6 +11533,14 @@ export interface Locale extends ILocale { * Change the background color of text. */ "backgroundDescription": string; + /** + * Border + */ + "border": string; + /** + * Draw a border around the content. + */ + "borderDescription": string; /** * Plain */ diff --git a/packages/backend/package.json b/packages/backend/package.json index dd6c9cc792..bace8c1f96 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": "^20.10.0 || ^22.0.0" + "node": "^22.0.0" }, "scripts": { "start": "node ./built/boot/entry.js", diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index eb6b353752..be45e75d74 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -528,44 +528,44 @@ export class NoteEntityService implements OnModuleInit { // パフォーマンスのためノートが作成されてから2秒以上経っていない場合はリアクションを取得しない const oldId = this.idService.gen(Date.now() - 2000); + const targetNotes: MiNote[] = []; for (const note of notes) { if (isPureRenote(note)) { - const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); - if (reactionsCount === 0) { - myReactionsMap.set(note.renote.id, null); - } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferedReactions?.get(note.renote.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferedReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); - if (pairInBuffer) { - myReactionsMap.set(note.renote.id, pairInBuffer[1]); - } else { - const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); - } - } else { - idsNeedFetchMyReaction.add(note.renote.id); + // we may need to fetch 'my reaction' for renote target. + targetNotes.push(note.renote); + if (note.renote.reply) { + // idem if the renote is also a reply. + targetNotes.push(note.renote.reply); } + } else if (note.reply) { + // idem for OP of a regular reply. + targetNotes.push(note.reply); } else { if (note.id < oldId) { - const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); - if (reactionsCount === 0) { - myReactionsMap.set(note.id, null); - } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); - if (pairInBuffer) { - myReactionsMap.set(note.id, pairInBuffer[1]); - } else { - const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); - } - } else { - idsNeedFetchMyReaction.add(note.id); - } + targetNotes.push(note); } else { myReactionsMap.set(note.id, null); } } } + for (const note of targetNotes) { + const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); + if (reactionsCount === 0) { + myReactionsMap.set(note.id, null); + } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.id, pairInBuffer[1]); + } else { + const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); + } + } else { + idsNeedFetchMyReaction.add(note.id); + } + } + const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({ userId: meId, noteId: In(Array.from(idsNeedFetchMyReaction)), diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 17ba71df3d..666a709ab9 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -17,6 +17,7 @@ import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserImportJobData } from '../types.js'; +import type { Config } from '@/config.js'; // TODO: 名前衝突時の動作を選べるようにする @Injectable() @@ -24,6 +25,9 @@ export class ImportCustomEmojisProcessorService { private logger: Logger; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -57,7 +61,7 @@ export class ImportCustomEmojisProcessorService { try { fs.writeFileSync(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadService.downloadUrl(file.url, destPath, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index f89dc46722..ee9819b29f 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -626,7 +626,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(videos[0].url, filePath); + await this.downloadUrl(videos[0].url, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } @@ -651,7 +651,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(file.media_url_https, filePath); + await this.downloadUrl(file.media_url_https, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts index 5ecae3161a..e52b177e2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts @@ -68,6 +68,8 @@ export default class extends Endpoint { // eslint- for (let i = 0; i < ps.count; i++) { ticketsPromises.push(this.registrationTicketsRepository.insertOne({ id: this.idService.gen(), + createdBy: me, + createdById: me.id, expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, code: generateInviteCode(), })); diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 3cc7f89ab9..4909c948e3 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { EmojisRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -59,7 +58,7 @@ export default class extends Endpoint { // eslint- const emojis = await this.emojisRepository.createQueryBuilder() .where('host IS NULL') .orderBy('LOWER(category)', 'ASC') - .orderBy('LOWER(name)', 'ASC') + .addOrderBy('LOWER(name)', 'ASC') .getMany(); return { emojis: await this.emojiEntityService.packSimpleMany(emojis), diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 93d5046902..047dedd5ce 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -103,11 +103,42 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; - public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService) { - if (this.user && Object.keys(note.reactions).length > 0) { - const myReaction = await noteEntityService.populateMyReaction(note, this.user.id); - note.myReaction = myReaction; + public async assignMyReaction(note: Packed<'Note'>, noteEntityService: NoteEntityService): Promise> { + let changed = false; + // StreamingApiServerService creates a single EventEmitter per server process, + // so a new note arriving from redis gets de-serialised once per server process, + // and then that single object is passed to all active channels on each connection. + // If we didn't clone the notes here, different connections would asynchronously write + // different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar + const clonedNote = { ...note }; + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { + if (note.renote && Object.keys(note.renote.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.renote, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.myReaction = myReaction; + } + } + if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.renote.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.renote = { ...note.renote }; + clonedNote.renote.reply = { ...note.renote.reply }; + clonedNote.renote.reply.myReaction = myReaction; + } + } } + if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) { + const myReaction = await noteEntityService.populateMyReaction(note.reply, this.user.id); + if (myReaction) { + changed = true; + clonedNote.reply = { ...note.reply }; + clonedNote.reply.myReaction = myReaction; + } + } + return changed ? clonedNote : note; } } diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index b2745db92d..98ecf16a83 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -65,24 +65,11 @@ class BubbleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - await Promise.all(reactionsToFetch); + this.connection.cacheNote(clonedNote); - this.connection.cacheNote(note); - - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 8df59d906d..4443b20bed 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -60,24 +60,11 @@ class GlobalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - await Promise.all(reactionsToFetch); + this.connection.cacheNote(clonedNote); - this.connection.cacheNote(note); - - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index f48eff85c9..af1b17b533 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -81,24 +81,11 @@ class HomeTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - await Promise.all(reactionsToFetch); + this.connection.cacheNote(clonedNote); - this.connection.cacheNote(note); - - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 8c58b2518e..7c604c0b58 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -98,24 +98,11 @@ class HybridTimelineChannel extends Channel { } } - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - await Promise.all(reactionsToFetch); + this.connection.cacheNote(clonedNote); - this.connection.cacheNote(note); - - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index cb832bd3c2..2d48b6ecfb 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -70,24 +70,11 @@ class LocalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const reactionsToFetch = []; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote) { - reactionsToFetch.push(this.assignMyReaction(note.renote, this.noteEntityService)); - if (note.renote.reply) { - reactionsToFetch.push(this.assignMyReaction(note.renote.reply, this.noteEntityService)); - } - } - } - if (this.user && note.reply) { - reactionsToFetch.push(this.assignMyReaction(note.reply, this.noteEntityService)); - } + const clonedNote = await this.assignMyReaction(note, this.noteEntityService); - await Promise.all(reactionsToFetch); + this.connection.cacheNote(clonedNote); - this.connection.cacheNote(note); - - this.send('note', note); + this.send('note', clonedNote); } @bindThis diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index dc589a28e0..a782ae9d3b 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -186,11 +186,13 @@ function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): Cu const parts = input.split('/').map(p => p.trim()); let currentNode: CustomEmojiFolderTree = root; + const currentPath = []; for (const part of parts) { + currentPath.push(part); let existingNode = currentNode.children.find((node) => node.value === part); if (!existingNode) { - const newNode: CustomEmojiFolderTree = { value: part, category: input, children: [] }; + const newNode: CustomEmojiFolderTree = { value: part, category: currentPath.join("/"), children: [] }; currentNode.children.push(newNode); existingNode = newNode; } diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 25d04a0b6a..5c6c6f45bb 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -128,11 +128,12 @@ SPDX-License-Identifier: AGPL-3.0-only