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