summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authortaichan <40626578+tai-cha@users.noreply.github.com>2025-06-25 17:09:23 +0900
committerGitHub <noreply@github.com>2025-06-25 17:09:23 +0900
commitb752dc72e531f6c63f09876a1c68a87a77c03b49 (patch)
treed9bd25825a9b1b06c8db07a1888594ffc9db45c8 /packages
parentfix(frontend): ファイルがドライブの既定アップロード先に... (diff)
downloadmisskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.tar.gz
misskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.tar.bz2
misskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.zip
feat: ノートの下書き(draft of note) (#15298)
* WIp (backend) * Remove unused * 下書きbackend 続き * fix(backedn): visibilityが下書きに反映されない * Update packages/backend/src/postgres.ts Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * Fix : import order * fix(backend) : createでcwが効かない * FIX FOREGIN KEY * wip: frontend(既存の下書きを挿入) まだ:チャンネル表示、下書きの作成、削除 * WIP: ノート選択ダイアログ 投稿時に下書きを削除 * Promiseに変更 * 連合なし、チャンネルも表示 * Hashtagの値抜け漏れ * hasthagを0文字でも作成可能に * 下書きの保存機構 * chore(misskey-js): build types * localOnly抜け漏れ * チャンネル情報の書き換え * enhance(frontend): ヘッダ部の表示改善 * fix(frontend): ファイル添付できない * fix: no file * fix(frontend): 投票が反映されない * ハッシュタグの展開(コメントアウト外し忘れ) * fix: visibleUserIdsが反映されない * enhance: APIの型を整備 * refactor: 型が整備できたのでasを削除 * Add userhost * fix * enhance: paginationを使う * fix * fix: 自分のアカウントでの投稿でしか下書きを利用できないように 完全に塞ぐことはできないが一応 * :art: * APIのエラーIDを追加 * enhance: スタイル調整 * remove unused code * :art: * fix: ロールポリシーの型 * ロールの編集画面 * ダイアログの挙動改善 * 下書き機能が利用できない場合は表示しないように * refactor * fix: ダブルクリックが効かない問題を修正 * add comments * fix * fix: 保存時のエラーの種別にかかわらずmodalを閉じないように * fix()backend: NoteDraftのreply, renoteの型が間違ってたので修正 (migtrationはあってた) * fix: 投稿フォームを空白にして通常リノートできるやつは下書きとしては弾くように * fix(backend): テキストが0文字でも下書きは保存できるように * Fix(backend): replyIdの型定義がミスっているのを修正 * chore(misskey-js): update types * Add CHANGELOG * lint * 常にサーバー下書きに保存し、上限を超えた場合のみ尋ねるように * NoteDraftServiceにcreate, updateの処理を移譲 * Fix typeerror * remove tooltip * Remove Mkbutton:short and use iconOnly * 不要なコメントの削除 * Remove Short Completely * wip * escキーまわりの挙動を改善 * 下書き選択時に下書き可能数と現在の量が分かるように * cleanUp * wip * wi * wip * Update MkPostForm.vue --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1736686850345-createNoteDraft.js91
-rw-r--r--packages/backend/src/core/CoreModule.ts12
-rw-r--r--packages/backend/src/core/NoteDraftService.ts314
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/core/entities/NoteDraftEntityService.ts177
-rw-r--r--packages/backend/src/di-symbols.ts1
-rw-r--r--packages/backend/src/misc/json-schema.ts2
-rw-r--r--packages/backend/src/models/Note.ts4
-rw-r--r--packages/backend/src/models/NoteDraft.ts157
-rw-r--r--packages/backend/src/models/RepositoryModule.ts9
-rw-r--r--packages/backend/src/models/_.ts3
-rw-r--r--packages/backend/src/models/json-schema/note-draft.ts169
-rw-r--r--packages/backend/src/models/json-schema/role.ts4
-rw-r--r--packages/backend/src/postgres.ts2
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/count.ts51
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/create.ts258
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/delete.ts61
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/list.ts66
-rw-r--r--packages/backend/src/server/api/endpoints/notes/drafts/update.ts302
-rw-r--r--packages/backend/src/types.ts2
-rw-r--r--packages/frontend-shared/js/const.ts1
-rw-r--r--packages/frontend/src/components/MkNoteDraftsDialog.vue218
-rw-r--r--packages/frontend/src/components/MkPostForm.vue217
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue12
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue19
-rw-r--r--packages/frontend/src/pages/admin/roles.vue7
-rw-r--r--packages/frontend/src/utility/get-note-summary.ts40
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md36
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts55
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts13
-rw-r--r--packages/misskey-js/src/autogen/entities.ts8
-rw-r--r--packages/misskey-js/src/autogen/models.ts1
-rw-r--r--packages/misskey-js/src/autogen/types.ts502
34 files changed, 2766 insertions, 56 deletions
diff --git a/packages/backend/migration/1736686850345-createNoteDraft.js b/packages/backend/migration/1736686850345-createNoteDraft.js
new file mode 100644
index 0000000000..3b525a7339
--- /dev/null
+++ b/packages/backend/migration/1736686850345-createNoteDraft.js
@@ -0,0 +1,91 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class CreateNoteDraft1736686850345 {
+ name = 'CreateNoteDraft1736686850345'
+
+ async up(queryRunner) {
+ await queryRunner.query(`
+ CREATE TABLE "note_draft" (
+ "id" varchar NOT NULL,
+ "replyId" varchar NULL,
+ "renoteId" varchar NULL,
+ "text" text NULL,
+ "cw" varchar(512) NULL,
+ "userId" varchar NOT NULL,
+ "localOnly" boolean DEFAULT false,
+ "reactionAcceptance" varchar(64) NULL,
+ "visibility" varchar NOT NULL,
+ "fileIds" varchar[] DEFAULT '{}',
+ "visibleUserIds" varchar[] DEFAULT '{}',
+ "hashtag" varchar(128) NULL,
+ "channelId" varchar NULL,
+ "hasPoll" boolean DEFAULT false,
+ "pollChoices" varchar(256)[] DEFAULT '{}',
+ "pollMultiple" boolean NULL,
+ "pollExpiresAt" TIMESTAMP WITH TIME ZONE NULL,
+ "pollExpiredAfter" bigint NULL,
+ PRIMARY KEY ("id")
+ )`);
+
+ await queryRunner.query(`
+ CREATE INDEX "IDX_NOTE_DRAFT_REPLY_ID" ON "note_draft" ("replyId")
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX "IDX_NOTE_DRAFT_RENOTE_ID" ON "note_draft" ("renoteId")
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX "IDX_NOTE_DRAFT_USER_ID" ON "note_draft" ("userId")
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX "IDX_NOTE_DRAFT_FILE_IDS" ON "note_draft" USING GIN ("fileIds")
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX "IDX_NOTE_DRAFT_VISIBLE_USER_IDS" ON "note_draft" USING GIN ("visibleUserIds")
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX "IDX_NOTE_DRAFT_CHANNEL_ID" ON "note_draft" ("channelId")
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE "note_draft"
+ ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE "note_draft"
+ ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE "note_draft"
+ ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE "note_draft"
+ ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE
+ `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`);
+ await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`);
+ await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`);
+ await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`);
+ await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_CHANNEL_ID"`);
+ await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_VISIBLE_USER_IDS"`);
+ await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_FILE_IDS"`);
+ await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_USER_ID"`);
+ await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_RENOTE_ID"`);
+ await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_REPLY_ID"`);
+ await queryRunner.query(`DROP TABLE "note_draft"`);
+ }
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index d8617e343c..0c0c5d3a39 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -44,6 +44,7 @@ import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
+import { NoteDraftService } from './NoteDraftService.js';
import { NotificationService } from './NotificationService.js';
import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js';
@@ -118,6 +119,7 @@ import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.
import { NoteEntityService } from './entities/NoteEntityService.js';
import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js';
import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js';
+import { NoteDraftEntityService } from './entities/NoteDraftEntityService.js';
import { NotificationEntityService } from './entities/NotificationEntityService.js';
import { PageEntityService } from './entities/PageEntityService.js';
import { PageLikeEntityService } from './entities/PageLikeEntityService.js';
@@ -185,6 +187,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
+const $NoteDraftService: Provider = { provide: 'NoteDraftService', useExisting: NoteDraftService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
@@ -266,6 +269,7 @@ const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityServi
const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService };
const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService };
const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService };
+const $NoteDraftEntityService: Provider = { provide: 'NoteDraftEntityService', useExisting: NoteDraftEntityService };
const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService };
const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting: PageEntityService };
const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService };
@@ -335,6 +339,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
+ NoteDraftService,
NotificationService,
PollService,
SystemAccountService,
@@ -416,6 +421,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteEntityService,
NoteFavoriteEntityService,
NoteReactionEntityService,
+ NoteDraftEntityService,
NotificationEntityService,
PageEntityService,
PageLikeEntityService,
@@ -481,6 +487,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
+ $NoteDraftService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -562,6 +569,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteEntityService,
$NoteFavoriteEntityService,
$NoteReactionEntityService,
+ $NoteDraftEntityService,
$NotificationEntityService,
$PageEntityService,
$PageLikeEntityService,
@@ -628,6 +636,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
+ NoteDraftService,
NotificationService,
PollService,
SystemAccountService,
@@ -708,6 +717,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteEntityService,
NoteFavoriteEntityService,
NoteReactionEntityService,
+ NoteDraftEntityService,
NotificationEntityService,
PageEntityService,
PageLikeEntityService,
@@ -773,6 +783,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
+ $NoteDraftService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -852,6 +863,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteEntityService,
$NoteFavoriteEntityService,
$NoteReactionEntityService,
+ $NoteDraftEntityService,
$NotificationEntityService,
$PageEntityService,
$PageLikeEntityService,
diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts
new file mode 100644
index 0000000000..c43be96efa
--- /dev/null
+++ b/packages/backend/src/core/NoteDraftService.ts
@@ -0,0 +1,314 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+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';
+
+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;
+};
+
+@Injectable()
+export class NoteDraftService {
+ constructor(
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.noteDraftsRepository)
+ private noteDraftsRepository: NoteDraftsRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ @Inject(DI.channelsRepository)
+ private channelsRepository: ChannelsRepository,
+
+ private roleService: RoleService,
+ private idService: IdService,
+ private noteEntityService: NoteEntityService,
+ ) {
+ }
+
+ @bindThis
+ public async get(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise<MiNoteDraft | null> {
+ const draft = await this.noteDraftsRepository.findOneBy({
+ id: draftId,
+ userId: me.id,
+ });
+
+ return draft;
+ }
+
+ @bindThis
+ public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> {
+ //#region check draft limit
+
+ const currentCount = await this.noteDraftsRepository.countBy({
+ userId: me.id,
+ });
+ if (currentCount >= (await this.roleService.getUserPolicies(me.id)).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);
+ }
+ }
+
+ const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data);
+
+ appliedDraft.id = this.idService.gen();
+ appliedDraft.userId = me.id;
+ const draft = this.noteDraftsRepository.save(appliedDraft);
+
+ return draft;
+ }
+
+ @bindThis
+ public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise<MiNoteDraft> {
+ const draft = await this.noteDraftsRepository.findOneBy({
+ id: draftId,
+ userId: me.id,
+ });
+
+ if (draft == null) {
+ 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);
+ }
+ }
+
+ const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data);
+
+ return await this.noteDraftsRepository.save(appliedDraft);
+ }
+
+ @bindThis
+ public async delete(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise<void> {
+ const draft = await this.noteDraftsRepository.findOneBy({
+ id: draftId,
+ userId: me.id,
+ });
+
+ if (draft == null) {
+ throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
+ }
+
+ await this.noteDraftsRepository.delete(draft.id);
+ }
+
+ @bindThis
+ public async getDraft(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise<MiNoteDraft> {
+ const draft = await this.noteDraftsRepository.findOneBy({
+ id: draftId,
+ userId: me.id,
+ });
+
+ if (draft == null) {
+ throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
+ }
+
+ return draft;
+ }
+
+ // 関連エンティティを取得し紐づける部分を共通化する
+ @bindThis
+ public async checkAndSetDraftNoteOptions(
+ 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;
+ }
+
+ let appliedDraft = draft;
+
+ //#region visibleUsers
+ let visibleUsers: MiUser[] = [];
+ if (data.visibleUserIds != null) {
+ visibleUsers = await this.usersRepository.findBy({
+ id: In(data.visibleUserIds),
+ });
+ }
+ //#endregion
+
+ //#region files
+ let files: MiDriveFile[] = [];
+ const fileIds = data.fileIds ?? null;
+ if (fileIds != null) {
+ files = await this.driveFilesRepository.createQueryBuilder('file')
+ .where('file.userId = :userId AND file.id IN (:...fileIds)', {
+ userId: me.id,
+ fileIds: fileIds,
+ })
+ .orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
+ .setParameters({ fileIds })
+ .getMany();
+
+ if (files.length !== fileIds.length) {
+ throw new IdentifiableError('b6992544-63e7-67f0-fa7f-32444b1b5306', 'No such drive file');
+ }
+ }
+ //#endregion
+
+ //#region renote
+ let renote: MiNote | null = null;
+ if (data.renoteId != null) {
+ renote = await this.notesRepository.findOneBy({ id: data.renoteId });
+
+ if (renote == null) {
+ throw new IdentifiableError('64929870-2540-4d11-af41-3b484d78c956', 'No such renote');
+ } else if (isRenote(renote) && !isQuote(renote)) {
+ throw new IdentifiableError('76cc5583-5a14-4ad3-8717-0298507e32db', 'Cannot renote');
+ }
+
+ // Check blocking
+ if (renote.userId !== me.id) {
+ const blockExist = await this.blockingsRepository.exists({
+ where: {
+ blockerId: renote.userId,
+ blockeeId: me.id,
+ },
+ });
+ if (blockExist) {
+ throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user');
+ }
+ }
+
+ if (renote.visibility === 'followers' && renote.userId !== me.id) {
+ // 他人のfollowers noteはreject
+ throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility');
+ } else if (renote.visibility === 'specified') {
+ // specified / direct noteはreject
+ throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility');
+ }
+
+ if (renote.channelId && renote.channelId !== data.channelId) {
+ // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
+ // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
+ const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
+ if (renoteChannel == null) {
+ // リノートしたいノートが書き込まれているチャンネルがない
+ throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel');
+ } else if (!renoteChannel.allowRenoteToExternal) {
+ // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
+ throw new IdentifiableError('ed1952ac-2d26-4957-8b30-2deda76bedf7', 'Cannot Renote to External');
+ }
+ }
+ }
+ //#endregion
+
+ //#region reply
+ let reply: MiNote | null = null;
+ if (data.replyId != null) {
+ // Fetch reply
+ reply = await this.notesRepository.findOneBy({ id: data.replyId });
+
+ if (reply == null) {
+ throw new IdentifiableError('c4721841-22fc-4bb7-ad3d-897ef1d375b5', 'No such reply');
+ } else if (isRenote(reply) && !isQuote(reply)) {
+ throw new IdentifiableError('e6c10b57-2c09-4da3-bd4d-eda05d51d140', 'Cannot reply To Pure Renote');
+ } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
+ throw new IdentifiableError('593c323c-6b6a-4501-a25c-2f36bd2a93d6', 'Cannot reply To Invisible Note');
+ } else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
+ throw new IdentifiableError('215dbc76-336c-4d2a-9605-95766ba7dab0', 'Cannot reply To Specified Note With Extended Visibility');
+ }
+
+ // Check blocking
+ if (reply.userId !== me.id) {
+ const blockExist = await this.blockingsRepository.exists({
+ where: {
+ blockerId: reply.userId,
+ blockeeId: me.id,
+ },
+ });
+ if (blockExist) {
+ throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user');
+ }
+ }
+ }
+ //#endregion
+
+ //#region channel
+ 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('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel');
+ }
+ }
+ //#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;
+
+ return appliedDraft;
+ }
+}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 76dafeb255..314f7e221a 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -66,6 +66,7 @@ export type RolePolicies = {
canImportUserLists: boolean;
chatAvailability: 'available' | 'readonly' | 'unavailable';
uploadableFileTypes: string[];
+ noteDraftLimit: number;
};
export const DEFAULT_POLICIES: RolePolicies = {
@@ -109,6 +110,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
'video/*',
'audio/*',
],
+ noteDraftLimit: 10,
};
@Injectable()
@@ -430,6 +432,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
return [...set];
}),
+ noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
};
}
diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts
new file mode 100644
index 0000000000..26455029d5
--- /dev/null
+++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts
@@ -0,0 +1,177 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
+import { DI } from '@/di-symbols.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { MiUser, MiNote, MiNoteDraft } from '@/models/_.js';
+import type { NoteDraftsRepository, ChannelsRepository } from '@/models/_.js';
+import { bindThis } from '@/decorators.js';
+import { DebounceLoader } from '@/misc/loader.js';
+import { IdService } from '@/core/IdService.js';
+import type { OnModuleInit } from '@nestjs/common';
+import type { UserEntityService } from './UserEntityService.js';
+import type { DriveFileEntityService } from './DriveFileEntityService.js';
+import type { NoteEntityService } from './NoteEntityService.js';
+
+@Injectable()
+export class NoteDraftEntityService implements OnModuleInit {
+ private userEntityService: UserEntityService;
+ private driveFileEntityService: DriveFileEntityService;
+ private idService: IdService;
+ private noteEntityService: NoteEntityService;
+ private noteDraftLoader = new DebounceLoader(this.findNoteDraftOrFail);
+
+ constructor(
+ private moduleRef: ModuleRef,
+
+ @Inject(DI.noteDraftsRepository)
+ private noteDraftsRepository: NoteDraftsRepository,
+
+ @Inject(DI.channelsRepository)
+ private channelsRepository: ChannelsRepository,
+ ) {
+ }
+
+ onModuleInit() {
+ this.userEntityService = this.moduleRef.get('UserEntityService');
+ this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
+ this.idService = this.moduleRef.get('IdService');
+ this.noteEntityService = this.moduleRef.get('NoteEntityService');
+ }
+
+ @bindThis
+ public async packAttachedFiles(fileIds: MiNote['fileIds'], packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
+ const missingIds = [];
+ for (const id of fileIds) {
+ if (!packedFiles.has(id)) missingIds.push(id);
+ }
+ if (missingIds.length) {
+ const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds);
+ for (const [k, v] of additionalMap) {
+ packedFiles.set(k, v);
+ }
+ }
+ return fileIds.map(id => packedFiles.get(id)).filter(x => x != null);
+ }
+
+ @bindThis
+ public async pack(
+ src: MiNoteDraft['id'] | MiNoteDraft,
+ me?: { id: MiUser['id'] } | null | undefined,
+ options?: {
+ detail?: boolean;
+ skipHide?: boolean;
+ withReactionAndUserPairCache?: boolean;
+ _hint_?: {
+ packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
+ packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
+ };
+ },
+ ): Promise<Packed<'NoteDraft'>> {
+ const opts = Object.assign({
+ detail: true,
+ }, options);
+
+ const noteDraft = typeof src === 'object' ? src : await this.noteDraftLoader.load(src);
+
+ const text = noteDraft.text;
+
+ const channel = noteDraft.channelId
+ ? noteDraft.channel
+ ? noteDraft.channel
+ : await this.channelsRepository.findOneBy({ id: noteDraft.channelId })
+ : null;
+
+ const packedFiles = options?._hint_?.packedFiles;
+ const packedUsers = options?._hint_?.packedUsers;
+
+ const packed: Packed<'NoteDraft'> = await awaitAll({
+ id: noteDraft.id,
+ createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
+ userId: noteDraft.userId,
+ user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me),
+ text: text,
+ cw: noteDraft.cw,
+ visibility: noteDraft.visibility,
+ localOnly: noteDraft.localOnly,
+ reactionAcceptance: noteDraft.reactionAcceptance,
+ visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined,
+ hashtag: noteDraft.hashtag ?? undefined,
+ 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,
+ channel: channel ? {
+ id: channel.id,
+ name: channel.name,
+ color: channel.color,
+ isSensitive: channel.isSensitive,
+ allowRenoteToExternal: channel.allowRenoteToExternal,
+ userId: channel.userId,
+ } : undefined,
+
+ ...(opts.detail ? {
+ reply: noteDraft.replyId ? this.noteEntityService.pack(noteDraft.replyId, me, {
+ detail: false,
+ skipHide: opts.skipHide,
+ }) : undefined,
+
+ renote: noteDraft.renoteId ? this.noteEntityService.pack(noteDraft.renoteId, me, {
+ detail: true,
+ skipHide: opts.skipHide,
+ }) : undefined,
+
+ poll: noteDraft.hasPoll ? {
+ choices: noteDraft.pollChoices,
+ multiple: noteDraft.pollMultiple,
+ expiresAt: noteDraft.pollExpiresAt?.toISOString(),
+ expiredAfter: noteDraft.pollExpiredAfter,
+ } : undefined,
+ } : {} ),
+ });
+
+ return packed;
+ }
+
+ @bindThis
+ public async packMany(
+ noteDrafts: MiNoteDraft[],
+ me?: { id: MiUser['id'] } | null | undefined,
+ options?: {
+ detail?: boolean;
+ },
+ ) {
+ if (noteDrafts.length === 0) return [];
+
+ // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
+ const fileIds = noteDrafts.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
+ const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
+ const users = [
+ ...noteDrafts.map(({ user, userId }) => user ?? userId),
+ ];
+ const packedUsers = await this.userEntityService.packMany(users, me)
+ .then(users => new Map(users.map(u => [u.id, u])));
+
+ return await Promise.all(noteDrafts.map(n => this.pack(n, me, {
+ ...options,
+ _hint_: {
+ packedFiles,
+ packedUsers,
+ },
+ })));
+ }
+
+ @bindThis
+ private findNoteDraftOrFail(id: string): Promise<MiNoteDraft> {
+ return this.noteDraftsRepository.findOneOrFail({
+ where: { id },
+ relations: ['user'],
+ });
+ }
+}
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index 77d2838e09..c915133453 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -89,5 +89,6 @@ export const DI = {
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
+ noteDraftsRepository: Symbol('noteDraftsRepository'),
//#endregion
};
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 5e5d7041b9..ed47edff9b 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -72,6 +72,7 @@ import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
+import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -89,6 +90,7 @@ export const refs = {
Announcement: packedAnnouncementSchema,
App: packedAppSchema,
Note: packedNoteSchema,
+ NoteDraft: packedNoteDraftSchema,
NoteReaction: packedNoteReactionSchema,
NoteFavorite: packedNoteFavoriteSchema,
Notification: packedNotificationSchema,
diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts
index 3dcbdb735b..0560ee17c0 100644
--- a/packages/backend/src/models/Note.ts
+++ b/packages/backend/src/models/Note.ts
@@ -4,7 +4,7 @@
*/
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
-import { noteVisibilities } from '@/types.js';
+import { noteVisibilities, noteReactionAcceptances } from '@/types.js';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiChannel } from './Channel.js';
@@ -96,7 +96,7 @@ export class MiNote {
@Column('varchar', {
length: 64, nullable: true,
})
- public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
+ public reactionAcceptance: typeof noteReactionAcceptances[number];
@Column('smallint', {
default: 0,
diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts
new file mode 100644
index 0000000000..edae254bb8
--- /dev/null
+++ b/packages/backend/src/models/NoteDraft.ts
@@ -0,0 +1,157 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
+import { noteVisibilities, noteReactionAcceptances } from '@/types.js';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiChannel } from './Channel.js';
+import { MiNote } from './Note.js';
+import type { MiDriveFile } from './DriveFile.js';
+
+@Entity('note_draft')
+export class MiNoteDraft {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ nullable: true,
+ comment: 'The ID of reply target.',
+ })
+ public replyId: MiNote['id'] | null;
+
+ @ManyToOne(type => MiNote, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public reply: MiNote | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ nullable: true,
+ comment: 'The ID of renote target.',
+ })
+ public renoteId: MiNote['id'] | null;
+
+ @ManyToOne(type => MiNote, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public renote: MiNote | null;
+
+ // TODO: varcharにしたい(Note.tsと同じ)
+ @Column('text', {
+ nullable: true,
+ })
+ public text: string | null;
+
+ @Column('varchar', {
+ length: 512, nullable: true,
+ })
+ public cw: string | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ comment: 'The ID of author.',
+ })
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public localOnly: boolean;
+
+ @Column('varchar', {
+ length: 64, nullable: true,
+ })
+ public reactionAcceptance: typeof noteReactionAcceptances[number];
+
+ /**
+ * public ... 公開
+ * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す
+ * followers ... フォロワーのみ
+ * specified ... visibleUserIds で指定したユーザーのみ
+ */
+ @Column('enum', { enum: noteVisibilities })
+ public visibility: typeof noteVisibilities[number];
+
+ @Index('IDX_NOTE_DRAFT_FILE_IDS', { synchronize: false })
+ @Column({
+ ...id(),
+ array: true, default: '{}',
+ })
+ public fileIds: MiDriveFile['id'][];
+
+ @Index('IDX_NOTE_DRAFT_VISIBLE_USER_IDS', { synchronize: false })
+ @Column({
+ ...id(),
+ array: true, default: '{}',
+ })
+ public visibleUserIds: MiUser['id'][];
+
+ @Column('varchar', {
+ length: 128, nullable: true,
+ })
+ public hashtag: string | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ nullable: true,
+ comment: 'The ID of source channel.',
+ })
+ public channelId: MiChannel['id'] | null;
+
+ @ManyToOne(type => MiChannel, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public channel: MiChannel | null;
+
+ // 以下、Pollについて追加
+
+ @Column('boolean', {
+ default: false,
+ })
+ public hasPoll: boolean;
+
+ @Column('varchar', {
+ length: 256, array: true, default: '{}',
+ })
+ public pollChoices: string[];
+
+ @Column('boolean')
+ public pollMultiple: boolean;
+
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public pollExpiresAt: Date | null;
+
+ @Column('bigint', {
+ nullable: true,
+ })
+ public pollExpiredAfter: number | null;
+
+ // ここまで追加
+
+ constructor(data: Partial<MiNoteDraft>) {
+ if (data == null) return;
+
+ for (const [k, v] of Object.entries(data)) {
+ (this as any)[k] = v;
+ }
+ }
+}
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index b7142d91bf..146dbbc3b8 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -42,6 +42,7 @@ import {
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
+ MiNoteDraft,
MiPage,
MiPageLike,
MiPasswordResetRequest,
@@ -140,6 +141,12 @@ const $noteReactionsRepository: Provider = {
inject: [DI.db],
};
+const $noteDraftsRepository: Provider = {
+ provide: DI.noteDraftsRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiNoteDraft).extend(miRepository as MiRepository<MiNoteDraft>),
+ inject: [DI.db],
+};
+
const $pollsRepository: Provider = {
provide: DI.pollsRepository,
useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository<MiPoll>),
@@ -542,6 +549,7 @@ const $reversiGamesRepository: Provider = {
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
+ $noteDraftsRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
@@ -618,6 +626,7 @@ const $reversiGamesRepository: Provider = {
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
+ $noteDraftsRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index e1ea2a2604..84b5cbed0a 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -55,6 +55,7 @@ import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js';
import { MiNote } from '@/models/Note.js';
+import { MiNoteDraft } from '@/models/NoteDraft.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
@@ -188,6 +189,7 @@ export {
MiMuting,
MiRenoteMuting,
MiNote,
+ MiNoteDraft,
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
@@ -266,6 +268,7 @@ export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepositor
export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>;
export type NotesRepository = Repository<MiNote> & MiRepository<MiNote>;
+export type NoteDraftsRepository = Repository<MiNoteDraft> & MiRepository<MiNoteDraft>;
export type NoteFavoritesRepository = Repository<MiNoteFavorite> & MiRepository<MiNoteFavorite>;
export type NoteReactionsRepository = Repository<MiNoteReaction> & MiRepository<MiNoteReaction>;
export type NoteThreadMutingsRepository = Repository<MiNoteThreadMuting> & MiRepository<MiNoteThreadMuting>;
diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts
new file mode 100644
index 0000000000..20c56d0795
--- /dev/null
+++ b/packages/backend/src/models/json-schema/note-draft.ts
@@ -0,0 +1,169 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedNoteDraftSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ example: 'xxxxxxxxxx',
+ },
+ createdAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'date-time',
+ },
+ text: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ cw: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ userId: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ user: {
+ type: 'object',
+ ref: 'UserLite',
+ optional: false, nullable: false,
+ },
+ replyId: {
+ type: 'string',
+ optional: true, nullable: true,
+ format: 'id',
+ example: 'xxxxxxxxxx',
+ },
+ renoteId: {
+ type: 'string',
+ optional: true, nullable: true,
+ format: 'id',
+ example: 'xxxxxxxxxx',
+ },
+ reply: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'Note',
+ },
+ renote: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'Note',
+ },
+ visibility: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['public', 'home', 'followers', 'specified'],
+ },
+ visibleUserIds: {
+ type: 'array',
+ optional: true, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ },
+ fileIds: {
+ type: 'array',
+ optional: true, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ },
+ files: {
+ type: 'array',
+ optional: true, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'DriveFile',
+ },
+ },
+ hashtag: {
+ type: 'string',
+ optional: true, nullable: false,
+ },
+ poll: {
+ type: 'object',
+ optional: true, nullable: true,
+ properties: {
+ expiresAt: {
+ type: 'string',
+ optional: true, nullable: true,
+ format: 'date-time',
+ },
+ expiredAfter: {
+ type: 'number',
+ optional: true, nullable: true,
+ },
+ multiple: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ choices: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
+ },
+ },
+ channelId: {
+ type: 'string',
+ optional: true, nullable: true,
+ format: 'id',
+ example: 'xxxxxxxxxx',
+ },
+ channel: {
+ type: 'object',
+ optional: true, nullable: true,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ name: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ color: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ isSensitive: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ allowRenoteToExternal: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ userId: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ },
+ },
+ localOnly: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ reactionAcceptance: {
+ type: 'string',
+ optional: false, nullable: true,
+ enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null],
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 8bd01c92a3..a3f679129d 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = {
optional: false, nullable: false,
enum: ['available', 'readonly', 'unavailable'],
},
+ noteDraftLimit: {
+ type: 'integer',
+ optional: false, nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index b06895fcc9..f6cbbbe64c 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -45,6 +45,7 @@ import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
+import { MiNoteDraft } from '@/models/NoteDraft.js';
import { MiPage } from '@/models/Page.js';
import { MiPageLike } from '@/models/PageLike.js';
import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
@@ -210,6 +211,7 @@ export const entities = [
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
+ MiNoteDraft,
MiPage,
MiPageLike,
MiGalleryPost,
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 092d296bd3..f7b2fad341 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -307,6 +307,11 @@ export * as 'notes/clips' from './endpoints/notes/clips.js';
export * as 'notes/conversation' from './endpoints/notes/conversation.js';
export * as 'notes/create' from './endpoints/notes/create.js';
export * as 'notes/delete' from './endpoints/notes/delete.js';
+export * as 'notes/drafts/list' from './endpoints/notes/drafts/list.js';
+export * as 'notes/drafts/create' from './endpoints/notes/drafts/create.js';
+export * as 'notes/drafts/delete' from './endpoints/notes/drafts/delete.js';
+export * as 'notes/drafts/update' from './endpoints/notes/drafts/update.js';
+export * as 'notes/drafts/count' from './endpoints/notes/drafts/count.js';
export * as 'notes/favorites/create' from './endpoints/notes/favorites/create.js';
export * as 'notes/favorites/delete' from './endpoints/notes/favorites/delete.js';
export * as 'notes/featured' from './endpoints/notes/featured.js';
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/count.ts b/packages/backend/src/server/api/endpoints/notes/drafts/count.ts
new file mode 100644
index 0000000000..002a545d32
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/count.ts
@@ -0,0 +1,51 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { NoteDraftsRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ tags: ['notes', 'drafts'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'read:account',
+
+ res: {
+ type: 'number',
+ optional: false, nullable: false,
+ description: 'The number of drafts',
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.noteDraftsRepository)
+ private noteDraftsRepository: NoteDraftsRepository,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const count = await this.noteDraftsRepository.createQueryBuilder('drafts')
+ .where('drafts.userId = :meId', { meId: me.id })
+ .getCount();
+
+ return count;
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
new file mode 100644
index 0000000000..1c28ec22d0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts
@@ -0,0 +1,258 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { NoteDraftService } from '@/core/NoteDraftService.js';
+import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
+import { ApiError } from '@/server/api/error.js';
+import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+
+export const meta = {
+ tags: ['notes', 'drafts'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:account',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ createdDraft: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'NoteDraft',
+ },
+ },
+ },
+
+ errors: {
+ noSuchRenoteTarget: {
+ message: 'No such renote target.',
+ code: 'NO_SUCH_RENOTE_TARGET',
+ id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4',
+ },
+
+ cannotReRenote: {
+ message: 'You can not Renote a pure Renote.',
+ code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE',
+ id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
+ },
+
+ cannotRenoteDueToVisibility: {
+ message: 'You can not Renote due to target visibility.',
+ code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
+ id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
+ },
+
+ noSuchReplyTarget: {
+ message: 'No such reply target.',
+ code: 'NO_SUCH_REPLY_TARGET',
+ id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
+ },
+
+ cannotReplyToInvisibleNote: {
+ message: 'You cannot reply to an invisible Note.',
+ code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE',
+ id: 'b98980fa-3780-406c-a935-b6d0eeee10d1',
+ },
+
+ cannotReplyToPureRenote: {
+ message: 'You can not reply to a pure Renote.',
+ code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
+ id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
+ },
+
+ cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
+ message: 'You cannot reply to a specified visibility note with extended visibility.',
+ code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
+ id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
+ },
+
+ cannotCreateAlreadyExpiredPoll: {
+ message: 'Poll is already expired.',
+ code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
+ id: '04da457d-b083-4055-9082-955525eda5a5',
+ },
+
+ noSuchChannel: {
+ message: 'No such channel.',
+ code: 'NO_SUCH_CHANNEL',
+ id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb',
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
+ },
+
+ noSuchFile: {
+ message: 'Some files are not found.',
+ code: 'NO_SUCH_FILE',
+ id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
+ },
+
+ cannotRenoteOutsideOfChannel: {
+ message: 'Cannot renote outside of channel.',
+ code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
+ id: '33510210-8452-094c-6227-4a6c05d99f00',
+ },
+
+ containsProhibitedWords: {
+ message: 'Cannot post because it contains prohibited words.',
+ code: 'CONTAINS_PROHIBITED_WORDS',
+ id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
+ },
+
+ containsTooManyMentions: {
+ message: 'Cannot post because it exceeds the allowed number of mentions.',
+ code: 'CONTAINS_TOO_MANY_MENTIONS',
+ id: '4de0363a-3046-481b-9b0f-feff3e211025',
+ },
+
+ tooManyDrafts: {
+ message: 'You cannot create drafts any more.',
+ code: 'TOO_MANY_DRAFTS',
+ id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8',
+ },
+
+ cannotRenoteToExternal: {
+ message: 'Cannot Renote to External.',
+ code: 'CANNOT_RENOTE_TO_EXTERNAL',
+ id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7',
+ },
+ },
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
+ visibleUserIds: { type: 'array', uniqueItems: true, items: {
+ type: 'string', format: 'misskey:id',
+ } },
+ cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
+ 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 },
+ replyId: { type: 'string', format: 'misskey:id', nullable: true },
+ renoteId: { type: 'string', format: 'misskey:id', nullable: true },
+ channelId: { type: 'string', format: 'misskey:id', nullable: true },
+
+ // anyOf内にバリデーションを書いても最初の一つしかチェックされない
+ text: {
+ type: 'string',
+ minLength: 0,
+ maxLength: MAX_NOTE_TEXT_LENGTH,
+ nullable: true,
+ },
+ fileIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ poll: {
+ type: 'object',
+ nullable: true,
+ properties: {
+ choices: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 0,
+ maxItems: 10,
+ items: { type: 'string', minLength: 1, maxLength: 50 },
+ },
+ multiple: { type: 'boolean' },
+ expiresAt: { type: 'integer', nullable: true },
+ expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
+ },
+ required: ['choices'],
+ },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private noteDraftService: NoteDraftService,
+ private noteDraftEntityService: NoteDraftEntityService,
+ ) {
+ 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 } : {}),
+ localOnly: ps.localOnly,
+ reactionAcceptance: ps.reactionAcceptance,
+ visibility: ps.visibility,
+ visibleUserIds: ps.visibleUserIds ?? [],
+ channelId: ps.channelId ?? undefined,
+ }).catch((err) => {
+ if (err instanceof IdentifiableError) {
+ switch (err.id) {
+ case '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8':
+ throw new ApiError(meta.errors.tooManyDrafts);
+ case '04da457d-b083-4055-9082-955525eda5a5':
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+ case 'b6992544-63e7-67f0-fa7f-32444b1b5306':
+ throw new ApiError(meta.errors.noSuchFile);
+ case '64929870-2540-4d11-af41-3b484d78c956':
+ throw new ApiError(meta.errors.noSuchRenoteTarget);
+ case '76cc5583-5a14-4ad3-8717-0298507e32db':
+ throw new ApiError(meta.errors.cannotReRenote);
+ case '075ca298-e6e7-485a-b570-51a128bb5168':
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ case '81eb8188-aea1-4e35-9a8f-3334a3be9855':
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ case '6815399a-6f13-4069-b60d-ed5156249d12':
+ throw new ApiError(meta.errors.noSuchChannel);
+ case 'ed1952ac-2d26-4957-8b30-2deda76bedf7':
+ throw new ApiError(meta.errors.cannotRenoteToExternal);
+ case 'c4721841-22fc-4bb7-ad3d-897ef1d375b5':
+ throw new ApiError(meta.errors.noSuchReplyTarget);
+ case 'e6c10b57-2c09-4da3-bd4d-eda05d51d140':
+ throw new ApiError(meta.errors.cannotReplyToPureRenote);
+ case '593c323c-6b6a-4501-a25c-2f36bd2a93d6':
+ throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
+ case '215dbc76-336c-4d2a-9605-95766ba7dab0':
+ throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
+ default:
+ throw err;
+ }
+ }
+ throw err;
+ });
+
+ const createdDraft = await this.noteDraftEntityService.pack(draft, me);
+
+ return {
+ createdDraft,
+ };
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/delete.ts b/packages/backend/src/server/api/endpoints/notes/drafts/delete.ts
new file mode 100644
index 0000000000..6c41145c18
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/delete.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { NoteDraftService } from '@/core/NoteDraftService.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+ tags: ['notes', 'drafts'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:account',
+
+ errors: {
+ noSuchNoteDraft: {
+ message: 'No such note draft.',
+ code: 'NO_SUCH_NOTE_DRAFT',
+ id: '49cd6b9d-848e-41ee-b0b9-adaca711a6b1',
+ },
+
+ accessDenied: {
+ message: 'Access denied.',
+ code: 'ACCESS_DENIED',
+ id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ draftId: { type: 'string', nullable: false, format: 'misskey:id' },
+ },
+ required: ['draftId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private noteDraftService: NoteDraftService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const draft = await this.noteDraftService.get(me, ps.draftId);
+ if (draft == null) {
+ throw new ApiError(meta.errors.noSuchNoteDraft);
+ }
+
+ if (draft.userId !== me.id) {
+ throw new ApiError(meta.errors.accessDenied);
+ }
+
+ await this.noteDraftService.delete(me, draft.id);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
new file mode 100644
index 0000000000..1834585aeb
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { MiNoteDraft, NoteDraftsRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { QueryService } from '@/core/QueryService.js';
+import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js';
+
+export const meta = {
+ tags: ['notes', 'drafts'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'read:account',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'NoteDraft',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.noteDraftsRepository)
+ private noteDraftsRepository: NoteDraftsRepository,
+
+ private queryService: QueryService,
+ private noteDraftEntityService: NoteDraftEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.queryService.makePaginationQuery<MiNoteDraft>(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId)
+ .andWhere('drafts.userId = :meId', { meId: me.id });
+
+ const drafts = await query
+ .limit(ps.limit)
+ .getMany();
+
+ return await this.noteDraftEntityService.packMany(drafts, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
new file mode 100644
index 0000000000..ee221fb765
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts
@@ -0,0 +1,302 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { NoteDraftService } from '@/core/NoteDraftService.js';
+import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
+import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+ tags: ['notes', 'drafts'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ kind: 'write:account',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ updatedDraft: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'NoteDraft',
+ },
+ },
+ },
+
+ errors: {
+ noSuchRenoteTarget: {
+ message: 'No such renote target.',
+ code: 'NO_SUCH_RENOTE_TARGET',
+ id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4',
+ },
+
+ cannotReRenote: {
+ message: 'You can not Renote a pure Renote.',
+ code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE',
+ id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
+ },
+
+ cannotRenoteDueToVisibility: {
+ message: 'You can not Renote due to target visibility.',
+ code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
+ id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
+ },
+
+ noSuchReplyTarget: {
+ message: 'No such reply target.',
+ code: 'NO_SUCH_REPLY_TARGET',
+ id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
+ },
+
+ cannotReplyToInvisibleNote: {
+ message: 'You cannot reply to an invisible Note.',
+ code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE',
+ id: 'b98980fa-3780-406c-a935-b6d0eeee10d1',
+ },
+
+ cannotReplyToPureRenote: {
+ message: 'You can not reply to a pure Renote.',
+ code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
+ id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
+ },
+
+ cannotReplyToSpecifiedNoteWithExtendedVisibility: {
+ message: 'You cannot reply to a specified visibility note with extended visibility.',
+ code: 'CANNOT_REPLY_TO_SPECIFIED_NOTE_WITH_EXTENDED_VISIBILITY',
+ id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
+ },
+
+ cannotCreateAlreadyExpiredPoll: {
+ message: 'Poll is already expired.',
+ code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
+ id: '04da457d-b083-4055-9082-955525eda5a5',
+ },
+
+ noSuchChannel: {
+ message: 'No such channel.',
+ code: 'NO_SUCH_CHANNEL',
+ id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb',
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
+ },
+
+ noSuchFile: {
+ message: 'Some files are not found.',
+ code: 'NO_SUCH_FILE',
+ id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
+ },
+
+ cannotRenoteOutsideOfChannel: {
+ message: 'Cannot renote outside of channel.',
+ code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
+ id: '33510210-8452-094c-6227-4a6c05d99f00',
+ },
+
+ containsProhibitedWords: {
+ message: 'Cannot post because it contains prohibited words.',
+ code: 'CONTAINS_PROHIBITED_WORDS',
+ id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
+ },
+
+ containsTooManyMentions: {
+ message: 'Cannot post because it exceeds the allowed number of mentions.',
+ code: 'CONTAINS_TOO_MANY_MENTIONS',
+ id: '4de0363a-3046-481b-9b0f-feff3e211025',
+ },
+
+ noSuchNoteDraft: {
+ message: 'No such note draft.',
+ code: 'NO_SUCH_NOTE_DRAFT',
+ id: '49cd6b9d-848e-41ee-b0b9-adaca711a6b1',
+ },
+
+ accessDenied: {
+ message: 'Access denied.',
+ code: 'ACCESS_DENIED',
+ id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e',
+ },
+
+ noSuchRenote: {
+ message: 'No such renote.',
+ code: 'NO_SUCH_RENOTE',
+ id: '64929870-2540-4d11-af41-3b484d78c956',
+ },
+
+ cannotRenote: {
+ message: 'Cannot renote.',
+ code: 'CANNOT_RENOTE',
+ id: '76cc5583-5a14-4ad3-8717-0298507e32db',
+ },
+
+ cannotRenoteToExternal: {
+ message: 'Cannot Renote to External.',
+ code: 'CANNOT_RENOTE_TO_EXTERNAL',
+ id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7',
+ },
+
+ noSuchReply: {
+ message: 'No such reply.',
+ code: 'NO_SUCH_REPLY',
+ id: 'c4721841-22fc-4bb7-ad3d-897ef1d375b5',
+ },
+
+ cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
+ message: 'You cannot reply to a specified visibility note with extended visibility.',
+ code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
+ id: '215dbc76-336c-4d2a-9605-95766ba7dab0',
+ },
+ },
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ draftId: { type: 'string', nullable: false, format: 'misskey:id' },
+ visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
+ visibleUserIds: { type: 'array', uniqueItems: true, items: {
+ type: 'string', format: 'misskey:id',
+ } },
+ cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
+ 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 },
+ replyId: { type: 'string', format: 'misskey:id', nullable: true },
+ renoteId: { type: 'string', format: 'misskey:id', nullable: true },
+ channelId: { type: 'string', format: 'misskey:id', nullable: true },
+
+ // anyOf内にバリデーションを書いても最初の一つしかチェックされない
+ // See https://github.com/misskey-dev/misskey/pull/10082
+ text: {
+ type: 'string',
+ minLength: 0,
+ maxLength: MAX_NOTE_TEXT_LENGTH,
+ nullable: true,
+ },
+ fileIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ poll: {
+ type: 'object',
+ nullable: true,
+ properties: {
+ choices: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 0,
+ maxItems: 10,
+ items: { type: 'string', minLength: 1, maxLength: 50 },
+ },
+ multiple: { type: 'boolean' },
+ expiresAt: { type: 'integer', nullable: true },
+ expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
+ },
+ required: ['choices'],
+ },
+ },
+ required: ['draftId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private noteDraftService: NoteDraftService,
+ private noteDraftEntityService: NoteDraftEntityService,
+ ) {
+ 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 } : {}),
+ localOnly: ps.localOnly,
+ reactionAcceptance: ps.reactionAcceptance,
+ visibility: ps.visibility,
+ visibleUserIds: ps.visibleUserIds ?? [],
+ channelId: ps.channelId ?? undefined,
+ }).catch((err) => {
+ if (err instanceof IdentifiableError) {
+ switch (err.id) {
+ case '49cd6b9d-848e-41ee-b0b9-adaca711a6b1':
+ throw new ApiError(meta.errors.noSuchNoteDraft);
+ case '04da457d-b083-4055-9082-955525eda5a5':
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+ case 'b6992544-63e7-67f0-fa7f-32444b1b5306':
+ throw new ApiError(meta.errors.noSuchFile);
+ case '64929870-2540-4d11-af41-3b484d78c956':
+ throw new ApiError(meta.errors.noSuchRenote);
+ case '76cc5583-5a14-4ad3-8717-0298507e32db':
+ throw new ApiError(meta.errors.cannotRenote);
+ case '075ca298-e6e7-485a-b570-51a128bb5168':
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ case '81eb8188-aea1-4e35-9a8f-3334a3be9855':
+ throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
+ case '6815399a-6f13-4069-b60d-ed5156249d12':
+ throw new ApiError(meta.errors.noSuchChannel);
+ case 'ed1952ac-2d26-4957-8b30-2deda76bedf7':
+ throw new ApiError(meta.errors.cannotRenoteToExternal);
+ case 'c4721841-22fc-4bb7-ad3d-897ef1d375b5':
+ throw new ApiError(meta.errors.noSuchReply);
+ case 'e6c10b57-2c09-4da3-bd4d-eda05d51d140':
+ throw new ApiError(meta.errors.cannotReplyToPureRenote);
+ case '593c323c-6b6a-4501-a25c-2f36bd2a93d6':
+ throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
+ case '215dbc76-336c-4d2a-9605-95766ba7dab0':
+ throw new ApiError(meta.errors.cannotReplyToSpecifiedNoteWithExtendedVisibility);
+ case 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4':
+ throw new ApiError(meta.errors.noSuchRenoteTarget);
+ case 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a':
+ throw new ApiError(meta.errors.cannotReRenote);
+ case '749ee0f6-d3da-459a-bf02-282e2da4292c':
+ throw new ApiError(meta.errors.noSuchReplyTarget);
+ case '33510210-8452-094c-6227-4a6c05d99f00':
+ throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
+ case 'aa6e01d3-a85c-669d-758a-76aab43af334':
+ throw new ApiError(meta.errors.containsProhibitedWords);
+ case '4de0363a-3046-481b-9b0f-feff3e211025':
+ throw new ApiError(meta.errors.containsTooManyMentions);
+ default:
+ throw err;
+ }
+ }
+ throw err;
+ });
+
+ const updatedDraft = await this.noteDraftEntityService.pack(draft, me);
+
+ return {
+ updatedDraft,
+ };
+ });
+ }
+}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 5d5f1e3b71..b20f2a2179 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -54,6 +54,8 @@ export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
+export const noteReactionAcceptances = ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null] as const;
+
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
export const followingVisibilities = ['public', 'followers', 'private'] as const;
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index c4c4a25d74..4498a5e2b2 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -111,6 +111,7 @@ export const ROLE_POLICIES = [
'canImportUserLists',
'chatAvailability',
'uploadableFileTypes',
+ 'noteDraftLimit',
] as const;
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue
new file mode 100644
index 0000000000..b4aff8d16f
--- /dev/null
+++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue
@@ -0,0 +1,218 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialogEl"
+ :width="600"
+ :height="650"
+ :withOkButton="false"
+ @click="cancel()"
+ @close="cancel()"
+ @closed="emit('closed')"
+ @esc="cancel()"
+>
+ <template #header>
+ {{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
+ </template>
+ <div :class="$style.drafts" class="_gaps">
+ <MkPagination ref="pagingEl" :pagination="paging">
+ <template #empty>
+ <MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
+ </template>
+
+ <template #default="{ items }">
+ <div class="_spacer _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-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-if="draft.channel" class="_nowrap">
+ <i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
+ </div>
+ </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>
+ </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>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, useTemplateRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import type { PagingCtx } from '@/composables/use-pagination.js';
+import MkButton from '@/components/MkButton.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { getNoteSummary } from '@/utility/get-note-summary.js';
+import { i18n } from '@/i18n.js';
+import * as os from '@/os.js';
+import { $i } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api';
+
+const emit = defineEmits<{
+ (ev: 'restore', draft: Misskey.entities.NoteDraft): void;
+ (ev: 'cancel'): void;
+ (ev: 'closed'): void;
+}>();
+
+const paging = {
+ endpoint: 'notes/drafts/list',
+ limit: 10,
+} satisfies PagingCtx;
+
+const pagingComponent = useTemplateRef('pagingEl');
+
+const currentDraftsCount = ref(0);
+misskeyApi('notes/drafts/count').then((count) => {
+ currentDraftsCount.value = count;
+});
+
+const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+function cancel() {
+ emit('cancel');
+ dialogEl.value?.close();
+}
+
+function restoreDraft(draft: Misskey.entities.NoteDraft) {
+ emit('restore', draft);
+ dialogEl.value?.close();
+}
+
+async function deleteDraft(draft: Misskey.entities.NoteDraft) {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts._drafts.deleteAreYouSure,
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => {
+ pagingComponent.value?.paginator.reload();
+ });
+}
+</script>
+
+<style lang="scss" module>
+.drafts {
+ overflow-x: hidden;
+ overflow-x: clip;
+ overflow-y: auto;
+}
+
+.draft {
+ padding: 16px;
+ gap: 16px;
+ border-radius: 10px;
+}
+
+.draftBody {
+ width: 100%;
+ min-width: 0;
+}
+
+.draftInfo {
+ display: flex;
+ width: 100%;
+ font-size: 0.85em;
+ opacity: 0.7;
+}
+
+.draftMeta {
+ flex-grow: 1;
+ min-width: 0;
+}
+
+.draftContent {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+ font-size: 0.9em;
+}
+
+.draftFooter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.draftVisibility {
+ flex-shrink: 0;
+}
+
+.draftCreatedAt {
+ font-size: 85%;
+ opacity: 0.7;
+}
+
+.draftActions {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 1px var(--MI_THEME-divider);
+}
+</style>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index e319c9bacb..f8e163c581 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -17,10 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
</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>
</div>
<div :class="$style.headerRight">
- <template v-if="!(channel != null && fixed)">
- <button v-if="channel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
+ <template v-if="!(targetChannel != null && fixed)">
+ <button v-if="targetChannel == null" ref="visibilityButton" v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
<span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span>
@@ -29,10 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
<button v-else class="_button" :class="[$style.headerRightItem, $style.visibility]" disabled>
<span><i class="ti ti-device-tv"></i></span>
- <span :class="$style.headerRightButtonText">{{ channel.name }}</span>
+ <span :class="$style.headerRightButtonText">{{ targetChannel.name }}</span>
</button>
</template>
- <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
+ <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified'" @click="toggleLocalOnly">
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
@@ -42,12 +43,12 @@ 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' : reply ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
+ <i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
</div>
</button>
</div>
</header>
- <MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
+ <MkNoteSimple v-if="replyTargetNote" :class="$style.targetNote" :note="replyTargetNote"/>
<MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :note="renoteTargetNote"/>
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null; renoteTargetNote = null;"><i class="ti ti-x"></i></button></div>
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
@@ -66,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div>
</div>
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
- <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div>
+ <div v-if="targetChannel" :class="$style.colorBar" :style="{ background: targetChannel.color }"></div>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
@@ -207,6 +208,10 @@ const showingOptions = ref(false);
const textAreaReadOnly = ref(false);
const justEndedComposition = ref(false);
const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
+const replyTargetNote: ShallowRef<PostFormProps['reply'] | null> = shallowRef(props.reply);
+const targetChannel = shallowRef(props.channel);
+
+const serverDraftId = ref<string | null>(null);
const postFormActions = getPluginHandlers('post_form_action');
const uploader = useUploader({
@@ -219,12 +224,12 @@ uploader.events.on('itemUploaded', ctx => {
});
const draftKey = computed((): string => {
- let key = props.channel ? `channel:${props.channel.id}` : '';
+ let key = targetChannel.value ? `channel:${targetChannel.value.id}` : '';
if (renoteTargetNote.value) {
key += `renote:${renoteTargetNote.value.id}`;
- } else if (props.reply) {
- key += `reply:${props.reply.id}`;
+ } else if (replyTargetNote.value) {
+ key += `reply:${replyTargetNote.value.id}`;
} else {
key += `note:${$i.id}`;
}
@@ -235,9 +240,9 @@ const draftKey = computed((): string => {
const placeholder = computed((): string => {
if (renoteTargetNote.value) {
return i18n.ts._postForm.quotePlaceholder;
- } else if (props.reply) {
+ } else if (replyTargetNote.value) {
return i18n.ts._postForm.replyPlaceholder;
- } else if (props.channel) {
+ } else if (targetChannel.value) {
return i18n.ts._postForm.channelPlaceholder;
} else {
const xs = [
@@ -255,7 +260,7 @@ const placeholder = computed((): string => {
const submitText = computed((): string => {
return renoteTargetNote.value
? i18n.ts.quote
- : props.reply
+ : replyTargetNote.value
? i18n.ts.reply
: i18n.ts.note;
});
@@ -296,6 +301,11 @@ const canPost = computed((): boolean => {
(!poll.value || poll.value.choices.length >= 2);
});
+// cannot save pure renote as draft
+const canSaveAsServerDraft = computed((): boolean => {
+ return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null);
+});
+
const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
const hashtags = computed(store.makeGetterSetter('postFormHashtags'));
@@ -318,13 +328,13 @@ if (props.mention) {
text.value += ' ';
}
-if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
- text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
+if (replyTargetNote.value && (replyTargetNote.value.user.username !== $i.username || (replyTargetNote.value.user.host != null && replyTargetNote.value.user.host !== host))) {
+ text.value = `@${replyTargetNote.value.user.username}${replyTargetNote.value.user.host != null ? '@' + toASCII(replyTargetNote.value.user.host) : ''} `;
}
-if (props.reply && props.reply.text != null) {
- const ast = mfm.parse(props.reply.text);
- const otherHost = props.reply.user.host;
+if (replyTargetNote.value && replyTargetNote.value.text != null) {
+ const ast = mfm.parse(replyTargetNote.value.text);
+ const otherHost = replyTargetNote.value.user.host;
for (const x of extractMentions(ast)) {
const mention = x.host ?
@@ -347,32 +357,32 @@ if ($i.isSilenced && visibility.value === 'public') {
visibility.value = 'home';
}
-if (props.channel) {
+if (targetChannel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
}
// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
-if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
- if (props.reply.visibility === 'home' && visibility.value === 'followers') {
+if (replyTargetNote.value && ['home', 'followers', 'specified'].includes(replyTargetNote.value.visibility)) {
+ if (replyTargetNote.value.visibility === 'home' && visibility.value === 'followers') {
visibility.value = 'followers';
- } else if (['home', 'followers'].includes(props.reply.visibility) && visibility.value === 'specified') {
+ } else if (['home', 'followers'].includes(replyTargetNote.value.visibility) && visibility.value === 'specified') {
visibility.value = 'specified';
} else {
- visibility.value = props.reply.visibility;
+ visibility.value = replyTargetNote.value.visibility;
}
if (visibility.value === 'specified') {
- if (props.reply.visibleUserIds) {
+ if (replyTargetNote.value.visibleUserIds) {
misskeyApi('users/show', {
- userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
+ userIds: replyTargetNote.value.visibleUserIds.filter(uid => uid !== $i.id && uid !== replyTargetNote.value?.userId),
}).then(users => {
users.forEach(u => pushVisibleUser(u));
});
}
- if (props.reply.userId !== $i.id) {
- misskeyApi('users/show', { userId: props.reply.userId }).then(user => {
+ if (replyTargetNote.value.userId !== $i.id) {
+ misskeyApi('users/show', { userId: replyTargetNote.value.userId }).then(user => {
pushVisibleUser(user);
});
}
@@ -385,9 +395,9 @@ if (props.specified) {
}
// keep cw when reply
-if (prefer.s.keepCw && props.reply && props.reply.cw) {
+if (prefer.s.keepCw && replyTargetNote.value && replyTargetNote.value.cw) {
useCw.value = true;
- cw.value = props.reply.cw;
+ cw.value = replyTargetNote.value.cw;
}
function watchForDraft() {
@@ -485,7 +495,7 @@ function updateFileName(file, name) {
}
function setVisibility() {
- if (props.channel) {
+ if (targetChannel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
return;
@@ -496,7 +506,7 @@ function setVisibility() {
isSilenced: $i.isSilenced,
localOnly: localOnly.value,
anchorElement: visibilityButton.value,
- ...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
+ ...(replyTargetNote.value ? { isReplyVisibilitySpecified: replyTargetNote.value.visibility === 'specified' } : {}),
}, {
changeVisibility: v => {
visibility.value = v;
@@ -509,7 +519,7 @@ function setVisibility() {
}
async function toggleLocalOnly() {
- if (props.channel) {
+ if (targetChannel.value) {
visibility.value = 'public';
localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す
return;
@@ -798,7 +808,7 @@ function saveDraft() {
localOnly: localOnly.value,
files: files.value,
poll: poll.value,
- visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
+ ...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value,
},
@@ -815,6 +825,32 @@ function deleteDraft() {
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
}
+async function saveServerDraft(clearLocal = false) {
+ return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
+ ...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
+ text: text.value,
+ useCw: useCw.value,
+ cw: cw.value,
+ visibility: visibility.value,
+ localOnly: localOnly.value,
+ hashtag: hashtags.value,
+ ...(files.value.length > 0 ? { 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 : undefined,
+ replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
+ quoteId: quoteId.value,
+ channelId: targetChannel.value ? targetChannel.value.id : undefined,
+ reactionAcceptance: reactionAcceptance.value,
+ }).then(() => {
+ if (clearLocal) {
+ clear();
+ deleteDraft();
+ }
+ }).catch((err) => {
+ });
+}
+
function isAnnoying(text: string): boolean {
return text.includes('$[x2') ||
text.includes('$[x3') ||
@@ -882,9 +918,9 @@ async function post(ev?: MouseEvent) {
let postData = {
text: text.value === '' ? null : text.value,
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
- replyId: props.reply ? props.reply.id : undefined,
+ replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
- channelId: props.channel ? props.channel.id : undefined,
+ channelId: targetChannel.value ? targetChannel.value.id : undefined,
poll: poll.value,
cw: useCw.value ? cw.value ?? '' : null,
localOnly: localOnly.value,
@@ -989,6 +1025,10 @@ async function post(ev?: MouseEvent) {
if (m === 0 && s === 0) {
claimAchievement('postedAt0min0sec');
}
+
+ if (serverDraftId.value != null) {
+ misskeyApi('notes/drafts/delete', { draftId: serverDraftId.value });
+ }
});
}).catch(err => {
posting.value = false;
@@ -1092,6 +1132,84 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent)
os.contextMenu(menu, ev);
}
+function showDraftMenu(ev: MouseEvent) {
+ function showDraftsDialog() {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, {
+ restore: async (draft: Misskey.entities.NoteDraft) => {
+ text.value = draft.text ?? '';
+ useCw.value = draft.cw != null;
+ cw.value = draft.cw ?? null;
+ visibility.value = draft.visibility;
+ localOnly.value = draft.localOnly ?? false;
+ files.value = draft.files ?? [];
+ hashtags.value = draft.hashtag ?? '';
+ if (draft.hashtag) withHashtags.value = true;
+ if (draft.poll) {
+ // 投票を一時的に空にしないと反映されないため
+ poll.value = null;
+ nextTick(() => {
+ poll.value = {
+ choices: draft.poll!.choices,
+ multiple: draft.poll!.multiple,
+ expiresAt: draft.poll!.expiresAt ? (new Date(draft.poll!.expiresAt)).getTime() : null,
+ expiredAfter: null,
+ };
+ });
+ }
+ if (draft.visibleUserIds) {
+ misskeyApi('users/show', { userIds: draft.visibleUserIds }).then(users => {
+ users.forEach(u => pushVisibleUser(u));
+ });
+ }
+ quoteId.value = draft.renoteId ?? null;
+ renoteTargetNote.value = draft.renote;
+ replyTargetNote.value = draft.reply;
+ reactionAcceptance.value = draft.reactionAcceptance;
+ if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel;
+
+ visibleUsers.value = [];
+ draft.visibleUserIds?.forEach(uid => {
+ if (!visibleUsers.value.some(u => u.id === uid)) {
+ misskeyApi('users/show', { userId: uid }).then(user => {
+ pushVisibleUser(user);
+ });
+ }
+ });
+
+ serverDraftId.value = draft.id;
+ },
+ cancel: () => {
+
+ },
+ closed: () => {
+ dispose();
+ },
+ });
+ }
+
+ os.popupMenu([{
+ type: 'button',
+ text: i18n.ts._drafts.saveToDraft,
+ icon: 'ti ti-cloud-upload',
+ action: async () => {
+ if (!canSaveAsServerDraft.value) {
+ return os.alert({
+ type: 'error',
+ text: i18n.ts._drafts.cannotCreateDraftOfRenote,
+ });
+ }
+ saveServerDraft();
+ },
+ }, {
+ type: 'button',
+ text: i18n.ts._drafts.listDrafts,
+ icon: 'ti ti-cloud-download',
+ action: () => {
+ showDraftsDialog();
+ },
+ }], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
+}
+
onMounted(() => {
if (props.autofocus) {
focus();
@@ -1204,21 +1322,18 @@ defineExpose({
.headerLeft {
display: flex;
- flex: 0 1 100px;
+ flex: 1;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 6px;
+ padding-left: 12px;
}
.cancel {
- padding: 0;
- font-size: 1em;
- height: 100%;
- flex: 0 1 50px;
+ padding: 8px;
}
.account {
- height: 100%;
- display: inline-flex;
- vertical-align: bottom;
- flex: 0 1 50px;
}
.avatar {
@@ -1227,6 +1342,20 @@ defineExpose({
margin: auto;
}
+.draftButton {
+ padding: 8px;
+ font-size: 90%;
+ border-radius: 6px;
+
+ &:hover {
+ background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
+ }
+
+ &:disabled {
+ background: none;
+ }
+}
+
.headerRight {
display: flex;
min-height: 48px;
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index 0a655bab99..1f7796bd83 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModal
ref="modal"
:preferType="'dialog'"
- @click="_close()"
+ @click="onBgClick()"
@closed="onModalClosed()"
- @esc="_close()"
+ @esc="onEsc"
>
<MkPostForm
ref="form"
@@ -57,6 +57,14 @@ async function _close() {
modal.value?.close();
}
+function onEsc(ev: KeyboardEvent) {
+ _close();
+}
+
+function onBgClick() {
+ _close();
+}
+
function onModalClosed() {
emit('closed');
}
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 24c3160fdd..a266e1df6f 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -761,6 +761,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.noteDraftLimit, 'noteDraftLimit'])">
+ <template #label>{{ i18n.ts._role._options.noteDraftLimit }}</template>
+ <template #suffix>
+ <span v-if="role.policies.noteDraftLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.noteDraftLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.noteDraftLimit)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.noteDraftLimit.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkInput v-model="role.policies.noteDraftLimit.value" :disabled="role.policies.noteDraftLimit.useDefault" type="number" :readonly="readonly">
+ </MkInput>
+ <MkRange v-model="role.policies.noteDraftLimit.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>
</div>
</FormSlot>
</div>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 70e8153544..dee0fb1e5c 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -284,6 +284,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.noteDraftLimit, 'noteDraftLimit'])">
+ <template #label>{{ i18n.ts._role._options.noteDraftLimit }}</template>
+ <template #suffix>{{ policies.noteDraftLimit }}</template>
+ <MkInput v-model="policies.noteDraftLimit" type="number" :min="0">
+ </MkInput>
+ </MkFolder>
</div>
</MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
diff --git a/packages/frontend/src/utility/get-note-summary.ts b/packages/frontend/src/utility/get-note-summary.ts
index 6fd9947ac1..66de997aab 100644
--- a/packages/frontend/src/utility/get-note-summary.ts
+++ b/packages/frontend/src/utility/get-note-summary.ts
@@ -10,16 +10,40 @@ import { i18n } from '@/i18n.js';
* 投稿を表す文字列を取得します。
* @param {*} note (packされた)投稿
*/
-export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
+export const getNoteSummary = (note?: Misskey.entities.Note | Misskey.entities.NoteDraft | null, opts?: {
+ /**
+ * ファイルの数を表示するかどうか
+ */
+ showFiles?: boolean;
+ /**
+ * 投票の有無を表示するかどうか
+ */
+ showPoll?: boolean;
+ /**
+ * 返信の有無を表示するかどうか
+ */
+ showReply?: boolean;
+ /**
+ * Renoteの有無を表示するかどうか
+ */
+ showRenote?: boolean;
+}): string => {
+ const _opts = Object.assign({
+ showFiles: true,
+ showPoll: true,
+ showReply: true,
+ showRenote: true,
+ }, opts);
+
if (note == null) {
return '';
}
- if (note.deletedAt) {
+ if ('deletedAt' in note && note.deletedAt) {
return `(${i18n.ts.deletedNote})`;
}
- if (note.isHidden) {
+ if ('isHidden' in note && note.isHidden) {
return `(${i18n.ts.invisibleNote})`;
}
@@ -33,17 +57,17 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
}
// ファイルが添付されているとき
- if ((note.files || []).length !== 0) {
- summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
+ if (_opts.showFiles && (note.files || []).length !== 0) {
+ summary += ` (${i18n.tsx.withNFiles({ n: note.files!.length })})`;
}
// 投票が添付されているとき
- if (note.poll) {
+ if (_opts.showPoll && note.poll) {
summary += ` (${i18n.ts.poll})`;
}
// 返信のとき
- if (note.replyId) {
+ if (_opts.showReply && note.replyId) {
if (note.reply) {
summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
} else {
@@ -52,7 +76,7 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => {
}
// Renoteのとき
- if (note.renoteId) {
+ if (_opts.showRenote && note.renoteId) {
if (note.renote) {
summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
} else {
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index a6bfe825b5..027293b210 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1953,6 +1953,14 @@ declare namespace entities {
NotesCreateRequest,
NotesCreateResponse,
NotesDeleteRequest,
+ NotesDraftsCountResponse,
+ NotesDraftsCreateRequest,
+ NotesDraftsCreateResponse,
+ NotesDraftsDeleteRequest,
+ NotesDraftsListRequest,
+ NotesDraftsListResponse,
+ NotesDraftsUpdateRequest,
+ NotesDraftsUpdateResponse,
NotesFavoritesCreateRequest,
NotesFavoritesDeleteRequest,
NotesFeaturedRequest,
@@ -2118,6 +2126,7 @@ declare namespace entities {
Announcement,
App,
Note,
+ NoteDraft,
NoteReaction,
NoteFavorite,
Notification_2 as Notification,
@@ -2963,6 +2972,9 @@ declare namespace note {
export { note }
// @public (undocumented)
+type NoteDraft = components['schemas']['NoteDraft'];
+
+// @public (undocumented)
type NoteFavorite = components['schemas']['NoteFavorite'];
// @public (undocumented)
@@ -2996,6 +3008,30 @@ type NotesCreateResponse = operations['notes___create']['responses']['200']['con
type NotesDeleteRequest = operations['notes___delete']['requestBody']['content']['application/json'];
// @public (undocumented)
+type NotesDraftsCountResponse = operations['notes___drafts___count']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsCreateRequest = operations['notes___drafts___create']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsCreateResponse = operations['notes___drafts___create']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsDeleteRequest = operations['notes___drafts___delete']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsListRequest = operations['notes___drafts___list']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsListResponse = operations['notes___drafts___list']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsUpdateRequest = operations['notes___drafts___update']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesDraftsUpdateResponse = operations['notes___drafts___update']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
type NotesFavoritesCreateRequest = operations['notes___favorites___create']['requestBody']['content']['application/json'];
// @public (undocumented)
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 037503cb7a..c638075777 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -3596,6 +3596,61 @@ declare module '../api.js' {
/**
* No description provided.
*
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ request<E extends 'notes/drafts/count', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ request<E extends 'notes/drafts/create', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ request<E extends 'notes/drafts/delete', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ request<E extends 'notes/drafts/list', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ request<E extends 'notes/drafts/update', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
* **Credential required**: *Yes* / **Permission**: *write:favorites*
*/
request<E extends 'notes/favorites/create', P extends Endpoints[E]['req']>(
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 45c65ef843..e6c0525f3b 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -488,6 +488,14 @@ import type {
NotesCreateRequest,
NotesCreateResponse,
NotesDeleteRequest,
+ NotesDraftsCountResponse,
+ NotesDraftsCreateRequest,
+ NotesDraftsCreateResponse,
+ NotesDraftsDeleteRequest,
+ NotesDraftsListRequest,
+ NotesDraftsListResponse,
+ NotesDraftsUpdateRequest,
+ NotesDraftsUpdateResponse,
NotesFavoritesCreateRequest,
NotesFavoritesDeleteRequest,
NotesFeaturedRequest,
@@ -963,6 +971,11 @@ export type Endpoints = {
'notes/conversation': { req: NotesConversationRequest; res: NotesConversationResponse };
'notes/create': { req: NotesCreateRequest; res: NotesCreateResponse };
'notes/delete': { req: NotesDeleteRequest; res: EmptyResponse };
+ 'notes/drafts/count': { req: EmptyRequest; res: NotesDraftsCountResponse };
+ 'notes/drafts/create': { req: NotesDraftsCreateRequest; res: NotesDraftsCreateResponse };
+ 'notes/drafts/delete': { req: NotesDraftsDeleteRequest; res: EmptyResponse };
+ 'notes/drafts/list': { req: NotesDraftsListRequest; res: NotesDraftsListResponse };
+ 'notes/drafts/update': { req: NotesDraftsUpdateRequest; res: NotesDraftsUpdateResponse };
'notes/favorites/create': { req: NotesFavoritesCreateRequest; res: EmptyResponse };
'notes/favorites/delete': { req: NotesFavoritesDeleteRequest; res: EmptyResponse };
'notes/featured': { req: NotesFeaturedRequest; res: NotesFeaturedResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index b7286c1018..1d92094ddf 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -491,6 +491,14 @@ export type NotesConversationResponse = operations['notes___conversation']['resp
export type NotesCreateRequest = operations['notes___create']['requestBody']['content']['application/json'];
export type NotesCreateResponse = operations['notes___create']['responses']['200']['content']['application/json'];
export type NotesDeleteRequest = operations['notes___delete']['requestBody']['content']['application/json'];
+export type NotesDraftsCountResponse = operations['notes___drafts___count']['responses']['200']['content']['application/json'];
+export type NotesDraftsCreateRequest = operations['notes___drafts___create']['requestBody']['content']['application/json'];
+export type NotesDraftsCreateResponse = operations['notes___drafts___create']['responses']['200']['content']['application/json'];
+export type NotesDraftsDeleteRequest = operations['notes___drafts___delete']['requestBody']['content']['application/json'];
+export type NotesDraftsListRequest = operations['notes___drafts___list']['requestBody']['content']['application/json'];
+export type NotesDraftsListResponse = operations['notes___drafts___list']['responses']['200']['content']['application/json'];
+export type NotesDraftsUpdateRequest = operations['notes___drafts___update']['requestBody']['content']['application/json'];
+export type NotesDraftsUpdateResponse = operations['notes___drafts___update']['responses']['200']['content']['application/json'];
export type NotesFavoritesCreateRequest = operations['notes___favorites___create']['requestBody']['content']['application/json'];
export type NotesFavoritesDeleteRequest = operations['notes___favorites___delete']['requestBody']['content']['application/json'];
export type NotesFeaturedRequest = operations['notes___featured']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index babe2b1859..c3c54b3bbb 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -14,6 +14,7 @@ export type Ad = components['schemas']['Ad'];
export type Announcement = components['schemas']['Announcement'];
export type App = components['schemas']['App'];
export type Note = components['schemas']['Note'];
+export type NoteDraft = components['schemas']['NoteDraft'];
export type NoteReaction = components['schemas']['NoteReaction'];
export type NoteFavorite = components['schemas']['NoteFavorite'];
export type Notification = components['schemas']['Notification'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index a4578bba94..3878a74813 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -2948,6 +2948,51 @@ export type paths = {
*/
post: operations['notes___delete'];
};
+ '/notes/drafts/count': {
+ /**
+ * notes/drafts/count
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ post: operations['notes___drafts___count'];
+ };
+ '/notes/drafts/create': {
+ /**
+ * notes/drafts/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ post: operations['notes___drafts___create'];
+ };
+ '/notes/drafts/delete': {
+ /**
+ * notes/drafts/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ post: operations['notes___drafts___delete'];
+ };
+ '/notes/drafts/list': {
+ /**
+ * notes/drafts/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:account*
+ */
+ post: operations['notes___drafts___list'];
+ };
+ '/notes/drafts/update': {
+ /**
+ * notes/drafts/update
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:account*
+ */
+ post: operations['notes___drafts___update'];
+ };
'/notes/favorites/create': {
/**
* notes/favorites/create
@@ -4315,6 +4360,61 @@ export type components = {
hasPoll?: boolean;
myReaction?: string | null;
};
+ NoteDraft: {
+ /**
+ * Format: id
+ * @example xxxxxxxxxx
+ */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ text: 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;
+ reply?: components['schemas']['Note'] | null;
+ renote?: components['schemas']['Note'] | null;
+ /** @enum {string} */
+ visibility: 'public' | 'home' | 'followers' | 'specified';
+ visibleUserIds?: string[];
+ fileIds?: string[];
+ files?: components['schemas']['DriveFile'][];
+ hashtag?: string;
+ poll?: {
+ /** Format: date-time */
+ expiresAt?: string | null;
+ expiredAfter?: number | null;
+ multiple: boolean;
+ choices: string[];
+ } | null;
+ /**
+ * Format: id
+ * @example xxxxxxxxxx
+ */
+ channelId?: string | null;
+ channel?: {
+ id: string;
+ name: string;
+ color: string;
+ isSensitive: boolean;
+ allowRenoteToExternal: boolean;
+ userId: string | null;
+ } | null;
+ localOnly?: boolean;
+ /** @enum {string|null} */
+ reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null;
+ };
NoteReaction: {
/**
* Format: id
@@ -5106,6 +5206,7 @@ export type components = {
canImportUserLists: boolean;
/** @enum {string} */
chatAvailability: 'available' | 'readonly' | 'unavailable';
+ noteDraftLimit: number;
};
ReversiGameLite: {
/** Format: id */
@@ -28586,6 +28687,407 @@ export interface operations {
};
};
};
+ notes___drafts___count: {
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': number;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ notes___drafts___create: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /**
+ * @default public
+ * @enum {string}
+ */
+ visibility?: 'public' | 'home' | 'followers' | 'specified';
+ visibleUserIds?: string[];
+ cw?: string | null;
+ hashtag?: string | null;
+ /** @default false */
+ localOnly?: boolean;
+ /**
+ * @default null
+ * @enum {string|null}
+ */
+ reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
+ /** Format: misskey:id */
+ replyId?: string | null;
+ /** Format: misskey:id */
+ renoteId?: string | null;
+ /** Format: misskey:id */
+ channelId?: string | null;
+ text?: string | null;
+ fileIds?: string[];
+ poll?: {
+ choices: string[];
+ multiple?: boolean;
+ expiresAt?: number | null;
+ expiredAfter?: number | null;
+ } | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': {
+ createdDraft: components['schemas']['NoteDraft'];
+ };
+ };
+ };
+ /** @description Client error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ notes___drafts___delete: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ draftId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (without any results) */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ notes___drafts___list: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 30 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['NoteDraft'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ notes___drafts___update: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ draftId: string;
+ /**
+ * @default public
+ * @enum {string}
+ */
+ visibility?: 'public' | 'home' | 'followers' | 'specified';
+ visibleUserIds?: string[];
+ cw?: string | null;
+ hashtag?: string | null;
+ /** @default false */
+ localOnly?: boolean;
+ /**
+ * @default null
+ * @enum {string|null}
+ */
+ reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
+ /** Format: misskey:id */
+ replyId?: string | null;
+ /** Format: misskey:id */
+ renoteId?: string | null;
+ /** Format: misskey:id */
+ channelId?: string | null;
+ text?: string | null;
+ fileIds?: string[];
+ poll?: {
+ choices: string[];
+ multiple?: boolean;
+ expiresAt?: number | null;
+ expiredAfter?: number | null;
+ } | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': {
+ updatedDraft: components['schemas']['NoteDraft'];
+ };
+ };
+ };
+ /** @description Client error */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
notes___favorites___create: {
requestBody: {
content: {