diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0577fe32b..2e2c5520f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 2026.6.0 ### General +<<<<<<< Updated upstream - Feat: ジョブキュー管理画面からキューの一時停止/再開ができるように - Feat: アンテナのタイムラインから個別のノートを削除できるように - Fix: コンパネからrootユーザーのパスワードをリセットしようとした際にエラーが通知されない問題を修正 @@ -18,8 +19,15 @@ - Fix: 「D」キーでダークモードを切り替える際にsyncDeviceDarkModeのチェックがバイパスされる問題を修正 - Fix: パスキー登録完了時の認証ダイアログの入力値が使われていない問題を修正 - Fix: メンションのサジェスト時に表示されるアイコン表示が画像サイズ次第で崩れる問題を修正 +======= +- Feat: ノート単位でリノートをロックできる機能を追加 (自分のノートを他人にリノートされないようにする / モデレーターが全員のリノートを禁止する) + +### Client +- Feat: 投稿フォームとノートメニューからリノートロックを設定できるように +>>>>>>> Stashed changes ### Server +- Feat: リノートロック用 API (`notes/renote-lock/create`・`delete`、`admin/notes/renote-lock/create`・`delete`) を追加 - Enhance: リモートノートクリーニングジョブのスキップ処理のパフォーマンス改善 - Fix: PerUserDriveChart がシステム所有ファイル (userId が null) の更新で `"group"` の非NULL制約違反によりクラッシュする問題を修正 (#17498) - Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index aa2ac97d8af..a421f54bdab 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2522,6 +2522,7 @@ _permissions: "write:admin:unsuspend-user": "ユーザーの凍結を解除する" "write:admin:meta": "インスタンスのメタデータを操作する" "write:admin:user-note": "モデレーションノートを操作する" + "write:admin:note-renote-lock": "ノートのリノートロックを操作する" "write:admin:roles": "ロールを操作する" "read:admin:roles": "ロールを見る" "write:admin:relays": "リレーを操作する" @@ -3076,6 +3077,8 @@ _moderationLogTypes: deleteGalleryPost: "ギャラリーの投稿を削除" deleteChatRoom: "ダイレクトメッセージのグループを削除" updateProxyAccountDescription: "プロキシアカウントの説明を更新" + lockNoteRenote: "ノートのリノートをロック" + unlockNoteRenote: "ノートのリノートのロックを解除" _fileViewer: title: "ファイルの詳細" @@ -3534,6 +3537,13 @@ _imageEffector: circle: "円形" drafts: "下書き" +_renoteLock: + lockRenoteByOthersInPost: "他の人によるリノートを禁止" + prohibitRenote: "リノートを禁止する" + allowRenote: "リノートを許可する" + lockByModeration: "リノートをロックする (モデレーション)" + unlockByModeration: "リノートのロックを解除する (モデレーション)" + _drafts: select: "下書きを選択" cannotCreateDraftAnymore: "下書きの作成可能数を超えています。" diff --git a/packages/backend/migration/1780190369217-noteRenoteLock.js b/packages/backend/migration/1780190369217-noteRenoteLock.js new file mode 100644 index 00000000000..2ef04fcc762 --- /dev/null +++ b/packages/backend/migration/1780190369217-noteRenoteLock.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteRenoteLock1780190369217 { + name = 'NoteRenoteLock1780190369217' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "userRenoteLock" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "note" ADD "moderationRenoteLock" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "moderationRenoteLock"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "userRenoteLock"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index c4a7d80190b..db247c19cf9 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -177,6 +177,7 @@ type Option = { poll?: IPoll | null; localOnly?: boolean | null; reactionAcceptance?: MiNote['reactionAcceptance']; + userRenoteLock?: boolean | null; cw?: string | null; visibility?: string; visibleUsers?: MinimumUser[] | null; @@ -291,6 +292,7 @@ export class NoteCreateService implements OnApplicationShutdown { channelId: MiChannel['id'] | null; localOnly: boolean; reactionAcceptance: MiNote['reactionAcceptance']; + userRenoteLock?: boolean; poll: IPoll | null; apMentions?: MinimumUser[] | null; apHashtags?: string[] | null; @@ -367,6 +369,18 @@ export class NoteCreateService implements OnApplicationShutdown { throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external'); } } + + // リノートロックのチェック (純粋リノートのみ対象。引用リノートはフロント側で制御する) + const isPureRenoteRequest = data.text == null && data.cw == null && data.replyId == null && data.poll == null && data.fileIds.length === 0; + if (isPureRenoteRequest) { + if (renote.moderationRenoteLock) { + // モデレーションによるロック: 所有者を含む全員のリノートを禁止 + throw new IdentifiableError('5f8d7a3c-2b1e-4c9a-9e7d-1a2b3c4d5e6f', 'Renote of this note has been locked by moderation'); + } else if (renote.userRenoteLock && renote.userId !== user.id) { + // ユーザーによるロック: 所有者本人以外のリノートを禁止 + throw new IdentifiableError('a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d', 'Renote of this note has been locked by the author'); + } + } } let reply: MiNote | null = null; @@ -428,6 +442,7 @@ export class NoteCreateService implements OnApplicationShutdown { cw: data.cw, localOnly: data.localOnly, reactionAcceptance: data.reactionAcceptance, + userRenoteLock: data.userRenoteLock, visibility: data.visibility, visibleUsers, channel, @@ -640,6 +655,7 @@ export class NoteCreateService implements OnApplicationShutdown { userId: user.id, localOnly: data.localOnly!, reactionAcceptance: data.reactionAcceptance ?? null, + userRenoteLock: data.userRenoteLock ?? false, visibility: data.visibility as any, visibleUserIds: data.visibility === 'specified' ? data.visibleUsers diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index b112912b1b6..b2c9afa66c9 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -82,6 +82,8 @@ function generateDummyNote(override?: Partial): MiNote { user: null, localOnly: true, reactionAcceptance: 'likeOnly', + userRenoteLock: false, + moderationRenoteLock: false, renoteCount: 10, repliesCount: 5, clippedCount: 0, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 11cbcf8205e..6233f6b05a5 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -402,6 +402,8 @@ export class NoteEntityService implements OnModuleInit { visibility: note.visibility, localOnly: note.localOnly, reactionAcceptance: note.reactionAcceptance, + userRenoteLock: note.userRenoteLock, + moderationRenoteLock: note.moderationRenoteLock, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 089fe8f1883..a54916f5bde 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -99,6 +99,18 @@ export class MiNote { }) public reactionAcceptance: typeof noteReactionAcceptances[number]; + @Column('boolean', { + default: false, + comment: 'Whether the note owner has locked renotes by others (the owner can still renote).', + }) + public userRenoteLock: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether a moderator has locked renotes of this note (nobody, including the owner, can renote).', + }) + public moderationRenoteLock: boolean; + @Column('smallint', { default: 0, }) diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index f3901691a48..3c2bb96e919 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -206,6 +206,14 @@ export const packedNoteSchema = { optional: false, nullable: true, enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null], }, + userRenoteLock: { + type: 'boolean', + optional: true, nullable: false, + }, + moderationRenoteLock: { + type: 'boolean', + optional: true, nullable: false, + }, reactionEmojis: { type: 'object', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 6a56c428a72..35d7204fade 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -63,6 +63,8 @@ export * as 'admin/get-user-ips' from './endpoints/admin/get-user-ips.js'; export * as 'admin/invite/create' from './endpoints/admin/invite/create.js'; export * as 'admin/invite/list' from './endpoints/admin/invite/list.js'; export * as 'admin/meta' from './endpoints/admin/meta.js'; +export * as 'admin/notes/renote-lock/create' from './endpoints/admin/notes/renote-lock/create.js'; +export * as 'admin/notes/renote-lock/delete' from './endpoints/admin/notes/renote-lock/delete.js'; export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; @@ -334,6 +336,8 @@ export * as 'notes/polls/vote' from './endpoints/notes/polls/vote.js'; export * as 'notes/reactions' from './endpoints/notes/reactions.js'; export * as 'notes/reactions/create' from './endpoints/notes/reactions/create.js'; export * as 'notes/reactions/delete' from './endpoints/notes/reactions/delete.js'; +export * as 'notes/renote-lock/create' from './endpoints/notes/renote-lock/create.js'; +export * as 'notes/renote-lock/delete' from './endpoints/notes/renote-lock/delete.js'; export * as 'notes/renotes' from './endpoints/notes/renotes.js'; export * as 'notes/replies' from './endpoints/notes/replies.js'; export * as 'notes/search' from './endpoints/notes/search.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/notes/renote-lock/create.ts b/packages/backend/src/server/api/endpoints/admin/notes/renote-lock/create.ts new file mode 100644 index 00000000000..5d27f1daebe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/notes/renote-lock/create.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'notes'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:note-renote-lock', + + description: 'Lock renotes of the note for everyone, including the author.', + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'e3f4a5b6-7c8d-9e0f-1a2b-3c4d5e6f7081', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private getterService: GetterService, + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + await this.notesRepository.update({ id: note.id }, { + moderationRenoteLock: true, + }); + + const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); + + this.moderationLogService.log(me, 'lockNoteRenote', { + noteId: note.id, + noteUserId: note.userId, + noteUserUsername: user.username, + noteUserHost: user.host, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/notes/renote-lock/delete.ts b/packages/backend/src/server/api/endpoints/admin/notes/renote-lock/delete.ts new file mode 100644 index 00000000000..97069ffc8c0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/notes/renote-lock/delete.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'notes'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:note-renote-lock', + + description: 'Unlock renotes of the note that were locked by moderation.', + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'f4a5b6c7-8d9e-0f1a-2b3c-4d5e6f708192', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private getterService: GetterService, + private moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + await this.notesRepository.update({ id: note.id }, { + moderationRenoteLock: false, + }); + + const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); + + this.moderationLogService.log(me, 'unlockNoteRenote', { + noteId: note.id, + noteUserId: note.userId, + noteUserUsername: user.username, + noteUserHost: user.host, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index e48aa69d0f5..27ca0f0659c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -123,6 +123,18 @@ export const meta = { code: 'CONTAINS_TOO_MANY_MENTIONS', id: '4de0363a-3046-481b-9b0f-feff3e211025', }, + + renoteLockedByModeration: { + message: 'Renote of this note has been locked by moderation.', + code: 'RENOTE_LOCKED_BY_MODERATION', + id: '5f8d7a3c-2b1e-4c9a-9e7d-1a2b3c4d5e6f', + }, + + renoteLockedByAuthor: { + message: 'Renote of this note has been locked by the author.', + code: 'RENOTE_LOCKED_BY_AUTHOR', + id: 'a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d', + }, }, } as const; @@ -136,6 +148,7 @@ export const paramDef = { cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + userRenoteLock: { type: 'boolean', default: false }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, @@ -235,6 +248,7 @@ export default class extends Endpoint { // eslint- cw: ps.cw ?? null, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, + userRenoteLock: ps.userRenoteLock, visibility: ps.visibility, visibleUserIds: ps.visibleUserIds ?? [], channelId: ps.channelId ?? null, @@ -259,6 +273,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRenoteTarget); } else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') { throw new ApiError(meta.errors.cannotReRenote); + } else if (err.id === '5f8d7a3c-2b1e-4c9a-9e7d-1a2b3c4d5e6f') { + throw new ApiError(meta.errors.renoteLockedByModeration); + } else if (err.id === 'a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d') { + throw new ApiError(meta.errors.renoteLockedByAuthor); } else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') { throw new ApiError(meta.errors.youHaveBeenBlocked); } else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') { diff --git a/packages/backend/src/server/api/endpoints/notes/renote-lock/create.ts b/packages/backend/src/server/api/endpoints/notes/renote-lock/create.ts new file mode 100644 index 00000000000..626be20f6a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/renote-lock/create.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'write:notes', + + description: 'Lock renotes of the note by others. The author can still renote it.', + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'efdcf08c-2b7b-4d3f-9d8f-6a1b9d2e7c10', + }, + + accessDenied: { + message: 'You are not the author of this note.', + code: 'ACCESS_DENIED', + id: 'b8c4dca1-9f63-4c7e-9a18-2f6d3a51b0e2', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.notesRepository.update({ id: note.id }, { + userRenoteLock: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/renote-lock/delete.ts b/packages/backend/src/server/api/endpoints/notes/renote-lock/delete.ts new file mode 100644 index 00000000000..60430456120 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/renote-lock/delete.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'write:notes', + + description: 'Unlock renotes of the note by others.', + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f', + }, + + accessDenied: { + message: 'You are not the author of this note.', + code: 'ACCESS_DENIED', + id: 'd2e3f4a5-6b7c-8d9e-0f1a-2b3c4d5e6f70', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.notesRepository.update({ id: note.id }, { + userRenoteLock: false, + }); + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8e78667d722..1d524474704 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -134,6 +134,8 @@ export const moderationLogTypes = [ 'deleteGalleryPost', 'deleteChatRoom', 'updateProxyAccountDescription', + 'lockNoteRenote', + 'unlockNoteRenote', ] as const; export type ModerationLogPayloads = { @@ -396,6 +398,18 @@ export type ModerationLogPayloads = { before: string | null; after: string | null; }; + lockNoteRenote: { + noteId: string; + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + }; + unlockNoteRenote: { + noteId: string; + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + }; }; export type Serialized = { diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 4e506a62027..74f4e0e17d4 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -159,6 +159,99 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.text, null); }); + test('リノートロックされたノートは他人がpure renoteできない', async () => { + const alicePost = await post(alice, { + text: 'test', + userRenoteLock: true, + }); + + const res = await api('notes/create', { + renoteId: alicePost.id, + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(castAsError(res.body).error.code, 'RENOTE_LOCKED_BY_AUTHOR'); + assert.strictEqual(castAsError(res.body).error.id, 'a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d'); + }); + + test('リノートロックされたノートでも本人はpure renoteできる', async () => { + const alicePost = await post(alice, { + text: 'test', + userRenoteLock: true, + }); + + const res = await api('notes/create', { + renoteId: alicePost.id, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.id); + }); + + test('リノートロックされたノートでも引用renoteはできる', async () => { + const alicePost = await post(alice, { + text: 'test', + userRenoteLock: true, + }); + + const res = await api('notes/create', { + text: 'quote', + renoteId: alicePost.id, + }, bob); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.id); + assert.strictEqual(res.body.createdNote.text, 'quote'); + }); + + test('notes/renote-lock/create で投稿後にリノートをロックできる', async () => { + const alicePost = await post(alice, { + text: 'test', + }); + + const lockRes = await api('notes/renote-lock/create', { + noteId: alicePost.id, + }, alice); + assert.strictEqual(lockRes.status, 204); + + const res = await api('notes/create', { + renoteId: alicePost.id, + }, bob); + assert.strictEqual(res.status, 400); + assert.strictEqual(castAsError(res.body).error.code, 'RENOTE_LOCKED_BY_AUTHOR'); + }); + + test('notes/renote-lock/delete でリノートロックを解除できる', async () => { + const alicePost = await post(alice, { + text: 'test', + userRenoteLock: true, + }); + + const unlockRes = await api('notes/renote-lock/delete', { + noteId: alicePost.id, + }, alice); + assert.strictEqual(unlockRes.status, 204); + + const res = await api('notes/create', { + renoteId: alicePost.id, + }, bob); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.createdNote.renoteId, alicePost.id); + }); + + test('他人のノートのリノートロックは操作できない', async () => { + const alicePost = await post(alice, { + text: 'test', + }); + + const res = await api('notes/renote-lock/create', { + noteId: alicePost.id, + }, bob); + assert.strictEqual(res.status, 400); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'ACCESS_DENIED'); + }); + test('visibility: followersでrenoteできる', async () => { const createRes = await api('notes/create', { text: 'test', diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index 3e493fd3d9a..1e811e0f9de 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -38,6 +38,8 @@ describe('NoteCreateService', () => { user: null, localOnly: false, reactionAcceptance: null, + userRenoteLock: false, + moderationRenoteLock: false, renoteCount: 0, repliesCount: 0, clippedCount: 0, diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 4be3b4992b1..46a0c9e6c71 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -21,6 +21,8 @@ const base: MiNote = { user: null, localOnly: false, reactionAcceptance: null, + userRenoteLock: false, + moderationRenoteLock: false, renoteCount: 0, repliesCount: 0, clippedCount: 0, diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 1cb562fb622..df1ae24e430 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -16,11 +16,34 @@ SPDX-License-Identifier: AGPL-3.0-only
+<<<<<<< Updated upstream @@ -315,7 +338,16 @@ const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); const translation = ref(null); const translating = ref(false); const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); +<<<<<<< Updated upstream const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)); +======= +const showTickerRenote = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); +const canRenote = computed(() => + (['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)) && + !appearNote.moderationRenoteLock && + (!appearNote.userRenoteLock || appearNote.userId === $i?.id), +); +>>>>>>> Stashed changes const renoteCollapsed = ref( prefer.s.collapseRenotes && isRenote && ( ($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 6bd47ebae45..ce130772638 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -343,7 +343,11 @@ const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renot const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance); const conversation = ref([]); const replies = ref([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id); +const canRenote = computed(() => + (['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id) && + !appearNote.moderationRenoteLock && + (!appearNote.userRenoteLock || appearNote.userId === $i?.id), +); useGlobalEvent('noteDeleted', (noteId) => { if (noteId === note.id || noteId === appearNote.id) { @@ -632,6 +636,13 @@ function blur() { const repliesLoaded = ref(false); +<<<<<<< Updated upstream +======= +if (note.repliesCount > 0) { + loadReplies(); +} + +>>>>>>> Stashed changes function loadReplies() { repliesLoaded.value = true; misskeyApi('notes/children', { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index d7092860413..47efa0ba11e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -211,6 +211,7 @@ if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(u => pushVisibleUser(u)); } const reactionAcceptance = ref(store.s.reactionAcceptance); +const userRenoteLock = ref(false); const scheduledAt = ref(null); const draghover = ref(false); const quoteId = ref(null); @@ -437,6 +438,7 @@ function watchForDraft() { watch(localOnly, () => saveDraft()); watch(quoteId, () => saveDraft()); watch(reactionAcceptance, () => saveDraft()); + watch(userRenoteLock, () => saveDraft()); watch(scheduledAt, () => saveDraft()); } @@ -649,6 +651,11 @@ function showOtherSettings() { action: () => { toggleReactionAcceptance(); }, + }, { + type: 'switch', + icon: 'ti ti-repeat-off', + text: i18n.ts._renoteLock.lockRenoteByOthersInPost, + ref: userRenoteLock, }, { type: 'divider' }, { type: 'button', text: i18n.ts._drafts.saveToDraft, @@ -1030,6 +1037,7 @@ async function post(ev?: PointerEvent) { visibility: visibility.value, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, reactionAcceptance: reactionAcceptance.value, + userRenoteLock: userRenoteLock.value, }; if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 40c7b0b1b4a..37472e208b4 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -82,6 +82,8 @@ SPDX-License-Identifier: AGPL-3.0-only : @{{ log.info.flashUserUsername }} : @{{ log.info.postUserUsername }} : @{{ log.info.room.name }} + : @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }} + : @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }}