summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-09-26 15:29:52 +0900
committerGitHub <noreply@github.com>2025-09-26 15:29:52 +0900
commitd1446d195abb52c560c7c97177d08103a175acf7 (patch)
tree355689adb542333f4c5abb186ab9819c29274612
parentfix(frontend): ビルド成果物のファイル名にlocalesのhashを含め... (diff)
downloadmisskey-d1446d195abb52c560c7c97177d08103a175acf7.tar.gz
misskey-d1446d195abb52c560c7c97177d08103a175acf7.tar.bz2
misskey-d1446d195abb52c560c7c97177d08103a175acf7.zip
feat: scheduled post (#16577)
* Update NoteDraft.ts * Update NoteDraft.ts * wip * Update CHANGELOG.md * wip * Update PostScheduledNoteProcessorService.ts * Update PostScheduledNoteProcessorService.ts * Update Notification.ts * wip * Update NoteDraftService.ts * Update NoteDraftService.ts * Update NoteDraftService.ts * wip * Create 1758677617888-scheduled-post.js * Update index.d.ts * Update stats.ts * wip * wip * wip * wip * wip * Update MkNotification.vue * wip * wip * wip * Update NoteDraftService.ts * Update NoteDraftService.ts * wip * wip * Update NoteDraftEntityService.ts * wip * Update index.d.ts * Update MkPostForm.vue * wip * wip * wip * Update NoteCreateService.ts * wip * wip * wip * Update NoteDraftEntityService.ts * Update NoteCreateService.ts * Update NoteDraftService.ts * wip * Update NoteDraftService.ts * wip * wip * Update MkPostForm.vue * wip * Update MkPostForm.vue * Update os.ts * wip * Update MkNoteDraftsDialog.vue
-rw-r--r--CHANGELOG.md2
-rw-r--r--locales/index.d.ts48
-rw-r--r--locales/ja-JP.yml12
-rw-r--r--packages/backend/migration/1758677617888-scheduled-post.js24
-rw-r--r--packages/backend/src/core/NoteCreateService.ts170
-rw-r--r--packages/backend/src/core/NoteDraftService.ts166
-rw-r--r--packages/backend/src/core/QueueModule.ts12
-rw-r--r--packages/backend/src/core/QueueService.ts4
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/core/entities/NoteDraftEntityService.ts21
-rw-r--r--packages/backend/src/core/entities/NotificationEntityService.ts13
-rw-r--r--packages/backend/src/models/NoteDraft.ts18
-rw-r--r--packages/backend/src/models/Notification.ts11
-rw-r--r--packages/backend/src/models/json-schema/note-draft.ts31
-rw-r--r--packages/backend/src/models/json-schema/notification.ts30
-rw-r--r--packages/backend/src/models/json-schema/role.ts4
-rw-r--r--packages/backend/src/models/json-schema/user.ts2
-rw-r--r--packages/backend/src/queue/QueueProcessorModule.ts2
-rw-r--r--packages/backend/src/queue/QueueProcessorService.ts20
-rw-r--r--packages/backend/src/queue/const.ts1
-rw-r--r--packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts72
-rw-r--r--packages/backend/src/queue/types.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/queue/stats.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/admin/show-user.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts207
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/create.ts41
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/list.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/update.ts44
-rw-r--r--packages/backend/src/types.ts4
-rw-r--r--packages/frontend/src/components/MkNoteDraftsDialog.vue261
-rw-r--r--packages/frontend/src/components/MkNotification.vue23
-rw-r--r--packages/frontend/src/components/MkPostForm.vue121
-rw-r--r--packages/frontend/src/os.ts8
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue21
-rw-r--r--packages/frontend/src/pages/admin/roles.vue7
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md6
-rw-r--r--packages/misskey-js/src/autogen/types.ts177
-rw-r--r--packages/misskey-js/src/consts.ts4
39 files changed, 1125 insertions, 483 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3672772665..66837034ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
- pnpm 10.16.0 が必要です
### General
+- Feat: 予約投稿ができるようになりました
+ - デフォルトで作成可能数は1になっています。適宜ロールのポリシーで設定を行ってください。
- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
### Client
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 4071d5c373..43e7d6e2a8 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5287,6 +5287,10 @@ export interface Locale extends ILocale {
*/
"draft": string;
/**
+ * 下書きと予約投稿
+ */
+ "draftsAndScheduledNotes": string;
+ /**
* リアクションする際に確認する
*/
"confirmOnReact": string;
@@ -5553,6 +5557,26 @@ export interface Locale extends ILocale {
* ユーザー指定ノートを作成
*/
"createUserSpecifiedNote": string;
+ /**
+ * 投稿を予約
+ */
+ "schedulePost": string;
+ /**
+ * {x}に投稿を予約します
+ */
+ "scheduleToPostOnX": ParameterizedString<"x">;
+ /**
+ * {x}に投稿が予約されています
+ */
+ "scheduledToPostOnX": ParameterizedString<"x">;
+ /**
+ * 予約
+ */
+ "schedule": string;
+ /**
+ * 予約
+ */
+ "scheduled": string;
"_compression": {
"_quality": {
/**
@@ -7934,6 +7958,10 @@ export interface Locale extends ILocale {
*/
"noteDraftLimit": string;
/**
+ * 予約投稿の同時作成可能数
+ */
+ "scheduledNoteLimit": string;
+ /**
* ウォーターマーク機能の使用可否
*/
"watermarkAvailable": string;
@@ -10329,6 +10357,14 @@ export interface Locale extends ILocale {
*/
"pollEnded": string;
/**
+ * 予約ノートが投稿されました
+ */
+ "scheduledNotePosted": string;
+ /**
+ * 予約ノートの投稿に失敗しました
+ */
+ "scheduledNotePostFailed": string;
+ /**
* 新しい投稿
*/
"newNote": string;
@@ -12641,6 +12677,18 @@ export interface Locale extends ILocale {
* 下書き一覧
*/
"listDrafts": string;
+ /**
+ * 投稿予約
+ */
+ "schedule": string;
+ /**
+ * 予約投稿一覧
+ */
+ "listScheduledNotes": string;
+ /**
+ * 予約解除
+ */
+ "cancelSchedule": string;
};
/**
* 二次元コード
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index c0e598ef7b..e193abeb09 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1317,6 +1317,7 @@ acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
draft: "下書き"
+draftsAndScheduledNotes: "下書きと予約投稿"
confirmOnReact: "リアクションする際に確認する"
reactAreYouSure: "\" {emoji} \" をリアクションしますか?"
markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?"
@@ -1383,6 +1384,11 @@ customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カ
themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!"
createUserSpecifiedNote: "ユーザー指定ノートを作成"
+schedulePost: "投稿を予約"
+scheduleToPostOnX: "{x}に投稿を予約します"
+scheduledToPostOnX: "{x}に投稿が予約されています"
+schedule: "予約"
+scheduled: "予約"
_compression:
_quality:
@@ -2056,6 +2062,7 @@ _role:
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
+ scheduledNoteLimit: "予約投稿の同時作成可能数"
watermarkAvailable: "ウォーターマーク機能の使用可否"
_condition:
roleAssignedTo: "マニュアルロールにアサイン済み"
@@ -2728,6 +2735,8 @@ _notification:
youReceivedFollowRequest: "フォローリクエストが来ました"
yourFollowRequestAccepted: "フォローリクエストが承認されました"
pollEnded: "アンケートの結果が出ました"
+ scheduledNotePosted: "予約ノートが投稿されました"
+ scheduledNotePostFailed: "予約ノートの投稿に失敗しました"
newNote: "新しい投稿"
unreadAntennaNote: "アンテナ {name}"
roleAssigned: "ロールが付与されました"
@@ -3385,6 +3394,9 @@ _drafts:
restoreFromDraft: "下書きから復元"
restore: "復元"
listDrafts: "下書き一覧"
+ schedule: "投稿予約"
+ listScheduledNotes: "予約投稿一覧"
+ cancelSchedule: "予約解除"
qr: "二次元コード"
_qr:
diff --git a/packages/backend/migration/1758677617888-scheduled-post.js b/packages/backend/migration/1758677617888-scheduled-post.js
new file mode 100644
index 0000000000..b31313d9db
--- /dev/null
+++ b/packages/backend/migration/1758677617888-scheduled-post.js
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class ScheduledPost1758677617888 {
+ name = 'ScheduledPost1758677617888'
+
+ /**
+ * @param {QueryRunner} queryRunner
+ */
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note_draft" ADD "scheduledAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`ALTER TABLE "note_draft" ADD "isActuallyScheduled" boolean NOT NULL DEFAULT false`);
+ }
+
+ /**
+ * @param {QueryRunner} queryRunner
+ */
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "isActuallyScheduled"`);
+ await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "scheduledAt"`);
+ }
+}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 1eefcfa054..b6acf4c5fb 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
-import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js';
+import { isQuote, isRenote } from '@/misc/is-renote.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
@@ -222,6 +229,167 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
+ public async fetchAndCreate(user: {
+ id: MiUser['id'];
+ username: MiUser['username'];
+ host: MiUser['host'];
+ isBot: MiUser['isBot'];
+ isCat: MiUser['isCat'];
+ }, data: {
+ createdAt: Date;
+ replyId: MiNote['id'] | null;
+ renoteId: MiNote['id'] | null;
+ fileIds: MiDriveFile['id'][];
+ text: string | null;
+ cw: string | null;
+ visibility: string;
+ visibleUserIds: MiUser['id'][];
+ channelId: MiChannel['id'] | null;
+ localOnly: boolean;
+ reactionAcceptance: MiNote['reactionAcceptance'];
+ poll: IPoll | null;
+ apMentions?: MinimumUser[] | null;
+ apHashtags?: string[] | null;
+ apEmojis?: string[] | null;
+ }): Promise<MiNote> {
+ const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({
+ id: In(data.visibleUserIds),
+ }) : [];
+
+ let files: MiDriveFile[] = [];
+ if (data.fileIds.length > 0) {
+ files = await this.driveFilesRepository.createQueryBuilder('file')
+ .where('file.userId = :userId AND file.id IN (:...fileIds)', {
+ userId: user.id,
+ fileIds: data.fileIds,
+ })
+ .orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
+ .setParameters({ fileIds: data.fileIds })
+ .getMany();
+
+ if (files.length !== data.fileIds.length) {
+ throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file');
+ }
+ }
+
+ let renote: MiNote | null = null;
+ if (data.renoteId != null) {
+ // Fetch renote to note
+ renote = await this.notesRepository.findOne({
+ where: { id: data.renoteId },
+ relations: ['user', 'renote', 'reply'],
+ });
+
+ if (renote == null) {
+ throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target');
+ } else if (isRenote(renote) && !isQuote(renote)) {
+ throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote');
+ }
+
+ // Check blocking
+ if (renote.userId !== user.id) {
+ const blockExist = await this.blockingsRepository.exists({
+ where: {
+ blockerId: renote.userId,
+ blockeeId: user.id,
+ },
+ });
+ if (blockExist) {
+ throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user');
+ }
+ }
+
+ if (renote.visibility === 'followers' && renote.userId !== user.id) {
+ // 他人のfollowers noteはreject
+ throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility');
+ } else if (renote.visibility === 'specified') {
+ // specified / direct noteはreject
+ throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility');
+ }
+
+ if (renote.channelId && renote.channelId !== data.channelId) {
+ // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
+ // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
+ const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
+ if (renoteChannel == null) {
+ // リノートしたいノートが書き込まれているチャンネルが無い
+ throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel');
+ } else if (!renoteChannel.allowRenoteToExternal) {
+ // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
+ throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external');
+ }
+ }
+ }
+
+ let reply: MiNote | null = null;
+ if (data.replyId != null) {
+ // Fetch reply
+ reply = await this.notesRepository.findOne({
+ where: { id: data.replyId },
+ relations: ['user'],
+ });
+
+ if (reply == null) {
+ throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target');
+ } else if (isRenote(reply) && !isQuote(reply)) {
+ throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote');
+ } else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) {
+ throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target');
+ } else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
+ throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility');
+ }
+
+ // Check blocking
+ if (reply.userId !== user.id) {
+ const blockExist = await this.blockingsRepository.exists({
+ where: {
+ blockerId: reply.userId,
+ blockeeId: user.id,
+ },
+ });
+ if (blockExist) {
+ throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user');
+ }
+ }
+ }
+
+ if (data.poll) {
+ if (data.poll.expiresAt != null) {
+ if (data.poll.expiresAt.getTime() < Date.now()) {
+ throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time');
+ }
+ }
+ }
+
+ let channel: MiChannel | null = null;
+ if (data.channelId != null) {
+ channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
+
+ if (channel == null) {
+ throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel');
+ }
+ }
+
+ return this.create(user, {
+ createdAt: data.createdAt,
+ files: files,
+ poll: data.poll,
+ text: data.text,
+ reply,
+ renote,
+ cw: data.cw,
+ localOnly: data.localOnly,
+ reactionAcceptance: data.reactionAcceptance,
+ visibility: data.visibility,
+ visibleUsers,
+ channel,
+ apMentions: data.apMentions,
+ apHashtags: data.apHashtags,
+ apEmojis: data.apEmojis,
+ });
+ }
+
+ @bindThis
public async create(user: {
id: MiUser['id'];
username: MiUser['username'];
diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts
index c43be96efa..7666407c1e 100644
--- a/packages/backend/src/core/NoteDraftService.ts
+++ b/packages/backend/src/core/NoteDraftService.ts
@@ -5,32 +5,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
-import type { noteVisibilities, noteReactionAcceptances } from '@/types.js';
import { DI } from '@/di-symbols.js';
import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
-import { IPoll } from '@/models/Poll.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRenote, isQuote } from '@/misc/is-renote.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
+import { QueueService } from '@/core/QueueService.js';
-export type NoteDraftOptions = {
- replyId?: MiNote['id'] | null;
- renoteId?: MiNote['id'] | null;
- text?: string | null;
- cw?: string | null;
- localOnly?: boolean | null;
- reactionAcceptance?: typeof noteReactionAcceptances[number];
- visibility?: typeof noteVisibilities[number];
- fileIds?: MiDriveFile['id'][];
- visibleUserIds?: MiUser['id'][];
- hashtag?: string;
- channelId?: MiChannel['id'] | null;
- poll?: (IPoll & { expiredAfter?: number | null }) | null;
-};
+export type NoteDraftOptions = Omit<MiNoteDraft, 'id' | 'userId' | 'user' | 'reply' | 'renote' | 'channel'>;
@Injectable()
export class NoteDraftService {
@@ -56,6 +42,7 @@ export class NoteDraftService {
private roleService: RoleService,
private idService: IdService,
private noteEntityService: NoteEntityService,
+ private queueService: QueueService,
) {
}
@@ -72,36 +59,43 @@ export class NoteDraftService {
@bindThis
public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> {
//#region check draft limit
+ const policies = await this.roleService.getUserPolicies(me.id);
const currentCount = await this.noteDraftsRepository.countBy({
userId: me.id,
});
- if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) {
+ if (currentCount >= policies.noteDraftLimit) {
throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts');
}
- //#endregion
- if (data.poll) {
- if (typeof data.poll.expiresAt === 'number') {
- if (data.poll.expiresAt < Date.now()) {
- throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
- }
- } else if (typeof data.poll.expiredAfter === 'number') {
- data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
+ if (data.isActuallyScheduled) {
+ const currentScheduledCount = await this.noteDraftsRepository.countBy({
+ userId: me.id,
+ isActuallyScheduled: true,
+ });
+ if (currentScheduledCount >= policies.scheduledNoteLimit) {
+ throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes');
}
}
+ //#endregion
- const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data);
+ await this.validate(me, data);
- appliedDraft.id = this.idService.gen();
- appliedDraft.userId = me.id;
- const draft = this.noteDraftsRepository.save(appliedDraft);
+ const draft = await this.noteDraftsRepository.insertOne({
+ ...data,
+ id: this.idService.gen(),
+ userId: me.id,
+ });
+
+ if (draft.scheduledAt && draft.isActuallyScheduled) {
+ this.schedule(draft);
+ }
return draft;
}
@bindThis
- public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise<MiNoteDraft> {
+ public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial<NoteDraftOptions>): Promise<MiNoteDraft> {
const draft = await this.noteDraftsRepository.findOneBy({
id: draftId,
userId: me.id,
@@ -111,19 +105,36 @@ export class NoteDraftService {
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
}
- if (data.poll) {
- if (typeof data.poll.expiresAt === 'number') {
- if (data.poll.expiresAt < Date.now()) {
- throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
- }
- } else if (typeof data.poll.expiredAfter === 'number') {
- data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
+ //#region check draft limit
+ const policies = await this.roleService.getUserPolicies(me.id);
+
+ if (!draft.isActuallyScheduled && data.isActuallyScheduled) {
+ const currentScheduledCount = await this.noteDraftsRepository.countBy({
+ userId: me.id,
+ isActuallyScheduled: true,
+ });
+ if (currentScheduledCount >= policies.scheduledNoteLimit) {
+ throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes');
}
}
+ //#endregion
+
+ await this.validate(me, data);
- const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data);
+ const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update()
+ .set(data)
+ .where('id = :id', { id: draftId })
+ .returning('*')
+ .execute()
+ .then((response) => response.raw[0]);
- return await this.noteDraftsRepository.save(appliedDraft);
+ this.clearSchedule(draftId).then(() => {
+ if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) {
+ this.schedule(updatedDraft);
+ }
+ });
+
+ return updatedDraft;
}
@bindThis
@@ -138,6 +149,8 @@ export class NoteDraftService {
}
await this.noteDraftsRepository.delete(draft.id);
+
+ this.clearSchedule(draftId);
}
@bindThis
@@ -154,27 +167,20 @@ export class NoteDraftService {
return draft;
}
- // 関連エンティティを取得し紐づける部分を共通化する
@bindThis
- public async checkAndSetDraftNoteOptions(
+ public async validate(
me: MiLocalUser,
- draft: MiNoteDraft,
- data: NoteDraftOptions,
- ): Promise<MiNoteDraft> {
- data.visibility ??= 'public';
- data.localOnly ??= false;
- if (data.reactionAcceptance === undefined) data.reactionAcceptance = null;
- if (data.channelId != null) {
- data.visibility = 'public';
- data.visibleUserIds = [];
- data.localOnly = true;
+ data: Partial<NoteDraftOptions>,
+ ): Promise<void> {
+ if (data.pollExpiresAt != null) {
+ if (data.pollExpiresAt.getTime() < Date.now()) {
+ throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
+ }
}
- let appliedDraft = draft;
-
//#region visibleUsers
let visibleUsers: MiUser[] = [];
- if (data.visibleUserIds != null) {
+ if (data.visibleUserIds != null && data.visibleUserIds.length > 0) {
visibleUsers = await this.usersRepository.findBy({
id: In(data.visibleUserIds),
});
@@ -184,7 +190,7 @@ export class NoteDraftService {
//#region files
let files: MiDriveFile[] = [];
const fileIds = data.fileIds ?? null;
- if (fileIds != null) {
+ if (fileIds != null && fileIds.length > 0) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
@@ -288,27 +294,37 @@ export class NoteDraftService {
}
}
//#endregion
+ }
- appliedDraft = {
- ...appliedDraft,
- visibility: data.visibility,
- cw: data.cw ?? null,
- fileIds: fileIds ?? [],
- replyId: data.replyId ?? null,
- renoteId: data.renoteId ?? null,
- channelId: data.channelId ?? null,
- text: data.text ?? null,
- hashtag: data.hashtag ?? null,
- hasPoll: data.poll != null,
- pollChoices: data.poll ? data.poll.choices : [],
- pollMultiple: data.poll ? data.poll.multiple : false,
- pollExpiresAt: data.poll ? data.poll.expiresAt : null,
- pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null,
- visibleUserIds: data.visibleUserIds ?? [],
- localOnly: data.localOnly,
- reactionAcceptance: data.reactionAcceptance,
- } satisfies MiNoteDraft;
+ @bindThis
+ public async schedule(draft: MiNoteDraft): Promise<void> {
+ if (!draft.isActuallyScheduled) return;
+ if (draft.scheduledAt == null) return;
+ if (draft.scheduledAt.getTime() <= Date.now()) return;
+
+ const delay = draft.scheduledAt.getTime() - Date.now();
+ this.queueService.postScheduledNoteQueue.add(draft.id, {
+ noteDraftId: draft.id,
+ }, {
+ delay,
+ removeOnComplete: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 30,
+ },
+ removeOnFail: {
+ age: 3600 * 24 * 7, // keep up to 7 days
+ count: 100,
+ },
+ });
+ }
- return appliedDraft;
+ @bindThis
+ public async clearSchedule(draftId: MiNoteDraft['id']): Promise<void> {
+ const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']);
+ for (const job of jobs) {
+ if (job.data.noteDraftId === draftId) {
+ await job.remove();
+ }
+ }
}
}
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index b10b8e5899..ecd96261e0 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -16,11 +16,13 @@ import {
RelationshipJobData,
UserWebhookDeliverJobData,
SystemWebhookDeliverJobData,
+ PostScheduledNoteJobData,
} from '../queue/types.js';
import type { Provider } from '@nestjs/common';
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
+export type PostScheduledNoteQueue = Bull.Queue<PostScheduledNoteJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue;
@@ -41,6 +43,12 @@ const $endedPollNotification: Provider = {
inject: [DI.config],
};
+const $postScheduledNote: Provider = {
+ provide: 'queue:postScheduledNote',
+ useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
+ inject: [DI.config],
+};
+
const $deliver: Provider = {
provide: 'queue:deliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = {
providers: [
$system,
$endedPollNotification,
+ $postScheduledNote,
$deliver,
$inbox,
$db,
@@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = {
exports: [
$system,
$endedPollNotification,
+ $postScheduledNote,
$deliver,
$inbox,
$db,
@@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown {
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown {
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
+ this.postScheduledNoteQueue.close(),
this.deliverQueue.close(),
this.inboxQueue.close(),
this.dbQueue.close(),
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 2d0e7b5d83..42782167bb 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -31,6 +31,7 @@ import type {
DbQueue,
DeliverQueue,
EndedPollNotificationQueue,
+ PostScheduledNoteQueue,
InboxQueue,
ObjectStorageQueue,
RelationshipQueue,
@@ -44,6 +45,7 @@ import type * as Bull from 'bullmq';
export const QUEUE_TYPES = [
'system',
'endedPollNotification',
+ 'postScheduledNote',
'deliver',
'inbox',
'db',
@@ -92,6 +94,7 @@ export class QueueService {
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@@ -717,6 +720,7 @@ export class QueueService {
switch (type) {
case 'system': return this.systemQueue;
case 'endedPollNotification': return this.endedPollNotificationQueue;
+ case 'postScheduledNote': return this.postScheduledNoteQueue;
case 'deliver': return this.deliverQueue;
case 'inbox': return this.inboxQueue;
case 'db': return this.dbQueue;
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 9e1d9cc370..6e4ac66e81 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -69,6 +69,7 @@ export type RolePolicies = {
chatAvailability: 'available' | 'readonly' | 'unavailable';
uploadableFileTypes: string[];
noteDraftLimit: number;
+ scheduledNoteLimit: number;
watermarkAvailable: boolean;
};
@@ -116,6 +117,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
'audio/*',
],
noteDraftLimit: 10,
+ scheduledNoteLimit: 1,
watermarkAvailable: true,
};
@@ -440,6 +442,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return [...set];
}),
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
+ scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)),
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
};
}
diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts
index 3ef8cdaa12..71e41a588d 100644
--- a/packages/backend/src/core/entities/NoteDraftEntityService.ts
+++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts
@@ -105,6 +105,8 @@ export class NoteDraftEntityService implements OnModuleInit {
const packed: Packed<'NoteDraft'> = await awaitAll({
id: noteDraft.id,
createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
+ scheduledAt: noteDraft.scheduledAt?.getTime() ?? null,
+ isActuallyScheduled: noteDraft.isActuallyScheduled,
userId: noteDraft.userId,
user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me),
text: text,
@@ -112,13 +114,13 @@ export class NoteDraftEntityService implements OnModuleInit {
visibility: noteDraft.visibility,
localOnly: noteDraft.localOnly,
reactionAcceptance: noteDraft.reactionAcceptance,
- visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined,
- hashtag: noteDraft.hashtag ?? undefined,
+ visibleUserIds: noteDraft.visibleUserIds,
+ hashtag: noteDraft.hashtag,
fileIds: noteDraft.fileIds,
files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds),
replyId: noteDraft.replyId,
renoteId: noteDraft.renoteId,
- channelId: noteDraft.channelId ?? undefined,
+ channelId: noteDraft.channelId,
channel: channel ? {
id: channel.id,
name: channel.name,
@@ -127,6 +129,12 @@ export class NoteDraftEntityService implements OnModuleInit {
allowRenoteToExternal: channel.allowRenoteToExternal,
userId: channel.userId,
} : undefined,
+ poll: noteDraft.hasPoll ? {
+ choices: noteDraft.pollChoices,
+ multiple: noteDraft.pollMultiple,
+ expiresAt: noteDraft.pollExpiresAt?.toISOString(),
+ expiredAfter: noteDraft.pollExpiredAfter,
+ } : null,
...(opts.detail ? {
reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, {
@@ -138,13 +146,6 @@ export class NoteDraftEntityService implements OnModuleInit {
detail: true,
skipHide: opts.skipHide,
})) : undefined,
-
- poll: noteDraft.hasPoll ? {
- choices: noteDraft.pollChoices,
- multiple: noteDraft.pollMultiple,
- expiresAt: noteDraft.pollExpiresAt?.toISOString(),
- expiredAfter: noteDraft.pollExpiredAfter,
- } : undefined,
} : {} ),
});
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index e91fb9eb51..0e96237d32 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -21,7 +21,18 @@ 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'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set([
+ 'note',
+ 'mention',
+ 'reply',
+ 'renote',
+ 'renote:grouped',
+ 'quote',
+ 'reaction',
+ 'reaction:grouped',
+ 'pollEnded',
+ 'scheduledNotePosted',
+] as (typeof groupedNotificationTypes[number])[]);
@Injectable()
export class NotificationEntityService implements OnModuleInit {
diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts
index 6483748bc2..0ece02c943 100644
--- a/packages/backend/src/models/NoteDraft.ts
+++ b/packages/backend/src/models/NoteDraft.ts
@@ -126,7 +126,7 @@ export class MiNoteDraft {
@JoinColumn()
public channel: MiChannel | null;
- // 以下、Pollについて追加
+ //#region 以下、Pollについて追加
@Column('boolean', {
default: false,
@@ -151,13 +151,15 @@ export class MiNoteDraft {
})
public pollExpiredAfter: number | null;
- // ここまで追加
+ //#endregion
- constructor(data: Partial<MiNoteDraft>) {
- if (data == null) return;
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public scheduledAt: Date | null;
- for (const [k, v] of Object.entries(data)) {
- (this as any)[k] = v;
- }
- }
+ @Column('boolean', {
+ default: false,
+ })
+ public isActuallyScheduled: boolean;
}
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index 0b4eeb3455..7fa17e20fa 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -9,6 +9,7 @@ import { MiNote } from './Note.js';
import { MiAccessToken } from './AccessToken.js';
import { MiRole } from './Role.js';
import { MiDriveFile } from './DriveFile.js';
+import { MiNoteDraft } from './NoteDraft.js';
// misskey-js の notificationTypes と同期すべし
export type MiNotification = {
@@ -61,6 +62,16 @@ export type MiNotification = {
notifierId: MiUser['id'];
noteId: MiNote['id'];
} | {
+ type: 'scheduledNotePosted';
+ id: string;
+ createdAt: string;
+ noteId: MiNote['id'];
+} | {
+ type: 'scheduledNotePostFailed';
+ id: string;
+ createdAt: string;
+ noteDraftId: MiNoteDraft['id'];
+} | {
type: 'receiveFollowRequest';
id: string;
createdAt: string;
diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts
index 504b263a6d..8144ac7b3b 100644
--- a/packages/backend/src/models/json-schema/note-draft.ts
+++ b/packages/backend/src/models/json-schema/note-draft.ts
@@ -23,7 +23,7 @@ export const packedNoteDraftSchema = {
},
cw: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
},
userId: {
type: 'string',
@@ -37,27 +37,23 @@ export const packedNoteDraftSchema = {
},
replyId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
- example: 'xxxxxxxxxx',
},
renoteId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
- example: 'xxxxxxxxxx',
},
reply: {
type: 'object',
optional: true, nullable: true,
ref: 'Note',
- description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.',
},
renote: {
type: 'object',
optional: true, nullable: true,
ref: 'Note',
- description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.',
},
visibility: {
type: 'string',
@@ -66,7 +62,7 @@ export const packedNoteDraftSchema = {
},
visibleUserIds: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
@@ -75,7 +71,7 @@ export const packedNoteDraftSchema = {
},
fileIds: {
type: 'array',
- optional: true, nullable: false,
+ optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
@@ -93,11 +89,11 @@ export const packedNoteDraftSchema = {
},
hashtag: {
type: 'string',
- optional: true, nullable: false,
+ optional: false, nullable: true,
},
poll: {
type: 'object',
- optional: true, nullable: true,
+ optional: false, nullable: true,
properties: {
expiresAt: {
type: 'string',
@@ -124,9 +120,8 @@ export const packedNoteDraftSchema = {
},
channelId: {
type: 'string',
- optional: true, nullable: true,
+ optional: false, nullable: true,
format: 'id',
- example: 'xxxxxxxxxx',
},
channel: {
type: 'object',
@@ -160,12 +155,20 @@ export const packedNoteDraftSchema = {
},
localOnly: {
type: 'boolean',
- optional: true, nullable: false,
+ optional: false, nullable: false,
},
reactionAcceptance: {
type: 'string',
optional: false, nullable: true,
enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null],
},
+ scheduledAt: {
+ type: 'number',
+ optional: false, nullable: true,
+ },
+ isActuallyScheduled: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 6de120c8d7..30e9c9327a 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -214,6 +214,36 @@ export const packedNotificationSchema = {
type: {
type: 'string',
optional: false, nullable: false,
+ enum: ['scheduledNotePosted'],
+ },
+ note: {
+ type: 'object',
+ ref: 'Note',
+ optional: false, nullable: false,
+ },
+ },
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['scheduledNotePostFailed'],
+ },
+ noteDraft: {
+ type: 'object',
+ ref: 'NoteDraft',
+ optional: false, nullable: false,
+ },
+ },
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
enum: ['follow'],
},
user: {
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 0b9234cb81..b9000152d4 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -317,6 +317,10 @@ export const packedRolePoliciesSchema = {
type: 'integer',
optional: false, nullable: false,
},
+ scheduledNoteLimit: {
+ type: 'integer',
+ optional: false, nullable: false,
+ },
watermarkAvailable: {
type: 'boolean',
optional: false, nullable: false,
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index c507d8d5c6..b5fd38a7d7 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -609,6 +609,8 @@ export const packedMeDetailedOnlySchema = {
quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index e01414cd53..e64882c4df 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
+import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
@@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
UserWebhookDeliverProcessorService,
SystemWebhookDeliverProcessorService,
EndedPollNotificationProcessorService,
+ PostScheduledNoteProcessorService,
DeliverProcessorService,
InboxProcessorService,
AggregateRetentionProcessorService,
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index 7b64182754..642d3fc8ad 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -14,6 +14,7 @@ import { CheckModeratorsActivityProcessorService } from '@/queue/processors/Chec
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
+import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
@@ -85,6 +86,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private relationshipQueueWorker: Bull.Worker;
private objectStorageQueueWorker: Bull.Worker;
private endedPollNotificationQueueWorker: Bull.Worker;
+ private postScheduledNoteQueueWorker: Bull.Worker;
constructor(
@Inject(DI.config)
@@ -94,6 +96,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
+ private postScheduledNoteProcessorService: PostScheduledNoteProcessorService,
private deliverProcessorService: DeliverProcessorService,
private inboxProcessorService: InboxProcessorService,
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
@@ -520,6 +523,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
});
}
//#endregion
+
+ //#region post scheduled note
+ {
+ this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
+ if (this.config.sentryForBackend) {
+ return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
+ } else {
+ return this.postScheduledNoteProcessorService.process(job);
+ }
+ }, {
+ ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
+ autorun: false,
+ });
+ }
+ //#endregion
}
@bindThis
@@ -534,6 +552,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.run(),
this.objectStorageQueueWorker.run(),
this.endedPollNotificationQueueWorker.run(),
+ this.postScheduledNoteQueueWorker.run(),
]);
}
@@ -549,6 +568,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
this.relationshipQueueWorker.close(),
this.objectStorageQueueWorker.close(),
this.endedPollNotificationQueueWorker.close(),
+ this.postScheduledNoteQueueWorker.close(),
]);
}
diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts
index 7e146a7e03..625204b7ad 100644
--- a/packages/backend/src/queue/const.ts
+++ b/packages/backend/src/queue/const.ts
@@ -12,6 +12,7 @@ export const QUEUE = {
INBOX: 'inbox',
SYSTEM: 'system',
ENDED_POLL_NOTIFICATION: 'endedPollNotification',
+ POST_SCHEDULED_NOTE: 'postScheduledNote',
DB: 'db',
RELATIONSHIP: 'relationship',
OBJECT_STORAGE: 'objectStorage',
diff --git a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts
new file mode 100644
index 0000000000..d0eaeee090
--- /dev/null
+++ b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts
@@ -0,0 +1,72 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { NoteDraftsRepository } from '@/models/_.js';
+import type Logger from '@/logger.js';
+import { NotificationService } from '@/core/NotificationService.js';
+import { bindThis } from '@/decorators.js';
+import { NoteCreateService } from '@/core/NoteCreateService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type * as Bull from 'bullmq';
+import type { PostScheduledNoteJobData } from '../types.js';
+
+@Injectable()
+export class PostScheduledNoteProcessorService {
+ private logger: Logger;
+
+ constructor(
+ @Inject(DI.noteDraftsRepository)
+ private noteDraftsRepository: NoteDraftsRepository,
+
+ private noteCreateService: NoteCreateService,
+ private notificationService: NotificationService,
+ private queueLoggerService: QueueLoggerService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('post-scheduled-note');
+ }
+
+ @bindThis
+ public async process(job: Bull.Job<PostScheduledNoteJobData>): Promise<void> {
+ const draft = await this.noteDraftsRepository.findOne({ where: { id: job.data.noteDraftId }, relations: ['user'] });
+ if (draft == null || draft.user == null || draft.scheduledAt == null || !draft.isActuallyScheduled) {
+ return;
+ }
+
+ try {
+ const note = await this.noteCreateService.fetchAndCreate(draft.user, {
+ createdAt: new Date(),
+ fileIds: draft.fileIds,
+ poll: draft.hasPoll ? {
+ choices: draft.pollChoices,
+ multiple: draft.pollMultiple,
+ expiresAt: draft.pollExpiredAfter ? new Date(Date.now() + draft.pollExpiredAfter) : draft.pollExpiresAt ? new Date(draft.pollExpiresAt) : null,
+ } : null,
+ text: draft.text ?? null,
+ replyId: draft.replyId,
+ renoteId: draft.renoteId,
+ cw: draft.cw,
+ localOnly: draft.localOnly,
+ reactionAcceptance: draft.reactionAcceptance,
+ visibility: draft.visibility,
+ visibleUserIds: draft.visibleUserIds,
+ channelId: draft.channelId,
+ });
+
+ // await不要
+ this.noteDraftsRepository.remove(draft);
+
+ // await不要
+ this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', {
+ noteId: note.id,
+ });
+ } catch (err) {
+ this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', {
+ noteDraftId: draft.id,
+ });
+ }
+ }
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 757daea88b..1cb2b93918 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -109,6 +109,10 @@ export type EndedPollNotificationJobData = {
noteId: MiNote['id'];
};
+export type PostScheduledNoteJobData = {
+ noteDraftId: string;
+};
+
export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = {
type: T;
content: SystemWebhookPayload<T>;
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..b69699c338 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, PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
export const meta = {
tags: ['admin'],
@@ -49,6 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
+ @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 1ba6853dbe..2fd7ab8ca2 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -103,6 +103,8 @@ export const meta = {
quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
+ scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 082d97f5d4..5c7958fc1c 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -209,6 +209,8 @@ export const paramDef = {
quote: notificationRecieveConfig,
reaction: notificationRecieveConfig,
pollEnded: notificationRecieveConfig,
+ scheduledNotePosted: notificationRecieveConfig,
+ scheduledNotePostFailed: notificationRecieveConfig,
receiveFollowRequest: notificationRecieveConfig,
followRequestAccepted: notificationRecieveConfig,
roleAssigned: notificationRecieveConfig,
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index 7caea8eedc..e48aa69d0f 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -6,17 +6,10 @@
import ms from 'ms';
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import type { MiUser } from '@/models/User.js';
-import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } 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 { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
-import { DI } from '@/di-symbols.js';
-import { isQuote, isRenote } from '@/misc/is-renote.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
@@ -223,168 +216,28 @@ export const paramDef = {
@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.blockingsRepository)
- private blockingsRepository: BlockingsRepository,
-
- @Inject(DI.driveFilesRepository)
- private driveFilesRepository: DriveFilesRepository,
-
- @Inject(DI.channelsRepository)
- private channelsRepository: ChannelsRepository,
-
private noteEntityService: NoteEntityService,
private noteCreateService: NoteCreateService,
) {
super(meta, paramDef, async (ps, me) => {
- 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.findOne({
- where: { id: ps.renoteId },
- relations: ['user', 'renote', 'reply'],
- });
-
- if (renote == null) {
- throw new ApiError(meta.errors.noSuchRenoteTarget);
- } else if (isRenote(renote) && !isQuote(renote)) {
- throw new ApiError(meta.errors.cannotReRenote);
- }
-
- // Check blocking
- if (renote.userId !== me.id) {
- const blockExist = await this.blockingsRepository.exists({
- 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);
- }
-
- if (renote.channelId && renote.channelId !== ps.channelId) {
- // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
- // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
- const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
- if (renoteChannel == null) {
- // リノートしたいノートが書き込まれているチャンネルが無い
- throw new ApiError(meta.errors.noSuchChannel);
- } else if (!renoteChannel.allowRenoteToExternal) {
- // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
- throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
- }
- }
- }
-
- let reply: MiNote | null = null;
- if (ps.replyId != null) {
- // Fetch reply
- reply = await this.notesRepository.findOne({
- where: { id: ps.replyId },
- relations: ['user'],
- });
-
- if (reply == null) {
- throw new ApiError(meta.errors.noSuchReplyTarget);
- } else if (isRenote(reply) && !isQuote(reply)) {
- throw new ApiError(meta.errors.cannotReplyToPureRenote);
- } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
- throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
- } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
- throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
- }
-
- // Check blocking
- if (reply.userId !== me.id) {
- const blockExist = await this.blockingsRepository.exists({
- where: {
- blockerId: reply.userId,
- blockeeId: me.id,
- },
- });
- if (blockExist) {
- throw new ApiError(meta.errors.youHaveBeenBlocked);
- }
- }
- }
-
- if (ps.poll) {
- if (typeof ps.poll.expiresAt === 'number') {
- if (ps.poll.expiresAt < Date.now()) {
- throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
- }
- } else if (typeof ps.poll.expiredAfter === 'number') {
- ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
- }
- }
-
- let channel: MiChannel | null = null;
- if (ps.channelId != null) {
- channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
-
- if (channel == null) {
- throw new ApiError(meta.errors.noSuchChannel);
- }
- }
-
- // 投稿を作成
try {
- const note = await this.noteCreateService.create(me, {
+ const note = await this.noteCreateService.fetchAndCreate(me, {
createdAt: new Date(),
- files: files,
+ fileIds: ps.fileIds ?? ps.mediaIds ?? [],
poll: ps.poll ? {
choices: ps.poll.choices,
multiple: ps.poll.multiple ?? false,
- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
- } : undefined,
- text: ps.text ?? undefined,
- reply,
- renote,
- cw: ps.cw,
+ expiresAt: ps.poll.expiredAfter ? new Date(Date.now() + ps.poll.expiredAfter) : ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
+ } : null,
+ text: ps.text ?? null,
+ replyId: ps.replyId ?? null,
+ renoteId: ps.renoteId ?? null,
+ cw: ps.cw ?? null,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
- visibleUsers,
- channel,
+ visibleUserIds: ps.visibleUserIds ?? [],
+ channelId: ps.channelId ?? null,
apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
@@ -393,16 +246,46 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return {
createdNote: await this.noteEntityService.pack(note, me),
};
- } catch (e) {
+ } catch (err) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
- if (e instanceof IdentifiableError) {
- if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
+ if (err instanceof IdentifiableError) {
+ if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords);
- } else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
+ } else if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
+ } else if (err.id === '801c046c-5bf5-4234-ad2b-e78fc20a2ac7') {
+ throw new ApiError(meta.errors.noSuchFile);
+ } else if (err.id === '53983c56-e163-45a6-942f-4ddc485d4290') {
+ throw new ApiError(meta.errors.noSuchRenoteTarget);
+ } else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') {
+ throw new ApiError(meta.errors.cannotReRenote);
+ } else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ } else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') {
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ } else if (err.id === '48d7a997-da5c-4716-b3c3-92db3f37bf7d') {
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ } else if (err.id === 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77') {
+ throw new ApiError(meta.errors.noSuchChannel);
+ } else if (err.id === '7e435f4a-780d-4cfc-a15a-42519bd6fb67') {
+ throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
+ } else if (err.id === '60142edb-1519-408e-926d-4f108d27bee0') {
+ throw new ApiError(meta.errors.noSuchReplyTarget);
+ } else if (err.id === 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36') {
+ throw new ApiError(meta.errors.cannotReplyToPureRenote);
+ } else if (err.id === '11cd37b3-a411-4f77-8633-c580ce6a8dce') {
+ throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
+ } else if (err.id === 'ced780a1-2012-4caf-bc7e-a95a291294cb') {
+ throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+ } else if (err.id === 'b0df6025-f2e8-44b4-a26a-17ad99104612') {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ } else if (err.id === '0c11c11e-0c8d-48e7-822c-76ccef660068') {
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+ } else if (err.id === 'bfa3905b-25f5-4894-b430-da331a490e4b') {
+ throw new ApiError(meta.errors.noSuchChannel);
}
}
- throw e;
+ throw err;
}
});
}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
index 1c28ec22d0..8f2fbf9197 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
@@ -124,6 +124,12 @@ export const meta = {
id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8',
},
+ tooManyScheduledNotes: {
+ message: 'You cannot create scheduled notes any more.',
+ code: 'TOO_MANY_SCHEDULED_NOTES',
+ id: '22ae69eb-09e3-4541-a850-773cfa45e693',
+ },
+
cannotRenoteToExternal: {
message: 'Cannot Renote to External.',
code: 'CANNOT_RENOTE_TO_EXTERNAL',
@@ -162,7 +168,7 @@ export const paramDef = {
fileIds: {
type: 'array',
uniqueItems: true,
- minItems: 1,
+ minItems: 0,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
@@ -183,8 +189,10 @@ export const paramDef = {
},
required: ['choices'],
},
+ scheduledAt: { type: 'integer', nullable: true },
+ isActuallyScheduled: { type: 'boolean', default: false },
},
- required: [],
+ required: ['visibility', 'visibleUserIds', 'cw', 'hashtag', 'localOnly', 'reactionAcceptance', 'replyId', 'renoteId', 'channelId', 'text', 'fileIds', 'poll', 'scheduledAt', 'isActuallyScheduled'],
} as const;
@Injectable()
@@ -196,22 +204,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const draft = await this.noteDraftService.create(me, {
fileIds: ps.fileIds,
- poll: ps.poll ? {
- choices: ps.poll.choices,
- multiple: ps.poll.multiple ?? false,
- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
- expiredAfter: ps.poll.expiredAfter ?? null,
- } : undefined,
- text: ps.text ?? null,
- replyId: ps.replyId ?? undefined,
- renoteId: ps.renoteId ?? undefined,
- cw: ps.cw ?? null,
- ...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
+ pollChoices: ps.poll?.choices ?? [],
+ pollMultiple: ps.poll?.multiple ?? false,
+ pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
+ pollExpiredAfter: ps.poll?.expiredAfter ?? null,
+ hasPoll: ps.poll != null,
+ text: ps.text,
+ replyId: ps.replyId,
+ renoteId: ps.renoteId,
+ cw: ps.cw,
+ hashtag: ps.hashtag,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
- visibleUserIds: ps.visibleUserIds ?? [],
- channelId: ps.channelId ?? undefined,
+ visibleUserIds: ps.visibleUserIds,
+ channelId: ps.channelId,
+ scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
+ isActuallyScheduled: ps.isActuallyScheduled,
}).catch((err) => {
if (err instanceof IdentifiableError) {
switch (err.id) {
@@ -241,6 +250,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
case '215dbc76-336c-4d2a-9605-95766ba7dab0':
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+ case 'c3275f19-4558-4c59-83e1-4f684b5fab66':
+ throw new ApiError(meta.errors.tooManyScheduledNotes);
default:
throw err;
}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
index f24f9b8fb2..0774f09228 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
@@ -41,6 +41,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
+ scheduled: { type: 'boolean', nullable: true },
},
required: [],
} as const;
@@ -58,6 +59,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery<MiNoteDraft>(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('drafts.userId = :meId', { meId: me.id });
+ if (ps.scheduled === true) {
+ query.andWhere('drafts.isActuallyScheduled = true');
+ } else if (ps.scheduled === false) {
+ query.andWhere('drafts.isActuallyScheduled = false');
+ }
+
const drafts = await query
.limit(ps.limit)
.getMany();
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
index ee221fb765..9a2e2ca415 100644
--- a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
@@ -159,6 +159,12 @@ export const meta = {
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
id: '215dbc76-336c-4d2a-9605-95766ba7dab0',
},
+
+ tooManyScheduledNotes: {
+ message: 'You cannot create scheduled notes any more.',
+ code: 'TOO_MANY_SCHEDULED_NOTES',
+ id: '02f5df79-08ae-4a33-8524-f1503c8f6212',
+ },
},
limit: {
@@ -171,14 +177,14 @@ export const paramDef = {
type: 'object',
properties: {
draftId: { type: 'string', nullable: false, format: 'misskey:id' },
- visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
+ visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] },
visibleUserIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
hashtag: { type: 'string', nullable: true, maxLength: 200 },
- localOnly: { type: 'boolean', default: false },
- reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
+ localOnly: { type: 'boolean' },
+ reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true },
@@ -194,7 +200,7 @@ export const paramDef = {
fileIds: {
type: 'array',
uniqueItems: true,
- minItems: 1,
+ minItems: 0,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
@@ -215,6 +221,8 @@ export const paramDef = {
},
required: ['choices'],
},
+ scheduledAt: { type: 'integer', nullable: true },
+ isActuallyScheduled: { type: 'boolean' },
},
required: ['draftId'],
} as const;
@@ -228,22 +236,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const draft = await this.noteDraftService.update(me, ps.draftId, {
fileIds: ps.fileIds,
- poll: ps.poll ? {
- choices: ps.poll.choices,
- multiple: ps.poll.multiple ?? false,
- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
- expiredAfter: ps.poll.expiredAfter ?? null,
- } : undefined,
- text: ps.text ?? null,
- replyId: ps.replyId ?? undefined,
- renoteId: ps.renoteId ?? undefined,
- cw: ps.cw ?? null,
- ...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
+ pollChoices: ps.poll?.choices,
+ pollMultiple: ps.poll?.multiple,
+ pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
+ pollExpiredAfter: ps.poll?.expiredAfter,
+ text: ps.text,
+ replyId: ps.replyId,
+ renoteId: ps.renoteId,
+ cw: ps.cw,
+ hashtag: ps.hashtag,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,
visibility: ps.visibility,
- visibleUserIds: ps.visibleUserIds ?? [],
- channelId: ps.channelId ?? undefined,
+ visibleUserIds: ps.visibleUserIds,
+ channelId: ps.channelId,
+ scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
+ isActuallyScheduled: ps.isActuallyScheduled,
}).catch((err) => {
if (err instanceof IdentifiableError) {
switch (err.id) {
@@ -285,6 +293,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.containsProhibitedWords);
case '4de0363a-3046-481b-9b0f-feff3e211025':
throw new ApiError(meta.errors.containsTooManyMentions);
+ case 'bacdf856-5c51-4159-b88a-804fa5103be5':
+ throw new ApiError(meta.errors.tooManyScheduledNotes);
default:
throw err;
}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index b20f2a2179..24654b0017 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -12,6 +12,8 @@
* quote - 投稿が引用Renoteされた
* reaction - 投稿にリアクションされた
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
+ * scheduledNotePosted - 予約したノートが投稿された
+ * scheduledNotePostFailed - 予約したノートの投稿に失敗した
* receiveFollowRequest - フォローリクエストされた
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
* roleAssigned - ロールが付与された
@@ -32,6 +34,8 @@ export const notificationTypes = [
'quote',
'reaction',
'pollEnded',
+ 'scheduledNotePosted',
+ 'scheduledNotePostFailed',
'receiveFollowRequest',
'followRequestAccepted',
'roleAssigned',
diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue
index 5b8211b715..3f0a5a5247 100644
--- a/packages/frontend/src/components/MkNoteDraftsDialog.vue
+++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue
@@ -15,101 +15,151 @@ SPDX-License-Identifier: AGPL-3.0-only
@esc="cancel()"
>
<template #header>
- {{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
+ {{ i18n.ts.draftsAndScheduledNotes }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
</template>
- <div class="_spacer">
- <MkPagination :paginator="paginator" withControl>
- <template #empty>
- <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
- </template>
- <template #default="{ items }">
- <div class="_gaps_s">
- <div
- v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
- :key="draft.id"
- v-panel
- :class="[$style.draft]"
- >
- <div :class="$style.draftBody" class="_gaps_s">
- <div :class="$style.draftInfo">
- <div :class="$style.draftMeta">
- <div v-if="draft.reply" class="_nowrap">
- <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
- <template #user>
- <Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
- <MkAcct v-else :user="draft.reply.user"/>
- </template>
- </I18n>
- </div>
- <div v-else-if="draft.replyId" class="_nowrap">
- <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
- <template #user>
- {{ i18n.ts.deletedNote }}
- </template>
- </I18n>
- </div>
- <div v-if="draft.renote && draft.text != null" class="_nowrap">
- <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
- <template #user>
- <Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
- <MkAcct v-else :user="draft.renote.user"/>
- </template>
- </I18n>
- </div>
- <div v-else-if="draft.renoteId" class="_nowrap">
- <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
- <template #user>
- {{ i18n.ts.deletedNote }}
- </template>
- </I18n>
+ <MkStickyContainer>
+ <template #header>
+ <MkTabs
+ v-model:tab="tab"
+ centered
+ :class="$style.tabs"
+ :tabs="[
+ {
+ key: 'drafts',
+ title: i18n.ts.drafts,
+ icon: 'ti ti-pencil-question',
+ },
+ {
+ key: 'scheduled',
+ title: i18n.ts.scheduled,
+ icon: 'ti ti-calendar-clock',
+ },
+ ]"
+ />
+ </template>
+
+ <div class="_spacer">
+ <MkPagination :key="tab" :paginator="tab === 'scheduled' ? scheduledPaginator : draftsPaginator" withControl>
+ <template #empty>
+ <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
+ </template>
+
+ <template #default="{ items }">
+ <div class="_gaps_s">
+ <div
+ v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
+ :key="draft.id"
+ v-panel
+ :class="[$style.draft]"
+ >
+ <div :class="$style.draftBody" class="_gaps_s">
+ <MkInfo v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
+ <I18n :src="i18n.ts.scheduledToPostOnX" tag="span">
+ <template #x>
+ <MkTime :time="draft.scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
+ </template>
+ </I18n>
+ </MkInfo>
+ <div :class="$style.draftInfo">
+ <div :class="$style.draftMeta">
+ <div v-if="draft.reply" class="_nowrap">
+ <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
+ <template #user>
+ <Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
+ <MkAcct v-else :user="draft.reply.user"/>
+ </template>
+ </I18n>
+ </div>
+ <div v-else-if="draft.replyId" class="_nowrap">
+ <i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
+ <template #user>
+ {{ i18n.ts.deletedNote }}
+ </template>
+ </I18n>
+ </div>
+ <div v-if="draft.renote && draft.text != null" class="_nowrap">
+ <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
+ <template #user>
+ <Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
+ <MkAcct v-else :user="draft.renote.user"/>
+ </template>
+ </I18n>
+ </div>
+ <div v-else-if="draft.renoteId" class="_nowrap">
+ <i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
+ <template #user>
+ {{ i18n.ts.deletedNote }}
+ </template>
+ </I18n>
+ </div>
+ <div v-if="draft.channel" class="_nowrap">
+ <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
+ </div>
</div>
- <div v-if="draft.channel" class="_nowrap">
- <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
+ </div>
+ <div :class="$style.draftContent">
+ <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
+ </div>
+ <div :class="$style.draftFooter">
+ <div :class="$style.draftVisibility">
+ <span :title="i18n.ts._visibility[draft.visibility]">
+ <i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
+ <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
+ <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
+ <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
+ </span>
+ <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
+ <MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
</div>
</div>
- <div :class="$style.draftContent">
- <Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
- </div>
- <div :class="$style.draftFooter">
- <div :class="$style.draftVisibility">
- <span :title="i18n.ts._visibility[draft.visibility]">
- <i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
- <i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
- <i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
- <i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
- </span>
- <span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
- </div>
- <MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
+
+ <div :class="$style.draftActions" class="_buttons">
+ <template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
+ <MkButton
+ :class="$style.itemButton"
+ small
+ @click="cancelSchedule(draft)"
+ >
+ <i class="ti ti-calendar-x"></i> {{ i18n.ts._drafts.cancelSchedule }}
+ </MkButton>
+ <!-- TODO
+ <MkButton
+ :class="$style.itemButton"
+ small
+ @click="reSchedule(draft)"
+ >
+ <i class="ti ti-calendar-time"></i> {{ i18n.ts._drafts.reSchedule }}
+ </MkButton>
+ -->
+ </template>
+ <MkButton
+ v-else
+ :class="$style.itemButton"
+ small
+ @click="restoreDraft(draft)"
+ >
+ <i class="ti ti-corner-up-left"></i> {{ i18n.ts._drafts.restore }}
+ </MkButton>
+ <MkButton
+ v-tooltip="i18n.ts._drafts.delete"
+ danger
+ small
+ :iconOnly="true"
+ :class="$style.itemButton"
+ style="margin-left: auto;"
+ @click="deleteDraft(draft)"
+ >
+ <i class="ti ti-trash"></i>
+ </MkButton>
</div>
</div>
- <div :class="$style.draftActions" class="_buttons">
- <MkButton
- :class="$style.itemButton"
- small
- @click="restoreDraft(draft)"
- >
- <i class="ti ti-corner-up-left"></i>
- {{ i18n.ts._drafts.restore }}
- </MkButton>
- <MkButton
- v-tooltip="i18n.ts._drafts.delete"
- danger
- small
- :iconOnly="true"
- :class="$style.itemButton"
- @click="deleteDraft(draft)"
- >
- <i class="ti ti-trash"></i>
- </MkButton>
- </div>
</div>
- </div>
- </template>
- </MkPagination>
- </div>
+ </template>
+ </MkPagination>
+ </div>
+ </MkStickyContainer>
</MkModalWindow>
</template>
@@ -125,6 +175,12 @@ import * as os from '@/os.js';
import { $i } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api';
import { Paginator } from '@/utility/paginator.js';
+import MkTabs from '@/components/MkTabs.vue';
+import MkInfo from '@/components/MkInfo.vue';
+
+const props = defineProps<{
+ scheduled?: boolean;
+}>();
const emit = defineEmits<{
(ev: 'restore', draft: Misskey.entities.NoteDraft): void;
@@ -132,8 +188,20 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
-const paginator = markRaw(new Paginator('notes/drafts/list', {
+const tab = ref<'drafts' | 'scheduled'>(props.scheduled ? 'scheduled' : 'drafts');
+
+const draftsPaginator = markRaw(new Paginator('notes/drafts/list', {
+ limit: 10,
+ params: {
+ scheduled: false,
+ },
+}));
+
+const scheduledPaginator = markRaw(new Paginator('notes/drafts/list', {
limit: 10,
+ params: {
+ scheduled: true,
+ },
}));
const currentDraftsCount = ref(0);
@@ -162,7 +230,17 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
if (canceled) return;
os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => {
- paginator.reload();
+ draftsPaginator.reload();
+ });
+}
+
+async function cancelSchedule(draft: Misskey.entities.NoteDraft) {
+ os.apiWithDialog('notes/drafts/update', {
+ draftId: draft.id,
+ isActuallyScheduled: false,
+ scheduledAt: null,
+ }).then(() => {
+ scheduledPaginator.reload();
});
}
</script>
@@ -220,4 +298,11 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
padding-top: 16px;
border-top: solid 1px var(--MI_THEME-divider);
}
+
+.tabs {
+ background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
+ -webkit-backdrop-filter: var(--MI-blur, blur(15px));
+ backdrop-filter: var(--MI-blur, blur(15px));
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
+}
</style>
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 21104b41df..45a74e3f02 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'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
- <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
+ <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'].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_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" 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>
@@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_mention]: notification.type === 'mention',
[$style.t_quote]: notification.type === 'quote',
[$style.t_pollEnded]: notification.type === 'pollEnded',
+ [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
+ [$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed',
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_login]: notification.type === 'login',
@@ -39,6 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
+ <i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i>
+ <i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
@@ -60,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tail">
<header :class="$style.header">
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
+ <span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
+ <span v-else-if="notification.type === 'scheduledNotePostFailed'">{{ i18n.ts._notification.scheduledNotePostFailed }}</span>
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span>
@@ -103,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ti ti-quote" :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="ti ti-quote" :class="$style.quote"></i>
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
+ <i class="ti ti-quote" :class="$style.quote"></i>
+ </MkA>
<div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
{{ notification.role.name }}
</div>
@@ -338,6 +349,16 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none;
}
+.t_scheduledNotePosted {
+ background: var(--eventOther);
+ pointer-events: none;
+}
+
+.t_scheduledNotePostFailed {
+ background: var(--eventOther);
+ pointer-events: none;
+}
+
.t_achievementEarned {
background: var(--eventAchievement);
pointer-events: none;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 17f93a4ec8..c1b950a6c8 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
- <MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
+ <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
</button>
- <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draft" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-pencil-minus"></i></button>
+ <button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draftsAndScheduledNotes" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-list"></i></button>
</div>
<div :class="$style.headerRight">
<template v-if="!(targetChannel != null && fixed)">
@@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template>
<template v-else>{{ submitText }}</template>
- <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
+ <i style="margin-left: 6px;" :class="submitIcon"></i>
</div>
</button>
</div>
@@ -61,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
</div>
</div>
+ <MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt">
+ <I18n :src="i18n.ts.scheduleToPostOnX" tag="span">
+ <template #x>
+ <MkTime :time="scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
+ </template>
+ </I18n> - <button class="_textButton" @click="cancelSchedule()">{{ i18n.ts.cancel }}</button>
+ </MkInfo>
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<div v-show="useCw" :class="$style.cwOuter">
<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
@@ -199,6 +206,7 @@ if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(store.s.reactionAcceptance);
+const scheduledAt = ref<number | null>(null);
const draghover = ref(false);
const quoteId = ref<string | null>(null);
const hasNotSpecifiedMentions = ref(false);
@@ -262,11 +270,17 @@ const placeholder = computed((): string => {
});
const submitText = computed((): string => {
- return renoteTargetNote.value
- ? i18n.ts.quote
- : replyTargetNote.value
- ? i18n.ts.reply
- : i18n.ts.note;
+ return scheduledAt.value != null
+ ? i18n.ts.schedule
+ : renoteTargetNote.value
+ ? i18n.ts.quote
+ : replyTargetNote.value
+ ? i18n.ts.reply
+ : i18n.ts.note;
+});
+
+const submitIcon = computed((): string => {
+ return posted.value ? 'ti ti-check' : scheduledAt.value != null ? 'ti ti-calendar-time' : replyTargetNote.value ? 'ti ti-arrow-back-up' : renoteTargetNote.value ? 'ti ti-quote' : 'ti ti-send';
});
const textLength = computed((): number => {
@@ -414,6 +428,7 @@ function watchForDraft() {
watch(localOnly, () => saveDraft());
watch(quoteId, () => saveDraft());
watch(reactionAcceptance, () => saveDraft());
+ watch(scheduledAt, () => saveDraft());
}
function checkMissingMention() {
@@ -605,7 +620,13 @@ function showOtherSettings() {
action: () => {
toggleReactionAcceptance();
},
- }, { type: 'divider' }, {
+ }, ...($i.policies.scheduledNoteLimit > 0 ? [{
+ icon: 'ti ti-calendar-time',
+ text: i18n.ts.schedulePost + '...',
+ action: () => {
+ schedule();
+ },
+ }] : []), { type: 'divider' }, {
type: 'switch',
icon: 'ti ti-eye',
text: i18n.ts.preview,
@@ -654,6 +675,7 @@ function clear() {
files.value = [];
poll.value = null;
quoteId.value = null;
+ scheduledAt.value = null;
}
function onKeydown(ev: KeyboardEvent) {
@@ -809,6 +831,7 @@ function saveDraft() {
...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value,
+ scheduledAt: scheduledAt.value,
},
};
@@ -823,7 +846,9 @@ function deleteDraft() {
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
}
-async function saveServerDraft(clearLocal = false) {
+async function saveServerDraft(options: {
+ isActuallyScheduled?: boolean;
+} = {}) {
return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
text: text.value,
@@ -831,19 +856,15 @@ async function saveServerDraft(clearLocal = false) {
visibility: visibility.value,
localOnly: localOnly.value,
hashtag: hashtags.value,
- ...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}),
+ fileIds: files.value.map(f => f.id),
poll: poll.value,
- ...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
- renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
- replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
- channelId: targetChannel.value ? targetChannel.value.id : undefined,
+ visibleUserIds: visibleUsers.value.map(x => x.id),
+ renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : null,
+ replyId: replyTargetNote.value ? replyTargetNote.value.id : null,
+ channelId: targetChannel.value ? targetChannel.value.id : null,
reactionAcceptance: reactionAcceptance.value,
- }).then(() => {
- if (clearLocal) {
- clear();
- deleteDraft();
- }
- }).catch((err) => {
+ scheduledAt: scheduledAt.value,
+ isActuallyScheduled: options.isActuallyScheduled ?? false,
});
}
@@ -878,6 +899,21 @@ async function post(ev?: MouseEvent) {
}
}
+ if (scheduledAt.value != null) {
+ if (uploader.items.value.some(x => x.uploaded == null)) {
+ await uploadFiles();
+
+ // アップロード失敗したものがあったら中止
+ if (uploader.items.value.some(x => x.uploaded == null)) {
+ return;
+ }
+ }
+
+ await postAsScheduled();
+ clear();
+ return;
+ }
+
if (props.mock) return;
if (visibility.value === 'public' && (
@@ -1049,6 +1085,14 @@ async function post(ev?: MouseEvent) {
});
}
+async function postAsScheduled() {
+ if (props.mock) return;
+
+ await saveServerDraft({
+ isActuallyScheduled: true,
+ });
+}
+
function cancel() {
emit('cancel');
}
@@ -1143,8 +1187,10 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent)
}
function showDraftMenu(ev: MouseEvent) {
- function showDraftsDialog() {
- const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, {
+ function showDraftsDialog(scheduled: boolean) {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {
+ scheduled,
+ }, {
restore: async (draft: Misskey.entities.NoteDraft) => {
text.value = draft.text ?? '';
useCw.value = draft.cw != null;
@@ -1175,6 +1221,7 @@ function showDraftMenu(ev: MouseEvent) {
renoteTargetNote.value = draft.renote;
replyTargetNote.value = draft.reply;
reactionAcceptance.value = draft.reactionAcceptance;
+ scheduledAt.value = draft.scheduledAt ?? null;
if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel;
visibleUsers.value = [];
@@ -1215,11 +1262,32 @@ function showDraftMenu(ev: MouseEvent) {
text: i18n.ts._drafts.listDrafts,
icon: 'ti ti-cloud-download',
action: () => {
- showDraftsDialog();
+ showDraftsDialog(false);
+ },
+ }, { type: 'divider' }, {
+ type: 'button',
+ text: i18n.ts._drafts.listScheduledNotes,
+ icon: 'ti ti-clock-down',
+ action: () => {
+ showDraftsDialog(true);
},
}], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
+async function schedule() {
+ const { canceled, result } = await os.inputDatetime({
+ title: i18n.ts.schedulePost,
+ });
+ if (canceled) return;
+ if (result.getTime() <= Date.now()) return;
+
+ scheduledAt.value = result.getTime();
+}
+
+function cancelSchedule() {
+ scheduledAt.value = null;
+}
+
onMounted(() => {
if (props.autofocus) {
focus();
@@ -1255,6 +1323,7 @@ onMounted(() => {
}
quoteId.value = draft.data.quoteId;
reactionAcceptance.value = draft.data.reactionAcceptance;
+ scheduledAt.value = draft.data.scheduledAt ?? null;
}
}
@@ -1519,6 +1588,10 @@ html[data-color-scheme=light] .preview {
margin: 0 20px 16px 20px;
}
+.scheduledAt {
+ margin: 0 20px 16px 20px;
+}
+
.cw,
.hashtags,
.text {
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 6c5f04c6b5..aafa1c4b21 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -76,7 +76,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints>(
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
- } else if (err.code.startsWith('TOO_MANY')) {
+ } else if (err.code.startsWith('TOO_MANY')) { // TODO: バックエンドに kind: client/contentsLimitExceeded みたいな感じで送るように統一してもらってそれで判定する
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
} else if (err.message.startsWith('Unexpected token')) {
@@ -460,7 +460,7 @@ export function inputNumber(props: {
});
}
-export function inputDate(props: {
+export function inputDatetime(props: {
title?: string;
text?: string;
placeholder?: string | null;
@@ -475,13 +475,13 @@ export function inputDate(props: {
title: props.title,
text: props.text,
input: {
- type: 'date',
+ type: 'datetime-local',
placeholder: props.placeholder,
default: props.default ?? null,
},
}, {
done: result => {
- resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
+ resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
},
closed: () => dispose(),
});
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index e10eb1163e..03df01a930 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -802,6 +802,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
+ <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
+ <template #suffix>
+ <span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.scheduledNoteLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly">
+ </MkInput>
+ <MkRange v-model="role.policies.scheduledNoteLimit.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.watermarkAvailable, 'watermarkAvailable'])">
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
<template #suffix>
@@ -831,6 +850,7 @@ import { watch, ref, computed } from 'vue';
import { throttle } from 'throttle-debounce';
import * as Misskey from 'misskey-js';
import RolesEditorFormula from './RolesEditorFormula.vue';
+import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue';
import MkSelect from '@/components/MkSelect.vue';
@@ -842,7 +862,6 @@ import FormSlot from '@/components/form/slot.vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { deepClone } from '@/utility/clone.js';
-import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
const emit = defineEmits<{
(ev: 'update:modelValue', v: any): void;
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 7a49860b2d..eca0284be3 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -304,6 +304,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
+ <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
+ <template #suffix>{{ policies.scheduledNoteLimit }}</template>
+ <MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0">
+ </MkInput>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
<template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index d07710fc67..fab205b939 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -3226,7 +3226,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", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
+export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
// @public (undocumented)
export function nyaize(text: string): string;
@@ -3339,7 +3339,7 @@ type QueueStats = {
type QueueStatsLog = QueueStats[];
// @public (undocumented)
-export const queueTypes: readonly ["system", "endedPollNotification", "deliver", "inbox", "db", "relationship", "objectStorage", "userWebhookDeliver", "systemWebhookDeliver"];
+export const queueTypes: readonly ["system", "endedPollNotification", "postScheduledNote", "deliver", "inbox", "db", "relationship", "objectStorage", "userWebhookDeliver", "systemWebhookDeliver"];
// @public (undocumented)
type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json'];
@@ -3441,7 +3441,7 @@ type RoleLite = components['schemas']['RoleLite'];
type RolePolicies = components['schemas']['RolePolicies'];
// @public (undocumented)
-export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "watermarkAvailable"];
+export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "scheduledNoteLimit", "watermarkAvailable"];
// @public (undocumented)
type RolesListResponse = operations['roles___list']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 22a3733fcb..2a059bdea6 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4159,6 +4159,24 @@ export type components = {
/** Format: misskey:id */
userListId: string;
};
+ scheduledNotePosted?: {
+ /** @enum {string} */
+ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+ } | {
+ /** @enum {string} */
+ type: 'list';
+ /** Format: misskey:id */
+ userListId: string;
+ };
+ scheduledNotePostFailed?: {
+ /** @enum {string} */
+ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+ } | {
+ /** @enum {string} */
+ type: 'list';
+ /** Format: misskey:id */
+ userListId: string;
+ };
receiveFollowRequest?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -4406,42 +4424,31 @@ export type components = {
/** Format: date-time */
createdAt: string;
text: string | null;
- cw?: string | null;
+ cw: string | null;
/** Format: id */
userId: string;
user: components['schemas']['UserLite'];
- /**
- * Format: id
- * @example xxxxxxxxxx
- */
- replyId?: string | null;
- /**
- * Format: id
- * @example xxxxxxxxxx
- */
- renoteId?: string | null;
- /** @description The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null. */
+ /** Format: id */
+ replyId: string | null;
+ /** Format: id */
+ renoteId: string | null;
reply?: components['schemas']['Note'] | null;
- /** @description The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null. */
renote?: components['schemas']['Note'] | null;
/** @enum {string} */
visibility: 'public' | 'home' | 'followers' | 'specified';
- visibleUserIds?: string[];
- fileIds?: string[];
+ visibleUserIds: string[];
+ fileIds: string[];
files?: components['schemas']['DriveFile'][];
- hashtag?: string;
- poll?: {
+ hashtag: string | null;
+ poll: {
/** Format: date-time */
expiresAt?: string | null;
expiredAfter?: number | null;
multiple: boolean;
choices: string[];
} | null;
- /**
- * Format: id
- * @example xxxxxxxxxx
- */
- channelId?: string | null;
+ /** Format: id */
+ channelId: string | null;
channel?: {
id: string;
name: string;
@@ -4450,9 +4457,11 @@ export type components = {
allowRenoteToExternal: boolean;
userId: string | null;
} | null;
- localOnly?: boolean;
+ localOnly: boolean;
/** @enum {string|null} */
reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
+ scheduledAt: number | null;
+ isActuallyScheduled: boolean;
};
NoteReaction: {
/** Format: id */
@@ -4567,6 +4576,22 @@ export type components = {
/** Format: date-time */
createdAt: string;
/** @enum {string} */
+ type: 'scheduledNotePosted';
+ note: components['schemas']['Note'];
+ } | {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** @enum {string} */
+ type: 'scheduledNotePostFailed';
+ noteDraft: components['schemas']['NoteDraft'];
+ } | {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** @enum {string} */
type: 'follow';
user: components['schemas']['UserLite'];
/** Format: id */
@@ -5253,6 +5278,7 @@ export type components = {
/** @enum {string} */
chatAvailability: 'available' | 'readonly' | 'unavailable';
noteDraftLimit: number;
+ scheduledNoteLimit: number;
watermarkAvailable: boolean;
};
ReversiGameLite: {
@@ -9553,7 +9579,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
/** @enum {string} */
state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed';
};
@@ -9740,7 +9766,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed' | 'paused')[];
search?: string;
};
@@ -9808,7 +9834,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
};
};
};
@@ -9871,7 +9897,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
};
};
};
@@ -9884,7 +9910,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- name: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ name: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
qualifiedName: string;
counts: {
[key: string]: number;
@@ -9974,7 +10000,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- name: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ name: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
counts: {
[key: string]: number;
};
@@ -10038,7 +10064,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string;
};
};
@@ -10102,7 +10128,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string;
};
};
@@ -10166,7 +10192,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string;
};
};
@@ -10233,7 +10259,7 @@ export interface operations {
content: {
'application/json': {
/** @enum {string} */
- queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
+ queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
jobId: string;
};
};
@@ -11653,6 +11679,24 @@ export interface operations {
/** Format: misskey:id */
userListId: string;
};
+ scheduledNotePosted?: {
+ /** @enum {string} */
+ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+ } | {
+ /** @enum {string} */
+ type: 'list';
+ /** Format: misskey:id */
+ userListId: string;
+ };
+ scheduledNotePostFailed?: {
+ /** @enum {string} */
+ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+ } | {
+ /** @enum {string} */
+ type: 'list';
+ /** Format: misskey:id */
+ userListId: string;
+ };
receiveFollowRequest?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -25954,8 +25998,8 @@ export interface operations {
untilDate?: number;
/** @default true */
markAsRead?: boolean;
- includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
- excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -26039,8 +26083,8 @@ export interface operations {
untilDate?: number;
/** @default true */
markAsRead?: boolean;
- includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
- excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -27314,6 +27358,24 @@ export interface operations {
/** Format: misskey:id */
userListId: string;
};
+ scheduledNotePosted?: {
+ /** @enum {string} */
+ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+ } | {
+ /** @enum {string} */
+ type: 'list';
+ /** Format: misskey:id */
+ userListId: string;
+ };
+ scheduledNotePostFailed?: {
+ /** @enum {string} */
+ type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
+ } | {
+ /** @enum {string} */
+ type: 'list';
+ /** Format: misskey:id */
+ userListId: string;
+ };
receiveFollowRequest?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -29143,31 +29205,34 @@ export interface operations {
* @default public
* @enum {string}
*/
- visibility?: 'public' | 'home' | 'followers' | 'specified';
- visibleUserIds?: string[];
- cw?: string | null;
- hashtag?: string | null;
+ visibility: 'public' | 'home' | 'followers' | 'specified';
+ visibleUserIds: string[];
+ cw: string | null;
+ hashtag: string | null;
/** @default false */
- localOnly?: boolean;
+ localOnly: boolean;
/**
* @default null
* @enum {string|null}
*/
- reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
+ reactionAcceptance: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
/** Format: misskey:id */
- replyId?: string | null;
+ replyId: string | null;
/** Format: misskey:id */
- renoteId?: string | null;
+ renoteId: string | null;
/** Format: misskey:id */
- channelId?: string | null;
- text?: string | null;
- fileIds?: string[];
- poll?: {
+ channelId: string | null;
+ text: string | null;
+ fileIds: string[];
+ poll: {
choices: string[];
multiple?: boolean;
expiresAt?: number | null;
expiredAfter?: number | null;
} | null;
+ scheduledAt: number | null;
+ /** @default false */
+ isActuallyScheduled: boolean;
};
};
};
@@ -29314,6 +29379,7 @@ export interface operations {
untilId?: string;
sinceDate?: number;
untilDate?: number;
+ scheduled?: boolean | null;
};
};
};
@@ -29380,20 +29446,13 @@ export interface operations {
'application/json': {
/** Format: misskey:id */
draftId: string;
- /**
- * @default public
- * @enum {string}
- */
+ /** @enum {string} */
visibility?: 'public' | 'home' | 'followers' | 'specified';
visibleUserIds?: string[];
cw?: string | null;
hashtag?: string | null;
- /** @default false */
localOnly?: boolean;
- /**
- * @default null
- * @enum {string|null}
- */
+ /** @enum {string|null} */
reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
/** Format: misskey:id */
replyId?: string | null;
@@ -29409,6 +29468,8 @@ export interface operations {
expiresAt?: number | null;
expiredAfter?: number | null;
} | null;
+ scheduledAt?: number | null;
+ isActuallyScheduled?: boolean;
};
};
};
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index 9afd1f8be6..148c5ed831 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -26,6 +26,8 @@ export const notificationTypes = [
'quote',
'reaction',
'pollEnded',
+ 'scheduledNotePosted',
+ 'scheduledNotePostFailed',
'receiveFollowRequest',
'followRequestAccepted',
'groupInvited',
@@ -227,12 +229,14 @@ export const rolePolicies = [
'chatAvailability',
'uploadableFileTypes',
'noteDraftLimit',
+ 'scheduledNoteLimit',
'watermarkAvailable',
] as const;
export const queueTypes = [
'system',
'endedPollNotification',
+ 'postScheduledNote',
'deliver',
'inbox',
'db',