From b752dc72e531f6c63f09876a1c68a87a77c03b49 Mon Sep 17 00:00:00 2001 From: taichan <40626578+tai-cha@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:09:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=8E=E3=83=BC=E3=83=88=E3=81=AE?= =?UTF-8?q?=E4=B8=8B=E6=9B=B8=E3=81=8D(draft=20of=20note)=20(#15298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- CHANGELOG.md | 2 +- locales/index.d.ts | 66 +++ locales/ja-JP.yml | 18 + .../1736686850345-createNoteDraft.js | 91 ++++ packages/backend/src/core/CoreModule.ts | 12 + packages/backend/src/core/NoteDraftService.ts | 314 +++++++++++ packages/backend/src/core/RoleService.ts | 3 + .../core/entities/NoteDraftEntityService.ts | 177 ++++++ packages/backend/src/di-symbols.ts | 1 + packages/backend/src/misc/json-schema.ts | 2 + packages/backend/src/models/Note.ts | 4 +- packages/backend/src/models/NoteDraft.ts | 157 ++++++ .../backend/src/models/RepositoryModule.ts | 9 + packages/backend/src/models/_.ts | 3 + .../src/models/json-schema/note-draft.ts | 169 ++++++ .../backend/src/models/json-schema/role.ts | 4 + packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/endpoint-list.ts | 5 + .../api/endpoints/notes/drafts/count.ts | 51 ++ .../api/endpoints/notes/drafts/create.ts | 258 +++++++++ .../api/endpoints/notes/drafts/delete.ts | 61 +++ .../server/api/endpoints/notes/drafts/list.ts | 66 +++ .../api/endpoints/notes/drafts/update.ts | 302 +++++++++++ packages/backend/src/types.ts | 2 + packages/frontend-shared/js/const.ts | 1 + .../src/components/MkNoteDraftsDialog.vue | 218 ++++++++ .../frontend/src/components/MkPostForm.vue | 217 ++++++-- .../src/components/MkPostFormDialog.vue | 12 +- .../frontend/src/pages/admin/roles.editor.vue | 19 + packages/frontend/src/pages/admin/roles.vue | 7 + .../frontend/src/utility/get-note-summary.ts | 40 +- packages/misskey-js/etc/misskey-js.api.md | 36 ++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 55 ++ packages/misskey-js/src/autogen/endpoint.ts | 13 + packages/misskey-js/src/autogen/entities.ts | 8 + packages/misskey-js/src/autogen/models.ts | 1 + packages/misskey-js/src/autogen/types.ts | 502 ++++++++++++++++++ 37 files changed, 2851 insertions(+), 57 deletions(-) create mode 100644 packages/backend/migration/1736686850345-createNoteDraft.js create mode 100644 packages/backend/src/core/NoteDraftService.ts create mode 100644 packages/backend/src/core/entities/NoteDraftEntityService.ts create mode 100644 packages/backend/src/models/NoteDraft.ts create mode 100644 packages/backend/src/models/json-schema/note-draft.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/drafts/count.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/drafts/create.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/drafts/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/drafts/list.ts create mode 100644 packages/backend/src/server/api/endpoints/notes/drafts/update.ts create mode 100644 packages/frontend/src/components/MkNoteDraftsDialog.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9ef8874d..a79e6f6d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Feat: ノートの下書き機能 ### Client - Enhance: 設定の自動バックアップをオンにした直後に自動バックアップするように diff --git a/locales/index.d.ts b/locales/index.d.ts index 492688773f..e87f0e342e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5270,6 +5270,10 @@ export interface Locale extends ILocale { * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。 */ "federationDisabled": string; + /** + * 下書き + */ + "draft": string; /** * リアクションする際に確認する */ @@ -7777,6 +7781,10 @@ export interface Locale extends ILocale { * ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。 */ "uploadableFileTypes_caption2": ParameterizedString<"x">; + /** + * サーバーサイドのノートの下書きの作成可能数 + */ + "noteDraftLimit": string; }; "_condition": { /** @@ -12234,6 +12242,64 @@ export interface Locale extends ILocale { "tearing": string; }; }; + /** + * 下書き + */ + "drafts": string; + "_drafts": { + /** + * 下書きを選択 + */ + "select": string; + /** + * 下書きの作成可能数を超えています。 + */ + "cannotCreateDraftAnymore": string; + /** + * リノートの下書きは作成できません。 + */ + "cannotCreateDraftOfRenote": string; + /** + * 下書きを削除 + */ + "delete": string; + /** + * 下書きを削除しますか? + */ + "deleteAreYouSure": string; + /** + * 下書きはありません + */ + "noDrafts": string; + /** + * {user}への返信 + */ + "replyTo": ParameterizedString<"user">; + /** + * {user}のノートへの引用 + */ + "quoteOf": ParameterizedString<"user">; + /** + * {channel}への投稿 + */ + "postTo": ParameterizedString<"channel">; + /** + * 下書きへ保存 + */ + "saveToDraft": string; + /** + * 下書きから復元 + */ + "restoreFromDraft": string; + /** + * 復元 + */ + "restore": string; + /** + * 下書き一覧 + */ + "listDrafts": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3aa8e44ce7..e872fcc96d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1313,6 +1313,7 @@ availableRoles: "利用可能なロール" acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。" federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" +draft: "下書き" confirmOnReact: "リアクションする際に確認する" reactAreYouSure: "\" {emoji} \" をリアクションしますか?" markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?" @@ -2013,6 +2014,7 @@ _role: uploadableFileTypes: "アップロード可能なファイル種別" uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)" uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。" + noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -3276,3 +3278,19 @@ _imageEffector: checker: "チェッカー" blockNoise: "ブロックノイズ" tearing: "ティアリング" + +drafts: "下書き" +_drafts: + select: "下書きを選択" + cannotCreateDraftAnymore: "下書きの作成可能数を超えています。" + cannotCreateDraftOfRenote: "リノートの下書きは作成できません。" + delete: "下書きを削除" + deleteAreYouSure: "下書きを削除しますか?" + noDrafts: "下書きはありません" + replyTo: "{user}への返信" + quoteOf: "{user}のノートへの引用" + postTo: "{channel}への投稿" + saveToDraft: "下書きへ保存" + restoreFromDraft: "下書きから復元" + restore: "復元" + listDrafts: "下書き一覧" 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 { + const draft = await this.noteDraftsRepository.findOneBy({ + id: draftId, + userId: me.id, + }); + + return draft; + } + + @bindThis + public async create(me: MiLocalUser, data: NoteDraftOptions): Promise { + //#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 { + 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 { + 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 { + 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 { + 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 | null>): Promise[]> { + 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 | null>; + packedUsers: Map> + }; + }, + ): Promise> { + 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 { + 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) { + 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), + inject: [DI.db], +}; + const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -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 & MiRepositor export type MutingsRepository = Repository & MiRepository; export type RenoteMutingsRepository = Repository & MiRepository; export type NotesRepository = Repository & MiRepository; +export type NoteDraftsRepository = Repository & MiRepository; export type NoteFavoritesRepository = Repository & MiRepository; export type NoteReactionsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; 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 { // 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 { // 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 { // 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 { // 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(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 { // 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 @@ + + + + + + + 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 +
-