From f8260e5fbb746a47d9f9715043c022db65b5a169 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Thu, 21 May 2026 08:47:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E3=82=A2=E3=83=B3=E3=83=86?= =?UTF-8?q?=E3=83=8A=E3=81=8B=E3=82=89=E7=89=B9=E5=AE=9A=E3=81=AE=E3=83=8E?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=92=E6=89=8B=E5=8B=95=E3=81=A7=E9=99=A4?= =?UTF-8?q?=E5=8E=BB=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 +++ locales/ja-JP.yml | 2 + .../backend/src/core/FanoutTimelineService.ts | 5 ++ .../backend/src/server/api/endpoint-list.ts | 1 + .../api/endpoints/antennas/remove-note.ts | 61 +++++++++++++++ packages/backend/test/e2e/antennas.ts | 75 +++++++++++++++++++ packages/frontend/src/components/MkNote.vue | 5 +- .../components/MkStreamingNotesTimeline.vue | 6 ++ packages/frontend/src/events.ts | 1 + .../frontend/src/pages/antenna-timeline.vue | 4 +- .../frontend/src/utility/get-note-menu.ts | 32 ++++++++ packages/i18n/src/autogen/locale.ts | 8 ++ packages/misskey-js/etc/misskey-js.api.md | 4 + .../misskey-js/src/autogen/apiClientJSDoc.ts | 11 +++ packages/misskey-js/src/autogen/endpoint.ts | 2 + packages/misskey-js/src/autogen/entities.ts | 1 + packages/misskey-js/src/autogen/types.ts | 74 ++++++++++++++++++ 17 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/antennas/remove-note.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ae56bc08a..5c545b84c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### General +- Feat: アンテナのタイムラインから個別のノートを削除できるように + +### Client +- + +### Server +- + + ## 2026.5.4 ### General 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 feb39e98bb3..ebeb4a6e306 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -115,6 +115,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