Compare commits

...

30 Commits

Author SHA1 Message Date
syuilo d287d43c98 Update ApInboxService.ts 2025-06-26 13:33:20 +09:00
syuilo b5f284aae3 Update ApInboxService.ts 2025-06-26 13:25:11 +09:00
syuilo a1da2135eb Update ApRendererService.ts 2025-06-26 13:22:21 +09:00
syuilo 74b3c19c3f wip 2025-06-26 12:47:23 +09:00
syuilo bf57557ba3 refactor(frontend): refactor uploader image editing features and menu
Replaces separate 'effect' and 'crop' features with a unified 'imageEditing' feature in the uploader. Groups crop and effect actions under a new parent 'editImage' menu item, adds localization for 'editImage', and updates supported types accordingly.
2025-06-26 12:10:15 +09:00
syuilo 8fda4fefaf refactor 2025-06-26 12:06:53 +09:00
syuilo f1983d1aa5 Update type from UploaderDialogFeatures to UploaderFeatures
Replaces the UploaderDialogFeatures type with UploaderFeatures in the select function and SelectFileOptions type to ensure consistency and correct type usage.
2025-06-26 12:02:25 +09:00
syuilo 60649f4d66 add note 2025-06-26 12:01:56 +09:00
syuilo 525a330637 tweak eslint config 2025-06-26 10:49:41 +09:00
taichan b455e63da7 chore(frontend): 開発モード時に言語ファイルの変更を自動で反映するように (#16215)
* chore(frontend): 開発モード時に言語ファイルの変更を自動で反映するように

* fix message

* naming

* SPDX
2025-06-26 08:26:44 +09:00
syuilo 5626677e86 Update CHANGELOG.md 2025-06-25 20:30:49 +09:00
keito c424554d4a ジョブキューのProgressの値を正しく計算する (#16218)
* fix: ジョブキューのProgressの値の範囲を 0~100 に統一

* fix(backend): ジョブキューのProgressの計算に用いる総数を最初に一度だけ取得する
2025-06-25 20:30:17 +09:00
syuilo eee9a5f853 enhance(frontend): ページネーションの並び順を逆にできるように 2025-06-25 20:26:20 +09:00
github-actions[bot] 4d72d6caf4 Bump version to 2025.6.4-alpha.0 2025-06-25 08:50:37 +00:00
taichan b752dc72e5 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>
2025-06-25 17:09:23 +09:00
syuilo 06d31c0b78 fix(frontend): ファイルがドライブの既定アップロード先に指定したフォルダにアップロードされない問題を修正
Fix #16206
2025-06-25 10:55:30 +09:00
syuilo 32d4c312ef enhance(frontend): ファイルアップロード前にキャプション設定を行えるように
Resolve #16210
2025-06-25 10:49:58 +09:00
syuilo 36fde67992 enhance(frontend): improve theme settings 2025-06-25 10:23:39 +09:00
syuilo 43abbce2af enhance(frontend): 全てのページネーションにおいてコンテキストメニューからリロードできるように 2025-06-25 10:08:44 +09:00
syuilo 684424f26a enhance(frontend): improve useScrollPositionKeeper 2025-06-24 20:30:32 +09:00
syuilo 36989e0cd3 Update about-misskey.vue 2025-06-24 20:24:34 +09:00
syuilo d518682e73 add note 2025-06-24 11:44:16 +09:00
syuilo 0ada970337 enhance(frontend): 設定の自動バックアップをオンにした直後に自動バックアップするように 2025-06-23 17:12:25 +09:00
github-actions[bot] a812dfe853 [skip ci] Update CHANGELOG.md (prepend template) 2025-06-16 11:13:27 +00:00
github-actions[bot] 2baec208f5 Release: 2025.6.3 2025-06-16 11:13:22 +00:00
github-actions[bot] 4093616e23 Bump version to 2025.6.3-alpha.0 2025-06-16 10:52:09 +00:00
syuilo 062d5170df fix(frontend): キャッシュを削除しないとクライアントが使用できないことがある問題を修正
Fix #16196
2025-06-16 19:51:26 +09:00
github-actions[bot] a279bd4d49 [skip ci] Update CHANGELOG.md (prepend template) 2025-06-16 08:58:37 +00:00
github-actions[bot] 978ae706eb Release: 2025.6.2 2025-06-16 08:58:31 +00:00
syuilo 824643a44e [skip ci] Update CHANGELOG.md 2025-06-16 17:57:09 +09:00
84 changed files with 3357 additions and 264 deletions
+18 -4
View File
@@ -1,14 +1,28 @@
## 2025.6.2
## 2025.6.4
### General
-
- Feat: ノートの下書き機能
### Client
- Enhance: 設定の自動バックアップをオンにした直後に自動バックアップするように
- Enhance: ファイルアップロード前にキャプション設定を行えるように
- Enhance: ページネーションの並び順を逆にできるように
- Fix: ファイルがドライブの既定アップロード先に指定したフォルダにアップロードされない問題を修正
### Server
- Fix: ジョブキューのProgressの値を正しく計算する
## 2025.6.3
### Client
- Fix: キャッシュを削除しないとクライアントが使用できないことがある問題を修正
### Server
-
## 2025.6.2
### Client
- Fix: キャッシュを削除しないとクライアントが使用できないことがある問題を修正
- 翻訳の更新
## 2025.6.1
+84
View File
@@ -5270,6 +5270,10 @@ export interface Locale extends ILocale {
*
*/
"federationDisabled": string;
/**
*
*/
"draft": string;
/**
*
*/
@@ -5489,6 +5493,16 @@ export interface Locale extends ILocale {
* <br>
*/
"defaultImageCompressionLevel_description": string;
"_order": {
/**
*
*/
"newest": string;
/**
*
*/
"oldest": string;
};
"_chat": {
/**
*
@@ -7777,6 +7791,10 @@ export interface Locale extends ILocale {
* {x}
*/
"uploadableFileTypes_caption2": ParameterizedString<"x">;
/**
*
*/
"noteDraftLimit": string;
};
"_condition": {
/**
@@ -8366,6 +8384,10 @@ export interface Locale extends ILocale {
*
*/
"code": string;
/**
*
*/
"copyThemeCode": string;
/**
*
*/
@@ -11969,6 +11991,10 @@ export interface Locale extends ILocale {
};
};
"_uploader": {
/**
*
*/
"editImage": string;
/**
* {x}
*/
@@ -12230,6 +12256,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;
+24
View File
@@ -1313,6 +1313,7 @@ availableRoles: "利用可能なロール"
acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。"
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
draft: "下書き"
confirmOnReact: "リアクションする際に確認する"
reactAreYouSure: "\" {emoji} \" をリアクションしますか?"
markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?"
@@ -1368,6 +1369,10 @@ hideAllTips: "全ての「ヒントとコツ」を非表示"
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。"
_order:
newest: "新しい順"
oldest: "古い順"
_chat:
noMessagesYet: "まだメッセージはありません"
newMessage: "新しいメッセージ"
@@ -2013,6 +2018,7 @@ _role:
uploadableFileTypes: "アップロード可能なファイル種別"
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
_condition:
roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー"
@@ -2193,6 +2199,7 @@ _theme:
install: "テーマのインストール"
manage: "テーマの管理"
code: "テーマコード"
copyThemeCode: "テーマコードをコピー"
description: "説明"
installed: "{name}をインストールしました"
installedThemes: "インストールされたテーマ"
@@ -3201,6 +3208,7 @@ _serverSetupWizard:
text3: "支援者向け特典もあります!"
_uploader:
editImage: "画像の編集"
compressedToX: "{x}に圧縮"
savedXPercent: "{x}%節約"
abortConfirm: "アップロードされていないファイルがありますが、中止しますか?"
@@ -3275,3 +3283,19 @@ _imageEffector:
checker: "チェッカー"
blockNoise: "ブロックノイズ"
tearing: "ティアリング"
drafts: "下書き"
_drafts:
select: "下書きを選択"
cannotCreateDraftAnymore: "下書きの作成可能数を超えています。"
cannotCreateDraftOfRenote: "リノートの下書きは作成できません。"
delete: "下書きを削除"
deleteAreYouSure: "下書きを削除しますか?"
noDrafts: "下書きはありません"
replyTo: "{user}への返信"
quoteOf: "{user}のノートへの引用"
postTo: "{channel}への投稿"
saveToDraft: "下書きへ保存"
restoreFromDraft: "下書きから復元"
restore: "復元"
listDrafts: "下書き一覧"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.6.2-alpha.0",
"version": "2025.6.4-alpha.0",
"codename": "nasubi",
"repository": {
"type": "git",
+1
View File
@@ -25,6 +25,7 @@ export default [
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'import/order': ['warn', {
groups: [
'builtin',
@@ -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"`);
}
}
+16
View File
@@ -28,6 +28,8 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { emojiRegex } from '@/misc/emoji-regex.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
const MAX_ROOM_MEMBERS = 50;
const MAX_REACTIONS_PER_MESSAGE = 100;
@@ -81,6 +83,7 @@ export class ChatService {
private chatEntityService: ChatEntityService,
private idService: IdService,
private globalEventService: GlobalEventService,
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
private queueService: QueueService,
private pushNotificationService: PushNotificationService,
@@ -236,6 +239,19 @@ export class ChatService {
}, 3000);
}
//#region AP deliver
if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
(async () => {
const content = await this.apRendererService.renderChatMessage(inserted, false);
const activity = this.apRendererService.addContext(content);
const dm = this.apDeliverManagerService.createDeliverManager(fromUser, activity);
dm.addDirectRecipe(toUser);
trackPromise(dm.execute());
})();
}
//#endregion
return packedMessage;
}
+12
View File
@@ -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;
}
}
+3
View File
@@ -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)),
};
}
@@ -457,6 +457,36 @@ export class ApInboxService {
}
}
@bindThis
private async chatMessage(resolver: Resolver, actor: MiRemoteUser, message: IObject): Promise<string> {
const uri = getApId(message);
if (typeof message === 'object') {
if (actor.uri !== message.attributedTo) {
return 'skip: actor.uri !== message.attributedTo';
}
if (typeof message.id === 'string') {
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(message.id)) {
return 'skip: host in actor.uri !== message.id';
}
} else {
return 'skip: message.id is not a string';
}
}
try {
await this.chatService.createMessageViaAp(message, actor, resolver);
return 'ok';
} catch (err) {
if (err instanceof StatusError && !err.isRetryable) {
return `skip ${err.statusCode}`;
} else {
throw err;
}
}
}
@bindThis
private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> {
if (actor.uri !== activity.actor) {
@@ -23,7 +23,7 @@ import { MfmService, type Appender } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository, MiMeta } from '@/models/_.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository, MiMeta, MiChatMessage } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
@@ -502,6 +502,31 @@ export class ApRendererService {
};
}
@bindThis
public async renderChatMessage(message: MiChatMessage, dive = true): Promise<IPost> {
const attributedTo = this.userEntityService.genLocalUserUri(message.fromUserId);
const file = message.fileId ? await this.driveFilesRepository.findOneBy({ id: message.fileId }) : null;
const emojis = await this.getEmojis(message.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const tag = [
...apemojis,
];
return {
id: `${this.config.url}/chat-messages/${message.id}`,
type: 'Misskey:ChatMessage',
attributedTo,
text: message.text,
published: this.idService.parse(message.id).date.toISOString(),
to: message.toUserId,
attachment: file ? [this.renderDocument(file)] : [],
tag,
};
}
@bindThis
public async renderPerson(user: MiLocalUser) {
const id = this.userEntityService.genLocalUserUri(user.id);
@@ -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'],
});
}
}
+1
View File
@@ -89,5 +89,6 @@ export const DI = {
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
noteDraftsRepository: Symbol('noteDraftsRepository'),
//#endregion
};
+2
View File
@@ -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,
@@ -55,6 +55,8 @@ export class MiChatMessage {
})
public text: string | null;
// 連合用
// ローカルはnull
@Column('varchar', {
length: 512, nullable: true,
})
@@ -82,4 +84,22 @@ export class MiChatMessage {
length: 1024, array: true, default: '{}',
})
public reactions: string[];
// 連合用
@Column('varchar', {
length: 128, array: true, default: '{}',
})
public emojis: string[];
// 連合用
@Column('boolean', {
default: false,
})
public isDelivering: boolean;
// 連合用
@Column('boolean', {
default: false,
})
public isDeliverFailed: boolean;
}
+2 -2
View File
@@ -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,
+157
View File
@@ -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,
+3
View File
@@ -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;
+2
View File
@@ -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,
@@ -34,6 +34,11 @@ export class CleanRemoteFilesProcessorService {
let deletedCount = 0;
let cursor: MiDriveFile['id'] | null = null;
const total = await this.driveFilesRepository.countBy({
userHost: Not(IsNull()),
isLink: false,
});
while (true) {
const files = await this.driveFilesRepository.find({
where: {
@@ -58,12 +63,7 @@ export class CleanRemoteFilesProcessorService {
deletedCount += 8;
const total = await this.driveFilesRepository.countBy({
userHost: Not(IsNull()),
isLink: false,
});
job.updateProgress(100 / total * deletedCount);
job.updateProgress(deletedCount * total / 100);
}
this.logger.succ('All cached remote files has been deleted.');
@@ -43,6 +43,10 @@ export class DeleteDriveFilesProcessorService {
let deletedCount = 0;
let cursor: MiDriveFile['id'] | null = null;
const total = await this.driveFilesRepository.countBy({
userId: user.id,
});
while (true) {
const files = await this.driveFilesRepository.find({
where: {
@@ -67,11 +71,7 @@ export class DeleteDriveFilesProcessorService {
deletedCount++;
}
const total = await this.driveFilesRepository.countBy({
userId: user.id,
});
job.updateProgress(deletedCount / total);
job.updateProgress(deletedCount / total * 100);
}
this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`);
@@ -58,6 +58,10 @@ export class ExportBlockingProcessorService {
let exportedCount = 0;
let cursor: MiBlocking['id'] | null = null;
const total = await this.blockingsRepository.countBy({
blockerId: user.id,
});
while (true) {
const blockings = await this.blockingsRepository.find({
where: {
@@ -97,11 +101,7 @@ export class ExportBlockingProcessorService {
exportedCount++;
}
const total = await this.blockingsRepository.countBy({
blockerId: user.id,
});
job.updateProgress(exportedCount / total);
job.updateProgress(exportedCount / total * 100);
}
stream.end();
@@ -95,6 +95,10 @@ export class ExportClipsProcessorService {
let exportedClipsCount = 0;
let cursor: MiClip['id'] | null = null;
const total = await this.clipsRepository.countBy({
userId: user.id,
});
while (true) {
const clips = await this.clipsRepository.find({
where: {
@@ -126,11 +130,7 @@ export class ExportClipsProcessorService {
exportedClipsCount++;
}
const total = await this.clipsRepository.countBy({
userId: user.id,
});
job.updateProgress(exportedClipsCount / total);
job.updateProgress(exportedClipsCount / total * 100);
}
}
@@ -78,6 +78,10 @@ export class ExportFavoritesProcessorService {
let exportedFavoritesCount = 0;
let cursor: MiNoteFavorite['id'] | null = null;
const total = await this.noteFavoritesRepository.countBy({
userId: user.id,
});
while (true) {
const favorites = await this.noteFavoritesRepository.find({
where: {
@@ -109,11 +113,7 @@ export class ExportFavoritesProcessorService {
exportedFavoritesCount++;
}
const total = await this.noteFavoritesRepository.countBy({
userId: user.id,
});
job.updateProgress(exportedFavoritesCount / total);
job.updateProgress(exportedFavoritesCount / total * 100);
}
await write(']');
@@ -58,6 +58,10 @@ export class ExportMutingProcessorService {
let exportedCount = 0;
let cursor: MiMuting['id'] | null = null;
const total = await this.mutingsRepository.countBy({
muterId: user.id,
});
while (true) {
const mutes = await this.mutingsRepository.find({
where: {
@@ -98,11 +102,7 @@ export class ExportMutingProcessorService {
exportedCount++;
}
const total = await this.mutingsRepository.countBy({
muterId: user.id,
});
job.updateProgress(exportedCount / total);
job.updateProgress(exportedCount / total * 100);
}
stream.end();
@@ -37,6 +37,8 @@ class NoteStream extends ReadableStream<Record<string, unknown>> {
let exportedNotesCount = 0;
let cursor: MiNote['id'] | null = null;
const totalPromise = notesRepository.countBy({ userId });
const serialize = (
note: MiNote,
poll: MiPoll | null,
@@ -88,8 +90,8 @@ class NoteStream extends ReadableStream<Record<string, unknown>> {
exportedNotesCount++;
}
const total = await notesRepository.countBy({ userId });
job.updateProgress(exportedNotesCount / total);
const total = await totalPromise;
job.updateProgress(exportedNotesCount / total * 100);
},
});
}
@@ -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,
};
});
}
}
+2
View File
@@ -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;
+1
View File
@@ -41,6 +41,7 @@ export default [
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
@@ -46,6 +46,7 @@ export default [
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
+1
View File
@@ -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'];
+2 -2
View File
@@ -72,7 +72,7 @@ export class I18n<T extends ILocale> {
console.error(`Unexpected locale key: ${String(p)}`);
return p;
return new Proxy({} as any, new Handler<TTarget[keyof TTarget] & ILocale>());
}
}
@@ -141,7 +141,7 @@ export class I18n<T extends ILocale> {
console.error(`Unexpected locale key: ${String(p)}`);
return p;
return new Proxy((() => p) as any, new Handler<TTarget[keyof TTarget] & ILocale>());
}
}
+1
View File
@@ -41,6 +41,7 @@ export default [
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import path from 'node:path'
import locales from '../../../locales/index.js';
const localesDir = path.resolve(__dirname, '../../../locales')
/**
* webSocketでメッセージを送るViteプラグイン
* @returns {import('vite').Plugin}
*/
export default function pluginWatchLocales() {
return {
name: 'watch-locales',
configureServer(server) {
const localeYmlPaths = Object.keys(locales).map(locale => path.join(localesDir, `${locale}.yml`));
// watcherにパスを追加
server.watcher.add(localeYmlPaths);
server.watcher.on('change', (filePath) => {
if (localeYmlPaths.includes(filePath)) {
server.ws.send({
type: 'custom',
event: 'locale-update',
data: filePath.match(/([^\/]+)\.yml$/)?.[1] || null,
})
}
});
},
};
}
+21 -2
View File
@@ -81,8 +81,10 @@ export async function common(createVue: () => Promise<App<Element>>) {
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
async function fetchAndUpdateLocale({ useCache } = { useCache: true }) {
const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' };
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
@@ -92,6 +94,23 @@ export async function common(createVue: () => Promise<App<Element>>) {
updateI18n(parsedNewLocale);
}
}
if (localeOutdated) {
fetchAndUpdateLocale();
}
if (import.meta.hot) {
import.meta.hot.on('locale-update', async (updatedLang: string) => {
console.info(`Locale updated: ${updatedLang}`);
if (updatedLang === lang) {
await new Promise(resolve => {
window.setTimeout(resolve, 500);
});
await fetchAndUpdateLocale({ useCache: false });
window.location.reload();
}
});
}
//#endregion
// タッチデバイスでCSSの:hoverを機能させる
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #header>{{ i18n.ts.describeFile }}</template>
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
<MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<MkDriveFileThumbnail v-if="file" :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
<template #label>{{ i18n.ts.caption }}</template>
</MkTextarea>
@@ -33,8 +33,8 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
file: Misskey.entities.DriveFile;
default: string;
file?: Misskey.entities.DriveFile | null;
default?: string | null;
}>();
const emit = defineEmits<{
@@ -44,7 +44,7 @@ const emit = defineEmits<{
const dialog = useTemplateRef('dialog');
const caption = ref(props.default);
const caption = ref(props.default ?? '');
async function ok() {
emit('done', caption.value);
@@ -0,0 +1,212 @@
<!--
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="_spacer">
<MkPagination ref="pagingEl" :pagination="paging" withControl>
<template #empty>
<MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
</template>
<template #default="{ items }">
<div class="_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>
.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>
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh">
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad" :pullToRefresh="pullToRefresh" :withControl="withControl">
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
<template #default="{ items: notes }">
@@ -45,8 +45,10 @@ const props = withDefaults(defineProps<{
noGap?: boolean;
disableAutoLoad?: boolean;
pullToRefresh?: boolean;
withControl?: boolean;
}>(), {
pullToRefresh: true,
withControl: true,
});
const pagingComponent = useTemplateRef('pagingComponent');
@@ -4,50 +4,62 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()">
<!-- :css="prefer.s.animation" にしたいけどバグる(おそらくvueのバグ) https://github.com/misskey-dev/misskey/issues/16078 -->
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
>
<MkLoading v-if="paginator.fetching.value"/>
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
<div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"><MkResult type="empty"/></slot>
<component :is="prefer.s.enablePullToRefresh && pullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => paginator.reload()" @contextmenu.prevent.stop="onContextmenu">
<div>
<div v-if="props.withControl" :class="$style.control">
<MkSelect v-model="order" :class="$style.order" :items="[{ label: i18n.ts._order.newest, value: 'newest' }, { label: i18n.ts._order.oldest, value: 'oldest' }]">
</MkSelect>
<MkButton iconOnly @click="paginator.reload()"><i class="ti ti-refresh"></i></MkButton>
</div>
<div v-else ref="rootEl" class="_gaps">
<div v-show="pagination.reversed && paginator.canFetchOlder.value" key="_more_">
<MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchNewer">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
<!-- :css="prefer.s.animation" にしたいけどバグる(おそらくvueのバグ) https://github.com/misskey-dev/misskey/issues/16078 -->
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_fade_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_fade_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_fade_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_fade_leaveTo : ''"
mode="out-in"
>
<MkLoading v-if="paginator.fetching.value"/>
<MkError v-else-if="paginator.error.value" @retry="paginator.init()"/>
<div v-else-if="paginator.items.value.length === 0" key="_empty_">
<slot name="empty"><MkResult type="empty"/></slot>
</div>
<slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
<div v-show="!pagination.reversed && paginator.canFetchOlder.value" key="_more_">
<MkButton v-if="!paginator.fetchingOlder.value" v-appear="(prefer.s.enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
<div v-else key="_root_" class="_gaps">
<slot :items="paginator.items.value" :fetching="paginator.fetching.value || paginator.fetchingOlder.value"></slot>
<div v-if="order === 'oldest'">
<MkButton v-if="!paginator.fetchingNewer.value" :class="$style.more" :wait="paginator.fetchingNewer.value" primary rounded @click="paginator.fetchNewer">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
<div v-else v-show="paginator.canFetchOlder.value">
<MkButton v-if="!paginator.fetchingOlder.value" :class="$style.more" :wait="paginator.fetchingOlder.value" primary rounded @click="paginator.fetchOlder">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else/>
</div>
</div>
</div>
</Transition>
</Transition>
</div>
</component>
</template>
<script lang="ts" setup generic="T extends PagingCtx">
import type { PagingCtx } from '@/composables/use-pagination.js';
import { isLink } from '@@/js/is-link.js';
import { ref, watch } from 'vue';
import type { UnwrapRef } from 'vue';
import type { PagingCtx } from '@/composables/use-pagination.js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { usePagination } from '@/composables/use-pagination.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
type Paginator = ReturnType<typeof usePagination<T['endpoint']>>;
@@ -56,21 +68,39 @@ const props = withDefaults(defineProps<{
disableAutoLoad?: boolean;
displayLimit?: number;
pullToRefresh?: boolean;
withControl?: boolean;
}>(), {
displayLimit: 20,
pullToRefresh: true,
withControl: false,
});
const order = ref<'newest' | 'oldest'>(props.pagination.order ?? 'newest');
const paginator: Paginator = usePagination({
ctx: props.pagination,
});
function appearFetchMoreAhead() {
paginator.fetchNewer();
}
watch(order, (newOrder) => {
paginator.updateCtx({
...props.pagination,
order: newOrder,
initialDirection: newOrder === 'oldest' ? 'newer' : 'older',
});
}, { immediate: false });
function appearFetchMore() {
paginator.fetchOlder();
function onContextmenu(ev: MouseEvent) {
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
// TODO:
os.contextMenu([{
icon: 'ti ti-refresh',
text: i18n.ts.reload,
action: () => {
paginator.reload();
},
}], ev);
}
defineSlots<{
@@ -93,6 +123,17 @@ defineExpose({
opacity: 0;
}
.control {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.order {
flex: 1;
margin-right: 8px;
}
.more {
margin-left: auto;
margin-right: auto;
+173 -44
View File
@@ -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');
}
@@ -33,8 +33,14 @@ export type PagingCtx<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoint
offsetMode?: boolean;
baseId?: MisskeyEntity['id'];
direction?: 'newer' | 'older';
initialId?: MisskeyEntity['id'];
initialDirection?: 'newer' | 'older';
// 配列内の要素をどのような順序で並べるか
// newest: 新しいものが先頭 (default)
// oldest: 古いものが先頭
// NOTE: このようなプロパティを用意してこっち側で並びを管理せずに、Setで持っておき参照者側が好きに並び変えるような設計の方がすっきりしそうなものの、Vueのレンダリングのたびに並び替え処理が発生することになったりしそうでパフォーマンス上の懸念がある
order?: 'newest' | 'oldest';
// 一部のAPIはさらに遡れる場合でもパフォーマンス上の理由でlimit以下の結果を返す場合があり、その場合はsafe、それ以外はlimitにすることを推奨
canFetchDetection?: 'safe' | 'limit';
@@ -51,6 +57,7 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
const queuedAheadItemsCount = ref(0);
const fetching = ref(true);
const fetchingOlder = ref(false);
const fetchingNewer = ref(false);
const canFetchOlder = ref(false);
const error = ref(false);
@@ -82,14 +89,14 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
...params,
limit: props.ctx.limit ?? FIRST_FETCH_LIMIT,
allowPartial: true,
...(props.ctx.baseId && props.ctx.direction === 'newer' ? {
sinceId: props.ctx.baseId,
} : props.ctx.baseId && props.ctx.direction === 'older' ? {
untilId: props.ctx.baseId,
...(props.ctx.initialDirection === 'newer' ? {
sinceId: props.ctx.initialId ?? '0',
} : props.ctx.initialId && props.ctx.initialDirection === 'older' ? {
untilId: props.ctx.initialId,
} : {}),
}).then(res => {
// 逆順で返ってくるので
if (props.ctx.baseId && props.ctx.direction === 'newer') {
if (props.ctx.initialId && props.ctx.initialDirection === 'newer') {
res.reverse();
}
@@ -167,6 +174,7 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
async function fetchNewer(options: {
toQueue?: boolean;
} = {}): Promise<void> {
fetchingNewer.value = true;
const params = props.ctx.params ? isRef(props.ctx.params) ? props.ctx.params.value : props.ctx.params : {};
await misskeyApi<T[]>(props.ctx.endpoint, {
...params,
@@ -186,8 +194,14 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
}
queuedAheadItemsCount.value = aheadQueue.length;
} else {
unshiftItems(res.toReversed());
if (props.ctx.order === 'oldest') {
pushItems(res);
} else {
unshiftItems(res.toReversed());
}
}
}).finally(() => {
fetchingNewer.value = false;
});
}
@@ -253,6 +267,11 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
}
}
function updateCtx(ctx: PagingCtx<Endpoint>) {
props.ctx = ctx;
reload();
}
if (props.autoInit !== false) {
onMounted(() => {
init();
@@ -264,6 +283,7 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
queuedAheadItemsCount,
fetching,
fetchingOlder,
fetchingNewer,
canFetchOlder,
init,
reload,
@@ -277,5 +297,6 @@ export function usePagination<Endpoint extends keyof Misskey.Endpoints, T extend
enqueue,
releaseQueue,
error,
updateCtx,
};
}
@@ -22,6 +22,12 @@ export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | nu
if (!el) return;
if (!ready) return;
if (el.scrollTop < 100) {
// 上部にいるときはanchorを参照するとズレの原因になるし位置復元するメリットも乏しいため設定しない
anchorId = null;
return;
}
const scrollContainerRect = el.getBoundingClientRect();
const viewPosition = scrollContainerRect.height / 2;
@@ -19,9 +19,8 @@ import { ensureSignin } from '@/i.js';
import { WatermarkRenderer } from '@/utility/watermark.js';
export type UploaderFeatures = {
effect?: boolean;
imageEditing?: boolean;
watermark?: boolean;
crop?: boolean;
};
const THUMBNAIL_SUPPORTED_TYPES = [
@@ -38,12 +37,6 @@ const IMAGE_COMPRESSION_SUPPORTED_TYPES = [
'image/svg+xml',
];
const CROPPING_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
const IMAGE_EDITING_SUPPORTED_TYPES = [
'image/jpeg',
'image/png',
@@ -55,7 +48,6 @@ const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
const IMAGE_PREPROCESS_NEEDED_TYPES = [
...WATERMARK_SUPPORTED_TYPES,
...IMAGE_COMPRESSION_SUPPORTED_TYPES,
...CROPPING_SUPPORTED_TYPES,
...IMAGE_EDITING_SUPPORTED_TYPES,
];
@@ -82,6 +74,7 @@ export type UploaderItem = {
file: File;
watermarkPresetId: string | null;
isSensitive?: boolean;
caption?: string | null;
abort?: (() => void) | null;
};
@@ -111,17 +104,14 @@ export function useUploader(options: {
multiple?: boolean;
features?: UploaderFeatures;
} = {}) {
const $i = ensureSignin();
const events = new EventEmitter<{
'itemUploaded': (ctx: { item: UploaderItem; }) => void;
}>();
const uploaderFeatures = computed<Required<UploaderFeatures>>(() => {
return {
effect: options.features?.effect ?? true,
imageEditing: options.features?.imageEditing ?? true,
watermark: options.features?.watermark ?? true,
crop: options.features?.crop ?? true,
};
});
@@ -193,66 +183,82 @@ export function useUploader(options: {
get: () => item.isSensitive ?? false,
set: (value) => item.isSensitive = value,
}),
}, {
text: i18n.ts.describeFile,
icon: 'ti ti-text-caption',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkFileCaptionEditWindow.vue').then(x => x.default), {
default: item.caption ?? null,
}, {
done: caption => {
if (caption != null) {
item.caption = caption.trim().length === 0 ? null : caption;
}
},
closed: () => dispose(),
});
},
}, {
type: 'divider',
});
}
if (
uploaderFeatures.value.crop &&
CROPPING_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
menu.push({
icon: 'ti ti-crop',
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
triggerRef(items);
});
},
});
}
if (
uploaderFeatures.value.effect &&
uploaderFeatures.value.imageEditing &&
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
!item.preprocessing &&
!item.uploading &&
!item.uploaded
) {
menu.push({
icon: 'ti ti-sparkles',
text: i18n.ts._imageEffector.title + ' (BETA)',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
image: item.file,
}, {
ok: (file) => {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(file),
thumbnail: window.URL.createObjectURL(file),
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
triggerRef(items);
});
},
closed: () => dispose(),
});
},
type: 'parent',
icon: 'ti ti-photo-edit',
text: i18n.ts._uploader.editImage,
children: [{
icon: 'ti ti-crop',
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
triggerRef(items);
});
},
}, /*{
icon: 'ti ti-resize',
text: i18n.ts.resize,
action: async () => {
// TODO
},
},*/ {
icon: 'ti ti-sparkles',
text: i18n.ts._imageEffector.title + ' (BETA)',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
image: item.file,
}, {
ok: (file) => {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(file),
thumbnail: window.URL.createObjectURL(file),
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
triggerRef(items);
});
},
closed: () => dispose(),
});
},
}],
});
}
@@ -406,8 +412,9 @@ export function useUploader(options: {
const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
name: item.uploadName ?? item.name,
folderId: options.folderId,
folderId: options.folderId === undefined ? prefer.s.uploadFolder : options.folderId,
isSensitive: item.isSensitive ?? false,
caption: item.caption ?? null,
onProgress: (progress) => {
if (item.progress == null) {
item.progress = { max: progress.total, value: progress.loaded };
@@ -280,6 +280,9 @@ const patronsWithIcon = [{
}, {
name: '新井 治',
icon: 'https://assets.misskey-hub.net/patrons/d160876f20394674a17963a0e609600a.jpg',
}, {
name: 'しきいし',
icon: 'https://assets.misskey-hub.net/patrons/77dd5387db41427ba9cbdc8849e76402.jpg',
}];
const patrons = [
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<template #suffix>
<MkTime :time="job.finishedOn ?? job.processedOn ?? job.timestamp" mode="relative"/>
<span v-if="job.progress != null && typeof job.progress === 'number' && job.progress > 0" style="margin-left: 1em;">{{ Math.floor(job.progress * 100) }}%</span>
<span v-if="job.progress != null && typeof job.progress === 'number' && job.progress > 0" style="margin-left: 1em;">{{ Math.floor(job.progress) }}%</span>
<span v-if="job.opts.attempts != null && job.opts.attempts > 0 && job.attempts > 1" style="margin-left: 1em; color: var(--MI_THEME-warn); font-variant-numeric: diagonal-fractions;">{{ job.attempts }}/{{ job.opts.attempts }}</span>
<span v-if="job.isFailed && job.finishedOn != null" style="margin-left: 1em; color: var(--MI_THEME-error)"><i class="ti ti-circle-x"></i></span>
<span v-else-if="job.isFailed" style="margin-left: 1em; color: var(--MI_THEME-warn)"><i class="ti ti-alert-triangle"></i></span>
@@ -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>
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="tab === 'my'" class="_gaps">
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps">
<MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps" withControl>
<MkClipPreview v-for="item in items" :key="item.id" :clip="item" :noUserInfo="true"/>
</MkPagination>
</div>
@@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
<MkPagination ref="paginationEl" :pagination="membershipsPagination">
<MkPagination ref="paginationEl" :pagination="membershipsPagination" withControl>
<template #default="{ items }">
<div class="_gaps_s">
<div v-for="item in items" :key="item.id">
+10 -9
View File
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<Transition :name="prefer.s.animation ? 'fade' : ''" mode="out-in">
<div v-if="note">
<div v-if="showNext" class="_margin">
<MkNotesTimeline :pullToRefresh="false" class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/>
<MkNotesTimeline :withControl="false" :pullToRefresh="false" class="" :pagination="showNext === 'channel' ? nextChannelPagination : nextUserPagination" :noGap="true" :disableAutoLoad="true"/>
</div>
<div class="_margin">
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="showPrev" class="_margin">
<MkNotesTimeline :pullToRefresh="false" class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/>
<MkNotesTimeline :withControl="false" :pullToRefresh="false" class="" :pagination="showPrev === 'channel' ? prevChannelPagination : prevUserPagination" :noGap="true"/>
</div>
</div>
<MkError v-else-if="error" @retry="fetchNote()"/>
@@ -81,8 +81,8 @@ const error = ref();
const prevUserPagination: PagingCtx = {
endpoint: 'users/notes',
limit: 10,
baseId: props.noteId,
direction: 'older',
initialId: props.noteId,
initialDirection: 'older',
params: computed(() => note.value ? ({
userId: note.value.userId,
}) : undefined),
@@ -91,8 +91,8 @@ const prevUserPagination: PagingCtx = {
const nextUserPagination: PagingCtx = {
endpoint: 'users/notes',
limit: 10,
baseId: props.noteId,
direction: 'newer',
initialId: props.noteId,
initialDirection: 'newer',
params: computed(() => note.value ? ({
userId: note.value.userId,
}) : undefined),
@@ -101,19 +101,20 @@ const nextUserPagination: PagingCtx = {
const prevChannelPagination: PagingCtx = {
endpoint: 'channels/timeline',
limit: 10,
initialId: props.noteId,
initialDirection: 'older',
params: computed(() => note.value ? ({
channelId: note.value.channelId,
untilId: note.value.id,
}) : undefined),
};
const nextChannelPagination: PagingCtx = {
reversed: true,
endpoint: 'channels/timeline',
limit: 10,
initialId: props.noteId,
initialDirection: 'newer',
params: computed(() => note.value ? ({
channelId: note.value.channelId,
sinceId: note.value.id,
}) : undefined),
};
@@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.manage }}</template>
<MkPagination :pagination="pagination">
<MkPagination :pagination="pagination" withControl>
<template #default="{items}">
<div class="_gaps">
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`">
@@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-repeat-off"></i></template>
<template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template>
<MkPagination :pagination="renoteMutingPagination">
<MkPagination :pagination="renoteMutingPagination" withControl>
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
<template #default="{ items }">
@@ -111,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.mutedUsers }}</template>
<MkPagination :pagination="mutingPagination">
<MkPagination :pagination="mutingPagination" withControl>
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
<template #default="{ items }">
@@ -144,7 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-ban"></i></template>
<template #label>{{ i18n.ts.blockedUsers }}</template>
<MkPagination :pagination="blockingPagination">
<MkPagination :pagination="blockingPagination" withControl>
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
<template #default="{ items }">
@@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template>
<MkPagination :pagination="pagination" disableAutoLoad>
<MkPagination :pagination="pagination" disableAutoLoad withControl>
<template #default="{items}">
<div>
<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
+28 -7
View File
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.themeRadio"
:value="instanceLightTheme.id"
/>
<label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button">
<label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)">
<MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/>
<div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div>
</label>
@@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.themeRadio"
:value="theme.id"
/>
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button">
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
</label>
@@ -96,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.themeRadio"
:value="theme.id"
/>
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button">
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
</label>
@@ -127,7 +127,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.themeRadio"
:value="instanceDarkTheme.id"
/>
<label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button">
<label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)">
<MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/>
<div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div>
</label>
@@ -147,7 +147,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.themeRadio"
:value="theme.id"
/>
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button">
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
</label>
@@ -167,7 +167,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.themeRadio"
:value="theme.id"
/>
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button">
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
</label>
@@ -210,7 +210,7 @@ import FormSection from '@/components/form/section.vue';
import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkThemePreview from '@/components/MkThemePreview.vue';
import { getBuiltinThemesRef, getThemesRef } from '@/theme.js';
import { getBuiltinThemesRef, getThemesRef, removeTheme } from '@/theme.js';
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
@@ -218,6 +218,7 @@ import { instance } from '@/instance.js';
import { uniqueBy } from '@/utility/array.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
const installedThemes = getThemesRef();
const builtinThemes = getBuiltinThemesRef();
@@ -295,6 +296,26 @@ function changeThemesSyncEnabled(value: boolean) {
}
}
function onThemeContextmenu(theme: Theme, ev: MouseEvent) {
os.contextMenu([{
type: 'label',
text: theme.name,
}, {
icon: 'ti ti-clipboard',
text: i18n.ts._theme.copyThemeCode,
action: () => {
copyToClipboard(JSON5.stringify(theme, null, '\t'));
},
}, {
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => {
removeTheme(theme);
},
}], ev);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
+1 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<div>
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
<MkPagination v-slot="{items}" :pagination="pagination" withControl>
<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" :class="$style.item" class="_panel _margin">
<b>{{ item.name }}</b>
<div v-if="item.description" :class="$style.description">{{ item.description }}</div>
+1 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 1100px;">
<div :class="$style.root">
<MkPagination v-slot="{items}" :pagination="pagination">
<MkPagination v-slot="{items}" :pagination="pagination" withControl>
<div :class="$style.stream">
<MkNoteMediaGrid v-for="note in items" :note="note" square/>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
<MkPagination v-slot="{items}" :pagination="pagination" withControl>
<MkFlashPreview v-for="flash in items" :key="flash.id" :flash="flash" class="_margin"/>
</MkPagination>
</div>
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination">
<MkPagination v-slot="{items}" :pagination="type === 'following' ? followingPagination : followersPagination" withControl>
<div :class="$style.users">
<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :user="user"/>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<MkPagination v-slot="{items}" :pagination="pagination">
<MkPagination v-slot="{items}" :pagination="pagination" withControl>
<div :class="$style.root">
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<div>
<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" class="lists">
<MkPagination v-slot="{items}" ref="pagingComponent" :pagination="pagination" withControl>
<MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/list/${ list.id }`">
<div>{{ list.name }}</div>
<MkAvatars :userIds="list.userIds"/>
+1 -1
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
<MkPagination v-slot="{items}" :pagination="pagination" withControl>
<MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_margin"/>
</MkPagination>
</div>
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 700px;">
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
<MkPagination v-slot="{items}" :pagination="pagination">
<div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="_panel _margin">
<div :class="$style.header">
<MkAvatar :class="$style.avatar" :user="user"/>
+27 -20
View File
@@ -112,7 +112,16 @@ export function getInitialPrefValue<K extends keyof PREF>(k: K): ValueOf<K> {
}
}
function isAccountDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true;
}
function isServerDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true;
}
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すれば$iのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
export class PreferencesManager {
private storageProvider: StorageProvider;
@@ -149,19 +158,12 @@ export class PreferencesManager {
// TODO: 定期的にクラウドの値をフェッチ
}
private static isAccountDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].accountDependent === true;
}
private static isServerDependentKey<K extends keyof PREF>(key: K): boolean {
return (PREF_DEF as PreferencesDefinition)[key].serverDependent === true;
}
private rewriteRawState<K extends keyof PREF>(key: K, value: ValueOf<K>) {
const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
this.r[key].value = this.s[key] = v;
}
// TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
@@ -176,7 +178,7 @@ export class PreferencesManager {
const record = this.getMatchedRecordOf(key);
if (parseScope(record[0]).account == null && PreferencesManager.isAccountDependentKey(key)) {
if (parseScope(record[0]).account == null && isAccountDependentKey(key)) {
this.profile.preferences[key].push([makeScope({
server: host,
account: $i!.id,
@@ -185,7 +187,7 @@ export class PreferencesManager {
return;
}
if (parseScope(record[0]).server == null && PreferencesManager.isServerDependentKey(key)) {
if (parseScope(record[0]).server == null && isServerDependentKey(key)) {
this.profile.preferences[key].push([makeScope({
server: host,
}), v, {}]);
@@ -287,12 +289,12 @@ export class PreferencesManager {
const data = {} as PreferencesProfile['preferences'];
for (const key in PREF_DEF) {
const v = getInitialPrefValue(key as keyof typeof PREF_DEF);
if (PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF)) {
if (isAccountDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = $i ? [[makeScope({}), v, {}], [makeScope({
server: host,
account: $i.id,
}), v, {}]] : [[makeScope({}), v, {}]];
} else if (PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF)) {
} else if (isServerDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = [[makeScope({
server: host,
}), v, {}]];
@@ -316,12 +318,12 @@ export class PreferencesManager {
const records = profileLike.preferences[key];
if (records == null || records.length === 0) {
const v = getInitialPrefValue(key as keyof typeof PREF_DEF);
if (PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF)) {
if (isAccountDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = $i ? [[makeScope({}), v, {}], [makeScope({
server: host,
account: $i.id,
}), v, {}]] : [[makeScope({}), v, {}]];
} else if (PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF)) {
} else if (isServerDependentKey(key as keyof typeof PREF_DEF)) {
data[key] = [[makeScope({
server: host,
}), v, {}]];
@@ -330,14 +332,14 @@ export class PreferencesManager {
}
continue;
} else {
if ($i && PreferencesManager.isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id)) {
if ($i && isAccountDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id)) {
data[key] = records.concat([[makeScope({
server: host,
account: $i.id,
}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]);
continue;
}
if ($i && PreferencesManager.isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) {
if ($i && isServerDependentKey(key as keyof typeof PREF_DEF) && !records.some(([scope]) => parseScope(scope).server === host)) {
data[key] = records.concat([[makeScope({
server: host,
}), getInitialPrefValue(key as keyof typeof PREF_DEF), {}]]);
@@ -372,17 +374,22 @@ export class PreferencesManager {
if (serverOverrideRecord) return serverOverrideRecord;
const record = records.find(([scope, v]) => parseScope(scope).account == null);
return record!;
if (record == null) { // 設計上あり得ないけどTSに怒られるため
throw new Error(`no record found for key: ${key}`);
}
return record;
}
public isAccountOverrided<K extends keyof PREF>(key: K): boolean {
if ($i == null) return false;
return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id) ?? false;
return this.profile.preferences[key].some(([scope, v]) => parseScope(scope).server === host && parseScope(scope).account === $i!.id);
}
public setAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (PreferencesManager.isAccountDependentKey(key)) throw new Error('already account-dependent');
if (isAccountDependentKey(key)) throw new Error('already account-dependent');
if (this.isAccountOverrided(key)) return;
const records = this.profile.preferences[key];
@@ -396,7 +403,7 @@ export class PreferencesManager {
public clearAccountOverride<K extends keyof PREF>(key: K) {
if ($i == null) return;
if (PreferencesManager.isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property');
if (isAccountDependentKey(key)) throw new Error('cannot clear override for this account-dependent property');
const records = this.profile.preferences[key];
@@ -35,6 +35,8 @@ export function getPreferencesProfileMenu(): MenuItem[] {
}
store.set('enablePreferencesAutoCloudBackup', true);
cloudBackup();
} else {
store.set('enablePreferencesAutoCloudBackup', false);
}
+4 -2
View File
@@ -33,6 +33,7 @@ export function uploadFile(file: File | Blob, options: {
name?: string;
folderId?: string | null;
isSensitive?: boolean;
caption?: string | null;
onProgress?: (ctx: { total: number; loaded: number; }) => void;
} = {}): UploadReturnType {
const xhr = new XMLHttpRequest();
@@ -142,6 +143,7 @@ export function uploadFile(file: File | Blob, options: {
formData.append('file', file);
formData.append('name', options.name ?? (file instanceof File ? file.name : 'untitled'));
formData.append('isSensitive', options.isSensitive ? 'true' : 'false');
if (options.caption != null) formData.append('comment', options.caption);
if (options.folderId) formData.append('folderId', options.folderId);
xhr.send(formData);
@@ -226,7 +228,7 @@ export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
});
}
function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderDialogFeatures): Promise<Misskey.entities.DriveFile[]> {
function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderFeatures): Promise<Misskey.entities.DriveFile[]> {
return new Promise((res, rej) => {
os.popupMenu([label ? {
text: label,
@@ -251,7 +253,7 @@ type SelectFileOptions<M extends boolean> = {
anchorElement: HTMLElement | EventTarget | null;
multiple: M;
label?: string | null;
features?: UploaderDialogFeatures;
features?: UploaderFeatures;
};
export async function selectFile<
@@ -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 {
+2
View File
@@ -13,6 +13,7 @@ import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-modul
import pluginJson5 from './vite.json5.js';
import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js';
import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js';
import pluginWatchLocales from './lib/vite-plugin-watch-locales.js';
const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null;
const host = url ? (new URL(url)).hostname : undefined;
@@ -102,6 +103,7 @@ export function getConfig(): UserConfig {
},
plugins: [
pluginWatchLocales(),
...searchIndexes.map(options => pluginCreateSearchIndex(options)),
pluginVue(),
pluginUnwindCssModuleClassName(),
+36
View File
@@ -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'];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2025.6.2-alpha.0",
"version": "2025.6.4-alpha.0",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
@@ -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'];
+502
View File
@@ -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: {