diff --git a/locales/index.d.ts b/locales/index.d.ts index 75e1703b4a..8ad934f397 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6686,6 +6686,10 @@ export interface Locale extends ILocale { * ノートのピン留めの最大数 */ "pinMax": string; + /** + * 一つのノートに対する最大リアクション数 + */ + "reactionsPerNoteLimit": string; /** * アンテナの作成可能数 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 98e3cbfa41..2d85f0840a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1728,6 +1728,7 @@ _role: alwaysMarkNsfw: "ファイルにNSFWを常に付与" canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" + reactionsPerNoteLimit: "一つのノートに対する最大リアクション数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" webhookMax: "Webhookの作成可能数" diff --git a/packages/backend/migration/1721117896543-multiple-reactions.js b/packages/backend/migration/1721117896543-multiple-reactions.js new file mode 100644 index 0000000000..d71128962e --- /dev/null +++ b/packages/backend/migration/1721117896543-multiple-reactions.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MultipleReactions1721117896543 { + name = 'MultipleReactions1721117896543'; + + async up(queryRunner) { + await queryRunner.query('DROP INDEX "public"."IDX_ad0c221b25672daf2df320a817"'); + await queryRunner.query('CREATE UNIQUE INDEX "IDX_a7751b74317122d11575bff31c" ON "note_reaction" ("userId", "noteId", "reaction") '); + await queryRunner.query('CREATE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") '); + } + + async down(queryRunner) { + await queryRunner.query('DROP INDEX "public"."IDX_ad0c221b25672daf2df320a817"'); + await queryRunner.query('DROP INDEX "public"."IDX_a7751b74317122d11575bff31c"'); + await queryRunner.query('CREATE UNIQUE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") '); + } +} diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 371207c33a..9889d334ed 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -174,24 +174,12 @@ export class ReactionService { reaction, }; - // Create reaction try { await this.noteReactionsRepository.insert(record); } catch (e) { if (isDuplicateKeyValueError(e)) { - const exists = await this.noteReactionsRepository.findOneByOrFail({ - noteId: note.id, - userId: user.id, - }); - - if (exists.reaction !== reaction) { - // 別のリアクションがすでにされていたら置き換える - await this.delete(user, note); - await this.noteReactionsRepository.insert(record); - } else { - // 同じリアクションがすでにされていたらエラー - throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); - } + // 同じリアクションがすでにされていたらエラー + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); } else { throw e; } @@ -286,11 +274,12 @@ export class ReactionService { } @bindThis - public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { + public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, _reaction?: string | null) { // if already unreacted const exist = await this.noteReactionsRepository.findOneBy({ noteId: note.id, userId: user.id, + reaction: _reaction ?? FALLBACK, }); if (exist == null) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 7966774673..f502c19aa6 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -49,6 +49,7 @@ export type RolePolicies = { alwaysMarkNsfw: boolean; canUpdateBioMedia: boolean; pinLimit: number; + reactionsPerNoteLimit: number; antennaLimit: number; wordMuteLimit: number; webhookLimit: number; @@ -78,6 +79,7 @@ export const DEFAULT_POLICIES: RolePolicies = { alwaysMarkNsfw: false, canUpdateBioMedia: true, pinLimit: 5, + reactionsPerNoteLimit: 1, antennaLimit: 5, wordMuteLimit: 200, webhookLimit: 3, @@ -380,6 +382,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), + reactionsPerNoteLimit: calc('reactionsPerNoteLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), webhookLimit: calc('webhookLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2cd092231c..d1949d1432 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -170,10 +170,10 @@ export class NoteEntityService implements OnModuleInit { @bindThis public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: { - myReactions: Map; + myReactionsMap: Map; }) { - if (_hint_?.myReactions) { - const reaction = _hint_.myReactions.get(note.id); + if (_hint_?.myReactionsMap) { + const reaction = _hint_.myReactionsMap.get(note.id); if (reaction) { return this.reactionService.convertLegacyReaction(reaction); } else { diff --git a/packages/backend/src/models/NoteReaction.ts b/packages/backend/src/models/NoteReaction.ts index 42dfcaa9ad..366a4423f7 100644 --- a/packages/backend/src/models/NoteReaction.ts +++ b/packages/backend/src/models/NoteReaction.ts @@ -9,7 +9,8 @@ import { MiUser } from './User.js'; import { MiNote } from './Note.js'; @Entity('note_reaction') -@Index(['userId', 'noteId'], { unique: true }) +@Index(['userId', 'noteId']) +@Index(['userId', 'noteId', 'reaction'], { unique: true }) export class MiNoteReaction { @PrimaryColumn(id()) public id: string; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7366f05356..5820aa32e3 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -236,6 +236,10 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + reactionsPerNoteLimit: { + type: 'integer', + optional: false, nullable: false, + }, antennaLimit: { type: 'integer', optional: false, nullable: false, diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index e135bc69a0..2bb534ccf2 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -89,6 +89,7 @@ export const ROLE_POLICIES = [ 'alwaysMarkNsfw', 'canUpdateBioMedia', 'pinLimit', + 'reactionsPerNoteLimit', 'antennaLimit', 'wordMuteLimit', 'webhookLimit', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 3e948abdf1..c298476a64 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -417,6 +417,25 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + +
+
+