summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/NoteDraftService.ts
diff options
context:
space:
mode:
authortaichan <40626578+tai-cha@users.noreply.github.com>2025-06-25 17:09:23 +0900
committerGitHub <noreply@github.com>2025-06-25 17:09:23 +0900
commitb752dc72e531f6c63f09876a1c68a87a77c03b49 (patch)
treed9bd25825a9b1b06c8db07a1888594ffc9db45c8 /packages/backend/src/core/NoteDraftService.ts
parentfix(frontend): ファイルがドライブの既定アップロード先に... (diff)
downloadmisskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.tar.gz
misskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.tar.bz2
misskey-b752dc72e531f6c63f09876a1c68a87a77c03b49.zip
feat: ノートの下書き(draft of note) (#15298)
* WIp (backend) * Remove unused * 下書きbackend 続き * fix(backedn): visibilityが下書きに反映されない * Update packages/backend/src/postgres.ts Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * Fix : import order * fix(backend) : createでcwが効かない * FIX FOREGIN KEY * wip: frontend(既存の下書きを挿入) まだ:チャンネル表示、下書きの作成、削除 * WIP: ノート選択ダイアログ 投稿時に下書きを削除 * Promiseに変更 * 連合なし、チャンネルも表示 * Hashtagの値抜け漏れ * hasthagを0文字でも作成可能に * 下書きの保存機構 * chore(misskey-js): build types * localOnly抜け漏れ * チャンネル情報の書き換え * enhance(frontend): ヘッダ部の表示改善 * fix(frontend): ファイル添付できない * fix: no file * fix(frontend): 投票が反映されない * ハッシュタグの展開(コメントアウト外し忘れ) * fix: visibleUserIdsが反映されない * enhance: APIの型を整備 * refactor: 型が整備できたのでasを削除 * Add userhost * fix * enhance: paginationを使う * fix * fix: 自分のアカウントでの投稿でしか下書きを利用できないように 完全に塞ぐことはできないが一応 * :art: * APIのエラーIDを追加 * enhance: スタイル調整 * remove unused code * :art: * fix: ロールポリシーの型 * ロールの編集画面 * ダイアログの挙動改善 * 下書き機能が利用できない場合は表示しないように * refactor * fix: ダブルクリックが効かない問題を修正 * add comments * fix * fix: 保存時のエラーの種別にかかわらずmodalを閉じないように * fix()backend: NoteDraftのreply, renoteの型が間違ってたので修正 (migtrationはあってた) * fix: 投稿フォームを空白にして通常リノートできるやつは下書きとしては弾くように * fix(backend): テキストが0文字でも下書きは保存できるように * Fix(backend): replyIdの型定義がミスっているのを修正 * chore(misskey-js): update types * Add CHANGELOG * lint * 常にサーバー下書きに保存し、上限を超えた場合のみ尋ねるように * NoteDraftServiceにcreate, updateの処理を移譲 * Fix typeerror * remove tooltip * Remove Mkbutton:short and use iconOnly * 不要なコメントの削除 * Remove Short Completely * wip * escキーまわりの挙動を改善 * 下書き選択時に下書き可能数と現在の量が分かるように * cleanUp * wip * wi * wip * Update MkPostForm.vue --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/backend/src/core/NoteDraftService.ts')
-rw-r--r--packages/backend/src/core/NoteDraftService.ts314
1 files changed, 314 insertions, 0 deletions
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;
+ }
+}