From 2528508cff9d8c90abd33e46b15220a49a00e2e2 Mon Sep 17 00:00:00 2001
From: NoriDev <m1nthing2322@gmail.com>
Date: Thu, 31 Oct 2024 13:52:01 +0900
Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=EA=B2=8C?=
 =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EC=98=88=EC=95=BD=ED=95=A0=20=EC=88=98=20?=
 =?UTF-8?q?=EC=9E=88=EC=9D=8C=20(yojo-art/cherrypick#483,=20[Type4ny-Proje?=
 =?UTF-8?q?ct/Type4ny@271c872c](https://github.com/Type4ny-Project/Type4ny?=
 =?UTF-8?q?/commit/271c872c97f215ef5d8e0be62251dd422a52e5b1))?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/en-US.yml                             |   2 +
 locales/index.d.ts                            |   8 +
 locales/ja-JP.yml                             |   2 +
 locales/ko-KR.yml                             |   2 +
 .../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 +
 packages/backend/src/di-symbols.ts            |   1 +
 packages/backend/src/models/NoteSchedule.ts   |  60 +++
 .../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       |  94 +++++
 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 +
 packages/frontend-shared/js/const.ts          |   1 +
 .../frontend/src/components/MkNoteHeader.vue  |   5 +-
 .../frontend/src/components/MkNoteSimple.vue  |  55 ++-
 .../frontend/src/components/MkPostForm.vue    |  59 ++-
 .../src/components/MkScheduleEditor.vue       |  69 +++
 .../components/MkSchedulePostListDialog.vue   |  60 +++
 packages/frontend/src/os.ts                   |   1 +
 .../frontend/src/pages/admin/roles.editor.vue |  19 +
 packages/frontend/src/pages/admin/roles.vue   |   7 +
 packages/misskey-js/etc/misskey-js.api.md     |  16 +
 .../misskey-js/src/autogen/apiClientJSDoc.ts  |  33 ++
 packages/misskey-js/src/autogen/endpoint.ts   |   7 +
 packages/misskey-js/src/autogen/entities.ts   |   4 +
 packages/misskey-js/src/autogen/types.ts      | 269 ++++++++++++
 packages/misskey-js/src/consts.ts             |   2 +
 41 files changed, 1455 insertions(+), 6 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/locales/en-US.yml b/locales/en-US.yml
index 6ea7fb4f8d..38e9b03acb 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -2073,6 +2073,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 5caebcfa02..92c09ffe12 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -8140,6 +8140,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 c448d4d50a..0d2229ac20 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2121,6 +2121,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 414202adab..351d5a23ce 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -2080,6 +2080,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 b10b8e5899..6dd48927c1 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -16,6 +16,7 @@ import {
 	RelationshipJobData,
 	UserWebhookDeliverJobData,
 	SystemWebhookDeliverJobData,
+	ScheduleNotePostJobData,
 } from '../queue/types.js';
 import type { Provider } from '@nestjs/common';
 
@@ -28,6 +29,7 @@ export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
 export type ObjectStorageQueue = Bull.Queue;
 export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
 export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
+export type ScheduleNotePostQueue = Bull.Queue<ScheduleNotePostJobData>;
 
 const $system: Provider = {
 	provide: 'queue:system',
@@ -83,6 +85,12 @@ const $systemWebhookDeliver: Provider = {
 	inject: [DI.config],
 };
 
+const $scheduleNotePost: Provider = {
+	provide: 'queue:scheduleNotePost',
+	useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST)),
+	inject: [DI.config],
+};
+
 @Module({
 	imports: [
 	],
@@ -96,6 +104,7 @@ const $systemWebhookDeliver: Provider = {
 		$objectStorage,
 		$userWebhookDeliver,
 		$systemWebhookDeliver,
+		$scheduleNotePost,
 	],
 	exports: [
 		$system,
@@ -107,6 +116,7 @@ const $systemWebhookDeliver: Provider = {
 		$objectStorage,
 		$userWebhookDeliver,
 		$systemWebhookDeliver,
+		$scheduleNotePost,
 	],
 })
 export class QueueModule implements OnApplicationShutdown {
@@ -120,6 +130,7 @@ export class QueueModule implements OnApplicationShutdown {
 		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 		@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
 		@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
+		@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
 	) {}
 
 	public async dispose(): Promise<void> {
@@ -136,6 +147,7 @@ export class QueueModule implements OnApplicationShutdown {
 			this.objectStorageQueue.close(),
 			this.userWebhookDeliverQueue.close(),
 			this.systemWebhookDeliverQueue.close(),
+			this.scheduleNotePostQueue.close(),
 		]);
 	}
 
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index dc13aa21bf..d9d282a168 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -32,6 +32,7 @@ import type {
 	SystemQueue,
 	UserWebhookDeliverQueue,
 	SystemWebhookDeliverQueue,
+	ScheduleNotePostQueue,
 } from './QueueModule.js';
 import type httpSignature from '@peertube/http-signature';
 import type * as Bull from 'bullmq';
@@ -52,6 +53,7 @@ export class QueueService {
 		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 		@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
 		@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
+		@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 64f7539031..5651b04ac2 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -36,6 +36,7 @@ export type RolePolicies = {
 	ltlAvailable: boolean;
 	btlAvailable: boolean;
 	canPublicNote: boolean;
+	scheduleNoteMax: number;
 	mentionLimit: number;
 	canInvite: boolean;
 	inviteLimit: number;
@@ -72,6 +73,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
 	ltlAvailable: true,
 	btlAvailable: false,
 	canPublicNote: true,
+	scheduleNoteMax: 5,
 	mentionLimit: 20,
 	canInvite: false,
 	inviteLimit: 0,
@@ -377,6 +379,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 			btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
 			ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
 			canPublicNote: calc('canPublicNote', 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/di-symbols.ts b/packages/backend/src/di-symbols.ts
index 5ea500ac77..296cc4815b 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -86,5 +86,6 @@ export const DI = {
 	noteEditRepository: Symbol('noteEditRepository'),
 	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..97ffe32ffa
--- /dev/null
+++ b/packages/backend/src/models/NoteSchedule.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 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'];
+	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 eb45b9a631..3a1158a42a 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -43,6 +43,7 @@ import {
 	MiNote,
 	MiNoteFavorite,
 	MiNoteReaction,
+	MiNoteSchedule,
 	MiNoteThreadMuting,
 	MiNoteUnread,
 	MiPage,
@@ -509,6 +510,12 @@ const $reversiGamesRepository: Provider = {
 	inject: [DI.db],
 };
 
+const $noteScheduleRepository: Provider = {
+	provide: DI.noteScheduleRepository,
+	useFactory: (db: DataSource) => db.getRepository(MiNoteSchedule).extend(miRepository as MiRepository<MiNoteSchedule>),
+	inject: [DI.db],
+};
+
 @Module({
 	imports: [],
 	providers: [
@@ -583,6 +590,7 @@ const $reversiGamesRepository: Provider = {
 		$noteEditRepository,
 		$bubbleGameRecordsRepository,
 		$reversiGamesRepository,
+		$noteScheduleRepository,
 	],
 	exports: [
 		$usersRepository,
@@ -656,6 +664,7 @@ const $reversiGamesRepository: Provider = {
 		$noteEditRepository,
 		$bubbleGameRecordsRepository,
 		$reversiGamesRepository,
+		$noteScheduleRepository,
 	],
 })
 export class RepositoryModule {
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index ac2dd62aa2..9a4ebfc90f 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -81,6 +81,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js';
 import { NoteEdit } from '@/models/NoteEdit.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<T extends ObjectLiteral> {
@@ -160,6 +161,7 @@ export {
 	MiNote,
 	MiNoteFavorite,
 	MiNoteReaction,
+	MiNoteSchedule,
 	MiNoteThreadMuting,
 	MiNoteUnread,
 	MiPage,
@@ -271,3 +273,4 @@ export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMem
 export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
 export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
 export type NoteEditRepository = Repository<NoteEdit> & MiRepository<NoteEdit>;
+export type NoteScheduleRepository = Repository<MiNoteSchedule>;
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 19ea6263c9..ef0bb9f141 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -296,6 +296,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 2d66e6e445..c964c3ffee 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -79,6 +79,7 @@ import { MiUserMemo } from '@/models/UserMemo.js';
 import { NoteEdit } from '@/models/NoteEdit.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';
@@ -158,6 +159,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 7c6675b15d..dd588e0115 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -42,6 +42,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
 import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
 import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
 import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js';
+import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
 
 @Module({
 	imports: [
@@ -85,6 +86,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
 		InboxProcessorService,
 		AggregateRetentionProcessorService,
 		QueueProcessorService,
+		ScheduleNotePostProcessorService,
 	],
 	exports: [
 		QueueProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index f130314e74..4cc5446062 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -44,6 +44,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
 import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
 import { CleanProcessorService } from './processors/CleanProcessorService.js';
 import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
+import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js';
 import { QueueLoggerService } from './QueueLoggerService.js';
 import { QUEUE, baseQueueOptions } from './const.js';
 import { ImportNotesProcessorService } from './processors/ImportNotesProcessorService.js';
@@ -86,6 +87,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 	private relationshipQueueWorker: Bull.Worker;
 	private objectStorageQueueWorker: Bull.Worker;
 	private endedPollNotificationQueueWorker: Bull.Worker;
+	private schedulerNotePostQueueWorker: Bull.Worker;
 
 	constructor(
 		@Inject(DI.config)
@@ -126,6 +128,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 		private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
 		private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
 		private cleanProcessorService: CleanProcessorService,
+		private scheduleNotePostProcessorService: ScheduleNotePostProcessorService,
 	) {
 		this.logger = this.queueLoggerService.logger;
 
@@ -530,6 +533,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),
+				autorun: false,
+			});
+		}
+		//#endregion
 	}
 
 	@bindThis
@@ -544,6 +556,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 			this.relationshipQueueWorker.run(),
 			this.objectStorageQueueWorker.run(),
 			this.endedPollNotificationQueueWorker.run(),
+			this.schedulerNotePostQueueWorker.run(),
 		]);
 	}
 
@@ -559,6 +572,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 			this.relationshipQueueWorker.close(),
 			this.objectStorageQueueWorker.close(),
 			this.endedPollNotificationQueueWorker.close(),
+			this.schedulerNotePostQueueWorker.close(),
 		]);
 	}
 
diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts
index 67f689b618..fdf012f149 100644
--- a/packages/backend/src/queue/const.ts
+++ b/packages/backend/src/queue/const.ts
@@ -16,6 +16,7 @@ export const QUEUE = {
 	OBJECT_STORAGE: 'objectStorage',
 	USER_WEBHOOK_DELIVER: 'userWebhookDeliver',
 	SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver',
+	SCHEDULE_NOTE_POST: 'scheduleNotePost',
 };
 
 export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): 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..62d527953d
--- /dev/null
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -0,0 +1,94 @@
+/*
+ * 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<ScheduleNotePostJobData>): Promise<void> {
+		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,
+					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 c0d246ebbc..9433392df5 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -155,3 +155,7 @@ export type UserWebhookDeliverJobData = {
 export type ThinUser = {
 	id: MiUser['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 5bdd7cf650..c478bebdaf 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -311,6 +311,9 @@ import * as ep___notes_renotes from './endpoints/notes/renotes.js';
 import * as ep___notes_replies from './endpoints/notes/replies.js';
 import * as ep___notes_edit from './endpoints/notes/edit.js';
 import * as ep___notes_versions from './endpoints/notes/versions.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';
@@ -711,6 +714,9 @@ const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete'
 const $notes_like: Provider = { provide: 'ep:notes/like', useClass: ep___notes_like.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 };
@@ -1117,6 +1123,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 		$notes_like,
 		$notes_renotes,
 		$notes_replies,
+		$notes_schedule_create,
+		$notes_schedule_delete,
+		$notes_schedule_list,
 		$notes_searchByTag,
 		$notes_search,
 		$notes_show,
@@ -1516,6 +1525,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 		$notes_like,
 		$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 7eb18fbfe2..269afbf14b 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -316,6 +316,9 @@ import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete
 import * as ep___notes_like from './endpoints/notes/like.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';
@@ -716,6 +719,9 @@ const eps = [
 	['notes/like', ep___notes_like],
 	['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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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 fbb8321730..aca98c4d37 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -33,6 +33,7 @@ import type {
 	SystemQueue,
 	UserWebhookDeliverQueue,
 	SystemWebhookDeliverQueue,
+	ScheduleNotePostQueue,
 } from '@/core/QueueModule.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@@ -124,6 +125,7 @@ export class ClientServerService {
 		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 		@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
 		@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
+		@Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue,
 	) {
 		//this.createServer = this.createServer.bind(this);
 	}
@@ -254,6 +256,7 @@ export class ClientServerService {
 				this.objectStorageQueue,
 				this.userWebhookDeliverQueue,
 				this.systemWebhookDeliverQueue,
+				this.scheduleNotePostQueue,
 			].map(q => new BullMQAdapter(q)),
 			serverAdapter: bullBoardServerAdapter,
 		});
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 42cbf081e8..882f19c7fd 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -140,6 +140,7 @@ export const ROLE_POLICIES = [
 	'btlAvailable',
 	'canPublicNote',
 	'canImportNotes',
+	'scheduleNoteMax',
 	'mentionLimit',
 	'canInvite',
 	'inviteLimit',
diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue
index cd6fdf576c..2c69048ec5 100644
--- a/packages/frontend/src/components/MkNoteHeader.vue
+++ b/packages/frontend/src/components/MkNoteHeader.vue
@@ -50,7 +50,10 @@ import { popupMenu } from '@/os.js';
 import { defaultStore } from '@/store.js';
 
 const props = defineProps<{
-	note: Misskey.entities.Note;
+	note: Misskey.entities.Note & {
+		isSchedule?: boolean
+	};
+	scheduled?: boolean;
 }>();
 
 const menuVersionsButton = shallowRef<HTMLElement>();
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 542e3e79ea..7d2bbb31d3 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
 -->
 
 <template>
-<div :class="$style.root">
+<div v-show="!isDeleted" :class="$style.root" :tabindex="!isDeleted ? '-1' : undefined">
 	<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
 	<div :class="$style.main">
 		<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
@@ -15,6 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</p>
 			<div v-show="note.cw == null || showContent">
 				<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/>
+				<div v-if="note.isSchedule" style="margin-top: 10px;">
+					<MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.deleteAndEdit }}</MkButton>
+					<MkButton :class="$style.button" inline danger @click.stop.prevent="deleteScheduleNote()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+				</div>
 			</div>
 		</div>
 	</div>
@@ -24,18 +28,60 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { ref, watch } from 'vue';
 import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
 import { defaultStore } from '@/store.js';
 
 const props = defineProps<{
-	note: Misskey.entities.Note;
+	note: Misskey.entities.Note & {
+		isSchedule? : boolean,
+		scheduledNoteId?: string
+	};
 	expandAllCws?: boolean;
 	hideFiles?: boolean;
 }>();
 
 let showContent = ref(defaultStore.state.uncollapseCW);
+const isDeleted = ref(false);
+
+const emit = defineEmits<{
+	(ev: 'editScheduleNote'): void;
+}>();
+
+async function deleteScheduleNote() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteConfirm,
+		okText: i18n.ts.delete,
+		cancelText: i18n.ts.cancel,
+	});
+	if (canceled) return;
+	await os.apiWithDialog('notes/schedule/delete', { noteId: props.note.id })
+		.then(() => {
+			isDeleted.value = true;
+		});
+}
+
+async function editScheduleNote() {
+	try {
+		await misskeyApi('notes/schedule/delete', { noteId: props.note.id })
+			.then(() => {
+				isDeleted.value = true;
+			});
+	} catch (err) {
+		console.error(err);
+	}
+
+	await os.post({
+		initialNote: props.note,
+		renote: props.note.renote,
+		reply: props.note.reply,
+		channel: props.note.channel,
+	});
+	emit('editScheduleNote');
+}
 
 watch(() => props.expandAllCws, (expandAllCws) => {
 	if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
@@ -50,6 +96,11 @@ watch(() => props.expandAllCws, (expandAllCws) => {
 	font-size: 0.95em;
 }
 
+.button{
+	margin-right: var(--margin);
+	margin-bottom: var(--margin);
+}
+
 .avatar {
 	flex-shrink: 0;
 	display: block;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 4a29b27ac4..443e9e7ee9 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -77,6 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
 	<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
 	<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
+	<MkScheduleEditor v-if="scheduleNote" v-model="scheduleNote" @destroyed="scheduleNote = null"/>
 	<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
 	<div v-if="showingOptions" style="padding: 8px 16px;">
 	</div>
@@ -90,6 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
 			<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
 			<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
+			<button v-tooltip="i18n.ts.otherSettings" :class="['_button', $style.footerButton]" @click="showOtherMenu"><i class="ti ti-dots"></i></button>
 		</div>
 		<div :class="$style.footerRight">
 			<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
@@ -110,6 +112,7 @@ import * as Misskey from 'misskey-js';
 import insertTextAtCursor from 'insert-text-at-cursor';
 import { toASCII } from 'punycode/';
 import { host, url } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import MkNotePreview from '@/components/MkNotePreview.vue';
 import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
@@ -133,6 +136,7 @@ import { miLocalStorage } from '@/local-storage.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { emojiPicker } from '@/scripts/emoji-picker.js';
 import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
+import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
 
 const $i = signinRequired();
 
@@ -150,7 +154,9 @@ const props = withDefaults(defineProps<{
 	initialFiles?: Misskey.entities.DriveFile[];
 	initialLocalOnly?: boolean;
 	initialVisibleUsers?: Misskey.entities.UserDetailed[];
-	initialNote?: Misskey.entities.Note;
+	initialNote?: Misskey.entities.Note & {
+		isSchedule?: boolean,
+	};
 	instant?: boolean;
 	fixed?: boolean;
 	autofocus?: boolean;
@@ -206,6 +212,9 @@ const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]'
 const imeText = ref('');
 const showingOptions = ref(false);
 const textAreaReadOnly = ref(false);
+const scheduleNote = ref<{
+	scheduledAt: number | null;
+} | null>(null);
 
 const draftKey = computed((): string => {
 	let key = props.channel ? `channel:${props.channel.id}` : '';
@@ -378,6 +387,7 @@ function watchForDraft() {
 	watch(localOnly, () => saveDraft());
 	watch(quoteId, () => saveDraft());
 	watch(reactionAcceptance, () => saveDraft());
+	watch(scheduleNote, () => saveDraft());
 }
 
 function MFMWindow() {
@@ -586,6 +596,7 @@ function clear() {
 	files.value = [];
 	poll.value = null;
 	quoteId.value = null;
+	scheduleNote.value = null;
 }
 
 function onKeydown(ev: KeyboardEvent) {
@@ -736,6 +747,7 @@ function saveDraft() {
 			visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
 			quoteId: quoteId.value,
 			reactionAcceptance: reactionAcceptance.value,
+			scheduleNote: scheduleNote.value,
 		},
 	};
 
@@ -843,6 +855,7 @@ async function post(ev?: MouseEvent) {
 		visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined,
 		reactionAcceptance: reactionAcceptance.value,
 		editId: props.editId ? props.editId : undefined,
+		scheduleNote: scheduleNote.value ?? undefined,
 	};
 
 	if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') {
@@ -879,7 +892,7 @@ async function post(ev?: MouseEvent) {
 	}
 
 	posting.value = true;
-	misskeyApi(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => {
+	misskeyApi(postData.editId ? 'notes/edit' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => {
 		if (props.freezeAfterPosted) {
 			posted.value = true;
 		} else {
@@ -901,6 +914,8 @@ async function post(ev?: MouseEvent) {
 				claimAchievement('notes1');
 			}
 
+			poll.value = null;
+
 			const text = postData.text ?? '';
 			const lowerCase = text.toLowerCase();
 			if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('sharkey')) {
@@ -1030,6 +1045,41 @@ function openAccountMenu(ev: MouseEvent) {
 	}, ev);
 }
 
+function toggleScheduleNote() {
+	if (scheduleNote.value) scheduleNote.value = null;
+	else {
+		scheduleNote.value = {
+			scheduledAt: null,
+		};
+	}
+}
+
+function showOtherMenu(ev: MouseEvent) {
+	const menuItems: MenuItem[] = [];
+
+	if ($i.policies.scheduleNoteMax > 0) {
+		menuItems.push({
+			type: 'button',
+			text: i18n.ts.schedulePost,
+			icon: 'ti ti-calendar-time',
+			action: toggleScheduleNote,
+		}, {
+			type: 'button',
+			text: i18n.ts.schedulePostList,
+			icon: 'ti ti-calendar-event',
+			action: () => {
+				const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, {
+					closed: () => {
+						dispose();
+					},
+				});
+			},
+		});
+	}
+
+	os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
+}
+
 onMounted(() => {
 	if (props.autofocus) {
 		focus();
@@ -1099,6 +1149,11 @@ onMounted(() => {
 			}
 			quoteId.value = init.renote ? init.renote.id : null;
 			reactionAcceptance.value = init.reactionAcceptance;
+			if (init.isSchedule) {
+				scheduleNote.value = {
+					scheduledAt: new Date(init.createdAt).getTime(),
+				};
+			}
 		}
 
 		nextTick(() => watchForDraft());
diff --git a/packages/frontend/src/components/MkScheduleEditor.vue b/packages/frontend/src/components/MkScheduleEditor.vue
new file mode 100644
index 0000000000..8f18f620ae
--- /dev/null
+++ b/packages/frontend/src/components/MkScheduleEditor.vue
@@ -0,0 +1,69 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div style="padding: 8px 16px;">
+	<section>
+		<MkInput v-model="atDate" small type="date" class="input">
+			<template #label>{{ i18n.ts._poll.deadlineDate }}</template>
+		</MkInput>
+		<MkInput v-model="atTime" small type="time" class="input">
+			<template #label>{{ i18n.ts._poll.deadlineTime }}</template>
+		</MkInput>
+	</section>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import MkInput from '@/components/MkInput.vue';
+import { formatDateTimeString } from '@/scripts/format-time-string.js';
+import { addTime } from '@/scripts/time.js';
+import { i18n } from '@/i18n.js';
+
+const props = defineProps<{
+  modelValue: {
+		scheduledAt: number | null;
+  };
+}>();
+
+const emit = defineEmits<{
+  (ev: 'update:modelValue', v: {
+		scheduledAt: number | null;
+  }): void;
+}>();
+
+const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
+const atTime = ref('00:00');
+
+if (props.modelValue.scheduledAt) {
+	const date = new Date(props.modelValue.scheduledAt);
+	atDate.value = formatDateTimeString(date, 'yyyy-MM-dd');
+	atTime.value = formatDateTimeString(date, 'HH:mm');
+}
+
+function get() {
+	const calcAt = () => {
+		return new Date(`${ atDate.value } ${ atTime.value }`).getTime();
+	};
+
+	return {
+		...(
+			{ scheduledAt: calcAt() }
+		),
+	};
+}
+
+watch([
+	atDate,
+	atTime,
+], () => emit('update:modelValue', get()), {
+	deep: true,
+});
+
+onMounted(() => {
+	emit('update:modelValue', get());
+});
+</script>
diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
new file mode 100644
index 0000000000..cf793c7110
--- /dev/null
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -0,0 +1,60 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+	ref="dialogEl"
+	:withOkButton="false"
+	@click="cancel()"
+	@close="cancel()"
+>
+	<template #header>{{ i18n.ts.schedulePostList }}</template>
+	<MkSpacer :marginMin="14" :marginMax="16">
+		<MkPagination ref="paginationEl" :pagination="pagination">
+			<template #empty>
+				<div class="_fullinfo">
+					<img :src="infoImageUrl" class="_ghost"/>
+					<div>{{ i18n.ts.nothing }}</div>
+				</div>
+			</template>
+
+			<template #default="{ items }">
+				<div class="_gaps">
+					<MkNoteSimple v-for="item in items" :key="item.id" :scheduled="true" :note="item.note" @editScheduleNote="listUpdate"/>
+				</div>
+			</template>
+		</MkPagination>
+	</MkSpacer>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import type { Paging } from '@/components/MkPagination.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkNoteSimple from '@/components/MkNoteSimple.vue';
+import { i18n } from '@/i18n.js';
+import { infoImageUrl } from '@/instance.js';
+
+const emit = defineEmits<{
+	(ev: 'cancel'): void;
+}>();
+
+const dialogEl = ref();
+const cancel = () => {
+	emit('cancel');
+	dialogEl.value.close();
+};
+const paginationEl = ref();
+const pagination: Paging = {
+	endpoint: 'notes/schedule/list',
+	limit: 10,
+};
+
+function listUpdate() {
+	paginationEl.value.reload();
+}
+</script>
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index e73aa77a3c..deb629d534 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -733,3 +733,4 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> {
 		});
 	});
 }*/
+
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 1763db2323..5d896db98c 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -200,6 +200,25 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</div>
 			</MkFolder>
 
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMax, 'scheduleNoteMax'])">
+				<template #label>{{ i18n.ts._role._options.scheduleNoteMax }}</template>
+				<template #suffix>
+					<span v-if="role.policies.scheduleNoteMax.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.scheduleNoteMax.value }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduleNoteMax)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.scheduleNoteMax.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkInput v-model="role.policies.scheduleNoteMax.value" :disabled="role.policies.scheduleNoteMax.useDefault" type="number" :readonly="readonly">
+					</MkInput>
+					<MkRange v-model="role.policies.scheduleNoteMax.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+						<template #label>{{ i18n.ts._role.priority }}</template>
+					</MkRange>
+				</div>
+			</MkFolder>
+
 			<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
 				<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
 				<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 00a25446ab..036f18fe0d 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -70,6 +70,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 							</MkSwitch>
 						</MkFolder>
 
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMax, 'scheduleNoteMax'])">
+							<template #label>{{ i18n.ts._role._options.scheduleNoteMax }}</template>
+							<template #suffix>{{ policies.scheduleNoteMax }}</template>
+							<MkInput v-model="policies.scheduleNoteMax" type="number">
+							</MkInput>
+						</MkFolder>
+
 						<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
 							<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
 							<template #suffix>{{ policies.mentionLimit }}</template>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 5af1a4112f..a74a4521e7 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1684,6 +1684,10 @@ declare namespace entities {
         NotesRenotesResponse,
         NotesRepliesRequest,
         NotesRepliesResponse,
+        NotesScheduleCreateRequest,
+        NotesScheduleDeleteRequest,
+        NotesScheduleListRequest,
+        NotesScheduleListResponse,
         NotesSearchByTagRequest,
         NotesSearchByTagResponse,
         NotesSearchRequest,
@@ -2807,6 +2811,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'];
 
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 3fa67b7990..4b0e8173f8 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -3385,6 +3385,39 @@ declare module '../api.js' {
       credential?: string | null,
     ): Promise<SwitchCaseResponseType<E, P>>;
 
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+     */
+    request<E extends 'notes/schedule/create', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+     */
+    request<E extends 'notes/schedule/delete', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
+    /**
+     * No description provided.
+     * 
+     * **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
+     */
+    request<E extends 'notes/schedule/list', P extends Endpoints[E]['req']>(
+      endpoint: E,
+      params: P,
+      credential?: string | null,
+    ): Promise<SwitchCaseResponseType<E, P>>;
+
     /**
      * No description provided.
      * 
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 609c9f99d8..5caddb602b 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -450,6 +450,10 @@ import type {
 	NotesRenotesResponse,
 	NotesRepliesRequest,
 	NotesRepliesResponse,
+	NotesScheduleCreateRequest,
+	NotesScheduleDeleteRequest,
+	NotesScheduleListRequest,
+	NotesScheduleListResponse,
 	NotesSearchByTagRequest,
 	NotesSearchByTagResponse,
 	NotesSearchRequest,
@@ -900,6 +904,9 @@ export type Endpoints = {
 	'notes/like': { req: NotesLikeRequest; 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/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 999dd4dd54..2da78f6a50 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -453,6 +453,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/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index ad30f47f2e..2e6320c5be 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -2935,6 +2935,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
@@ -5036,6 +5063,7 @@ export type components = {
       canImportFollowing: boolean;
       canImportMuting: boolean;
       canImportUserLists: boolean;
+      scheduleNoteMax: number;
     };
     ReversiGameLite: {
       /** Format: id */
@@ -24424,6 +24452,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<string, never>;
+          }) | 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/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index c99b8f5570..d090a6b46f 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-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',
-- 
GitLab


From 4f58b8de20625da577a2b7a8055d065bbddb94d1 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sun, 3 Nov 2024 03:39:19 +0100
Subject: [PATCH 02/18] fix: drive content not being loaded

---
 .../api/endpoints/notes/schedule/create.ts    | 28 ++-----------------
 .../api/endpoints/notes/schedule/list.ts      |  3 ++
 .../frontend/src/components/MkMediaList.vue   |  2 +-
 .../frontend/src/components/MkNoteSimple.vue  |  2 ++
 .../components/MkSchedulePostListDialog.vue   |  3 ++
 packages/misskey-js/etc/misskey-js.api.md     |  2 +-
 packages/misskey-js/src/autogen/types.ts      |  8 ------
 7 files changed, 13 insertions(+), 35 deletions(-)

diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index ecdfa4bf2e..c22c29ae31 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -6,7 +6,7 @@
 import ms from 'ms';
 import { In } from 'typeorm';
 import { Inject, Injectable } from '@nestjs/common';
-import { isPureRenote } from 'cherrypick-js/note.js';
+import { isPureRenote } from '@/misc/is-renote.js';
 import type { MiUser } from '@/models/User.js';
 import type {
 	UsersRepository,
@@ -19,7 +19,6 @@ import type {
 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';
@@ -129,7 +128,6 @@ export const paramDef = {
 		} },
 		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 },
@@ -141,7 +139,6 @@ export const paramDef = {
 		text: {
 			type: 'string',
 			minLength: 1,
-			maxLength: MAX_NOTE_TEXT_LENGTH,
 			nullable: true,
 		},
 		fileIds: {
@@ -175,16 +172,6 @@ export const paramDef = {
 			},
 			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,
@@ -227,11 +214,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 
 		private queueService: QueueService,
 		private roleService: RoleService,
-    private idService: IdService,
+		private idService: IdService,
 	) {
-		super({
-			...meta,
-		}, paramDef, async (ps, me) => {
+		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) {
@@ -358,13 +343,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				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) {
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
index 88da4f4043..4895733d4e 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
@@ -9,6 +9,7 @@ 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 { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
 import { QueryService } from '@/core/QueryService.js';
 import { Packed } from '@/misc/json-schema.js';
 import { noteVisibilities } from '@/types.js';
@@ -81,6 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private noteScheduleRepository: NoteScheduleRepository,
 
 		private userEntityService: UserEntityService,
+		private driveFileEntityService: DriveFileEntityService,
 		private queryService: QueryService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
@@ -115,6 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 						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 : [],
+						files: await this.driveFileEntityService.packManyByIds(item.note.files),
 						createdAt: item.scheduledAt.toISOString(),
 						isSchedule: true,
 						id: item.id,
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 5209489046..4ef929e81f 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -35,13 +35,13 @@ import * as Misskey from 'misskey-js';
 import PhotoSwipeLightbox from 'photoswipe/lightbox';
 import PhotoSwipe from 'photoswipe';
 import 'photoswipe/style.css';
+import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@@/js/const.js';
 import XBanner from '@/components/MkMediaBanner.vue';
 import XImage from '@/components/MkMediaImage.vue';
 import XVideo from '@/components/MkMediaVideo.vue';
 import XModPlayer from '@/components/SkModPlayer.vue';
 import XFlashPlayer from '@/components/SkFlashPlayer.vue';
 import * as os from '@/os.js';
-import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js';
 import { defaultStore } from '@/store.js';
 import { focusParent } from '@/scripts/focus.js';
 
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 7d2bbb31d3..48bf53fab5 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -33,6 +33,8 @@ import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
 import { defaultStore } from '@/store.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
 	note: Misskey.entities.Note & {
diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
index cf793c7110..8311981a75 100644
--- a/packages/frontend/src/components/MkSchedulePostListDialog.vue
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -52,8 +52,11 @@ const paginationEl = ref();
 const pagination: Paging = {
 	endpoint: 'notes/schedule/list',
 	limit: 10,
+	offsetMode: true,
 };
 
+console.log(pagination);
+
 function listUpdate() {
 	paginationEl.value.reload();
 }
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index a74a4521e7..ca7a374a67 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2953,7 +2953,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:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-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:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-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/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 2e6320c5be..c8d7194405 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -24475,8 +24475,6 @@ export type operations = {
            */
           reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
           /** @default false */
-          disableRightClick?: boolean;
-          /** @default false */
           noExtractMentions?: boolean;
           /** @default false */
           noExtractHashtags?: boolean;
@@ -24495,12 +24493,6 @@ export type operations = {
             expiresAt?: number | null;
             expiredAfter?: number | null;
           }) | null;
-          event?: ({
-            title?: string;
-            start?: number;
-            end?: number | null;
-            metadata?: Record<string, never>;
-          }) | null;
           scheduleNote: {
             scheduledAt?: number;
           };
-- 
GitLab


From 1ee41d239c46f2f2ef64cc1fe77888ffd38259e2 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sun, 3 Nov 2024 03:43:09 +0100
Subject: [PATCH 03/18] chore: add new ti icon to replace

---
 packages/frontend/vite.replaceIcons.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/packages/frontend/vite.replaceIcons.ts b/packages/frontend/vite.replaceIcons.ts
index ae9a8f81d6..71005fab31 100644
--- a/packages/frontend/vite.replaceIcons.ts
+++ b/packages/frontend/vite.replaceIcons.ts
@@ -175,6 +175,7 @@ export function pluginReplaceIcons() {
 					'ti ti-cake': 'ph-cake ph-bold ph-lg',
 					'ti ti-calendar': 'ph-calendar ph-bold ph-lg',
 					'ti ti-calendar-time': 'ph-calendar ph-bold ph-lg',
+					'ti ti-calendar-event': 'ph-calendar-star ph-bold ph-lg',
 					'ti ti-camera': 'ph-camera ph-bold ph-lg',
 					'ti ti-carousel-horizontal': 'ph-split-horizontal ph-bold ph-lg',
 					'ti ti-carousel-vertical': 'ph-split-vertical ph-bold ph-lg',
-- 
GitLab


From aeb10751ed9ee0ca8725183b20565b87e47d9616 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sun, 3 Nov 2024 03:55:44 +0100
Subject: [PATCH 04/18] chore: fix locales

---
 locales/en-US.yml         |  2 --
 locales/index.d.ts        | 28 ++++++++++++++++++++--------
 locales/ja-JP.yml         |  2 --
 locales/ko-KR.yml         |  2 --
 sharkey-locales/en-US.yml |  8 ++++++++
 sharkey-locales/ja-JP.yml |  6 ++++++
 6 files changed, 34 insertions(+), 14 deletions(-)

diff --git a/locales/en-US.yml b/locales/en-US.yml
index 38e9b03acb..6ea7fb4f8d 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -2073,8 +2073,6 @@ _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 92c09ffe12..e181b13f33 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -6947,6 +6947,10 @@ export interface Locale extends ILocale {
              * Can import notes
              */
             "canImportNotes": string;
+            /**
+             * Maximum number of scheduled notes
+             */
+            "scheduleNoteMax": string;
         };
         "_condition": {
             /**
@@ -8140,14 +8144,6 @@ export interface Locale extends ILocale {
          * ノートを作成・削除する
          */
         "write:notes": string;
-        /**
-         * 予約投稿を見る
-         */
-        "read:notes-schedule": string;
-        /**
-         * 予約投稿を作成・削除する
-         */
-        "write:notes-schedule": string;
         /**
          * 通知を見る
          */
@@ -8424,6 +8420,14 @@ export interface Locale extends ILocale {
          * 違反を報告する
          */
         "write:report-abuse": string;
+        /**
+         * View your list of scheduled notes
+         */
+        "read:notes-schedule": string;
+        /**
+         * Compose or delete scheduled notes
+         */
+        "write:notes-schedule": string;
     };
     "_auth": {
         /**
@@ -11436,6 +11440,14 @@ export interface Locale extends ILocale {
      * Select a follow relationship...
      */
     "selectFollowRelationship": string;
+		/**
+     * Schedule a note
+     */
+    "schedulePost": string;
+    /**
+     * List of scheduled notes
+     */
+    "schedulePostList": string;
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 0d2229ac20..c448d4d50a 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2121,8 +2121,6 @@ _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 351d5a23ce..414202adab 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -2080,8 +2080,6 @@ _permissions:
   "read:mutes": "뮤트 여부를 확인합니다"
   "write:mutes": "뮤트를 하거나 해제합니다"
   "write:notes": "노트를 작성하거나 삭제합니다"
-  "read:notes-schedule": "게시를 예약한 노트를 봅니다"
-  "write:notes-schedule": "노트 게시를 예약하거나 삭제합니다"
   "read:notifications": "알림을 확인합니다"
   "write:notifications": "알림을 모두 읽음 처리합니다"
   "read:reactions": "리액션을 확인합니다"
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index 827112facc..79c362cfd8 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -225,6 +225,7 @@ _role:
     btlAvailable: "Can view the bubble timeline"
     canImportNotes: "Can import notes"
     canUpdateBioMedia: "Allow users to edit their avatar or banner"
+    scheduleNoteMax: "Maximum number of scheduled notes"
   _condition:
     isLocked: "Private account"
     isExplorable: "Account is discoverable"
@@ -414,3 +415,10 @@ _deck:
     following: "Following"
 
 selectFollowRelationship: "Select a follow relationship..."
+
+schedulePost: "Schedule a note"
+schedulePostList: "List of scheduled notes"
+
+_permissions:
+  "read:notes-schedule": "View your list of scheduled notes"
+  "write:notes-schedule": "Compose or delete scheduled notes"
diff --git a/sharkey-locales/ja-JP.yml b/sharkey-locales/ja-JP.yml
index 22bd5235ca..46566c39ac 100644
--- a/sharkey-locales/ja-JP.yml
+++ b/sharkey-locales/ja-JP.yml
@@ -210,6 +210,7 @@ _role:
     btlAvailable: "バブルタイムラインの閲覧"
     canImportNotes: "ノートのインポートが可能"
     canUpdateBioMedia: "アイコンとバナーの更新を許可"
+    scheduleNoteMax: "予約投稿の最大数"
   _condition:
     isLocked: "鍵アカウントユーザー"
     isExplorable: "「アカウントを見つけやすくする」が有効なユーザー"
@@ -384,3 +385,8 @@ _externalNavigationWarning:
   title: "外部サイトに移動します"
   description: "{host}を離れて外部サイトに移動します"
   trustThisDomain: "このデバイスで今後このドメインを信頼する"
+schedulePost: "予約投稿"
+schedulePostList: "予約投稿一覧"
+_permissions:
+  "read:notes-schedule": "予約投稿を見る"
+  "write:notes-schedule": "予約投稿を作成・削除する"
-- 
GitLab


From f08d1ec38f846ad46bf95bd4d2d67ebaa143e042 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sun, 3 Nov 2024 04:04:17 +0100
Subject: [PATCH 05/18] chore: remove leftover log

---
 packages/frontend/src/components/MkSchedulePostListDialog.vue | 2 --
 1 file changed, 2 deletions(-)

diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
index 8311981a75..cfae94951b 100644
--- a/packages/frontend/src/components/MkSchedulePostListDialog.vue
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -55,8 +55,6 @@ const pagination: Paging = {
 	offsetMode: true,
 };
 
-console.log(pagination);
-
 function listUpdate() {
 	paginationEl.value.reload();
 }
-- 
GitLab


From fc9d777dc3161b40c5c62bb65cb03e2c7d8f4380 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sun, 3 Nov 2024 17:59:50 +0100
Subject: [PATCH 06/18] upd: add notification for failures, add reasons for
 failure, apply suggestions

---
 locales/index.d.ts                            |  4 ++
 .../entities/NotificationEntityService.ts     |  5 +-
 packages/backend/src/models/NoteSchedule.ts   |  2 -
 packages/backend/src/models/Notification.ts   |  5 ++
 .../src/models/json-schema/notification.ts    | 14 +++++
 .../ScheduleNotePostProcessorService.ts       | 59 +++++++++++++++----
 .../api/endpoints/notes/schedule/create.ts    |  5 +-
 packages/backend/src/types.ts                 |  1 +
 packages/frontend-shared/js/const.ts          |  1 +
 .../src/components/MkNotification.vue         |  8 ++-
 .../frontend/src/components/MkPostForm.vue    |  5 +-
 .../components/MkSchedulePostListDialog.vue   |  1 +
 packages/misskey-js/etc/misskey-js.api.md     |  2 +-
 packages/misskey-js/src/autogen/types.ts      | 16 +++--
 packages/misskey-js/src/consts.ts             |  2 +-
 .../sw/src/scripts/create-notification.ts     |  7 +++
 sharkey-locales/en-US.yml                     |  1 +
 17 files changed, 110 insertions(+), 28 deletions(-)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index e181b13f33..4cfc220731 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9558,6 +9558,10 @@ export interface Locale extends ILocale {
          * Note got edited
          */
         "edited": string;
+        /**
+         * Posting scheduled note failed
+         */
+        "scheduledNoteFailed": string;
     };
     "_deck": {
         /**
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index bbaf0cb7c8..27b8231854 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
 import type { UserEntityService } from './UserEntityService.js';
 import type { NoteEntityService } from './NoteEntityService.js';
 
-const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNoteFailed'] as (typeof groupedNotificationTypes[number])[]);
 
 @Injectable()
 export class NotificationEntityService implements OnModuleInit {
@@ -169,6 +169,9 @@ export class NotificationEntityService implements OnModuleInit {
 				exportedEntity: notification.exportedEntity,
 				fileId: notification.fileId,
 			} : {}),
+			...(notification.type === 'scheduledNoteFailed' ? {
+				reason: notification.reason,
+			} : {}),
 			...(notification.type === 'app' ? {
 				body: notification.customBody,
 				header: notification.customHeader,
diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts
index 97ffe32ffa..dde0af6ad7 100644
--- a/packages/backend/src/models/NoteSchedule.ts
+++ b/packages/backend/src/models/NoteSchedule.ts
@@ -18,8 +18,6 @@ type MinimumUser = {
 };
 
 export type MiScheduleNoteType={
-	/** Date.toISOString() */
-	createdAt: string;
 	visibility: 'public' | 'home' | 'followers' | 'specified';
 	visibleUsers: MinimumUser[];
 	channel?: MiChannel['id'];
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index c4f046c565..5003e02d96 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -122,6 +122,11 @@ export type MiNotification = {
 	createdAt: string;
 	notifierId: MiUser['id'];
 	noteId: MiNote['id'];
+} | {
+	type: 'scheduledNoteFailed';
+	id: string;
+	createdAt: string;
+	reason: string;
 };
 
 export type MiGroupedNotification = MiNotification | {
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 990e8957cf..69bd9531ec 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -369,6 +369,20 @@ export const packedNotificationSchema = {
 				optional: false, nullable: false,
 			},
 		},
+	}, {
+		type: 'object',
+		properties: {
+			...baseSchema.properties,
+			type: {
+				type: 'string',
+				optional: false, nullable: false,
+				enum: ['scheduledNoteFailed'],
+			},
+			reason: {
+				type: 'string',
+				optional: false, nullable: false,
+			},
+		},
 	}, {
 		type: 'object',
 		properties: {
diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
index 62d527953d..59e23b865e 100644
--- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -9,6 +9,7 @@ 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 { NotificationService } from '@/core/NotificationService.js';
 import { QueueLoggerService } from '../QueueLoggerService.js';
 import type * as Bull from 'bullmq';
 import type { ScheduleNotePostJobData } from '../types.js';
@@ -32,6 +33,7 @@ export class ScheduleNotePostProcessorService {
 
 		private noteCreateService: NoteCreateService,
 		private queueLoggerService: QueueLoggerService,
+		private notificationService: NotificationService,
 	) {
 		this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
 	}
@@ -50,8 +52,9 @@ export class ScheduleNotePostProcessorService {
 				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) {
+				const fileIds = note.files;
+
+				if (fileIds.length > 0 && me) {
 					files = await this.driveFilesRepository.createQueryBuilder('file')
 						.where('file.userId = :userId AND file.id IN (:...fileIds)', {
 							userId: me.id,
@@ -61,22 +64,52 @@ export class ScheduleNotePostProcessorService {
 						.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');
+
+				if (!data.userId || !me) {
+					this.logger.warn('Schedule Note Failed Reason: User Not Found');
+					await this.noteScheduleRepository.remove(data);
+					return;
+				}
+
+				if (note.files.length !== files.length) {
+					this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
+					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+						reason: 'Some attached files on your scheduled note no longer exist',
+					});
+					await this.noteScheduleRepository.remove(data);
+					return;
+				}
+
+				if (note.reply && !reply) {
+					this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
+					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+						reason: 'Replied to note on your scheduled note no longer exists',
+					});
 					await this.noteScheduleRepository.remove(data);
 					return;
 				}
+
+				if (note.renote && !renote) {
+					this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
+					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+						reason: 'A quoted note from one of your scheduled notes no longer exists',
+					});
+					await this.noteScheduleRepository.remove(data);
+					return;
+				}
+
+				if (note.channel && !channel) {
+					this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
+					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+						reason: 'An attached channel on your scheduled note no longer exists',
+					});
+					await this.noteScheduleRepository.remove(data);
+					return;
+				}
+
 				await this.noteCreateService.create(me, {
 					...note,
-					createdAt: new Date(note.createdAt), //typeORMのjsonbで何故かstringにされるから戻す
+					createdAt: new Date(),
 					files,
 					poll: note.poll ? {
 						choices: note.poll.choices,
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index c22c29ae31..b8ae3f44a3 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -292,7 +292,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 
 				// Check blocking
 				if (reply.userId !== me.id) {
-					const blockExist = await this.blockingsRepository.exist({
+					const blockExist = await this.blockingsRepository.exists({
 						where: {
 							blockerId: reply.userId,
 							blockeeId: me.id,
@@ -324,8 +324,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			} else {
 				throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
 			}
-			const note:MiScheduleNoteType = {
-				createdAt: new Date(ps.scheduleNote.scheduledAt!).toISOString(),
+			const note: MiScheduleNoteType = {
 				files: files.map(f => f.id),
 				poll: ps.poll ? {
 					choices: ps.poll.choices,
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 2aa4f279ea..7930129002 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -35,6 +35,7 @@ export const notificationTypes = [
 	'roleAssigned',
 	'achievementEarned',
 	'exportCompleted',
+	'scheduledNoteFailed',
 	'app',
 	'test',
 ] as const;
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 882f19c7fd..5bc75db908 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -131,6 +131,7 @@ export const notificationTypes = [
 	'test',
 	'app',
 	'edited',
+	'scheduledNoteFailed',
 ] as const;
 export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
 
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 7bec9bdc65..3c4f56b537 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <div :class="$style.root">
 	<div :class="$style.head">
 		<MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
-		<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
+		<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
 		<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				[$style.t_exportCompleted]: notification.type === 'exportCompleted',
 				[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
 				[$style.t_pollEnded]: notification.type === 'edited',
+				[$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
 			}]"
 		> <!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
 			<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
@@ -46,6 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<i v-else class="ti ti-badges"></i>
 			</template>
 			<i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i>
+			<i v-else-if="notification.type === 'scheduledNoteFailed'" class="ti ti-calendar-event"></i>
 			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
 			<MkReactionIcon
 				v-else-if="notification.type === 'reaction'"
@@ -70,6 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
 			<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
 			<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
+			<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</span>
 			<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
 		</header>
 		<div>
@@ -109,6 +112,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
 				{{ i18n.ts.showFile }}
 			</MkA>
+			<div v-else-if="notification.type === 'scheduledNoteFailed'" :class="$style.text">
+				{{ notification.reason }}
+			</div>
 			<template v-else-if="notification.type === 'follow'">
 				<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
 			</template>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 443e9e7ee9..bbde7c65f9 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -1046,8 +1046,9 @@ function openAccountMenu(ev: MouseEvent) {
 }
 
 function toggleScheduleNote() {
-	if (scheduleNote.value) scheduleNote.value = null;
-	else {
+	if (scheduleNote.value) {
+		scheduleNote.value = null;
+	} else {
 		scheduleNote.value = {
 			scheduledAt: null,
 		};
diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
index cfae94951b..d0716ead79 100644
--- a/packages/frontend/src/components/MkSchedulePostListDialog.vue
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -48,6 +48,7 @@ const cancel = () => {
 	emit('cancel');
 	dialogEl.value.close();
 };
+
 const paginationEl = ref();
 const pagination: Paging = {
 	endpoint: 'notes/schedule/list',
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index ca7a374a67..880be518fa 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2890,7 +2890,7 @@ type Notification_2 = components['schemas']['Notification'];
 type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
 
 // @public (undocumented)
-export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited"];
+export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited", "scheduledNoteFailed"];
 
 // @public (undocumented)
 export function nyaize(text: string): string;
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index c8d7194405..6eb1819037 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4517,6 +4517,14 @@ export type components = {
       /** Format: id */
       userId: string;
       note: components['schemas']['Note'];
+    } | {
+      /** Format: id */
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      /** @enum {string} */
+      type: 'scheduledNoteFailed';
+      reason: string;
     } | {
       /** Format: id */
       id: string;
@@ -19984,8 +19992,8 @@ export type operations = {
           untilId?: string;
           /** @default true */
           markAsRead?: boolean;
-          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
-          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
         };
       };
     };
@@ -20052,8 +20060,8 @@ export type operations = {
           untilId?: string;
           /** @default true */
           markAsRead?: boolean;
-          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
-          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
         };
       };
     };
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index d090a6b46f..34fc7c1a03 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -16,7 +16,7 @@ import type {
 	UserLite,
 } from './autogen/models.js';
 
-export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited'] as const;
+export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited', 'scheduledNoteFailed'] as const;
 
 export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
 
diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts
index 9c56e338c7..8442552e3b 100644
--- a/packages/sw/src/scripts/create-notification.ts
+++ b/packages/sw/src/scripts/create-notification.ts
@@ -258,6 +258,13 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
 						data,
 					}];
 
+				case 'scheduledNoteFailed':
+					return [i18n.ts._notification.scheduledNoteFailed, {
+						body: data.body.reason,
+						badge: iconUrl('bell'),
+						data,
+					}];
+
 				default:
 					return null;
 			}
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index 79c362cfd8..582f4eda0a 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -277,6 +277,7 @@ _notification:
   youRenoted: "Boost from {name}"
   renotedBySomeUsers: "Boosted by {n} users"
   edited: "Note got edited"
+  scheduledNoteFailed: "Posting scheduled note failed"
   _types:
     renote: "Boosts"
     edited: "Edits"
-- 
GitLab


From 170093f2a2873f6fc74ddff2c47d546238e33716 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Sun, 3 Nov 2024 17:01:46 +0000
Subject: [PATCH 07/18] chore: remove from note required type

---
 packages/backend/src/core/entities/NotificationEntityService.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index 27b8231854..447d95cc68 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
 import type { UserEntityService } from './UserEntityService.js';
 import type { NoteEntityService } from './NoteEntityService.js';
 
-const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNoteFailed'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]);
 
 @Injectable()
 export class NotificationEntityService implements OnModuleInit {
-- 
GitLab


From 1d3c8253981fba2d431df2f5cc12dd78b21d37bc Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 4 Nov 2024 11:22:37 +0100
Subject: [PATCH 08/18] upd: add notification for when scheduled note gets
 posted

---
 locales/index.d.ts                            |  4 +++
 .../entities/NotificationEntityService.ts     |  2 +-
 packages/backend/src/models/Notification.ts   |  5 ++++
 .../src/models/json-schema/notification.ts    | 25 +++++++++++++++++++
 .../ScheduleNotePostProcessorService.ts       |  5 +++-
 packages/backend/src/types.ts                 |  1 +
 packages/frontend-shared/js/const.ts          |  1 +
 .../src/components/MkNotification.vue         | 11 +++++++-
 packages/misskey-js/etc/misskey-js.api.md     |  2 +-
 packages/misskey-js/src/autogen/types.ts      | 19 +++++++++++---
 packages/misskey-js/src/consts.ts             |  2 +-
 .../sw/src/scripts/create-notification.ts     |  8 ++++++
 sharkey-locales/en-US.yml                     |  1 +
 13 files changed, 77 insertions(+), 9 deletions(-)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 4cfc220731..89541e6414 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9562,6 +9562,10 @@ export interface Locale extends ILocale {
          * Posting scheduled note failed
          */
         "scheduledNoteFailed": string;
+        /**
+         * Scheduled Note was posted
+         */
+        "scheduledNotePosted": string;
     };
     "_deck": {
         /**
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index 447d95cc68..31a9809323 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
 import type { UserEntityService } from './UserEntityService.js';
 import type { NoteEntityService } from './NoteEntityService.js';
 
-const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]);
 
 @Injectable()
 export class NotificationEntityService implements OnModuleInit {
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index 5003e02d96..53003a0a5a 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -127,6 +127,11 @@ export type MiNotification = {
 	id: string;
 	createdAt: string;
 	reason: string;
+} | {
+	type: 'scheduledNotePosted';
+	id: string;
+	createdAt: string;
+	noteId: MiNote['id'];
 };
 
 export type MiGroupedNotification = MiNotification | {
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 69bd9531ec..26498e3e9d 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -383,6 +383,31 @@ export const packedNotificationSchema = {
 				optional: false, nullable: false,
 			},
 		},
+	}, {
+		type: 'object',
+		properties: {
+			...baseSchema.properties,
+			type: {
+				type: 'string',
+				optional: false, nullable: false,
+				enum: ['scheduledNotePosted'],
+			},
+			user: {
+				type: 'object',
+				ref: 'UserLite',
+				optional: false, nullable: false,
+			},
+			userId: {
+				type: 'string',
+				optional: false, nullable: false,
+				format: 'id',
+			},
+			note: {
+				type: 'object',
+				ref: 'Note',
+				optional: false, nullable: false,
+			},
+		},
 	}, {
 		type: 'object',
 		properties: {
diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
index 59e23b865e..f281b0ed7b 100644
--- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -107,7 +107,7 @@ export class ScheduleNotePostProcessorService {
 					return;
 				}
 
-				await this.noteCreateService.create(me, {
+				const createdNote = await this.noteCreateService.create(me, {
 					...note,
 					createdAt: new Date(),
 					files,
@@ -121,6 +121,9 @@ export class ScheduleNotePostProcessorService {
 					channel,
 				});
 				await this.noteScheduleRepository.remove(data);
+				this.notificationService.createNotification(me.id, 'scheduledNotePosted', {
+					noteId: createdNote.id,
+				});
 			}
 		});
 	}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 7930129002..95f049f768 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -36,6 +36,7 @@ export const notificationTypes = [
 	'achievementEarned',
 	'exportCompleted',
 	'scheduledNoteFailed',
+	'scheduledNotePosted',
 	'app',
 	'test',
 ] as const;
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 5bc75db908..a6eebe8e15 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -132,6 +132,7 @@ export const notificationTypes = [
 	'app',
 	'edited',
 	'scheduledNoteFailed',
+	'scheduledNotePosted',
 ] as const;
 export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
 
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 3c4f56b537..f713d46137 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <div :class="$style.root">
 	<div :class="$style.head">
-		<MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
+		<MkAvatar v-if="['pollEnded', 'note', 'edited', 'scheduledNotePosted'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
 		<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'scheduledNoteFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
 		<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
 		<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
@@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
 				[$style.t_pollEnded]: notification.type === 'edited',
 				[$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
+				[$style.t_pollEnded]: notification.type === 'scheduledNotePosted',
 			}]"
 		> <!-- we re-use t_pollEnded for "edited" instead of making an identical style -->
 			<i v-if="notification.type === 'follow'" class="ti ti-plus"></i>
@@ -48,6 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			</template>
 			<i v-else-if="notification.type === 'edited'" class="ph-pencil ph-bold ph-lg"></i>
 			<i v-else-if="notification.type === 'scheduledNoteFailed'" class="ti ti-calendar-event"></i>
+			<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-calendar-event"></i>
 			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
 			<MkReactionIcon
 				v-else-if="notification.type === 'reaction'"
@@ -73,6 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<span v-else-if="notification.type === 'app'">{{ notification.header }}</span>
 			<span v-else-if="notification.type === 'edited'">{{ i18n.ts._notification.edited }}</span>
 			<span v-else-if="notification.type === 'scheduledNoteFailed'">{{ i18n.ts._notification.scheduledNoteFailed }}</span>
+			<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
 			<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
 		</header>
 		<div>
@@ -162,6 +165,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
 				<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
 			</MkA>
+
+			<MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+				<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
+				<Mfm :text="getNoteSummary(notification.note)" :isBlock="true" :plain="true" :nowrap="true" :author="notification.note.user"/>
+				<i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i>
+			</MkA>
 		</div>
 	</div>
 </div>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 880be518fa..4a261028ed 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2890,7 +2890,7 @@ type Notification_2 = components['schemas']['Notification'];
 type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
 
 // @public (undocumented)
-export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited", "scheduledNoteFailed"];
+export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited", "scheduledNoteFailed", "scheduledNotePosted"];
 
 // @public (undocumented)
 export function nyaize(text: string): string;
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 6eb1819037..29c2e814d2 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4525,6 +4525,17 @@ export type components = {
       /** @enum {string} */
       type: 'scheduledNoteFailed';
       reason: string;
+    } | {
+      /** Format: id */
+      id: string;
+      /** Format: date-time */
+      createdAt: string;
+      /** @enum {string} */
+      type: 'scheduledNotePosted';
+      user: components['schemas']['UserLite'];
+      /** Format: id */
+      userId: string;
+      note: components['schemas']['Note'];
     } | {
       /** Format: id */
       id: string;
@@ -19992,8 +20003,8 @@ export type operations = {
           untilId?: string;
           /** @default true */
           markAsRead?: boolean;
-          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
-          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
         };
       };
     };
@@ -20060,8 +20071,8 @@ export type operations = {
           untilId?: string;
           /** @default true */
           markAsRead?: boolean;
-          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
-          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+          includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+          excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'scheduledNotePosted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
         };
       };
     };
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index 34fc7c1a03..6e32060fb7 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -16,7 +16,7 @@ import type {
 	UserLite,
 } from './autogen/models.js';
 
-export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited', 'scheduledNoteFailed'] as const;
+export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited', 'scheduledNoteFailed', 'scheduledNotePosted'] as const;
 
 export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
 
diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts
index 8442552e3b..4fda784dba 100644
--- a/packages/sw/src/scripts/create-notification.ts
+++ b/packages/sw/src/scripts/create-notification.ts
@@ -265,6 +265,14 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
 						data,
 					}];
 
+				case 'scheduledNotePosted':
+					return [i18n.ts._notification.scheduledNotePosted, {
+						body: data.body.note.text ?? '',
+						icon: data.body.user.avatarUrl ?? undefined,
+						badge: iconUrl('bell'),
+						data,
+					}];
+
 				default:
 					return null;
 			}
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index 582f4eda0a..f15646a156 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -278,6 +278,7 @@ _notification:
   renotedBySomeUsers: "Boosted by {n} users"
   edited: "Note got edited"
   scheduledNoteFailed: "Posting scheduled note failed"
+  scheduledNotePosted: "Scheduled Note was posted"
   _types:
     renote: "Boosts"
     edited: "Edits"
-- 
GitLab


From 3efb99dd576a2e3492fea7bd15a77c0414289fff Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 4 Nov 2024 18:05:29 +0100
Subject: [PATCH 09/18] fix: missing MKButton import

---
 packages/frontend/src/components/MkNoteSimple.vue | 1 +
 1 file changed, 1 insertion(+)

diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 48bf53fab5..6db95f1987 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -32,6 +32,7 @@ import * as os from '@/os.js';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
+import MkButton from '@/components/MkButton.vue';
 import { defaultStore } from '@/store.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
-- 
GitLab


From 7eb0996b60943cf4aeb7e753fec9cfac5d2322ed Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 4 Nov 2024 18:06:38 +0100
Subject: [PATCH 10/18] upd: use apiWithDialog instead of misskeyApi

---
 packages/frontend/src/components/MkNoteSimple.vue | 13 ++++---------
 1 file changed, 4 insertions(+), 9 deletions(-)

diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 6db95f1987..e7a357d1b6 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -34,7 +34,6 @@ import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
 import MkCwButton from '@/components/MkCwButton.vue';
 import MkButton from '@/components/MkButton.vue';
 import { defaultStore } from '@/store.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
 import { i18n } from '@/i18n.js';
 
 const props = defineProps<{
@@ -68,14 +67,10 @@ async function deleteScheduleNote() {
 }
 
 async function editScheduleNote() {
-	try {
-		await misskeyApi('notes/schedule/delete', { noteId: props.note.id })
-			.then(() => {
-				isDeleted.value = true;
-			});
-	} catch (err) {
-		console.error(err);
-	}
+	await os.apiWithDialog('notes/schedule/delete', { noteId: props.note.id })
+		.then(() => {
+			isDeleted.value = true;
+		});
 
 	await os.post({
 		initialNote: props.note,
-- 
GitLab


From b10eb7ada9072190767cdb4ea4a016e2ee95eda8 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 05:10:52 +0100
Subject: [PATCH 11/18] fix missing imports

---
 packages/frontend/src/components/MkMediaList.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 4ef929e81f..9952f511f3 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -35,7 +35,7 @@ import * as Misskey from 'misskey-js';
 import PhotoSwipeLightbox from 'photoswipe/lightbox';
 import PhotoSwipe from 'photoswipe';
 import 'photoswipe/style.css';
-import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@@/js/const.js';
+import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js';
 import XBanner from '@/components/MkMediaBanner.vue';
 import XImage from '@/components/MkMediaImage.vue';
 import XVideo from '@/components/MkMediaVideo.vue';
-- 
GitLab


From 1d0fd4c40b7fca14fbea8375e329c113800d5770 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 05:11:22 +0100
Subject: [PATCH 12/18] update locales

---
 locales/index.d.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 89541e6414..7ab1b7f545 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -11448,7 +11448,7 @@ export interface Locale extends ILocale {
      * Select a follow relationship...
      */
     "selectFollowRelationship": string;
-		/**
+    /**
      * Schedule a note
      */
     "schedulePost": string;
-- 
GitLab


From 152cc074831b784bb1e12267587184cea293a186 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 05:58:25 +0100
Subject: [PATCH 13/18] Apply suggestions

---
 .../queue/processors/ScheduleNotePostProcessorService.ts   | 7 +++++++
 .../src/server/api/endpoints/notes/schedule/create.ts      | 2 +-
 packages/frontend/src/components/MkPostForm.vue            | 4 +---
 3 files changed, 9 insertions(+), 4 deletions(-)

diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
index f281b0ed7b..ea43448ed0 100644
--- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -10,6 +10,7 @@ import { NoteCreateService } from '@/core/NoteCreateService.js';
 import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
 import { DI } from '@/di-symbols.js';
 import { NotificationService } from '@/core/NotificationService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
 import { QueueLoggerService } from '../QueueLoggerService.js';
 import type * as Bull from 'bullmq';
 import type { ScheduleNotePostJobData } from '../types.js';
@@ -119,6 +120,12 @@ export class ScheduleNotePostProcessorService {
 					reply,
 					renote,
 					channel,
+				}).catch(async (err: IdentifiableError) => {
+					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+						reason: err.message,
+					});
+					await this.noteScheduleRepository.remove(data);
+					throw this.logger.error(`Schedule Note Failed Reason: ${err.message}`);
 				});
 				await this.noteScheduleRepository.remove(data);
 				this.notificationService.createNotification(me.id, 'scheduledNotePosted', {
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index b8ae3f44a3..7d20b6b82a 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -360,7 +360,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				}, {
 					delay,
 					removeOnComplete: true,
-					jobId: noteId,
+					jobId: `schedNote:${noteId}`,
 				});
 			}
 
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index bbde7c65f9..c7d5611847 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -821,7 +821,7 @@ async function post(ev?: MouseEvent) {
 		const filesData = toRaw(files.value);
 
 		const isMissingAltText = filesData.filter(
-			file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/')
+			file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'),
 		).some(file => !file.comment);
 
 		if (isMissingAltText) {
@@ -914,8 +914,6 @@ async function post(ev?: MouseEvent) {
 				claimAchievement('notes1');
 			}
 
-			poll.value = null;
-
 			const text = postData.text ?? '';
 			const lowerCase = text.toLowerCase();
 			if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('sharkey')) {
-- 
GitLab


From 116a14720285707f2b884ccf6d39e224b04ec390 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 05:00:20 +0000
Subject: [PATCH 14/18] remove comma added by vscode prettifer

---
 packages/frontend/src/components/MkPostForm.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index c7d5611847..93328fe52f 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -821,7 +821,7 @@ async function post(ev?: MouseEvent) {
 		const filesData = toRaw(files.value);
 
 		const isMissingAltText = filesData.filter(
-			file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'),
+			file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/')
 		).some(file => !file.comment);
 
 		if (isMissingAltText) {
-- 
GitLab


From f02d0994132c5774f3adf0d4a993f4ecca549b77 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 06:10:32 +0100
Subject: [PATCH 15/18] fix deletion of scheduled note

---
 .../backend/src/server/api/endpoints/notes/schedule/delete.ts   | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
index df406f99f0..628fd89926 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
@@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				throw new ApiError(meta.errors.permissionDenied);
 			}
 			await this.noteScheduleRepository.delete({ id: ps.noteId });
-			await this.queueService.ScheduleNotePostQueue.remove(ps.noteId);
+			await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${ps.noteId}`);
 		});
 	}
 }
-- 
GitLab


From dda922a030cdbd80f35aef38f2a67114a067c5c4 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 06:13:46 +0100
Subject: [PATCH 16/18] remove splatting

---
 packages/frontend/src/components/MkScheduleEditor.vue | 6 +-----
 1 file changed, 1 insertion(+), 5 deletions(-)

diff --git a/packages/frontend/src/components/MkScheduleEditor.vue b/packages/frontend/src/components/MkScheduleEditor.vue
index 8f18f620ae..60a60bed28 100644
--- a/packages/frontend/src/components/MkScheduleEditor.vue
+++ b/packages/frontend/src/components/MkScheduleEditor.vue
@@ -49,11 +49,7 @@ function get() {
 		return new Date(`${ atDate.value } ${ atTime.value }`).getTime();
 	};
 
-	return {
-		...(
-			{ scheduledAt: calcAt() }
-		),
-	};
+	return { scheduledAt: calcAt() };
 }
 
 watch([
-- 
GitLab


From 234d14a892153f8249ced4e81d569418879f5f8c Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 05:21:53 +0000
Subject: [PATCH 17/18] Revert import order update caused by saving

---
 packages/frontend/src/components/MkMediaList.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 9952f511f3..5209489046 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -35,13 +35,13 @@ import * as Misskey from 'misskey-js';
 import PhotoSwipeLightbox from 'photoswipe/lightbox';
 import PhotoSwipe from 'photoswipe';
 import 'photoswipe/style.css';
-import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js';
 import XBanner from '@/components/MkMediaBanner.vue';
 import XImage from '@/components/MkMediaImage.vue';
 import XVideo from '@/components/MkMediaVideo.vue';
 import XModPlayer from '@/components/SkModPlayer.vue';
 import XFlashPlayer from '@/components/SkFlashPlayer.vue';
 import * as os from '@/os.js';
+import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js';
 import { defaultStore } from '@/store.js';
 import { focusParent } from '@/scripts/focus.js';
 
-- 
GitLab


From bf01dcd8fb770d88f8509bbcb63bdaef1aa97fa5 Mon Sep 17 00:00:00 2001
From: Marie <github@yuugi.dev>
Date: Mon, 9 Dec 2024 18:58:57 +0100
Subject: [PATCH 18/18] Apply suggestions

---
 .../ScheduleNotePostProcessorService.ts       | 65 ++++++++++---------
 .../frontend/src/components/MkNoteSimple.vue  |  2 +-
 2 files changed, 37 insertions(+), 30 deletions(-)

diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
index ea43448ed0..62e3d1072f 100644
--- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -11,6 +11,7 @@ import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteSchedul
 import { DI } from '@/di-symbols.js';
 import { NotificationService } from '@/core/NotificationService.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
+import type { MiScheduleNoteType } from '@/models/NoteSchedule.js';
 import { QueueLoggerService } from '../QueueLoggerService.js';
 import type * as Bull from 'bullmq';
 import type { ScheduleNotePostJobData } from '../types.js';
@@ -39,7 +40,36 @@ export class ScheduleNotePostProcessorService {
 		this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
 	}
 
-    @bindThis
+	@bindThis
+	private async isValidNoteSchedule(note: MiScheduleNoteType, id: string): Promise<boolean> {
+		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;
+		if (note.reply && !reply) {
+			this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
+			this.notificationService.createNotification(id, 'scheduledNoteFailed', {
+				reason: 'Replied to note on your scheduled note no longer exists',
+			});
+			return false;
+		}
+		if (note.renote && !renote) {
+			this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
+			this.notificationService.createNotification(id, 'scheduledNoteFailed', {
+				reason: 'A quoted note from one of your scheduled notes no longer exists',
+			});
+			return false;
+		}
+		if (note.channel && !channel) {
+			this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
+			this.notificationService.createNotification(id, 'scheduledNoteFailed', {
+				reason: 'An attached channel on your scheduled note no longer exists',
+			});
+			return false;
+		}
+		return true;
+	}
+
+	@bindThis
 	public async process(job: Bull.Job<ScheduleNotePostJobData>): Promise<void> {
 		this.noteScheduleRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => {
 			if (!data) {
@@ -47,11 +77,10 @@ export class ScheduleNotePostProcessorService {
 			} 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;
 
@@ -72,37 +101,15 @@ export class ScheduleNotePostProcessorService {
 					return;
 				}
 
-				if (note.files.length !== files.length) {
-					this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
-					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
-						reason: 'Some attached files on your scheduled note no longer exist',
-					});
-					await this.noteScheduleRepository.remove(data);
-					return;
-				}
-
-				if (note.reply && !reply) {
-					this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
-					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
-						reason: 'Replied to note on your scheduled note no longer exists',
-					});
-					await this.noteScheduleRepository.remove(data);
-					return;
-				}
-
-				if (note.renote && !renote) {
-					this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
-					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
-						reason: 'A quoted note from one of your scheduled notes no longer exists',
-					});
+				if (!await this.isValidNoteSchedule(note, me.id)) {
 					await this.noteScheduleRepository.remove(data);
 					return;
 				}
 
-				if (note.channel && !channel) {
-					this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
+				if (note.files.length !== files.length) {
+					this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
 					this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
-						reason: 'An attached channel on your scheduled note no longer exists',
+						reason: 'Some attached files on your scheduled note no longer exist',
 					});
 					await this.noteScheduleRepository.remove(data);
 					return;
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index e7a357d1b6..924262d62e 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
 -->
 
 <template>
-<div v-show="!isDeleted" :class="$style.root" :tabindex="!isDeleted ? '-1' : undefined">
+<div v-if="!isDeleted" :class="$style.root">
 	<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
 	<div :class="$style.main">
 		<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
-- 
GitLab