From e902193fcb43a52c14a148f4280b3185c0819ad6 Mon Sep 17 00:00:00 2001 From: NoriDev Date: Thu, 31 Oct 2024 13:52:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=98=88=EC=95=BD=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EC=9D=8C=20(yojo-art/cherrypick#483,=20[Type4ny-Project/Type4n?= =?UTF-8?q?y@271c872c](https://github.com/Type4ny-Project/Type4ny/commit/2?= =?UTF-8?q?71c872c97f215ef5d8e0be62251dd422a52e5b1))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG_CHERRYPICK.md | 1 + locales/en-US.yml | 7 +- locales/index.d.ts | 20 + locales/ja-JP.yml | 5 + locales/ko-KR.yml | 5 + .../migration/1699437894737-scheduleNote.js | 17 + packages/backend/src/core/QueueModule.ts | 12 + packages/backend/src/core/QueueService.ts | 2 + packages/backend/src/core/RoleService.ts | 3 + .../entities/NotificationEntityService.ts | 2 +- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/NoteSchedule.ts | 70 ++++ .../backend/src/models/RepositoryModule.ts | 9 + packages/backend/src/models/_.ts | 3 + .../backend/src/models/json-schema/role.ts | 4 + packages/backend/src/postgres.ts | 2 + .../backend/src/queue/QueueProcessorModule.ts | 2 + .../src/queue/QueueProcessorService.ts | 14 + packages/backend/src/queue/const.ts | 1 + .../ScheduleNotePostProcessorService.ts | 100 +++++ packages/backend/src/queue/types.ts | 4 + .../backend/src/server/api/EndpointsModule.ts | 12 + packages/backend/src/server/api/endpoints.ts | 6 + .../server/api/endpoints/admin/queue/stats.ts | 3 +- .../api/endpoints/notes/schedule/create.ts | 393 ++++++++++++++++++ .../api/endpoints/notes/schedule/delete.ts | 67 +++ .../api/endpoints/notes/schedule/list.ts | 128 ++++++ .../src/server/web/ClientServerService.ts | 3 + .../cherrypick-js/etc/cherrypick-js.api.md | 18 +- .../src/autogen/apiClientJSDoc.ts | 33 ++ .../cherrypick-js/src/autogen/endpoint.ts | 7 + .../cherrypick-js/src/autogen/entities.ts | 4 + packages/cherrypick-js/src/autogen/types.ts | 269 ++++++++++++ packages/cherrypick-js/src/consts.ts | 2 + packages/frontend-shared/js/const.ts | 1 + .../frontend/src/components/MkNoteHeader.vue | 5 +- .../frontend/src/components/MkNoteSimple.vue | 62 ++- .../frontend/src/components/MkPostForm.vue | 59 ++- .../src/components/MkPostFormSimple.vue | 59 ++- .../src/components/MkScheduleEditor.vue | 69 +++ .../components/MkSchedulePostListDialog.vue | 60 +++ packages/frontend/src/os.ts | 6 + .../frontend/src/pages/admin/roles.editor.vue | 19 + packages/frontend/src/pages/admin/roles.vue | 7 + 44 files changed, 1559 insertions(+), 17 deletions(-) create mode 100644 packages/backend/migration/1699437894737-scheduleNote.js create mode 100644 packages/backend/src/models/NoteSchedule.ts create mode 100644 packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/schedule/create.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/schedule/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/schedule/list.ts create mode 100644 packages/frontend/src/components/MkScheduleEditor.vue create mode 100644 packages/frontend/src/components/MkSchedulePostListDialog.vue diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index f85ce6c777..c30af52084 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -35,6 +35,7 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE - Feat: 사용자 메뉴에서 서버를 뮤트할 수 있음 (kokonect-link/cherrypick#502) - 이전 빌드에 추가된 기능은 관리자 전용이며, 이 빌드에서 추가된 기능은 일반 사용자용 기능입니다. - Feat: 새 노트 알림을 묶어서 표시 (yojo-art/cherrypick#328) +- Feat: 노트 게시를 예약할 수 있음 (yojo-art/cherrypick#483, [Type4ny-Project/Type4ny@271c872c](https://github.com/Type4ny-Project/Type4ny/commit/271c872c97f215ef5d8e0be62251dd422a52e5b1)) ### Client - Enhance: (Friendly) 모바일 환경에서 계정 목록을 표시할 때 내 프로필을 표시함 diff --git a/locales/en-US.yml b/locales/en-US.yml index 3aed3c7be2..1024c25d3a 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1,5 +1,7 @@ --- _lang_: "English" +schedulePost: "Posting a scheduled note" +schedulePostList: "List of scheduled notes" welcomeBackToast: "Display a welcome message when you log in after a certain period of time" invalidTextLengthError: "Too many characters entered" invalidTextLengthDescription: "The number of characters is limited to {limitValue} characters. The current number of characters entered is {value} characters." @@ -13,7 +15,7 @@ widgets: "Widgets" postNote: "Post note" bottomNavbar: "Bottom navigation bar" bottomNavbarDescription: "This setting is only available in a mobile environment." -scheduledNoteDelete: "Schedule note deletion" +scheduledNoteDelete: "Schedule deletion of note" getQRCode: "Get QR code" customSplashText: "Custom splash text" customSplashTextDescription: "This text will be displayed on the loading page." @@ -1960,6 +1962,7 @@ _role: ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" canEditNote: "Note editing" + scheduleNoteMax: "Maximum number of scheduled notes" mentionMax: "Maximum number of mentions in a note" canInvite: "Can create instance invite codes" inviteLimit: "Invite limit" @@ -2387,6 +2390,8 @@ _permissions: "read:mutes": "View your list of muted users" "write:mutes": "Edit your list of muted users" "write:notes": "Compose or delete notes" + "read:notes-schedule": "View your list of scheduled notes" + "write:notes-schedule": "Compose or delete scheduled notes" "read:notifications": "View your notifications" "write:notifications": "Manage your notifications" "read:reactions": "View your reactions" diff --git a/locales/index.d.ts b/locales/index.d.ts index f1d69e6e88..fb056bbe3d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13,6 +13,14 @@ export interface Locale extends ILocale { * 日本語 */ "_lang_": string; + /** + * 予約投稿 + */ + "schedulePost": string; + /** + * 予約投稿一覧 + */ + "schedulePostList": string; /** * 一定時間が経過した後に接続したときに歓迎メッセージを表示 */ @@ -7723,6 +7731,10 @@ export interface Locale extends ILocale { * ノートの編集 */ "canEditNote": string; + /** + * 予約投稿の最大数 + */ + "scheduleNoteMax": string; /** * ノート内の最大メンション数 */ @@ -9386,6 +9398,14 @@ export interface Locale extends ILocale { * ノートを作成・削除する */ "write:notes": string; + /** + * 予約投稿を見る + */ + "read:notes-schedule": string; + /** + * 予約投稿を作成・削除する + */ + "write:notes-schedule": string; /** * 通知を見る */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cd4de67b4b..bfada6d58b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1,5 +1,7 @@ _lang_: "日本語" +schedulePost: "予約投稿" +schedulePostList: "予約投稿一覧" welcomeBackToast: "一定時間が経過した後に接続したときに歓迎メッセージを表示" invalidTextLengthError: "入力された文字数が多すぎます" invalidTextLengthDescription: "文字数が{limitValue}文字に制限されています。現在入力された文字数は{value}文字です。" @@ -1999,6 +2001,7 @@ _role: ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" canEditNote: "ノートの編集" + scheduleNoteMax: "予約投稿の最大数" mentionMax: "ノート内の最大メンション数" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" @@ -2462,6 +2465,8 @@ _permissions: "read:mutes": "ミュートを見る" "write:mutes": "ミュートを操作する" "write:notes": "ノートを作成・削除する" + "read:notes-schedule": "予約投稿を見る" + "write:notes-schedule": "予約投稿を作成・削除する" "read:notifications": "通知を見る" "write:notifications": "通知を操作する" "read:reactions": "リアクションを見る" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index ca36aab18d..90dfb4f02c 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1,5 +1,7 @@ --- _lang_: "한국어" +schedulePost: "노트 게시 예약" +schedulePostList: "게시가 예약된 노트 목록" welcomeBackToast: "일정 시간이 지난 후 접속했을 때 환영 메시지 표시" invalidTextLengthError: "입력된 문자수가 너무 많아요" invalidTextLengthDescription: "문자수가 {limitValue}자로 제한되어 있어요. 현재 입력된 문자수는 {value}자예요." @@ -1978,6 +1980,7 @@ _role: ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" canEditNote: "노트 편집 허용" + scheduleNoteMax: "게시를 예약한 노트의 최대 수" mentionMax: "노트에서 언급할 수 있는 멘션 수" canInvite: "서버 초대 코드 발행" inviteLimit: "초대 한도" @@ -2410,6 +2413,8 @@ _permissions: "read:mutes": "뮤트 여부를 확인합니다" "write:mutes": "뮤트를 하거나 해제합니다" "write:notes": "노트를 작성하거나 삭제합니다" + "read:notes-schedule": "게시를 예약한 노트를 봅니다" + "write:notes-schedule": "노트 게시를 예약하거나 삭제합니다" "read:notifications": "알림을 확인합니다" "write:notifications": "알림을 모두 읽음 처리합니다" "read:reactions": "리액션을 확인합니다" diff --git a/packages/backend/migration/1699437894737-scheduleNote.js b/packages/backend/migration/1699437894737-scheduleNote.js new file mode 100644 index 0000000000..28dc290f25 --- /dev/null +++ b/packages/backend/migration/1699437894737-scheduleNote.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ScheduleNote1699437894737 { + name = 'ScheduleNote1699437894737' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "note_schedule" ("id" character varying(32) NOT NULL, "note" jsonb NOT NULL, "userId" character varying(260) NOT NULL, "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3a1ae2db41988f4994268218436" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e798958c40009bf0cdef4f28b5" ON "note_schedule" ("userId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "note_schedule"`); + } +} diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index e4395792e1..a10949f361 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -18,6 +18,7 @@ import { UserWebhookDeliverJobData, SystemWebhookDeliverJobData, ScheduledNoteDeleteJobData, + ScheduleNotePostJobData, } from '../queue/types.js'; import type { Provider } from '@nestjs/common'; @@ -31,6 +32,7 @@ export type ObjectStorageQueue = Bull.Queue; export type UserWebhookDeliverQueue = Bull.Queue; export type SystemWebhookDeliverQueue = Bull.Queue; export type ScheduledNoteDeleteQueue = Bull.Queue; +export type ScheduleNotePostQueue = Bull.Queue; const $system: Provider = { provide: 'queue:system', @@ -92,6 +94,12 @@ const $scheduledNoteDelete: Provider = { inject: [DI.config, DI.redisForJobQueue], }; +const $scheduleNotePost: Provider = { + provide: 'queue:scheduleNotePost', + useFactory: (config: Config, redisForJobQueue: Redis.Redis) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST, redisForJobQueue)), + inject: [DI.config, DI.redisForJobQueue], +}; + @Module({ imports: [ ], @@ -106,6 +114,7 @@ const $scheduledNoteDelete: Provider = { $userWebhookDeliver, $systemWebhookDeliver, $scheduledNoteDelete, + $scheduleNotePost, ], exports: [ $system, @@ -118,6 +127,7 @@ const $scheduledNoteDelete: Provider = { $userWebhookDeliver, $systemWebhookDeliver, $scheduledNoteDelete, + $scheduleNotePost, ], }) export class QueueModule implements OnApplicationShutdown { @@ -132,6 +142,7 @@ export class QueueModule implements OnApplicationShutdown { @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) {} public async dispose(): Promise { @@ -149,6 +160,7 @@ export class QueueModule implements OnApplicationShutdown { this.userWebhookDeliverQueue.close(), this.systemWebhookDeliverQueue.close(), this.scheduledNoteDeleteQueue.close(), + this.scheduleNotePostQueue.close(), ]); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 497fb54349..6fce806f19 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -34,6 +34,7 @@ import type { UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduledNoteDeleteQueue, + ScheduleNotePostQueue, } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -54,6 +55,7 @@ export class QueueService { @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, + @Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue, ) { this.systemQueue.add('tickCharts', { }, { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index c8c8a64c4b..860f7a142d 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -36,6 +36,7 @@ export type RolePolicies = { ltlAvailable: boolean; canPublicNote: boolean; canEditNote: boolean; + scheduleNoteMax: number; mentionLimit: number; canInvite: boolean; inviteLimit: number; @@ -72,6 +73,7 @@ export const DEFAULT_POLICIES: RolePolicies = { ltlAvailable: true, canPublicNote: true, canEditNote: true, + scheduleNoteMax: 5, mentionLimit: 20, canInvite: false, inviteLimit: 0, @@ -379,6 +381,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), + scheduleNoteMax: calc('scheduleNoteMax', vs => Math.max(...vs)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 45b6845595..4fd399d285 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -149,7 +149,7 @@ export class NotificationEntityService implements OnModuleInit { return packedUser; } - return this.userEntityService.pack(notifier, {id: meId}); + return this.userEntityService.pack(notifier, { id: meId }); }))).filter(x => x != null); // if all users have been deleted, don't show this notification if (users.length === 0) { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 52fcd5c60c..1fe4008839 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -92,5 +92,6 @@ export const DI = { userMemosRepository: Symbol('userMemosRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), + noteScheduleRepository: Symbol('noteScheduleRepository'), //#endregion }; diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts new file mode 100644 index 0000000000..25b8efb765 --- /dev/null +++ b/packages/backend/src/models/NoteSchedule.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { MiNote } from '@/models/Note.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChannel } from './Channel.js'; +import { EventSchema } from './Event.js'; +import type { MiDriveFile } from './DriveFile.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type MiScheduleNoteType={ + /** Date.toISOString() */ + createdAt: string; + visibility: 'public' | 'home' | 'followers' | 'specified'; + visibleUsers: MinimumUser[]; + channel?: MiChannel['id']; + poll: { + multiple: boolean; + choices: string[]; + /** Date.toISOString() */ + expiresAt: string | null + } | undefined; + renote?: MiNote['id']; + localOnly: boolean; + cw?: string | null; + reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; + files: MiDriveFile['id'][]; + text?: string | null; + reply?: MiNote['id']; + event?: { + /** Date.toISOString() */ + start: string; + /** Date.toISOString() */ + end: string | null; + title: string; + metadata: EventSchema; + } | null; + disableRightClick: boolean, + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; +} + +@Entity('note_schedule') +export class MiNoteSchedule { + @PrimaryColumn(id()) + public id: string; + + @Column('jsonb') + public note: MiScheduleNoteType; + + @Index() + @Column('varchar', { + length: 260, + }) + public userId: MiUser['id']; + + @Column('timestamp with time zone') + public scheduledAt: Date; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 325645cbd6..c976e902c1 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -44,6 +44,7 @@ import { MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteSchedule, MiNoteThreadMuting, MiNoteUnread, MiPage, @@ -537,6 +538,12 @@ const $abuseReportResolversRepository: Provider = { inject: [DI.db], }; +const $noteScheduleRepository: Provider = { + provide: DI.noteScheduleRepository, + useFactory: (db: DataSource) => db.getRepository(MiNoteSchedule).extend(miRepository as MiRepository), + inject: [DI.db], +}; + @Module({ imports: [], providers: [ @@ -615,6 +622,7 @@ const $abuseReportResolversRepository: Provider = { $abuseReportResolversRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $noteScheduleRepository, ], exports: [ $usersRepository, @@ -692,6 +700,7 @@ const $abuseReportResolversRepository: Provider = { $abuseReportResolversRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $noteScheduleRepository, ], }) export class RepositoryModule { diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 92f9dbf21e..0889179a97 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -85,6 +85,7 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository { @@ -166,6 +167,7 @@ export { MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteSchedule, MiNoteThreadMuting, MiNoteUnread, MiPage, @@ -283,3 +285,4 @@ export type FlashLikesRepository = Repository & MiRepository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; +export type NoteScheduleRepository = Repository; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 84d8e2a79c..3b11ade641 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -300,6 +300,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + scheduleNoteMax: { + type: 'integer', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index ee9ecafacc..2083fb5741 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -84,6 +84,7 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -165,6 +166,7 @@ export const entities = [ MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteSchedule, MiNoteThreadMuting, MiNoteUnread, MiPage, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 8e604909ae..7ca8d3a779 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -42,6 +42,7 @@ import { AggregateRetentionProcessorService } from './processors/AggregateRetent import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; @Module({ imports: [ @@ -85,6 +86,7 @@ import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteD AggregateRetentionProcessorService, QueueProcessorService, ScheduledNoteDeleteProcessorService, + ScheduleNotePostProcessorService, ], exports: [ QueueProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index bc082dec5f..126723557b 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -45,6 +45,7 @@ import { BakeBufferedReactionsProcessorService } from './processors/BakeBuffered import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QUEUE, baseQueueOptions } from './const.js'; @@ -87,6 +88,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; private scheduledNoteDeleteQueueWorker: Bull.Worker; + private schedulerNotePostQueueWorker: Bull.Worker; constructor( @Inject(DI.config) @@ -130,6 +132,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private cleanProcessorService: CleanProcessorService, private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService, + private scheduleNotePostProcessorService: ScheduleNotePostProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -529,6 +532,15 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } //#endregion + + //#region schedule note post + { + this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST, this.redisForJobQueue), + autorun: false, + }); + } + //#endregion } @bindThis @@ -544,6 +556,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), this.scheduledNoteDeleteQueueWorker.run(), + this.schedulerNotePostQueueWorker.run(), ]); } @@ -560,6 +573,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), this.scheduledNoteDeleteQueueWorker.close(), + this.schedulerNotePostQueueWorker.close(), ]); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 2dc0a08e1c..860ec07f27 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -18,6 +18,7 @@ export const QUEUE = { USER_WEBHOOK_DELIVER: 'userWebhookDeliver', SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver', SCHEDULED_NOTE_DELETE: 'scheduledNoteDelete', + SCHEDULE_NOTE_POST: 'scheduleNotePost', }; export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE], redisConnection: Redis.Redis): Bull.QueueOptions { diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts new file mode 100644 index 0000000000..6b5b9db71b --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { ScheduleNotePostJobData } from '../types.js'; + +@Injectable() +export class ScheduleNotePostProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.noteScheduleRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => { + if (!data) { + this.logger.warn(`Schedule note ${job.data.scheduleNoteId} not found`); + } else { + const me = await this.usersRepository.findOneBy({ id: data.userId }); + const note = data.note; + + //idの形式でキューに積んであったのをDBから取り寄せる + const reply = note.reply ? await this.notesRepository.findOneBy({ id: note.reply }) : undefined; + const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined; + const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined; + let files: MiDriveFile[] = []; + const fileIds = note.files ?? null; + if (fileIds != null && fileIds.length > 0 && me) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + } + if ( + !data.userId || + !me || + (note.reply && !reply) || + (note.renote && !renote) || + (note.channel && !channel) || + (note.files.length !== files.length) + ) { + //キューに積んだときは有った物が消滅してたら予約投稿をキャンセルする + this.logger.warn('cancel schedule note'); + await this.noteScheduleRepository.remove(data); + return; + } + await this.noteCreateService.create(me, { + ...note, + createdAt: new Date(note.createdAt), //typeORMのjsonbで何故かstringにされるから戻す + files, + poll: note.poll ? { + choices: note.poll.choices, + multiple: note.poll.multiple, + expiresAt: note.poll.expiresAt ? new Date(note.poll.expiresAt) : null, + } : undefined, + event: note.event ? { + start: new Date(note.event.start), + end: note.event.end ? new Date(note.event.end) : null, + title: note.event.title, + metadata: note.event.metadata, + } : undefined, + reply, + renote, + channel, + }); + await this.noteScheduleRepository.remove(data); + } + }); + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 0a5da0d3bc..b9afc7ecc4 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -137,3 +137,7 @@ export type ThinUser = { export type ScheduledNoteDeleteJobData = { noteId: MiNote['id']; }; + +export type ScheduleNotePostJobData = { + scheduleNoteId: MiNote['id']; +} diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index dcf2ce59de..4643153d39 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -312,6 +312,9 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -728,6 +731,9 @@ const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create' const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; +const $notes_schedule_create: Provider = { provide: 'ep:notes/schedule/create', useClass: ep___notes_schedule_create.default }; +const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default }; +const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default }; const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; @@ -1149,6 +1155,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_reactions_delete, $notes_renotes, $notes_replies, + $notes_schedule_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_searchByTag, $notes_search, $notes_show, @@ -1562,6 +1571,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_reactions_delete, $notes_renotes, $notes_replies, + $notes_schedule_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_searchByTag, $notes_search, $notes_show, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index a29da92138..f02da4145b 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -317,6 +317,9 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -731,6 +734,9 @@ const eps = [ ['notes/reactions/delete', ep___notes_reactions_delete], ['notes/renotes', ep___notes_renotes], ['notes/replies', ep___notes_replies], + ['notes/schedule/create', ep___notes_schedule_create], + ['notes/schedule/delete', ep___notes_schedule_delete], + ['notes/schedule/list', ep___notes_schedule_list], ['notes/search-by-tag', ep___notes_searchByTag], ['notes/search', ep___notes_search], ['notes/show', ep___notes_show], diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index d7f9e4eaa3..e2bd38aac6 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduleNotePostQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -55,6 +55,7 @@ export default class extends Endpoint { // eslint- @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) { super(meta, paramDef, async (ps, me) => { const deliverJobCounts = await this.deliverQueue.getJobCounts(); diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts new file mode 100644 index 0000000000..ecdfa4bf2e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts @@ -0,0 +1,393 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { In } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { isPureRenote } from 'cherrypick-js/note.js'; +import type { MiUser } from '@/models/User.js'; +import type { + UsersRepository, + NotesRepository, + BlockingsRepository, + DriveFilesRepository, + ChannelsRepository, + NoteScheduleRepository, +} from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiChannel } from '@/models/Channel.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { MiScheduleNoteType } from '@/models/NoteSchedule.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: 'write:notes-schedule', + + errors: { + scheduleNoteMax: { + message: 'Schedule note max.', + code: 'SCHEDULE_NOTE_MAX', + id: '168707c3-e7da-4031-989e-f42aa3a274b2', + }, + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4', + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', + }, + + cannotRenoteDueToVisibility: { + message: 'You can not Renote due to target visibility.', + code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY', + id: 'be9529e9-fe72-4de0-ae43-0b363c4938af', + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c', + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + + cannotCreateAlreadyExpiredSchedule: { + message: 'Schedule is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE', + id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07', + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb', + }, + noSuchSchedule: { + message: 'No such schedule.', + code: 'NO_SUCH_SCHEDULE', + id: '44dee229-8da1-4a61-856d-e3a4bbc12032', + }, + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', + }, + + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibleUserIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + disableRightClick: { type: 'boolean', default: false }, + noExtractMentions: { type: 'boolean', default: false }, + noExtractHashtags: { type: 'boolean', default: false }, + noExtractEmojis: { type: 'boolean', default: false }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + // See https://github.com/misskey-dev/misskey/pull/10082 + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: true, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + event: { + type: 'object', + nullable: true, + properties: { + title: { type: 'string', minLength: 1, maxLength: 128, nullable: false }, + start: { type: 'integer', nullable: false }, + end: { type: 'integer', nullable: true }, + metadata: { type: 'object' }, + }, + }, + scheduleNote: { + type: 'object', + nullable: false, + properties: { + scheduledAt: { type: 'integer', nullable: false }, + }, + }, + }, + // (re)note with text, files and poll are optional + anyOf: [ + { required: ['text'] }, + { required: ['renoteId'] }, + { required: ['fileIds'] }, + { required: ['mediaIds'] }, + { required: ['poll'] }, + ], + required: ['scheduleNote'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private queueService: QueueService, + private roleService: RoleService, + private idService: IdService, + ) { + super({ + ...meta, + }, paramDef, async (ps, me) => { + const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id }); + const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax; + if (scheduleNoteCount >= scheduleNoteMax) { + throw new ApiError(meta.errors.scheduleNoteMax); + } + let visibleUsers: MiUser[] = []; + if (ps.visibleUserIds) { + visibleUsers = await this.usersRepository.findBy({ + id: In(ps.visibleUserIds), + }); + } + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + let renote: MiNote | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (isPureRenote(renote)) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== me.id) { + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + if (renote.visibility === 'followers' && renote.userId !== me.id) { + // 他人のfollowers noteはreject + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + } else if (renote.visibility === 'specified') { + // specified / direct noteはreject + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + } + } + + let reply: MiNote | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } else if (isPureRenote(reply)) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== me.id) { + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + let scheduleNote_scheduledAt = Date.now(); + if (typeof ps.scheduleNote.scheduledAt === 'number') { + scheduleNote_scheduledAt = ps.scheduleNote.scheduledAt; + } + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < scheduleNote_scheduledAt) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = scheduleNote_scheduledAt + ps.poll.expiredAfter; + } + } + if (typeof ps.scheduleNote.scheduledAt === 'number') { + if (ps.scheduleNote.scheduledAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); + } + } else { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); + } + const note:MiScheduleNoteType = { + createdAt: new Date(ps.scheduleNote.scheduledAt!).toISOString(), + files: files.map(f => f.id), + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt).toISOString() : null, + } : undefined, + text: ps.text ?? undefined, + reply: reply?.id, + renote: renote?.id, + cw: ps.cw, + localOnly: false, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUsers, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + event: ps.event ? { + start: new Date(ps.event.start!).toISOString(), + end: ps.event.end ? new Date(ps.event.end).toISOString() : null, + title: ps.event.title!, + metadata: ps.event.metadata ?? {}, + } : undefined, + disableRightClick: ps.disableRightClick, + }; + + if (ps.scheduleNote.scheduledAt) { + me.token = null; + const noteId = this.idService.gen(new Date().getTime()); + await this.noteScheduleRepository.insert({ + id: noteId, + note: note, + userId: me.id, + scheduledAt: new Date(ps.scheduleNote.scheduledAt), + }); + + const delay = new Date(ps.scheduleNote.scheduledAt).getTime() - Date.now(); + await this.queueService.ScheduleNotePostQueue.add(String(delay), { + scheduleNoteId: noteId, + }, { + delay, + removeOnComplete: true, + jobId: noteId, + }); + } + + return ''; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts new file mode 100644 index 0000000000..df406f99f0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NoteScheduleRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'write:notes-schedule', + + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a58056ba-8ba1-4323-8ebf-e0b585bc244f', + }, + permissionDenied: { + message: 'Permission denied.', + code: 'PERMISSION_DENIED', + id: 'c0da2fed-8f61-4c47-a41d-431992607b5c', + httpStatusCode: 403, + }, + }, +} 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.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.noteScheduleRepository.findOneBy({ id: ps.noteId }); + if (note === null) { + throw new ApiError(meta.errors.noSuchNote); + } + if (note.userId !== me.id) { + throw new ApiError(meta.errors.permissionDenied); + } + await this.noteScheduleRepository.delete({ id: ps.noteId }); + await this.queueService.ScheduleNotePostQueue.remove(ps.noteId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts new file mode 100644 index 0000000000..88da4f4043 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { noteVisibilities } from '@/types.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'read:notes-schedule', + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { type: 'string', format: 'misskey:id', optional: false, nullable: false }, + note: { + type: 'object', + optional: false, nullable: false, + properties: { + createdAt: { type: 'string', optional: false, nullable: false }, + text: { type: 'string', optional: true, nullable: false }, + cw: { type: 'string', optional: true, nullable: true }, + fileIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', format: 'misskey:id', optional: false, nullable: false } }, + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], optional: false, nullable: false }, + visibleUsers: { + type: 'array', optional: false, nullable: false, items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'User', + }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + isSchedule: { type: 'boolean', optional: false, nullable: false }, + }, + }, + userId: { type: 'string', optional: false, nullable: false }, + scheduledAt: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + private userEntityService: UserEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.noteScheduleRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.userId = :userId', { userId: me.id }); + const scheduleNotes = await query.limit(ps.limit).getMany(); + const user = await this.userEntityService.pack(me, me); + const scheduleNotesPack: { + id: string; + note: { + text?: string; + cw?: string|null; + fileIds: string[]; + visibility: typeof noteVisibilities[number]; + visibleUsers: Packed<'UserLite'>[]; + reactionAcceptance: MiNote['reactionAcceptance']; + user: Packed<'User'>; + createdAt: string; + isSchedule: boolean; + }; + userId: string; + scheduledAt: string; + }[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => { + return { + ...item, + scheduledAt: item.scheduledAt.toISOString(), + note: { + ...item.note, + text: item.note.text ?? '', + user: user, + visibility: item.note.visibility ?? 'public', + reactionAcceptance: item.note.reactionAcceptance ?? null, + visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [], + fileIds: item.note.files ? item.note.files : [], + createdAt: item.scheduledAt.toISOString(), + isSchedule: true, + id: item.id, + }, + }; + })); + + return scheduleNotesPack; + }); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index bb6d9bf163..598c8e2ca5 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -34,6 +34,7 @@ import type { UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduledNoteDeleteQueue, + ScheduleNotePostQueue, } from '@/core/QueueModule.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -126,6 +127,7 @@ export class ClientServerService { @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) { //this.createServer = this.createServer.bind(this); } @@ -255,6 +257,7 @@ export class ClientServerService { this.userWebhookDeliverQueue, this.systemWebhookDeliverQueue, this.scheduledNoteDeleteQueue, + this.scheduleNotePostQueue, ].map(q => new BullMQAdapter(q)), serverAdapter: bullBoardServerAdapter, }); diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index af0637b38e..3ee013adeb 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -1717,6 +1717,10 @@ declare namespace entities { NotesRenotesResponse, NotesRepliesRequest, NotesRepliesResponse, + NotesScheduleCreateRequest, + NotesScheduleDeleteRequest, + NotesScheduleListRequest, + NotesScheduleListResponse, NotesSearchByTagRequest, NotesSearchByTagResponse, NotesSearchRequest, @@ -2862,6 +2866,18 @@ type NotesRequest = operations['notes']['requestBody']['content']['application/j // @public (undocumented) type NotesResponse = operations['notes']['responses']['200']['content']['application/json']; +// @public (undocumented) +type NotesScheduleCreateRequest = operations['notes___schedule___create']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesScheduleDeleteRequest = operations['notes___schedule___delete']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesScheduleListRequest = operations['notes___schedule___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesScheduleListResponse = operations['notes___schedule___list']['responses']['200']['content']['application/json']; + // @public (undocumented) type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json']; @@ -2989,7 +3005,7 @@ type PartialRolePolicyOverride = Partial<{ }>; // @public (undocumented) -export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; +export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; // @public (undocumented) type PingResponse = operations['ping']['responses']['200']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index 6bb89d432f..b496cdff06 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -3409,6 +3409,39 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:notes-schedule* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index 18a2ec0f72..5571be9fbe 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -457,6 +457,10 @@ import type { NotesRenotesResponse, NotesRepliesRequest, NotesRepliesResponse, + NotesScheduleCreateRequest, + NotesScheduleDeleteRequest, + NotesScheduleListRequest, + NotesScheduleListResponse, NotesSearchByTagRequest, NotesSearchByTagResponse, NotesSearchRequest, @@ -926,6 +930,9 @@ export type Endpoints = { 'notes/reactions/delete': { req: NotesReactionsDeleteRequest; res: EmptyResponse }; 'notes/renotes': { req: NotesRenotesRequest; res: NotesRenotesResponse }; 'notes/replies': { req: NotesRepliesRequest; res: NotesRepliesResponse }; + 'notes/schedule/create': { req: NotesScheduleCreateRequest; res: EmptyResponse }; + 'notes/schedule/delete': { req: NotesScheduleDeleteRequest; res: EmptyResponse }; + 'notes/schedule/list': { req: NotesScheduleListRequest; res: NotesScheduleListResponse }; 'notes/search-by-tag': { req: NotesSearchByTagRequest; res: NotesSearchByTagResponse }; 'notes/search': { req: NotesSearchRequest; res: NotesSearchResponse }; 'notes/show': { req: NotesShowRequest; res: NotesShowResponse }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index 16f6073c0d..f8fb57377f 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -460,6 +460,10 @@ export type NotesRenotesRequest = operations['notes___renotes']['requestBody'][' export type NotesRenotesResponse = operations['notes___renotes']['responses']['200']['content']['application/json']; export type NotesRepliesRequest = operations['notes___replies']['requestBody']['content']['application/json']; export type NotesRepliesResponse = operations['notes___replies']['responses']['200']['content']['application/json']; +export type NotesScheduleCreateRequest = operations['notes___schedule___create']['requestBody']['content']['application/json']; +export type NotesScheduleDeleteRequest = operations['notes___schedule___delete']['requestBody']['content']['application/json']; +export type NotesScheduleListRequest = operations['notes___schedule___list']['requestBody']['content']['application/json']; +export type NotesScheduleListResponse = operations['notes___schedule___list']['responses']['200']['content']['application/json']; export type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json']; export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json']; export type NotesSearchRequest = operations['notes___search']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index e81164cca3..17a16a2aed 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -2948,6 +2948,33 @@ export type paths = { */ post: operations['notes___replies']; }; + '/notes/schedule/create': { + /** + * notes/schedule/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* + */ + post: operations['notes___schedule___create']; + }; + '/notes/schedule/delete': { + /** + * notes/schedule/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* + */ + post: operations['notes___schedule___delete']; + }; + '/notes/schedule/list': { + /** + * notes/schedule/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:notes-schedule* + */ + post: operations['notes___schedule___list']; + }; '/notes/search-by-tag': { /** * notes/search-by-tag @@ -5195,6 +5222,7 @@ export type components = { canImportMuting: boolean; canImportUserLists: boolean; canEditNote: boolean; + scheduleNoteMax: number; }; ReversiGameLite: { /** Format: id */ @@ -23771,6 +23799,247 @@ export type operations = { }; }; }; + /** + * notes/schedule/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* + */ + notes___schedule___create: { + requestBody: { + content: { + 'application/json': { + /** + * @default public + * @enum {string} + */ + visibility?: 'public' | 'home' | 'followers' | 'specified'; + visibleUserIds?: string[]; + cw?: string | null; + /** + * @default null + * @enum {string|null} + */ + reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote'; + /** @default false */ + disableRightClick?: boolean; + /** @default false */ + noExtractMentions?: boolean; + /** @default false */ + noExtractHashtags?: boolean; + /** @default false */ + noExtractEmojis?: boolean; + /** Format: misskey:id */ + replyId?: string | null; + /** Format: misskey:id */ + renoteId?: string | null; + text?: string | null; + fileIds?: string[]; + mediaIds?: string[]; + poll?: ({ + choices: string[]; + multiple?: boolean; + expiresAt?: number | null; + expiredAfter?: number | null; + }) | null; + event?: ({ + title?: string; + start?: number; + end?: number | null; + metadata?: Record; + }) | null; + scheduleNote: { + scheduledAt?: number; + }; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description To many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * notes/schedule/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notes-schedule* + */ + notes___schedule___delete: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + noteId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description To many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * notes/schedule/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:notes-schedule* + */ + notes___schedule___list: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default 10 */ + limit?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': ({ + /** Format: misskey:id */ + id: string; + note: { + createdAt: string; + text?: string; + cw?: string | null; + fileIds: string[]; + /** @enum {string} */ + visibility: 'public' | 'home' | 'followers' | 'specified'; + visibleUsers: components['schemas']['UserLite'][]; + user: components['schemas']['User']; + /** + * @default null + * @enum {string|null} + */ + reactionAcceptance: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote'; + isSchedule: boolean; + }; + userId: string; + scheduledAt: string; + })[]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description To many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * notes/search-by-tag * @description No description provided. diff --git a/packages/cherrypick-js/src/consts.ts b/packages/cherrypick-js/src/consts.ts index b4fbcffa97..fe64319128 100644 --- a/packages/cherrypick-js/src/consts.ts +++ b/packages/cherrypick-js/src/consts.ts @@ -42,6 +42,8 @@ export const permissions = [ 'read:mutes', 'write:mutes', 'write:notes', + 'read:notes-schedule', + 'write:notes-schedule', 'read:notifications', 'write:notifications', 'read:reactions', diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 5b56d97870..6d9b8edda5 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -79,6 +79,7 @@ export const ROLE_POLICIES = [ 'ltlAvailable', 'canPublicNote', 'canEditNote', + 'scheduleNoteMax', 'mentionLimit', 'canInvite', 'inviteLimit', diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 7e78076a55..93b1dac60b 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -62,7 +62,10 @@ import { useRouter } from '@/router/supplier.js'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; const props = defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note & { + isSchedule?: boolean + }; + scheduled?: boolean; }>(); const mock = inject('mock', false); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index eb1f85e057..8f2e07bb8c 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only -->