From 3061147bd3fe180847508e75d95acfc216e78bc1 Mon Sep 17 00:00:00 2001
From: Lhcfl <Lhcfl@outlook.com>
Date: Fri, 3 May 2024 21:42:40 +0800
Subject: [PATCH] feat: scheduled note creation

---
 .gitignore                                    |   3 -
 locales/en-US.yml                             |  12 ++
 locales/zh-CN.yml                             |  12 ++
 packages/backend/src/db/postgre.ts            |   2 +
 ...28200194-create-scheduled-note-creation.ts |  54 +++++++++
 .../entities/scheduled-note-creation.ts       |  44 +++++++
 packages/backend/src/models/index.ts          |   2 +
 .../backend/src/models/repositories/note.ts   |  11 ++
 packages/backend/src/queue/index.ts           |  13 +-
 .../backend/src/queue/processors/db/index.ts  |   2 +
 .../processors/db/scheduled-create-note.ts    |  66 +++++++++++
 packages/backend/src/queue/types.ts           |  14 ++-
 .../src/server/api/endpoints/notes/create.ts  | 112 ++++++++++++++----
 packages/backend/src/services/note/create.ts  |   3 +
 packages/client/src/components/MkNote.vue     |  14 ++-
 .../client/src/components/MkNoteHeader.vue    |   3 +-
 packages/client/src/components/MkPostForm.vue |  52 ++++++++
 .../client/src/components/MkVisibility.vue    |  13 +-
 .../client/src/components/global/MkTime.vue   |  26 ++--
 packages/client/src/types/form.ts             |   4 +-
 packages/firefish-js/src/api.types.ts         |   1 +
 packages/firefish-js/src/entities.ts          |   1 +
 22 files changed, 414 insertions(+), 50 deletions(-)
 create mode 100644 packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts
 create mode 100644 packages/backend/src/models/entities/scheduled-note-creation.ts
 create mode 100644 packages/backend/src/queue/processors/db/scheduled-create-note.ts

diff --git a/.gitignore b/.gitignore
index beb0b8df5c..8469fff2e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,7 +40,6 @@ coverage
 
 # misskey
 built
-db
 elasticsearch
 redis
 npm-debug.log
@@ -56,8 +55,6 @@ packages/backend/assets/instance.css
 packages/backend/assets/sounds/None.mp3
 packages/backend/assets/LICENSE
 
-!/packages/backend/queue/processors/db
-!/packages/backend/src/db
 !/packages/backend/src/server/api/endpoints/drive/files
 
 packages/megalodon/lib
diff --git a/locales/en-US.yml b/locales/en-US.yml
index 08fcc490ea..bf7ae0e67a 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1583,6 +1583,16 @@ _ago:
   weeksAgo: "{n}w ago"
   monthsAgo: "{n}mo ago"
   yearsAgo: "{n}y ago"
+_later:
+  future: "Future"
+  justNow: "Immediate"
+  secondsAgo: "{n}s later"
+  minutesAgo: "{n}m later"
+  hoursAgo: "{n}h later"
+  daysAgo: "{n}d later"
+  weeksAgo: "{n}w later"
+  monthsAgo: "{n}mo later"
+  yearsAgo: "{n}y later"
 _time:
   second: "Second(s)"
   minute: "Minute(s)"
@@ -2241,3 +2251,5 @@ incorrectLanguageWarning: "It looks like your post is in {detected}, but you sel
 noteEditHistory: "Post edit history"
 slashQuote: "Chain quote"
 foldNotification: "Group similar notifications"
+scheduledPost: "Scheduled post"
+scheduledDate: "Scheduled date"
diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 2b326c4066..511abb0ff6 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -1230,6 +1230,16 @@ _ago:
   weeksAgo: "{n} 周前"
   monthsAgo: "{n} 月前"
   yearsAgo: "{n} 年前"
+_later:
+  future: "将来"
+  justNow: "马上"
+  secondsAgo: "{n} 秒后"
+  minutesAgo: "{n} 分后"
+  hoursAgo: "{n} 时后"
+  daysAgo: "{n} 天后"
+  weeksAgo: "{n} 周后"
+  monthsAgo: "{n} 月后"
+  yearsAgo: "{n} 年后"
 _time:
   second: "秒"
   minute: "分"
@@ -2068,3 +2078,5 @@ noteEditHistory: "帖子编辑历史"
 media: 媒体
 slashQuote: "斜杠引用"
 foldNotification: "将通知按同类型分组"
+scheduledPost: "定时发送"
+scheduledDate: "发送日期"
diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts
index 360ccfa38c..96059f9567 100644
--- a/packages/backend/src/db/postgre.ts
+++ b/packages/backend/src/db/postgre.ts
@@ -77,6 +77,7 @@ import { NoteFile } from "@/models/entities/note-file.js";
 
 import { entities as charts } from "@/services/chart/entities.js";
 import { dbLogger } from "./logger.js";
+import { ScheduledNoteCreation } from "@/models/entities/scheduled-note-creation.js";
 
 const sqlLogger = dbLogger.createSubLogger("sql", "gray", false);
 
@@ -182,6 +183,7 @@ export const entities = [
 	UserPending,
 	Webhook,
 	UserIp,
+	ScheduledNoteCreation,
 	...charts,
 ];
 
diff --git a/packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts b/packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts
new file mode 100644
index 0000000000..1af13a229b
--- /dev/null
+++ b/packages/backend/src/migration/1714728200194-create-scheduled-note-creation.ts
@@ -0,0 +1,54 @@
+import type { MigrationInterface, QueryRunner } from "typeorm";
+
+export class CreateScheduledNoteCreation1714728200194
+	implements MigrationInterface
+{
+	public async up(queryRunner: QueryRunner): Promise<void> {
+		await queryRunner.query(
+			`CREATE TABLE "scheduled_note_creation" (
+				"id" character varying(32) NOT NULL,
+				"noteId" character varying(32) NOT NULL,
+				"userId" character varying(32) NOT NULL,
+				"scheduledAt" TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+				CONSTRAINT "PK_id_ScheduledNoteCreation" PRIMARY KEY ("id")
+		)`,
+		);
+		await queryRunner.query(`
+			COMMENT ON COLUMN "scheduled_note_creation"."noteId" IS 'The ID of note scheduled.'
+		`);
+		await queryRunner.query(`
+			CREATE INDEX "IDX_noteId_ScheduledNoteCreation" ON "scheduled_note_creation" ("noteId")
+		`);
+		await queryRunner.query(`
+			CREATE INDEX "IDX_userId_ScheduledNoteCreation" ON "scheduled_note_creation" ("userId")
+		`);
+		await queryRunner.query(`
+			ALTER TABLE "scheduled_note_creation"
+			ADD CONSTRAINT "FK_noteId_ScheduledNoteCreation"
+			FOREIGN KEY ("noteId")
+			REFERENCES "note"("id")
+			ON DELETE CASCADE
+			ON UPDATE NO ACTION
+		`);
+		await queryRunner.query(`
+			ALTER TABLE "scheduled_note_creation"
+			ADD CONSTRAINT "FK_userId_ScheduledNoteCreation"
+			FOREIGN KEY ("userId")
+			REFERENCES "user"("id")
+			ON DELETE CASCADE
+			ON UPDATE NO ACTION
+		`);
+	}
+
+	public async down(queryRunner: QueryRunner): Promise<void> {
+		await queryRunner.query(`
+			ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_noteId_ScheduledNoteCreation"
+		`);
+		await queryRunner.query(`
+			ALTER TABLE "scheduled_note_creation" DROP CONSTRAINT "FK_userId_ScheduledNoteCreation"
+		`);
+		await queryRunner.query(`
+			DROP TABLE "scheduled_note_creation"
+		`);
+	}
+}
diff --git a/packages/backend/src/models/entities/scheduled-note-creation.ts b/packages/backend/src/models/entities/scheduled-note-creation.ts
new file mode 100644
index 0000000000..4e3b484326
--- /dev/null
+++ b/packages/backend/src/models/entities/scheduled-note-creation.ts
@@ -0,0 +1,44 @@
+import {
+	Entity,
+	JoinColumn,
+	Column,
+	ManyToOne,
+	PrimaryColumn,
+	Index,
+} from "typeorm";
+import { Note } from "./note.js";
+import { id } from "../id.js";
+import { User } from "./user.js";
+
+@Entity()
+export class ScheduledNoteCreation {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index()
+	@Column({
+		...id(),
+		comment: "The ID of note scheduled.",
+	})
+	public noteId: Note["id"];
+
+	@Index()
+	@Column(id())
+	public userId: User["id"];
+
+	@Column("timestamp without time zone")
+	public scheduledAt: Date;
+
+	//#region Relations
+	@ManyToOne(() => Note, {
+		onDelete: "CASCADE",
+	})
+	@JoinColumn()
+	public note: Note;
+	@ManyToOne(() => User, {
+		onDelete: "CASCADE",
+	})
+	@JoinColumn()
+	public user: User;
+	//#endregion
+}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index c578d9d409..adf73e0571 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -67,6 +67,7 @@ import { Webhook } from "./entities/webhook.js";
 import { UserIp } from "./entities/user-ip.js";
 import { NoteFileRepository } from "./repositories/note-file.js";
 import { NoteEditRepository } from "./repositories/note-edit.js";
+import { ScheduledNoteCreation } from "./entities/scheduled-note-creation.js";
 
 export const Announcements = db.getRepository(Announcement);
 export const AnnouncementReads = db.getRepository(AnnouncementRead);
@@ -135,3 +136,4 @@ export const RegistryItems = db.getRepository(RegistryItem);
 export const Webhooks = db.getRepository(Webhook);
 export const Ads = db.getRepository(Ad);
 export const PasswordResetRequests = db.getRepository(PasswordResetRequest);
+export const ScheduledNoteCreations = db.getRepository(ScheduledNoteCreation);
diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts
index 7fa26373b8..33304e4e6c 100644
--- a/packages/backend/src/models/repositories/note.ts
+++ b/packages/backend/src/models/repositories/note.ts
@@ -11,6 +11,7 @@ import {
 	Polls,
 	Channels,
 	Notes,
+	ScheduledNoteCreations,
 } from "../index.js";
 import type { Packed } from "@/misc/schema.js";
 import { countReactions, decodeReaction, nyaify } from "backend-rs";
@@ -198,6 +199,15 @@ export const NoteRepository = db.getRepository(Note).extend({
 			host,
 		);
 
+		let scheduledAt: string | undefined;
+		if (note.visibility === "specified" && note.visibleUserIds.length === 0) {
+			scheduledAt = (
+				await ScheduledNoteCreations.findOneBy({
+					noteId: note.id,
+				})
+			)?.scheduledAt?.toISOString();
+		}
+
 		const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
 		const packed: Packed<"Note"> = await awaitAll({
 			id: note.id,
@@ -231,6 +241,7 @@ export const NoteRepository = db.getRepository(Note).extend({
 						},
 					})
 				: undefined,
+			scheduledAt,
 			reactions: countReactions(note.reactions),
 			reactionEmojis: reactionEmoji,
 			emojis: noteEmoji,
diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts
index e2fb0febe6..d2fd2ae8e9 100644
--- a/packages/backend/src/queue/index.ts
+++ b/packages/backend/src/queue/index.ts
@@ -24,7 +24,7 @@ import {
 	endedPollNotificationQueue,
 	webhookDeliverQueue,
 } from "./queues.js";
-import type { ThinUser } from "./types.js";
+import type { DbUserScheduledCreateNoteData, ThinUser } from "./types.js";
 import type { Note } from "@/models/entities/note.js";
 
 function renderError(e: Error): any {
@@ -455,6 +455,17 @@ export function createDeleteAccountJob(
 	);
 }
 
+export function createScheduledCreateNoteJob(
+	options: DbUserScheduledCreateNoteData,
+	delay: number,
+) {
+	return dbQueue.add("scheduledCreateNote", options, {
+		delay,
+		removeOnComplete: true,
+		removeOnFail: true,
+	});
+}
+
 export function createDeleteObjectStorageFileJob(key: string) {
 	return objectStorageQueue.add(
 		"deleteFile",
diff --git a/packages/backend/src/queue/processors/db/index.ts b/packages/backend/src/queue/processors/db/index.ts
index d20fc2c71a..351e6fdf49 100644
--- a/packages/backend/src/queue/processors/db/index.ts
+++ b/packages/backend/src/queue/processors/db/index.ts
@@ -16,6 +16,7 @@ import { importMastoPost } from "./import-masto-post.js";
 import { importCkPost } from "./import-firefish-post.js";
 import { importBlocking } from "./import-blocking.js";
 import { importCustomEmojis } from "./import-custom-emojis.js";
+import { scheduledCreateNote } from "./scheduled-create-note.js";
 
 const jobs = {
 	deleteDriveFiles,
@@ -34,6 +35,7 @@ const jobs = {
 	importCkPost,
 	importCustomEmojis,
 	deleteAccount,
+	scheduledCreateNote,
 } as Record<
 	string,
 	| Bull.ProcessCallbackFunction<DbJobData>
diff --git a/packages/backend/src/queue/processors/db/scheduled-create-note.ts b/packages/backend/src/queue/processors/db/scheduled-create-note.ts
new file mode 100644
index 0000000000..4c29b1a061
--- /dev/null
+++ b/packages/backend/src/queue/processors/db/scheduled-create-note.ts
@@ -0,0 +1,66 @@
+import { Users, Notes, ScheduledNoteCreations } from "@/models/index.js";
+import type { DbUserScheduledCreateNoteData } from "@/queue/types.js";
+import { queueLogger } from "../../logger.js";
+import type Bull from "bull";
+import deleteNote from "@/services/note/delete.js";
+import createNote from "@/services/note/create.js";
+import { In } from "typeorm";
+
+const logger = queueLogger.createSubLogger("scheduled-post");
+
+export async function scheduledCreateNote(
+	job: Bull.Job<DbUserScheduledCreateNoteData>,
+	done: () => void,
+): Promise<void> {
+	logger.info("Scheduled creating note...");
+
+	const user = await Users.findOneBy({ id: job.data.user.id });
+	if (user == null) {
+		done();
+		return;
+	}
+
+	const note = await Notes.findOneBy({ id: job.data.noteId });
+	if (note == null) {
+		done();
+		return;
+	}
+
+	if (user.isSuspended) {
+		deleteNote(user, note);
+		done();
+		return;
+	}
+
+	await ScheduledNoteCreations.delete({
+		noteId: note.id,
+		userId: user.id,
+	});
+
+	const visibleUsers = job.data.option.visibleUserIds
+		? await Users.findBy({
+				id: In(job.data.option.visibleUserIds),
+			})
+		: [];
+
+	await createNote(user, {
+		createdAt: new Date(),
+		files: note.files,
+		poll: job.data.option.poll,
+		text: note.text || undefined,
+		lang: note.lang,
+		reply: note.reply,
+		renote: note.renote,
+		cw: note.cw,
+		localOnly: note.localOnly,
+		visibility: job.data.option.visibility,
+		visibleUsers,
+		channel: note.channel,
+	});
+
+	await deleteNote(user, note);
+
+	logger.info("Success");
+
+	done();
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 6383f3fdd5..7af8687e23 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -1,5 +1,6 @@
 import type { DriveFile } from "@/models/entities/drive-file.js";
 import type { Note } from "@/models/entities/note";
+import type { IPoll } from "@/models/entities/poll";
 import type { User } from "@/models/entities/user.js";
 import type { Webhook } from "@/models/entities/webhook";
 import type { IActivity } from "@/remote/activitypub/type.js";
@@ -24,7 +25,8 @@ export type DbJobData =
 	| DbUserImportPostsJobData
 	| DbUserImportJobData
 	| DbUserDeleteJobData
-	| DbUserImportMastoPostJobData;
+	| DbUserImportMastoPostJobData
+	| DbUserScheduledCreateNoteData;
 
 export type DbUserJobData = {
 	user: ThinUser;
@@ -55,6 +57,16 @@ export type DbUserImportMastoPostJobData = {
 	parent: Note | null;
 };
 
+export type DbUserScheduledCreateNoteData = {
+	user: ThinUser;
+	option: {
+		visibility: string;
+		visibleUserIds?: string[] | null;
+		poll?: IPoll;
+	};
+	noteId: Note["id"];
+};
+
 export type ObjectStorageJobData =
 	| ObjectStorageFileJobData
 	| Record<string, unknown>;
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index eb4d9ca5a2..d78bab954f 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -7,6 +7,7 @@ import {
 	Notes,
 	Channels,
 	Blockings,
+	ScheduledNoteCreations,
 } from "@/models/index.js";
 import type { DriveFile } from "@/models/entities/drive-file.js";
 import type { Note } from "@/models/entities/note.js";
@@ -15,9 +16,10 @@ import { config } from "@/config.js";
 import { noteVisibilities } from "@/types.js";
 import { ApiError } from "@/server/api/error.js";
 import define from "@/server/api/define.js";
-import { HOUR } from "backend-rs";
+import { HOUR, genId } from "backend-rs";
 import { getNote } from "@/server/api/common/getters.js";
 import { langmap } from "@/misc/langmap.js";
+import { createScheduledCreateNoteJob } from "@/queue";
 
 export const meta = {
 	tags: ["notes"],
@@ -156,6 +158,7 @@ export const paramDef = {
 			},
 			required: ["choices"],
 		},
+		scheduledAt: { type: "integer", nullable: true },
 	},
 	anyOf: [
 		{
@@ -274,8 +277,20 @@ export default define(meta, paramDef, async (ps, user) => {
 			if (ps.poll.expiresAt < Date.now()) {
 				throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
 			}
+			if (
+				ps.poll.expiresAt &&
+				ps.scheduledAt &&
+				ps.poll.expiresAt < Number(new Date(ps.scheduledAt))
+			) {
+				throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+			}
 		} else if (typeof ps.poll.expiredAfter === "number") {
-			ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
+			if (ps.scheduledAt) {
+				ps.poll.expiresAt =
+					Number(new Date(ps.scheduledAt)) + ps.poll.expiredAfter;
+			} else {
+				ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
+			}
 		}
 	}
 
@@ -288,31 +303,80 @@ export default define(meta, paramDef, async (ps, user) => {
 		}
 	}
 
+	let delay: number | null = null;
+	if (ps.scheduledAt) {
+		delay = Number(ps.scheduledAt) - Number(new Date());
+		if (delay < 0) {
+			delay = null;
+		}
+	}
+
 	// Create a post
-	const note = await create(user, {
-		createdAt: new Date(),
-		files: files,
-		poll: ps.poll
-			? {
-					choices: ps.poll.choices,
-					multiple: ps.poll.multiple,
-					expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
+	const note = await create(
+		user,
+		{
+			createdAt: new Date(),
+			files: files,
+			poll: ps.poll
+				? {
+						choices: ps.poll.choices,
+						multiple: ps.poll.multiple,
+						expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
+					}
+				: undefined,
+			text: ps.text || undefined,
+			lang: ps.lang,
+			reply,
+			renote,
+			cw: ps.cw,
+			localOnly: ps.localOnly,
+			...(delay != null
+				? {
+						visibility: "specified",
+						visibleUsers: [],
+					}
+				: {
+						visibility: ps.visibility,
+						visibleUsers,
+					}),
+			channel,
+			apMentions: ps.noExtractMentions ? [] : undefined,
+			apHashtags: ps.noExtractHashtags ? [] : undefined,
+			apEmojis: ps.noExtractEmojis ? [] : undefined,
+		},
+		false,
+		delay
+			? async (note) => {
+					await ScheduledNoteCreations.insert({
+						id: genId(),
+						noteId: note.id,
+						userId: user.id,
+						scheduledAt: new Date(ps.scheduledAt as number),
+					});
+
+					createScheduledCreateNoteJob(
+						{
+							user: { id: user.id },
+							noteId: note.id,
+							option: {
+								poll: ps.poll
+									? {
+											choices: ps.poll.choices,
+											multiple: ps.poll.multiple,
+											expiresAt: ps.poll.expiresAt
+												? new Date(ps.poll.expiresAt)
+												: null,
+										}
+									: undefined,
+								visibility: ps.visibility,
+								visibleUserIds: ps.visibleUserIds,
+							},
+						},
+						delay,
+					);
 				}
 			: undefined,
-		text: ps.text || undefined,
-		lang: ps.lang,
-		reply,
-		renote,
-		cw: ps.cw,
-		localOnly: ps.localOnly,
-		visibility: ps.visibility,
-		visibleUsers,
-		channel,
-		apMentions: ps.noExtractMentions ? [] : undefined,
-		apHashtags: ps.noExtractHashtags ? [] : undefined,
-		apEmojis: ps.noExtractEmojis ? [] : undefined,
-	});
-
+	);
 	return {
 		createdNote: await Notes.pack(note, user),
 	};
diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 679a2f886e..de162c44ce 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -175,6 +175,7 @@ export default async (
 	},
 	data: Option,
 	silent = false,
+	waitToPublish?: (note: Note) => Promise<void>,
 ) =>
 	// biome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
 	new Promise<Note>(async (res, rej) => {
@@ -356,6 +357,8 @@ export default async (
 
 		res(note);
 
+		if (waitToPublish) await waitToPublish(note);
+
 		// Register host
 		if (Users.isRemoteUser(user)) {
 			registerOrFetchInstanceDoc(user.host).then((i) => {
diff --git a/packages/client/src/components/MkNote.vue b/packages/client/src/components/MkNote.vue
index 3d5d6d59b5..22131de344 100644
--- a/packages/client/src/components/MkNote.vue
+++ b/packages/client/src/components/MkNote.vue
@@ -65,7 +65,8 @@
 							v-if="isMyRenote"
 							:class="icon('ph-dots-three-outline dropdownIcon')"
 						></i>
-						<MkTime :time="note.createdAt" />
+						<MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/>
+						<MkTime v-else :time="note.createdAt" />
 					</button>
 					<MkVisibility :note="note" />
 				</div>
@@ -147,7 +148,8 @@
 						class="created-at"
 						:to="notePage(appearNote)"
 					>
-						<MkTime :time="appearNote.createdAt" mode="absolute" />
+						<MkTime v-if="appearNote.scheduledAt != null" :time="appearNote.scheduledAt"/>
+						<MkTime v-else :time="appearNote.createdAt" mode="absolute" />
 					</MkA>
 					<MkA
 						v-if="appearNote.channel && !inChannel"
@@ -173,6 +175,7 @@
 						v-tooltip.noDelay.bottom="i18n.ts.reply"
 						class="button _button"
 						@click.stop="reply()"
+						:disabled="note.scheduledAt != null"
 					>
 						<i :class="icon('ph-arrow-u-up-left')"></i>
 						<template
@@ -187,6 +190,7 @@
 						:note="appearNote"
 						:count="appearNote.renoteCount"
 						:detailed-view="detailedView"
+						:disabled="note.scheduledAt != null"
 					/>
 					<XStarButtonNoEmoji
 						v-if="!enableEmojiReactions"
@@ -194,6 +198,7 @@
 						:note="appearNote"
 						:count="reactionCount"
 						:reacted="appearNote.myReaction != null"
+						:disabled="note.scheduledAt != null"
 					/>
 					<XStarButton
 						v-if="
@@ -203,6 +208,7 @@
 						ref="starButton"
 						class="button"
 						:note="appearNote"
+						:disabled="note.scheduledAt != null"
 					/>
 					<button
 						v-if="
@@ -213,6 +219,7 @@
 						v-tooltip.noDelay.bottom="i18n.ts.reaction"
 						class="button _button"
 						@click.stop="react()"
+						:disabled="note.scheduledAt != null"
 					>
 						<i :class="icon('ph-smiley')"></i>
 						<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
@@ -226,11 +233,12 @@
 						v-tooltip.noDelay.bottom="i18n.ts.removeReaction"
 						class="button _button reacted"
 						@click.stop="undoReact(appearNote)"
+						:disabled="note.scheduledAt != null"
 					>
 						<i :class="icon('ph-minus')"></i>
 						<p v-if="reactionCount > 0 && hideEmojiViewer" class="count">{{reactionCount}}</p>
 					</button>
-					<XQuoteButton class="button" :note="appearNote" />
+					<XQuoteButton class="button" :note="appearNote" :disabled="note.scheduledAt != null"/>
 					<button
 						v-if="
 							isSignedIn(me) &&
diff --git a/packages/client/src/components/MkNoteHeader.vue b/packages/client/src/components/MkNoteHeader.vue
index 80ee12d9e6..cb87c8df0a 100644
--- a/packages/client/src/components/MkNoteHeader.vue
+++ b/packages/client/src/components/MkNoteHeader.vue
@@ -17,7 +17,8 @@
 			<div>
 				<div class="info">
 					<MkA class="created-at" :to="notePage(note)">
-						<MkTime :time="note.createdAt" />
+						<MkTime v-if="note.scheduledAt != null" :time="note.scheduledAt"/>
+						<MkTime v-else :time="note.createdAt" />
 						<i
 							v-if="note.updatedAt"
 							v-tooltip.noDelay="
diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue
index 78cb6350f7..8a530dc0d9 100644
--- a/packages/client/src/components/MkPostForm.vue
+++ b/packages/client/src/components/MkPostForm.vue
@@ -54,6 +54,15 @@
 						><i :class="icon('ph-eye-slash')"></i
 					></span>
 				</button>
+				<button
+					v-if="editId == null"
+					v-tooltip="i18n.ts.scheduledPost"
+					class="_button schedule"
+					:class="{ active: scheduledAt }"
+					@click="setScheduledAt"
+				>
+					<i :class="icon('ph-clock')"></i>
+				</button>
 				<button
 					ref="languageButton"
 					v-tooltip="i18n.ts.language"
@@ -432,6 +441,7 @@ const recentHashtags = ref(
 	JSON.parse(localStorage.getItem("hashtags") || "[]"),
 );
 const imeText = ref("");
+const scheduledAt = ref<number | null>(null);
 
 const typing = throttle(3000, () => {
 	if (props.channel) {
@@ -772,6 +782,38 @@ function setVisibility() {
 	);
 }
 
+async function setScheduledAt() {
+	function getDateStr(type: "date" | "time", value: number) {
+		const tmp = document.createElement("input");
+		tmp.type = type;
+		tmp.valueAsNumber = value - new Date().getTimezoneOffset() * 60000;
+		return tmp.value;
+	}
+
+	const at = scheduledAt.value ?? Date.now();
+
+	const result = await os.form(i18n.ts.scheduledPost, {
+		at_date: {
+			type: "date",
+			label: i18n.ts.scheduledDate,
+			default: getDateStr("date", at),
+		},
+		at_time: {
+			type: "time",
+			label: i18n.ts._poll.deadlineTime,
+			default: getDateStr("time", at),
+		},
+	});
+
+	if (!result.canceled && result.result) {
+		scheduledAt.value = Number(
+			new Date(`${result.result.at_date}T${result.result.at_time}`),
+		);
+	} else {
+		scheduledAt.value = null;
+	}
+}
+
 const language = ref<string | null>(
 	props.initialLanguage ??
 		defaultStore.state.recentlyUsedPostLanguages[0] ??
@@ -1176,6 +1218,7 @@ async function post() {
 				: visibility.value === "specified"
 					? visibleUsers.value.map((u) => u.id)
 					: undefined,
+		scheduledAt: scheduledAt.value,
 	};
 
 	if (withHashtags.value && hashtags.value && hashtags.value.trim() !== "") {
@@ -1224,6 +1267,7 @@ async function post() {
 				}
 				posting.value = false;
 				postAccount.value = null;
+				scheduledAt.value = null;
 				nextTick(() => autosize.update(textareaEl.value!));
 			});
 		})
@@ -1434,6 +1478,14 @@ onMounted(() => {
 			display: flex;
 			align-items: center;
 
+			> .schedule {
+				width: 34px;
+				height: 34px;
+				&.active {
+					color: var(--accent);
+				}
+			}
+
 			> .text-count {
 				opacity: 0.7;
 				line-height: 66px;
diff --git a/packages/client/src/components/MkVisibility.vue b/packages/client/src/components/MkVisibility.vue
index 1feafb21f1..9903abfd03 100644
--- a/packages/client/src/components/MkVisibility.vue
+++ b/packages/client/src/components/MkVisibility.vue
@@ -10,6 +10,12 @@
 			v-tooltip="i18n.ts._visibility.followers"
 			:class="icon('ph-lock')"
 		></i>
+		<i
+			v-else-if="note.visibility === 'specified' && note.scheduledAt"
+			ref="specified"
+			v-tooltip="`scheduled at ${note.scheduledAt}`"
+			:class="icon('ph-clock')"
+		></i>
 		<i
 			v-else-if="
 				note.visibility === 'specified' &&
@@ -41,13 +47,10 @@ import * as os from "@/os";
 import { useTooltip } from "@/scripts/use-tooltip";
 import { i18n } from "@/i18n";
 import icon from "@/scripts/icon";
+import type { entities } from "firefish-js";
 
 const props = defineProps<{
-	note: {
-		visibility: string;
-		localOnly?: boolean;
-		visibleUserIds?: string[];
-	};
+	note: entities.Note;
 }>();
 
 const specified = ref<HTMLElement>();
diff --git a/packages/client/src/components/global/MkTime.vue b/packages/client/src/components/global/MkTime.vue
index 1f9333cd74..b90179d99f 100644
--- a/packages/client/src/components/global/MkTime.vue
+++ b/packages/client/src/components/global/MkTime.vue
@@ -42,36 +42,42 @@ const relative = computed<string>(() => {
 	if (props.mode === "absolute") return ""; // absoluteではrelativeを使わないので計算しない
 	if (invalid) return i18n.ts._ago.invalid;
 
-	const ago = (now.value - _time) / 1000; /* ms */
+	let ago = (now.value - _time) / 1000; /* ms */
+
+	const agoType = ago > 0 ? "_ago" : "_later";
+	ago = Math.abs(ago);
+
 	return ago >= 31536000
-		? i18n.t("_ago.yearsAgo", { n: Math.floor(ago / 31536000).toString() })
+		? i18n.t(`${agoType}.yearsAgo`, {
+				n: Math.floor(ago / 31536000).toString(),
+			})
 		: ago >= 2592000
-			? i18n.t("_ago.monthsAgo", {
+			? i18n.t(`${agoType}.monthsAgo`, {
 					n: Math.floor(ago / 2592000).toString(),
 				})
 			: ago >= 604800
-				? i18n.t("_ago.weeksAgo", {
+				? i18n.t(`${agoType}.weeksAgo`, {
 						n: Math.floor(ago / 604800).toString(),
 					})
 				: ago >= 86400
-					? i18n.t("_ago.daysAgo", {
+					? i18n.t(`${agoType}.daysAgo`, {
 							n: Math.floor(ago / 86400).toString(),
 						})
 					: ago >= 3600
-						? i18n.t("_ago.hoursAgo", {
+						? i18n.t(`${agoType}.hoursAgo`, {
 								n: Math.floor(ago / 3600).toString(),
 							})
 						: ago >= 60
-							? i18n.t("_ago.minutesAgo", {
+							? i18n.t(`${agoType}.minutesAgo`, {
 									n: (~~(ago / 60)).toString(),
 								})
 							: ago >= 10
-								? i18n.t("_ago.secondsAgo", {
+								? i18n.t(`${agoType}.secondsAgo`, {
 										n: (~~(ago % 60)).toString(),
 									})
 								: ago >= -1
-									? i18n.ts._ago.justNow
-									: i18n.ts._ago.future;
+									? i18n.ts[agoType].justNow
+									: i18n.ts[agoType].future;
 });
 
 let tickId: number;
diff --git a/packages/client/src/types/form.ts b/packages/client/src/types/form.ts
index c5e169c465..4fd283fd7f 100644
--- a/packages/client/src/types/form.ts
+++ b/packages/client/src/types/form.ts
@@ -38,11 +38,11 @@ export type FormItemUrl = BaseFormItem & {
 };
 export type FormItemDate = BaseFormItem & {
 	type: "date";
-	default?: Date | null;
+	default?: string | Date | null;
 };
 export type FormItemTime = BaseFormItem & {
 	type: "time";
-	default?: number | Date | null;
+	default?: string | Date | null;
 };
 export type FormItemSearch = BaseFormItem & {
 	type: "search";
diff --git a/packages/firefish-js/src/api.types.ts b/packages/firefish-js/src/api.types.ts
index 1ee94b9954..fe47460fb8 100644
--- a/packages/firefish-js/src/api.types.ts
+++ b/packages/firefish-js/src/api.types.ts
@@ -69,6 +69,7 @@ export type NoteSubmitReq = {
 		expiredAfter: number | null;
 	};
 	lang?: string;
+	scheduledAt?: number | null;
 };
 
 export type Endpoints = {
diff --git a/packages/firefish-js/src/entities.ts b/packages/firefish-js/src/entities.ts
index 9ab3c2fff6..3c63d26530 100644
--- a/packages/firefish-js/src/entities.ts
+++ b/packages/firefish-js/src/entities.ts
@@ -193,6 +193,7 @@ export type Note = {
 	url?: string;
 	updatedAt?: DateString;
 	isHidden?: boolean;
+	scheduledAt?: DateString;
 	/** if the note is a history */
 	historyId?: ID;
 };