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: 自分のアカウントでの投稿でしか下書きを利用できないように 完全に塞ぐことはできないが一応 * 🎨 * APIのエラーIDを追加 * enhance: スタイル調整 * remove unused code * 🎨 * 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>
This commit is contained in:
parent
06d31c0b78
commit
b752dc72e5
|
@ -1,7 +1,7 @@
|
|||
## Unreleased
|
||||
|
||||
### General
|
||||
-
|
||||
- Feat: ノートの下書き機能
|
||||
|
||||
### Client
|
||||
- Enhance: 設定の自動バックアップをオンにした直後に自動バックアップするように
|
||||
|
|
|
@ -5270,6 +5270,10 @@ export interface Locale extends ILocale {
|
|||
* このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。
|
||||
*/
|
||||
"federationDisabled": string;
|
||||
/**
|
||||
* 下書き
|
||||
*/
|
||||
"draft": string;
|
||||
/**
|
||||
* リアクションする際に確認する
|
||||
*/
|
||||
|
@ -7777,6 +7781,10 @@ export interface Locale extends ILocale {
|
|||
* ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。
|
||||
*/
|
||||
"uploadableFileTypes_caption2": ParameterizedString<"x">;
|
||||
/**
|
||||
* サーバーサイドのノートの下書きの作成可能数
|
||||
*/
|
||||
"noteDraftLimit": string;
|
||||
};
|
||||
"_condition": {
|
||||
/**
|
||||
|
@ -12234,6 +12242,64 @@ export interface Locale extends ILocale {
|
|||
"tearing": string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* 下書き
|
||||
*/
|
||||
"drafts": string;
|
||||
"_drafts": {
|
||||
/**
|
||||
* 下書きを選択
|
||||
*/
|
||||
"select": string;
|
||||
/**
|
||||
* 下書きの作成可能数を超えています。
|
||||
*/
|
||||
"cannotCreateDraftAnymore": string;
|
||||
/**
|
||||
* リノートの下書きは作成できません。
|
||||
*/
|
||||
"cannotCreateDraftOfRenote": string;
|
||||
/**
|
||||
* 下書きを削除
|
||||
*/
|
||||
"delete": string;
|
||||
/**
|
||||
* 下書きを削除しますか?
|
||||
*/
|
||||
"deleteAreYouSure": string;
|
||||
/**
|
||||
* 下書きはありません
|
||||
*/
|
||||
"noDrafts": string;
|
||||
/**
|
||||
* {user}への返信
|
||||
*/
|
||||
"replyTo": ParameterizedString<"user">;
|
||||
/**
|
||||
* {user}のノートへの引用
|
||||
*/
|
||||
"quoteOf": ParameterizedString<"user">;
|
||||
/**
|
||||
* {channel}への投稿
|
||||
*/
|
||||
"postTo": ParameterizedString<"channel">;
|
||||
/**
|
||||
* 下書きへ保存
|
||||
*/
|
||||
"saveToDraft": string;
|
||||
/**
|
||||
* 下書きから復元
|
||||
*/
|
||||
"restoreFromDraft": string;
|
||||
/**
|
||||
* 復元
|
||||
*/
|
||||
"restore": string;
|
||||
/**
|
||||
* 下書き一覧
|
||||
*/
|
||||
"listDrafts": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -1313,6 +1313,7 @@ availableRoles: "利用可能なロール"
|
|||
acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。"
|
||||
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
|
||||
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
|
||||
draft: "下書き"
|
||||
confirmOnReact: "リアクションする際に確認する"
|
||||
reactAreYouSure: "\" {emoji} \" をリアクションしますか?"
|
||||
markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?"
|
||||
|
@ -2013,6 +2014,7 @@ _role:
|
|||
uploadableFileTypes: "アップロード可能なファイル種別"
|
||||
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
|
||||
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
|
||||
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
|
||||
_condition:
|
||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||
isLocal: "ローカルユーザー"
|
||||
|
@ -3276,3 +3278,19 @@ _imageEffector:
|
|||
checker: "チェッカー"
|
||||
blockNoise: "ブロックノイズ"
|
||||
tearing: "ティアリング"
|
||||
|
||||
drafts: "下書き"
|
||||
_drafts:
|
||||
select: "下書きを選択"
|
||||
cannotCreateDraftAnymore: "下書きの作成可能数を超えています。"
|
||||
cannotCreateDraftOfRenote: "リノートの下書きは作成できません。"
|
||||
delete: "下書きを削除"
|
||||
deleteAreYouSure: "下書きを削除しますか?"
|
||||
noDrafts: "下書きはありません"
|
||||
replyTo: "{user}への返信"
|
||||
quoteOf: "{user}のノートへの引用"
|
||||
postTo: "{channel}への投稿"
|
||||
saveToDraft: "下書きへ保存"
|
||||
restoreFromDraft: "下書きから復元"
|
||||
restore: "復元"
|
||||
listDrafts: "下書き一覧"
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
|
@ -89,5 +89,6 @@ export const DI = {
|
|||
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
noteDraftsRepository: Symbol('noteDraftsRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
|
@ -309,6 +309,10 @@ export const packedRolePoliciesSchema = {
|
|||
optional: false, nullable: false,
|
||||
enum: ['available', 'readonly', 'unavailable'],
|
||||
},
|
||||
noteDraftLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
@ -2962,6 +2971,9 @@ declare namespace note {
|
|||
}
|
||||
export { note }
|
||||
|
||||
// @public (undocumented)
|
||||
type NoteDraft = components['schemas']['NoteDraft'];
|
||||
|
||||
// @public (undocumented)
|
||||
type NoteFavorite = components['schemas']['NoteFavorite'];
|
||||
|
||||
|
@ -2995,6 +3007,30 @@ type NotesCreateResponse = operations['notes___create']['responses']['200']['con
|
|||
// @public (undocumented)
|
||||
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'];
|
||||
|
||||
|
|
|
@ -3593,6 +3593,61 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in New Issue