diff --git a/CHANGELOG.md b/CHANGELOG.md index adaed053d19..539c2a485c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### General - Feat: ジョブキュー管理画面からキューの一時停止/再開ができるように +- Feat: アンテナのタイムラインから個別のノートを削除できるように ### Client - diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 26ef054b9f1..f2dded75d7b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -753,6 +753,8 @@ optional: "任意" createNewClip: "新しいクリップを作成" unclip: "クリップ解除" confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれています。ノートをこのクリップから除外しますか?" +removeFromAntenna: "このアンテナから削除" +removeNoteFromAntennaConfirm: "「{name}」からこのノートを削除しますか?" public: "パブリック" private: "非公開" i18nInfo: "Misskeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。" diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 24999bf4dae..ae387dc8e4f 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -112,4 +112,9 @@ export class FanoutTimelineService { public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); } + + @bindThis + public remove(name: FanoutTimelineName, id: string) { + return this.redisForTimelines.lrem('list:' + name, 1, id); + } } diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index aec331c9823..6a56c428a72 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -117,6 +117,7 @@ export * as 'antennas/create' from './endpoints/antennas/create.js'; export * as 'antennas/delete' from './endpoints/antennas/delete.js'; export * as 'antennas/list' from './endpoints/antennas/list.js'; export * as 'antennas/notes' from './endpoints/antennas/notes.js'; +export * as 'antennas/remove-note' from './endpoints/antennas/remove-note.js'; export * as 'antennas/show' from './endpoints/antennas/show.js'; export * as 'antennas/update' from './endpoints/antennas/update.js'; export * as 'ap/get' from './endpoints/ap/get.js'; diff --git a/packages/backend/src/server/api/endpoints/antennas/remove-note.ts b/packages/backend/src/server/api/endpoints/antennas/remove-note.ts new file mode 100644 index 00000000000..2730ed18d18 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/remove-note.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AntennasRepository } from '@/models/_.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['antennas', 'account', 'notes'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:account', + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + antennaId: { type: 'string', format: 'misskey:id' }, + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['antennaId', 'noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + private fanoutTimelineService: FanoutTimelineService, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + await this.fanoutTimelineService.remove(`antennaTimeline:${antenna.id}`, ps.noteId); + }); + } +} diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index ea7cd77d665..c332371432a 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -359,6 +359,81 @@ describe('アンテナ', () => { assert.deepStrictEqual(response, expected); }); + test('から指定したノートだけ削除でき、ノート本体や他人のアンテナには影響しないこと。', async () => { + const keyword = 'キーワード'; + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]] }, + user: alice, + }); + const otherAntenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]] }, + user: bob, + }); + const remainingNote = await post(bob, { text: `test ${keyword} remaining` }); + const removedNote = await post(bob, { text: `test ${keyword} removed` }); + + await successfulApiCall({ + endpoint: 'antennas/remove-note', + parameters: { antennaId: antenna.id, noteId: removedNote.id }, + user: alice, + }); + + const response = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.deepStrictEqual(response, [remainingNote]); + + const note = await successfulApiCall({ + endpoint: 'notes/show', + parameters: { noteId: removedNote.id }, + user: alice, + }); + assert.deepStrictEqual(note, removedNote); + + const otherResponse = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: otherAntenna.id }, + user: bob, + }); + assert.deepStrictEqual(otherResponse, [removedNote, remainingNote]); + }); + + test('から存在しないノートを削除しても成功すること。', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: defaultParam, + user: alice, + }); + + await successfulApiCall({ + endpoint: 'antennas/remove-note', + parameters: { antennaId: antenna.id, noteId: 'doesnotexist' }, + user: alice, + }); + }); + + test('から他人のアンテナを指定してノートを削除できないこと。', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: defaultParam, + user: alice, + }); + + await failedApiCall({ + endpoint: 'antennas/remove-note', + parameters: { antennaId: antenna.id, noteId: alicePost.id }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe', + }); + }); + const keyword = 'キーワード'; test.each([ { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index ba68971034d..1cb562fb622 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -265,6 +265,7 @@ const inTimeline = inject('inTimeline', false); const tl_withSensitive = inject>('tl_withSensitive', ref(true)); const inChannel = inject(DI.inChannel, null); const currentClip = inject | null>('currentClip', null); +const currentAntenna = inject | null>('currentAntenna', null); let note = deepClone(props.note); @@ -606,7 +607,7 @@ function onContextmenu(ev: PointerEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } @@ -616,7 +617,7 @@ function showMenu(): void { return; } - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined }); os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup); } diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index 00fd778a5ea..0182a96416c 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -271,6 +271,12 @@ useGlobalEvent('noteDeleted', (noteId) => { paginator.removeItem(noteId); }); +useGlobalEvent('noteRemovedFromAntenna', (antennaId, noteId) => { + if (props.src === 'antenna' && props.antenna === antennaId) { + paginator.removeItem(noteId); + } +}); + function releaseQueue() { paginator.releaseQueue(); scrollToTop(rootEl.value!); diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index 9dfaedd462c..a31298762d9 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -11,6 +11,7 @@ type Events = { clientNotification: (notification: Misskey.entities.Notification) => void; notePosted: (note: Misskey.entities.Note) => void; noteDeleted: (noteId: Misskey.entities.Note['id']) => void; + noteRemovedFromAntenna: (antennaId: Misskey.entities.Antenna['id'], noteId: Misskey.entities.Note['id']) => void; driveFileCreated: (file: Misskey.entities.DriveFile) => void; driveFilesUpdated: (files: Misskey.entities.DriveFile[]) => void; driveFilesDeleted: (files: Misskey.entities.DriveFile[]) => void; diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 9030fa0e294..4b495f1bd80 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only