summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api
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/backend/src/server/api
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/backend/src/server/api')
-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
6 files changed, 743 insertions, 0 deletions
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,
+ };
+ });
+ }
+}