Compare commits

...

48 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
github-actions[bot] 213c569242 Bump version to 2025.6.2-alpha.0 2025-06-16 05:08:24 +00:00
syuilo a1cf2d3074 New Crowdin updates (#16192)
* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Spanish)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Simplified)
2025-06-16 14:07:48 +09:00
syuilo 4ea7c76c02 fix(frontend): キャッシュを削除しないとクライアントが使用できないことがある問題を修正 2025-06-16 13:55:27 +09:00
github-actions[bot] 1782a353d3 [skip ci] Update CHANGELOG.md (prepend template) 2025-06-16 02:33:19 +00:00
github-actions[bot] c69a13b592 Release: 2025.6.1 2025-06-16 02:33:14 +00:00
syuilo 40e35c051a Update CHANGELOG.md 2025-06-15 11:10:03 +09:00
syuilo b93717be33 Update CHANGELOG.md
Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2025-06-15 11:08:53 +09:00
syuilo fe805fb7f0 enhance(frontend/image-effector): tweak fxs 2025-06-15 11:06:46 +09:00
syuilo e9af9d4451 enhance(frontend/image-effector): tweak fxs 2025-06-15 10:57:29 +09:00
syuilo ce90fee586 enhance(frontend/image-effector): add blockNoise fx 2025-06-15 10:55:11 +09:00
syuilo 5bec8ba6b0 enhance(frontend/image-effector): tweak fxs 2025-06-15 10:19:42 +09:00
syuilo 3dbfd80d65 enhance(frontend/image-effector): tweak colorAdjust fx 2025-06-15 09:25:57 +09:00
syuilo b33eeb1366 enhance(frontend/image-effector): tweak distort fx 2025-06-15 08:47:59 +09:00
syuilo 420756d744 Update CHANGELOG.md 2025-06-14 19:50:56 +09:00
かっこかり 32d721abf1 refactor(frontend): checkWordMuteの返り値が誤っている問題を修正 (#16188)
* refactor(frontend): checkWordMuteの返り値が誤っている問題を修正

* fix lint
2025-06-14 16:08:14 +09:00
github-actions[bot] 8ea6aa2ef3 Bump version to 2025.6.1-rc.0 2025-06-14 03:16:12 +00:00
syuilo bc07b79a23 fix(frontend): デッキのタイムラインカラムで新着ノート時のサウンドが再生されない問題を修正
Fix #16164
2025-06-14 11:36:42 +09:00
syuilo aae7961540 New Crowdin updates (#16187)
* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Simplified)
2025-06-13 10:31:14 +09:00
107 changed files with 3635 additions and 329 deletions
+30 -1
View File
@@ -1,10 +1,38 @@
## 2025.6.4
### General
- Feat: ノートの下書き機能
### Client
- Enhance: 設定の自動バックアップをオンにした直後に自動バックアップするように
- Enhance: ファイルアップロード前にキャプション設定を行えるように
- Enhance: ページネーションの並び順を逆にできるように
- Fix: ファイルがドライブの既定アップロード先に指定したフォルダにアップロードされない問題を修正
### Server
- Fix: ジョブキューのProgressの値を正しく計算する
## 2025.6.3
### Client
- Fix: キャッシュを削除しないとクライアントが使用できないことがある問題を修正
## 2025.6.2
### Client
- Fix: キャッシュを削除しないとクライアントが使用できないことがある問題を修正
- 翻訳の更新
## 2025.6.1
### Note
- Misskey Webプラグインのnote_view_interruptorは不具合の影響により現在一時的に無効化されています。
- AiScript Misskey拡張APIMisskey Webプラグイン)の[note_view_interruptor](https://misskey-hub.net/ja/docs/for-developers/plugin/plugin-api-reference/#pluginregister_note_view_interruptorfn)は不具合の影響により現在一時的に無効化されています。
- Misskey Web投稿フォームのプレビュー切り替えは「...」メニュー内に配置されました
### Client
- Feat: 画像にウォーターマークを付与できるようになりました
- Feat: 画像の加工ができるようになりました(実験的)
- Enhance: ノートのリアクション一覧で、押せるリアクションを優先して表示できるようにするオプションを追加
- Enhance: 全てのチャットメッセージを既読にできるように(設定→その他)
- Enhance: ミュートした絵文字をデバイス間で同期できるように
@@ -13,6 +41,7 @@
- Fix: ユーザーの検索結果を追加で読み込むことができない問題を修正
- Fix: タッチ操作時にチャートのツールチップが消えなくなる場合がある問題を修正
- Fix: ウェルカムタイムラインでリアクションが表示されない問題を修正
- Fix: デッキのタイムラインカラムで新着ノート時のサウンドが再生されない問題を修正
### Server
- Feat: 全てのチャットメッセージを既読にするAPIを追加(chat/read-all)
+2
View File
@@ -3169,3 +3169,5 @@ _imageEffector:
stripe: "Bandes"
polkadot: "Lunars"
checker: "Escacs"
blockNoise: "Bloqueig de soroll"
tearing: "Trencament d'imatge "
+4
View File
@@ -2465,6 +2465,8 @@ _visibility:
disableFederation: "Defederate"
disableFederationDescription: "Don't transmit to other instances"
_postForm:
quitInspiteOfThereAreUnuploadedFilesConfirm: "There are files that have not been uploaded, do you want to discard them and close the form?"
uploaderTip: "The file has not yet been uploaded. From the file menu, you can rename, crop images, watermark and compress or uncompress the file. Files are automatically uploaded when you publish a note."
replyPlaceholder: "Reply to this note..."
quotePlaceholder: "Quote this note..."
channelPlaceholder: "Post to a channel..."
@@ -3167,3 +3169,5 @@ _imageEffector:
stripe: "Stripes"
polkadot: "Polkadot"
checker: "Checker"
blockNoise: "Block Noise"
tearing: "Tearing"
+2
View File
@@ -3169,3 +3169,5 @@ _imageEffector:
stripe: "Rayas"
polkadot: "Lunares"
checker: "Corrector"
blockNoise: "Bloquear Ruido"
tearing: "Rasgado de Imagen (Tearing)"
+92
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}
*/
@@ -12220,8 +12246,74 @@ export interface Locale extends ILocale {
*
*/
"checker": string;
/**
*
*/
"blockNoise": string;
/**
*
*/
"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;
+26
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: "アップロードされていないファイルがありますが、中止しますか?"
@@ -3273,3 +3281,21 @@ _imageEffector:
stripe: "ストライプ"
polkadot: "ポルカドット"
checker: "チェッカー"
blockNoise: "ブロックノイズ"
tearing: "ティアリング"
drafts: "下書き"
_drafts:
select: "下書きを選択"
cannotCreateDraftAnymore: "下書きの作成可能数を超えています。"
cannotCreateDraftOfRenote: "リノートの下書きは作成できません。"
delete: "下書きを削除"
deleteAreYouSure: "下書きを削除しますか?"
noDrafts: "下書きはありません"
replyTo: "{user}への返信"
quoteOf: "{user}のノートへの引用"
postTo: "{channel}への投稿"
saveToDraft: "下書きへ保存"
restoreFromDraft: "下書きから復元"
restore: "復元"
listDrafts: "下書き一覧"
+3 -1
View File
@@ -3107,7 +3107,7 @@ _uploader:
savedXPercent: "{x}% 절약"
abortConfirm: "업로드되지 않은 파일이 있습니다만, 그만 두시겠습니까?"
doneConfirm: "업로드되지 않은 파일이 있습니다만, 완료하시겠습니까?"
maxFileSizeIsX: "업드 가능한 최대 파일 크기는 {x}입니다."
maxFileSizeIsX: "업드 가능한 최대 파일 크기는 {x}입니다."
allowedTypes: "업로드 가능한 파일 유형"
tip: "파일은 아직 업로드되지 않았습니다. 이 다이얼로그에서 업로드 전의 확인, 이름 바꾸기, 압축, 자르기 등을 하실 수 있습니다. 준비가 되셨다면 '업로드' 버튼을 클릭해 업로드를 시작하실 수 있습니다."
_clientPerformanceIssueTip:
@@ -3169,3 +3169,5 @@ _imageEffector:
stripe: "줄무늬"
polkadot: "물방울 무늬"
checker: "체크 무늬"
blockNoise: "노이즈 방지"
tearing: "티어링"
+5 -3
View File
@@ -1631,7 +1631,7 @@ _serverSettings:
inquiryUrl: "联络地址"
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
openRegistration: "开放注册"
openRegistrationWarning: "开放注册有风险。建议仅当能够持续监控服务器并在出现问题时能够立即响应时才打开它。"
openRegistrationWarning: "开放注册有风险。建议仅当能够持续监控服务器并在出现问题时能够立即响应时才打开它。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
deliverSuspendedSoftware: "停止投递的软件"
deliverSuspendedSoftwareDescription: "可因安全漏洞之类的原因,停止向指定的服务器及服务器版本送信。版本信息由服务器提供,不保证可靠性。可使用 semver 范围来指定版本,但指定 >= 2024.3.1 将不包括如 2024.3.1-custom.0 等自定义版本,因此建议像 >= 2024.3.1-0 这样指定 prerelease 版本。"
@@ -2143,7 +2143,7 @@ _wordMute:
muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。"
muteWordsDescription2: "正则表达式用斜线包裹"
_instanceMute:
instanceMuteDescription: "隐藏服务器中所有帖子和转帖,包括这些服务器上用户回复。"
instanceMuteDescription: "隐藏服务器中所有帖子和转帖,包括这些服务器上用户回复。"
instanceMuteDescription2: "一行一个"
title: "下面实例中的帖子将被隐藏。"
heading: "已隐藏的服务器"
@@ -2493,7 +2493,7 @@ _profile:
avatarDecorationMax: "最多可添加 {max} 个挂件"
followedMessage: "被关注时显示的消息"
followedMessageDescription: "可以设置被关注时向对方显示的短消息。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息在请求被批准后显示。"
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息在请求被批准后显示。"
_exportOrImport:
allNotes: "所有帖子"
favoritedNotes: "收藏的帖子"
@@ -3169,3 +3169,5 @@ _imageEffector:
stripe: "条纹"
polkadot: "波点"
checker: "检查"
blockNoise: "块状噪点"
tearing: "撕裂"
+2
View File
@@ -3169,3 +3169,5 @@ _imageEffector:
stripe: "條紋"
polkadot: "波卡圓點"
checker: "棋盤格"
blockNoise: "阻擋雜訊"
tearing: "撕裂"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.6.1-beta.2",
"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'];
+7 -3
View File
@@ -39,7 +39,11 @@ export class I18n<T extends ILocale> {
private devMode: boolean;
constructor(public locale: T, devMode = false) {
this.devMode = devMode;
// 場合によってはバージョンアップ前の翻訳データを参照した結果存在しないプロパティにアクセスしてクライアントが起動できなくなることがある問題の応急処置として非devモードでもプロキシする
// TODO: https://github.com/misskey-dev/misskey/issues/14453 が実装されたらそのようなことは発生し得なくなるため消す
const oukyuusyoti = true;
this.devMode = devMode || oukyuusyoti;
//#region BIND
this.t = this.t.bind(this);
@@ -68,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>());
}
}
@@ -137,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);
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-if="v.type === 'boolean'"
v-model="layer.params[k]"
>
<template #label>{{ k }}</template>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkSwitch>
<MkRange
v-else-if="v.type === 'number'"
@@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:min="v.min"
:max="v.max"
:step="v.step"
:textConverter="fx.params[k].toViewValue"
@thumbDoubleClicked="() => {
if (fx.params[k].default != null) {
layer.params[k] = fx.params[k].default;
@@ -37,13 +38,13 @@ SPDX-License-Identifier: AGPL-3.0-only
}
}"
>
<template #label>{{ k }}</template>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkRange>
<MkRadios
v-else-if="v.type === 'number:enum'"
v-model="layer.params[k]"
>
<template #label>{{ k }}</template>
<template #label>{{ fx.params[k].label ?? k }}</template>
<option v-for="item in v.enum" :value="item.value">{{ item.label }}</option>
</MkRadios>
<div v-else-if="v.type === 'seed'">
@@ -55,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:max="10000"
:step="1"
>
<template #label>{{ k }}</template>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkRange>
</div>
<MkInput
@@ -64,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
type="color"
@update:modelValue="v => { const c = getRgb(v); if (c != null) layer.params[k] = c; }"
>
<template #label>{{ k }}</template>
<template #label>{{ fx.params[k].label ?? k }}</template>
</MkInput>
</div>
</div>
@@ -96,7 +96,7 @@ watch(layers, async () => {
}, { deep: true });
function addEffect(ev: MouseEvent) {
os.popupMenu(FXS.filter(fx => fx.id !== 'watermarkPlacement').map((fx) => ({
os.popupMenu(FXS.map((fx) => ({
text: fx.name,
action: () => {
layers.push({
+16 -8
View File
@@ -321,20 +321,27 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
url: `https://${host}/notes/${appearNote.id}`,
}));
/* Overload FunctionLint
/* eslint-disable no-redeclare */
/** checkOnlyでは純粋なワードミュート結果をbooleanで返却する */
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
*/
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly?: false): Array<string | string[]> | false | 'sensitiveMute';
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | boolean | 'sensitiveMute' {
if (mutedWords != null) {
const result = checkWordMute(noteToCheck, $i, mutedWords);
if (Array.isArray(result)) return result;
if (Array.isArray(result)) {
return checkOnly ? (result.length > 0) : result;
}
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
if (Array.isArray(replyResult)) return replyResult;
if (Array.isArray(replyResult)) {
return checkOnly ? (replyResult.length > 0) : replyResult;
}
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
if (Array.isArray(renoteResult)) return renoteResult;
if (Array.isArray(renoteResult)) {
return checkOnly ? (renoteResult.length > 0) : renoteResult;
}
}
if (checkOnly) return false;
@@ -345,6 +352,7 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
return false;
}
/* eslint-enable no-redeclare */
const keymap = {
'r': () => {
@@ -417,7 +425,7 @@ if (!props.mock) {
const users = renotes.map(x => x.user);
if (users.length < 1) return;
if (users.length < 1 || renoteButton.value == null) return;
const { dispose } = os.popup(MkUsersTooltip, {
showing,
@@ -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');
}
@@ -62,6 +62,7 @@ import { useInterval } from '@@/js/use-interval.js';
import { getScrollContainer, scrollToTop } from '@@/js/scroll.js';
import type { BasicTimelineType } from '@/timelines.js';
import type { PagingCtx } from '@/composables/use-pagination.js';
import type { SoundStore } from '@/preferences/def.js';
import { usePagination } from '@/composables/use-pagination.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { useStream } from '@/stream.js';
@@ -83,6 +84,7 @@ const props = withDefaults(defineProps<{
channel?: string;
role?: string;
sound?: boolean;
customSound?: SoundStore | null;
withRenotes?: boolean;
withReplies?: boolean;
withSensitive?: boolean;
@@ -92,6 +94,8 @@ const props = withDefaults(defineProps<{
withReplies: false,
withSensitive: true,
onlyFiles: false,
sound: false,
customSound: null,
});
provide('inTimeline', true);
@@ -190,7 +194,11 @@ function prepend(note: Misskey.entities.Note) {
}
if (props.sound) {
sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
if (props.customSound) {
sound.playMisskeySfxFile(props.customSound);
} else {
sound.playMisskeySfx($i && (note.userId === $i.id) ? 'noteMy' : 'note');
}
}
}
@@ -420,7 +428,7 @@ defineExpose({
background: var(--MI_THEME-panel);
}
.note {
.note:not(:empty) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
@@ -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);
}
@@ -26,6 +26,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:withReplies="withReplies"
:withSensitive="withSensitive"
:onlyFiles="onlyFiles"
:sound="true"
:customSound="soundSetting"
/>
</XColumn>
</template>
+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<
@@ -542,7 +542,7 @@ function smallerVisibility(a: Visibility, b: Visibility): Visibility {
export function getRenoteMenu(props: {
note: Misskey.entities.Note;
renoteButton: ShallowRef<HTMLElement | undefined>;
renoteButton: ShallowRef<HTMLElement | null | undefined>;
mock?: boolean;
}) {
const appearNote = getAppearNote(props.note);
@@ -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 {
@@ -19,6 +19,8 @@ type ParamTypeToPrimitive = {
type ImageEffectorFxParamDefs = Record<string, {
type: keyof ParamTypeToPrimitive;
default: any;
label?: string;
toViewValue?: (v: any) => string;
}>;
export function defineImageEffectorFx<ID extends string, PS extends ImageEffectorFxParamDefs, US extends string[]>(fx: ImageEffectorFx<ID, PS, US>) {
@@ -10,20 +10,17 @@ import { FX_colorClamp } from './fxs/colorClamp.js';
import { FX_colorClampAdvanced } from './fxs/colorClampAdvanced.js';
import { FX_distort } from './fxs/distort.js';
import { FX_polkadot } from './fxs/polkadot.js';
import { FX_glitch } from './fxs/glitch.js';
import { FX_tearing } from './fxs/tearing.js';
import { FX_grayscale } from './fxs/grayscale.js';
import { FX_invert } from './fxs/invert.js';
import { FX_mirror } from './fxs/mirror.js';
import { FX_stripe } from './fxs/stripe.js';
import { FX_threshold } from './fxs/threshold.js';
import { FX_watermarkPlacement } from './fxs/watermarkPlacement.js';
import { FX_zoomLines } from './fxs/zoomLines.js';
import { FX_blockNoise } from './fxs/blockNoise.js';
import type { ImageEffectorFx } from './ImageEffector.js';
export const FXS = [
FX_watermarkPlacement,
FX_chromaticAberration,
FX_glitch,
FX_mirror,
FX_invert,
FX_grayscale,
@@ -36,4 +33,7 @@ export const FXS = [
FX_stripe,
FX_polkadot,
FX_checker,
FX_chromaticAberration,
FX_tearing,
FX_blockNoise,
] as const satisfies ImageEffectorFx<string, any>[];
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import seedrandom from 'seedrandom';
import { defineImageEffectorFx } from '../ImageEffector.js';
import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
in vec2 in_uv;
uniform sampler2D in_texture;
uniform vec2 in_resolution;
uniform int u_amount;
uniform float u_shiftStrengths[128];
uniform vec2 u_shiftOrigins[128];
uniform vec2 u_shiftSizes[128];
uniform float u_channelShift;
out vec4 out_color;
void main() {
// TODO: ピクセル毎に計算する必要はないのでuniformにする
float aspect_ratio = min(in_resolution.x, in_resolution.y) / max(in_resolution.x, in_resolution.y);
float aspect_ratio_x = in_resolution.x > in_resolution.y ? 1.0 : aspect_ratio;
float aspect_ratio_y = in_resolution.x < in_resolution.y ? 1.0 : aspect_ratio;
float v = 0.0;
for (int i = 0; i < u_amount; i++) {
if (
in_uv.x * aspect_ratio_x > ((u_shiftOrigins[i].x * aspect_ratio_x) - u_shiftSizes[i].x) &&
in_uv.x * aspect_ratio_x < ((u_shiftOrigins[i].x * aspect_ratio_x) + u_shiftSizes[i].x) &&
in_uv.y * aspect_ratio_y > ((u_shiftOrigins[i].y * aspect_ratio_y) - u_shiftSizes[i].y) &&
in_uv.y * aspect_ratio_y < ((u_shiftOrigins[i].y * aspect_ratio_y) + u_shiftSizes[i].y)
) {
v += u_shiftStrengths[i];
}
}
float r = texture(in_texture, vec2(in_uv.x + (v * (1.0 + u_channelShift)), in_uv.y)).r;
float g = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).g;
float b = texture(in_texture, vec2(in_uv.x + (v * (1.0 + (u_channelShift / 2.0))), in_uv.y)).b;
float a = texture(in_texture, vec2(in_uv.x + v, in_uv.y)).a;
out_color = vec4(r, g, b, a);
}
`;
export const FX_blockNoise = defineImageEffectorFx({
id: 'blockNoise' as const,
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
shader,
uniforms: ['amount', 'channelShift'] as const,
params: {
amount: {
type: 'number' as const,
default: 50,
min: 1,
max: 100,
step: 1,
},
strength: {
type: 'number' as const,
default: 0.05,
min: -1,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
width: {
type: 'number' as const,
default: 0.05,
min: 0.01,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
height: {
type: 'number' as const,
default: 0.01,
min: 0.01,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
channelShift: {
type: 'number' as const,
default: 0,
min: 0,
max: 10,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
seed: {
type: 'seed' as const,
default: 100,
},
},
main: ({ gl, program, u, params }) => {
gl.uniform1i(u.amount, params.amount);
gl.uniform1f(u.channelShift, params.channelShift);
const margin = 0;
const rnd = seedrandom(params.seed.toString());
for (let i = 0; i < params.amount; i++) {
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
gl.uniform2f(sizes, params.width, params.height);
}
},
});
@@ -58,6 +58,7 @@ export const FX_checker = defineImageEffectorFx({
min: -1.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 90) + '°',
},
scale: {
type: 'number' as const,
@@ -76,6 +77,7 @@ export const FX_checker = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
@@ -72,7 +72,7 @@ void main() {
vec3 color = in_color.rgb;
color = color * u_brightness;
color += vec3(clamp(u_lightness, 0.0, 2.0) - 1.0);
color += vec3(u_lightness);
color = (color - 0.5) * u_contrast + 0.5;
vec3 hsl = rgb2hsl(color);
@@ -92,45 +92,50 @@ export const FX_colorAdjust = defineImageEffectorFx({
params: {
lightness: {
type: 'number' as const,
default: 100,
min: 0,
max: 200,
step: 1,
default: 0,
min: -1,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
contrast: {
type: 'number' as const,
default: 100,
default: 1,
min: 0,
max: 200,
step: 1,
max: 4,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
hue: {
type: 'number' as const,
default: 0,
min: -360,
max: 360,
step: 1,
min: -1,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 180) + '°',
},
brightness: {
type: 'number' as const,
default: 100,
default: 1,
min: 0,
max: 200,
step: 1,
max: 4,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
saturation: {
type: 'number' as const,
default: 100,
default: 1,
min: 0,
max: 200,
step: 1,
max: 4,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
gl.uniform1f(u.brightness, params.brightness / 100);
gl.uniform1f(u.contrast, params.contrast / 100);
gl.uniform1f(u.hue, params.hue / 360);
gl.uniform1f(u.lightness, params.lightness / 100);
gl.uniform1f(u.saturation, params.saturation / 100);
gl.uniform1f(u.brightness, params.brightness);
gl.uniform1f(u.contrast, params.contrast);
gl.uniform1f(u.hue, params.hue / 2);
gl.uniform1f(u.lightness, params.lightness);
gl.uniform1f(u.saturation, params.saturation);
},
});
@@ -37,6 +37,7 @@ export const FX_colorClamp = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
min: {
type: 'number' as const,
@@ -44,6 +45,7 @@ export const FX_colorClamp = defineImageEffectorFx({
min: -1.0,
max: 0.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
@@ -41,6 +41,7 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
rMin: {
type: 'number' as const,
@@ -48,6 +49,7 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
min: -1.0,
max: 0.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
gMax: {
type: 'number' as const,
@@ -55,6 +57,7 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
gMin: {
type: 'number' as const,
@@ -62,6 +65,7 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
min: -1.0,
max: 0.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
bMax: {
type: 'number' as const,
@@ -69,6 +73,7 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
bMin: {
type: 'number' as const,
@@ -76,6 +81,7 @@ export const FX_colorClampAdvanced = defineImageEffectorFx({
min: -1.0,
max: 0.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
@@ -9,6 +9,10 @@ import { i18n } from '@/i18n.js';
const shader = `#version 300 es
precision mediump float;
const float PI = 3.141592653589793;
const float TWO_PI = 6.283185307179586;
const float HALF_PI = 1.5707963267948966;
in vec2 in_uv;
uniform sampler2D in_texture;
uniform vec2 in_resolution;
@@ -20,8 +24,8 @@ out vec4 out_color;
void main() {
float v = u_direction == 0 ?
sin(u_phase + in_uv.y * u_frequency) * u_strength :
sin(u_phase + in_uv.x * u_frequency) * u_strength;
sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.y * u_frequency) * u_strength :
sin((HALF_PI + (u_phase * PI) - (u_frequency / 2.0)) + in_uv.x * u_frequency) * u_strength;
vec4 in_color = u_direction == 0 ?
texture(in_texture, vec2(in_uv.x + v, in_uv.y)) :
texture(in_texture, vec2(in_uv.x, in_uv.y + v));
@@ -38,32 +42,34 @@ export const FX_distort = defineImageEffectorFx({
direction: {
type: 'number:enum' as const,
enum: [{ value: 0, label: 'v' }, { value: 1, label: 'h' }],
default: 0,
default: 1,
},
phase: {
type: 'number' as const,
default: 50.0,
min: 0.0,
max: 100,
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
frequency: {
type: 'number' as const,
default: 50,
default: 30,
min: 0,
max: 100,
step: 0.1,
},
strength: {
type: 'number' as const,
default: 0.1,
default: 0.05,
min: 0,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
gl.uniform1f(u.phase, params.phase / 10);
gl.uniform1f(u.phase, params.phase);
gl.uniform1f(u.frequency, params.frequency);
gl.uniform1f(u.strength, params.strength);
gl.uniform1i(u.direction, params.direction);
@@ -90,6 +90,7 @@ export const FX_polkadot = defineImageEffectorFx({
min: -1.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 90) + '°',
},
scale: {
type: 'number' as const,
@@ -111,6 +112,7 @@ export const FX_polkadot = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
minorDivisions: {
type: 'number' as const,
@@ -132,6 +134,7 @@ export const FX_polkadot = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
color: {
type: 'color' as const,
@@ -60,6 +60,7 @@ export const FX_stripe = defineImageEffectorFx({
min: -1.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 90) + '°',
},
frequency: {
type: 'number' as const,
@@ -74,6 +75,7 @@ export const FX_stripe = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
color: {
type: 'color' as const,
@@ -85,6 +87,7 @@ export const FX_stripe = defineImageEffectorFx({
min: 0.0,
max: 1.0,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
},
main: ({ gl, u, params }) => {
@@ -37,9 +37,9 @@ void main() {
}
`;
export const FX_glitch = defineImageEffectorFx({
id: 'glitch' as const,
name: i18n.ts._imageEffector._fxs.glitch,
export const FX_tearing = defineImageEffectorFx({
id: 'tearing' as const,
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.tearing,
shader,
uniforms: ['amount', 'channelShift'] as const,
params: {
@@ -52,17 +52,19 @@ export const FX_glitch = defineImageEffectorFx({
},
strength: {
type: 'number' as const,
default: 5,
min: -100,
max: 100,
default: 0.05,
min: -1,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
size: {
type: 'number' as const,
default: 20,
default: 0.2,
min: 0,
max: 100,
max: 1,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
channelShift: {
type: 'number' as const,
@@ -70,6 +72,7 @@ export const FX_glitch = defineImageEffectorFx({
min: 0,
max: 10,
step: 0.01,
toViewValue: v => Math.round(v * 100) + '%',
},
seed: {
type: 'seed' as const,
@@ -87,10 +90,10 @@ export const FX_glitch = defineImageEffectorFx({
gl.uniform1f(o, rnd());
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
gl.uniform1f(s, (1 - (rnd() * 2)) * (params.strength / 100));
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
const h = gl.getUniformLocation(program, `u_shiftHeights[${i.toString()}]`);
gl.uniform1f(h, rnd() * (params.size / 100));
gl.uniform1f(h, rnd() * params.size);
}
},
});
+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(),

Some files were not shown because too many files have changed in this diff Show More