Merge branch 'develop' into deps-update-openapits
This commit is contained in:
commit
02b5dce8d7
|
|
@ -215,20 +215,9 @@ proxyBypassHosts:
|
|||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Proxy remote files (default: true)
|
||||
proxyRemoteFiles: true
|
||||
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
|
||||
# Disable automatic redirect for ActivityPub object lookup. (default: false)
|
||||
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
|
||||
# However it will make it impossible for other instances to lookup third-party user and notes through your URL.
|
||||
#disallowExternalApRedirect: true
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
|
|
|||
|
|
@ -227,12 +227,6 @@ proxyBypassHosts:
|
|||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Proxy remote files (default: true)
|
||||
proxyRemoteFiles: true
|
||||
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
|
|
@ -240,11 +234,6 @@ signToActivityPubGet: true
|
|||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
||||
# Disable automatic redirect for ActivityPub object lookup. (default: false)
|
||||
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
|
||||
# However it will make it impossible for other instances to lookup third-party user and notes through your URL.
|
||||
#disallowExternalApRedirect: true
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
|
|
|
|||
|
|
@ -319,19 +319,12 @@ proxyBypassHosts:
|
|||
# * Perform image compression (on a different server resource than the main process)
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Proxy remote files (default: true)
|
||||
# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains.
|
||||
proxyRemoteFiles: true
|
||||
|
||||
# Movie Thumbnail Generation URL
|
||||
# There is no reference implementation.
|
||||
# For example, Misskey will point to the following URL:
|
||||
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
||||
#videoThumbnailGenerator: https://example.com
|
||||
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
# For security reasons, uploading attachments from the intranet is prohibited,
|
||||
# but exceptions can be made from the following settings. Default value is "undefined".
|
||||
# Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)).
|
||||
|
|
@ -339,11 +332,6 @@ signToActivityPubGet: true
|
|||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
||||
# Disable automatic redirect for ActivityPub object lookup. (default: false)
|
||||
# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation.
|
||||
# However it will make it impossible for other instances to lookup third-party user and notes through your URL.
|
||||
#disallowExternalApRedirect: true
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
|
|
|
|||
|
|
@ -202,12 +202,6 @@ proxyBypassHosts:
|
|||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Proxy remote files (default: true)
|
||||
proxyRemoteFiles: true
|
||||
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,5 +1,12 @@
|
|||
## 2025.5.1
|
||||
|
||||
### Note
|
||||
- 設定ファイルの以下の項目がコントロールパネルから設定するようになりました
|
||||
- signToActivityPubGet
|
||||
- proxyRemoteFiles
|
||||
- disallowExternalApRedirect
|
||||
- 許可しないかどうかではなく、許可するかどうかの設定(allowExternalApRedirect)になりました
|
||||
|
||||
### General
|
||||
- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました
|
||||
- モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます
|
||||
|
|
@ -7,6 +14,15 @@
|
|||
- デフォルト値は「ローカルのコンテンツだけ公開」になっています
|
||||
|
||||
### Client
|
||||
- Feat: ドライブのUIが強化されました
|
||||
- 複数のファイルをまとめて移動できるようになりました
|
||||
- Feat: ファイルのアップロードUIが一新されました
|
||||
- アップロード前にファイル情報を確認できるようになりました
|
||||
- 圧縮の品質を選択できるようになりました
|
||||
- アップロードに失敗したときに再試行できるようになりました
|
||||
- アップロード前に画像のクロッピングを行えるようになりました
|
||||
- ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました
|
||||
- ファイルのアップロードを中断できるようになりました
|
||||
- Feat: サーバー初期設定ウィザードが実装されました
|
||||
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
|
||||
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)
|
||||
|
|
@ -14,19 +30,28 @@
|
|||
- 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です
|
||||
- 従来のWebsocket接続を行うモードはリアルタイムモードとして再定義されました
|
||||
- チャットなど、一部の機能は引き続き設定に関わらずWebsocket接続が行われます
|
||||
- Feat: 絵文字をミュート可能にする機能
|
||||
- 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました
|
||||
- Feat: モバイルデバイスで折りたたまれたUIの展開表示に全画面ページを使用できるように(実験的)
|
||||
- Enhance: メモリ使用量を軽減しました
|
||||
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
|
||||
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように
|
||||
- Enhance: リプライ元にアンケートがあることが表示されるように
|
||||
- Enhance: ノートのサーバー情報のデザインを改善・パフォーマンス向上
|
||||
(Based on https://github.com/taiyme/misskey/pull/198, https://github.com/taiyme/misskey/pull/211, https://github.com/taiyme/misskey/pull/283)
|
||||
- Enhance: ユーザー設定でURLプレビューを無効化できるように
|
||||
- Enhance: AiScriptからtoastを表示する関数 `Mk:toast` を追加
|
||||
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: チャットルームの最大メンバー数を30人から50人に調整
|
||||
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
|
||||
- Enhance: チャットルームのレスポンスに招待されているかどうかを示すフラグ`invitationExists`を追加
|
||||
- Enhance: レートリミットの計算方法を調整 (#13997)
|
||||
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
|
||||
- Fix: ユーザ除外アンテナをインポートできない問題を修正
|
||||
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
|
||||
- Fix: 連合モードが「なし」の場合に、生成されるHTML内のactivity jsonへのリンクタグを省略するように
|
||||
|
||||
|
||||
## 2025.5.0
|
||||
|
|
|
|||
|
|
@ -221,9 +221,6 @@ id: "aidx"
|
|||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
#allowedPrivateNetworks: [
|
||||
# '127.0.0.1/32'
|
||||
#]
|
||||
|
|
|
|||
|
|
@ -707,7 +707,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"cacheRemoteFiles": string;
|
||||
/**
|
||||
* この設定を有効にすると、リモートファイルをこのサーバーのストレージにキャッシュするようになります。画像の表示が高速になりますが、サーバーのストレージを多く消費します。リモートユーザーがどれほどキャッシュを保持するかは、ロールによるドライブ容量制限によって決定されます。この制限を超えた場合、古いファイルからキャッシュが削除されリンクになります。この設定が無効の場合、リモートのファイルを最初からリンクとして保持しますが、画像のサムネイル生成やユーザーのプライバシー保護のために、default.ymlでproxyRemoteFilesをtrueにすることをお勧めします。
|
||||
* この設定を有効にすると、リモートファイルをこのサーバーのストレージにキャッシュするようになります。画像の表示が高速になりますが、サーバーのストレージを多く消費します。リモートユーザーがどれほどキャッシュを保持するかは、ロールによるドライブ容量制限によって決定されます。この制限を超えた場合、古いファイルからキャッシュが削除されリンクになります。この設定が無効の場合、リモートのファイルを最初からリンクとして保持します。
|
||||
*/
|
||||
"cacheRemoteFilesDescription": string;
|
||||
/**
|
||||
|
|
@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
|
|||
* アップロードが完了するまで時間がかかる場合があります。
|
||||
*/
|
||||
"uploadFromUrlMayTakeTime": string;
|
||||
/**
|
||||
* {n}個のファイルをアップロード
|
||||
*/
|
||||
"uploadNFiles": ParameterizedString<"n">;
|
||||
/**
|
||||
* みつける
|
||||
*/
|
||||
|
|
@ -2330,6 +2334,10 @@ export interface Locale extends ILocale {
|
|||
* サウンド
|
||||
*/
|
||||
"sound": string;
|
||||
/**
|
||||
* 通知音の設定
|
||||
*/
|
||||
"notificationSoundSettings": string;
|
||||
/**
|
||||
* 聴く
|
||||
*/
|
||||
|
|
@ -3186,6 +3194,10 @@ export interface Locale extends ILocale {
|
|||
* 反映には再起動が必要です。
|
||||
*/
|
||||
"needReloadToApply": string;
|
||||
/**
|
||||
* 反映にはサーバーの再起動が必要です。
|
||||
*/
|
||||
"needToRestartServerToApply": string;
|
||||
/**
|
||||
* タイトルバーを表示する
|
||||
*/
|
||||
|
|
@ -5425,6 +5437,26 @@ export interface Locale extends ILocale {
|
|||
* オフにする
|
||||
*/
|
||||
"turnItOff": string;
|
||||
/**
|
||||
* 絵文字ミュート
|
||||
*/
|
||||
"emojiMute": string;
|
||||
/**
|
||||
* 絵文字ミュート解除
|
||||
*/
|
||||
"emojiUnmute": string;
|
||||
/**
|
||||
* {x}をミュート
|
||||
*/
|
||||
"muteX": ParameterizedString<"x">;
|
||||
/**
|
||||
* {x}のミュートを解除
|
||||
*/
|
||||
"unmuteX": ParameterizedString<"x">;
|
||||
/**
|
||||
* 中止
|
||||
*/
|
||||
"abort": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* まだメッセージはありません
|
||||
|
|
@ -5713,6 +5745,14 @@ export interface Locale extends ILocale {
|
|||
* アイコンをスクロールに追従させる
|
||||
*/
|
||||
"useStickyIcons": string;
|
||||
/**
|
||||
* 高品質な画像のプレースホルダを表示
|
||||
*/
|
||||
"enableHighQualityImagePlaceholders": string;
|
||||
/**
|
||||
* UIのアニメーション
|
||||
*/
|
||||
"uiAnimations": string;
|
||||
/**
|
||||
* ナビゲーションバーに副ボタンを表示
|
||||
*/
|
||||
|
|
@ -5753,6 +5793,10 @@ export interface Locale extends ILocale {
|
|||
* リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。
|
||||
*/
|
||||
"contentsUpdateFrequency_description2": string;
|
||||
/**
|
||||
* URLプレビューを表示する
|
||||
*/
|
||||
"showUrlPreview": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* 送信者の名前を表示
|
||||
|
|
@ -6428,6 +6472,30 @@ export interface Locale extends ILocale {
|
|||
* このサーバーを利用するのが自分だけの場合、このモードを有効にすることで動作が最適化されます。
|
||||
*/
|
||||
"singleUserMode_description": string;
|
||||
/**
|
||||
* GETリクエストに署名する
|
||||
*/
|
||||
"signToActivityPubGet": string;
|
||||
/**
|
||||
* 通常は有効にしてください。連合の通信に関する問題がある場合に、無効にすると改善することがありますが、逆にサーバーによっては通信が不可になることがあります。
|
||||
*/
|
||||
"signToActivityPubGet_description": string;
|
||||
/**
|
||||
* リモートファイルをプロキシする
|
||||
*/
|
||||
"proxyRemoteFiles": string;
|
||||
/**
|
||||
* 有効にすると、リモートのファイルをプロキシして提供します。画像のサムネイル生成やユーザーのプライバシー保護に役立ちます。
|
||||
*/
|
||||
"proxyRemoteFiles_description": string;
|
||||
/**
|
||||
* ActivityPub経由の照会にリダイレクトを許可する
|
||||
*/
|
||||
"allowExternalApRedirect": string;
|
||||
/**
|
||||
* 有効にすると、他のサーバーがこのサーバーを通して第三者のコンテンツを照会することが可能になりますが、コンテンツのなりすましが発生する可能性があります。
|
||||
*/
|
||||
"allowExternalApRedirect_description": string;
|
||||
/**
|
||||
* 非利用者に対するユーザー作成コンテンツの公開範囲
|
||||
*/
|
||||
|
|
@ -8479,10 +8547,6 @@ export interface Locale extends ILocale {
|
|||
* 入力ボックスの縁取り
|
||||
*/
|
||||
"inputBorder": string;
|
||||
/**
|
||||
* ドライブフォルダーの背景
|
||||
*/
|
||||
"driveFolderBg": string;
|
||||
/**
|
||||
* バッジ
|
||||
*/
|
||||
|
|
@ -10902,7 +10966,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"description": string;
|
||||
};
|
||||
"_urlPreview": {
|
||||
"_urlPreviewThumbnail": {
|
||||
/**
|
||||
* URLプレビューのサムネイルを非表示
|
||||
*/
|
||||
|
|
@ -10912,6 +10976,16 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"description": string;
|
||||
};
|
||||
"_disableUrlPreview": {
|
||||
/**
|
||||
* URLプレビューを無効化
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* URLプレビュー機能を無効化します。サムネイル画像だけと違い、リンク先の情報の読み込み自体を削減できます。
|
||||
*/
|
||||
"description": string;
|
||||
};
|
||||
"_code": {
|
||||
/**
|
||||
* コードハイライトを非表示
|
||||
|
|
@ -11397,22 +11471,6 @@ export interface Locale extends ILocale {
|
|||
* ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を"category"に入力します。
|
||||
*/
|
||||
"directoryToCategoryCaption": string;
|
||||
/**
|
||||
* いずれかの方法で登録する絵文字を選択してください。
|
||||
*/
|
||||
"emojiInputAreaCaption": string;
|
||||
/**
|
||||
* この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ
|
||||
*/
|
||||
"emojiInputAreaList1": string;
|
||||
/**
|
||||
* このリンクをクリックしてPCから選択する
|
||||
*/
|
||||
"emojiInputAreaList2": string;
|
||||
/**
|
||||
* このリンクをクリックしてドライブから選択する
|
||||
*/
|
||||
"emojiInputAreaList3": string;
|
||||
/**
|
||||
* リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)
|
||||
*/
|
||||
|
|
@ -11846,6 +11904,58 @@ export interface Locale extends ILocale {
|
|||
"text3": string;
|
||||
};
|
||||
};
|
||||
"_uploader": {
|
||||
/**
|
||||
* {x}に圧縮
|
||||
*/
|
||||
"compressedToX": ParameterizedString<"x">;
|
||||
/**
|
||||
* {x}%節約
|
||||
*/
|
||||
"savedXPercent": ParameterizedString<"x">;
|
||||
/**
|
||||
* アップロードされていないファイルがありますが、中止しますか?
|
||||
*/
|
||||
"abortConfirm": string;
|
||||
/**
|
||||
* アップロードされていないファイルがありますが、完了しますか?
|
||||
*/
|
||||
"doneConfirm": string;
|
||||
/**
|
||||
* アップロード可能な最大ファイルサイズは{x}です。
|
||||
*/
|
||||
"maxFileSizeIsX": ParameterizedString<"x">;
|
||||
};
|
||||
"_clientPerformanceIssueTip": {
|
||||
/**
|
||||
* バッテリー消費が多いと感じたら
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* アドブロッカーを無効にしてください
|
||||
*/
|
||||
"makeSureDisabledAdBlocker": string;
|
||||
/**
|
||||
* アドブロッカーはパフォーマンスに影響を及ぼすことがあります。OSの機能やブラウザの機能・アドオンなどでアドブロッカーが有効になっていないか確認してください。
|
||||
*/
|
||||
"makeSureDisabledAdBlocker_description": string;
|
||||
/**
|
||||
* カスタムCSSを無効にしてください
|
||||
*/
|
||||
"makeSureDisabledCustomCss": string;
|
||||
/**
|
||||
* スタイルを上書きするとパフォーマンスに影響を及ぼすことがあります。カスタムCSSや、スタイルを上書きする拡張機能が有効になっていないか確認してください。
|
||||
*/
|
||||
"makeSureDisabledCustomCss_description": string;
|
||||
/**
|
||||
* 拡張機能を無効にしてください
|
||||
*/
|
||||
"makeSureDisabledAddons": string;
|
||||
/**
|
||||
* 一部の拡張機能はクライアントの動作に干渉しパフォーマンスに影響を及ぼすことがあります。ブラウザの拡張機能を無効にして改善するか確認してください。
|
||||
*/
|
||||
"makeSureDisabledAddons_description": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ emojiUrl: "絵文字画像URL"
|
|||
addEmoji: "絵文字を追加"
|
||||
settingGuide: "おすすめ設定"
|
||||
cacheRemoteFiles: "リモートのファイルをキャッシュする"
|
||||
cacheRemoteFilesDescription: "この設定を有効にすると、リモートファイルをこのサーバーのストレージにキャッシュするようになります。画像の表示が高速になりますが、サーバーのストレージを多く消費します。リモートユーザーがどれほどキャッシュを保持するかは、ロールによるドライブ容量制限によって決定されます。この制限を超えた場合、古いファイルからキャッシュが削除されリンクになります。この設定が無効の場合、リモートのファイルを最初からリンクとして保持しますが、画像のサムネイル生成やユーザーのプライバシー保護のために、default.ymlでproxyRemoteFilesをtrueにすることをお勧めします。"
|
||||
cacheRemoteFilesDescription: "この設定を有効にすると、リモートファイルをこのサーバーのストレージにキャッシュするようになります。画像の表示が高速になりますが、サーバーのストレージを多く消費します。リモートユーザーがどれほどキャッシュを保持するかは、ロールによるドライブ容量制限によって決定されます。この制限を超えた場合、古いファイルからキャッシュが削除されリンクになります。この設定が無効の場合、リモートのファイルを最初からリンクとして保持します。"
|
||||
youCanCleanRemoteFilesCache: "ファイル管理の🗑️ボタンで全てのキャッシュを削除できます。"
|
||||
cacheRemoteSensitiveFiles: "リモートのセンシティブなファイルをキャッシュする"
|
||||
cacheRemoteSensitiveFilesDescription: "この設定を無効にすると、リモートのセンシティブなファイルはキャッシュせず直リンクするようになります。"
|
||||
|
|
@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
|
|||
uploadFromUrlDescription: "アップロードしたいファイルのURL"
|
||||
uploadFromUrlRequested: "アップロードをリクエストしました"
|
||||
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
|
||||
uploadNFiles: "{n}個のファイルをアップロード"
|
||||
explore: "みつける"
|
||||
messageRead: "既読"
|
||||
noMoreHistory: "これより過去の履歴はありません"
|
||||
|
|
@ -578,6 +579,7 @@ newNoteRecived: "新しいノートがあります"
|
|||
newNote: "新しいノート"
|
||||
sounds: "サウンド"
|
||||
sound: "サウンド"
|
||||
notificationSoundSettings: "通知音の設定"
|
||||
listen: "聴く"
|
||||
none: "なし"
|
||||
showInPage: "ページで表示"
|
||||
|
|
@ -792,6 +794,7 @@ wide: "広い"
|
|||
narrow: "狭い"
|
||||
reloadToApplySetting: "設定はページリロード後に反映されます。"
|
||||
needReloadToApply: "反映には再起動が必要です。"
|
||||
needToRestartServerToApply: "反映にはサーバーの再起動が必要です。"
|
||||
showTitlebar: "タイトルバーを表示する"
|
||||
clearCache: "キャッシュをクリア"
|
||||
onlineUsersCount: "{n}人がオンライン"
|
||||
|
|
@ -1351,6 +1354,11 @@ advice: "アドバイス"
|
|||
realtimeMode: "リアルタイムモード"
|
||||
turnItOn: "オンにする"
|
||||
turnItOff: "オフにする"
|
||||
emojiMute: "絵文字ミュート"
|
||||
emojiUnmute: "絵文字ミュート解除"
|
||||
muteX: "{x}をミュート"
|
||||
unmuteX: "{x}のミュートを解除"
|
||||
abort: "中止"
|
||||
|
||||
_chat:
|
||||
noMessagesYet: "まだメッセージはありません"
|
||||
|
|
@ -1428,6 +1436,8 @@ _settings:
|
|||
makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする"
|
||||
makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。"
|
||||
useStickyIcons: "アイコンをスクロールに追従させる"
|
||||
enableHighQualityImagePlaceholders: "高品質な画像のプレースホルダを表示"
|
||||
uiAnimations: "UIのアニメーション"
|
||||
showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示"
|
||||
ifOn: "オンのとき"
|
||||
ifOff: "オフのとき"
|
||||
|
|
@ -1438,6 +1448,7 @@ _settings:
|
|||
contentsUpdateFrequency: "コンテンツの取得頻度"
|
||||
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されますが、パフォーマンスが低下し、通信量とバッテリーの消費が多くなります。"
|
||||
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
|
||||
showUrlPreview: "URLプレビューを表示する"
|
||||
|
||||
_chat:
|
||||
showSenderName: "送信者の名前を表示"
|
||||
|
|
@ -1633,6 +1644,12 @@ _serverSettings:
|
|||
deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。"
|
||||
singleUserMode: "お一人様モード"
|
||||
singleUserMode_description: "このサーバーを利用するのが自分だけの場合、このモードを有効にすることで動作が最適化されます。"
|
||||
signToActivityPubGet: "GETリクエストに署名する"
|
||||
signToActivityPubGet_description: "通常は有効にしてください。連合の通信に関する問題がある場合に、無効にすると改善することがありますが、逆にサーバーによっては通信が不可になることがあります。"
|
||||
proxyRemoteFiles: "リモートファイルをプロキシする"
|
||||
proxyRemoteFiles_description: "有効にすると、リモートのファイルをプロキシして提供します。画像のサムネイル生成やユーザーのプライバシー保護に役立ちます。"
|
||||
allowExternalApRedirect: "ActivityPub経由の照会にリダイレクトを許可する"
|
||||
allowExternalApRedirect_description: "有効にすると、他のサーバーがこのサーバーを通して第三者のコンテンツを照会することが可能になりますが、コンテンツのなりすましが発生する可能性があります。"
|
||||
userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲"
|
||||
userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。"
|
||||
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。"
|
||||
|
|
@ -2223,7 +2240,6 @@ _theme:
|
|||
buttonBg: "ボタンの背景"
|
||||
buttonHoverBg: "ボタンの背景 (ホバー)"
|
||||
inputBorder: "入力ボックスの縁取り"
|
||||
driveFolderBg: "ドライブフォルダーの背景"
|
||||
badge: "バッジ"
|
||||
messageBg: "チャットの背景"
|
||||
fgHighlighted: "強調された文字"
|
||||
|
|
@ -2897,9 +2913,12 @@ _dataSaver:
|
|||
_avatar:
|
||||
title: "アイコン画像のアニメーションを無効化"
|
||||
description: "アイコン画像のアニメーションが停止します。アニメーション画像は通常の画像よりファイルサイズが大きいことがあるので、データ通信量をさらに削減できます。"
|
||||
_urlPreview:
|
||||
_urlPreviewThumbnail:
|
||||
title: "URLプレビューのサムネイルを非表示"
|
||||
description: "URLプレビューのサムネイル画像が読み込まれなくなります。"
|
||||
_disableUrlPreview:
|
||||
title: "URLプレビューを無効化"
|
||||
description: "URLプレビュー機能を無効化します。サムネイル画像だけと違い、リンク先の情報の読み込み自体を削減できます。"
|
||||
_code:
|
||||
title: "コードハイライトを非表示"
|
||||
description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。"
|
||||
|
|
@ -3039,10 +3058,6 @@ _customEmojisManager:
|
|||
uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。"
|
||||
directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する"
|
||||
directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。"
|
||||
emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。"
|
||||
emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ"
|
||||
emojiInputAreaList2: "このリンクをクリックしてPCから選択する"
|
||||
emojiInputAreaList3: "このリンクをクリックしてドライブから選択する"
|
||||
confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)"
|
||||
confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?"
|
||||
confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?"
|
||||
|
|
@ -3168,3 +3183,19 @@ _serverSetupWizard:
|
|||
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
|
||||
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
|
||||
text3: "支援者向け特典もあります!"
|
||||
|
||||
_uploader:
|
||||
compressedToX: "{x}に圧縮"
|
||||
savedXPercent: "{x}%節約"
|
||||
abortConfirm: "アップロードされていないファイルがありますが、中止しますか?"
|
||||
doneConfirm: "アップロードされていないファイルがありますが、完了しますか?"
|
||||
maxFileSizeIsX: "アップロード可能な最大ファイルサイズは{x}です。"
|
||||
|
||||
_clientPerformanceIssueTip:
|
||||
title: "バッテリー消費が多いと感じたら"
|
||||
makeSureDisabledAdBlocker: "アドブロッカーを無効にしてください"
|
||||
makeSureDisabledAdBlocker_description: "アドブロッカーはパフォーマンスに影響を及ぼすことがあります。OSの機能やブラウザの機能・アドオンなどでアドブロッカーが有効になっていないか確認してください。"
|
||||
makeSureDisabledCustomCss: "カスタムCSSを無効にしてください"
|
||||
makeSureDisabledCustomCss_description: "スタイルを上書きするとパフォーマンスに影響を及ぼすことがあります。カスタムCSSや、スタイルを上書きする拡張機能が有効になっていないか確認してください。"
|
||||
makeSureDisabledAddons: "拡張機能を無効にしてください"
|
||||
makeSureDisabledAddons_description: "一部の拡張機能はクライアントの動作に干渉しパフォーマンスに影響を及ぼすことがあります。ブラウザの拡張機能を無効にして改善するか確認してください。"
|
||||
|
|
|
|||
28
package.json
28
package.json
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.5.1-alpha.1",
|
||||
"version": "2025.5.1-alpha.4",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0",
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"workspaces": [
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend",
|
||||
|
|
@ -51,30 +51,30 @@
|
|||
"lodash": "4.17.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"cssnano": "7.0.6",
|
||||
"esbuild": "0.25.3",
|
||||
"execa": "9.5.2",
|
||||
"cssnano": "7.0.7",
|
||||
"esbuild": "0.25.4",
|
||||
"execa": "9.5.3",
|
||||
"fast-glob": "3.3.3",
|
||||
"glob": "11.0.2",
|
||||
"ignore-walk": "7.0.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"postcss": "8.5.3",
|
||||
"tar": "7.4.3",
|
||||
"terser": "5.39.0",
|
||||
"terser": "5.39.2",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/node": "22.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||
"@typescript-eslint/parser": "8.31.0",
|
||||
"@types/node": "22.15.21",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "14.3.2",
|
||||
"eslint": "9.25.1",
|
||||
"globals": "16.0.0",
|
||||
"cypress": "14.4.0",
|
||||
"eslint": "9.27.0",
|
||||
"globals": "16.1.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "10.10.0",
|
||||
"start-server-and-test": "2.0.11"
|
||||
"pnpm": "10.11.0",
|
||||
"start-server-and-test": "2.0.12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "4.22.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {loadConfig} from "./js/migration-config.js";
|
||||
|
||||
export class MigrateSomeConfigFileSettingsToMeta1746949539915 {
|
||||
name = 'MigrateSomeConfigFileSettingsToMeta1746949539915'
|
||||
|
||||
async up(queryRunner) {
|
||||
const config = loadConfig();
|
||||
// $1 cannot be used in ALTER TABLE queries
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT ${config.proxyRemoteFiles}`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT ${config.signToActivityPubGet}`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT ${!config.disallowExternalApRedirect}`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowExternalApRedirect"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "signToActivityPubGet"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyRemoteFiles"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,29 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { path as configYamlPath } from '../../built/config.js';
|
||||
import * as yaml from 'js-yaml';
|
||||
import fs from "node:fs";
|
||||
|
||||
export function isConcurrentIndexMigrationEnabled() {
|
||||
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
|
||||
}
|
||||
|
||||
let loadedConfigCache = undefined;
|
||||
|
||||
function loadConfigInternal() {
|
||||
const config = yaml.load(fs.readFileSync(configYamlPath, 'utf-8'));
|
||||
|
||||
return {
|
||||
disallowExternalApRedirect: Boolean(config.disallowExternalApRedirect ?? false),
|
||||
proxyRemoteFiles: Boolean(config.proxyRemoteFiles ?? false),
|
||||
signToActivityPubGet: Boolean(config.signToActivityPubGet ?? true),
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig() {
|
||||
if (loadedConfigCache === undefined) {
|
||||
loadedConfigCache = loadConfigInternal();
|
||||
}
|
||||
return loadedConfigCache;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,17 +37,17 @@
|
|||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"@swc/core-darwin-arm64": "1.11.22",
|
||||
"@swc/core-darwin-x64": "1.11.22",
|
||||
"@swc/core-darwin-arm64": "1.11.29",
|
||||
"@swc/core-darwin-x64": "1.11.29",
|
||||
"@swc/core-freebsd-x64": "1.3.11",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.11.22",
|
||||
"@swc/core-linux-arm64-gnu": "1.11.22",
|
||||
"@swc/core-linux-arm64-musl": "1.11.22",
|
||||
"@swc/core-linux-x64-gnu": "1.11.22",
|
||||
"@swc/core-linux-x64-musl": "1.11.22",
|
||||
"@swc/core-win32-arm64-msvc": "1.11.22",
|
||||
"@swc/core-win32-ia32-msvc": "1.11.22",
|
||||
"@swc/core-win32-x64-msvc": "1.11.22",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.11.29",
|
||||
"@swc/core-linux-arm64-gnu": "1.11.29",
|
||||
"@swc/core-linux-arm64-musl": "1.11.29",
|
||||
"@swc/core-linux-x64-gnu": "1.11.29",
|
||||
"@swc/core-linux-x64-musl": "1.11.29",
|
||||
"@swc/core-win32-arm64-msvc": "1.11.29",
|
||||
"@swc/core-win32-ia32-msvc": "1.11.29",
|
||||
"@swc/core-win32-x64-msvc": "1.11.29",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-node": "4.22.0",
|
||||
"bufferutil": "4.0.9",
|
||||
|
|
@ -67,8 +67,8 @@
|
|||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.797.0",
|
||||
"@aws-sdk/lib-storage": "3.797.0",
|
||||
"@aws-sdk/client-s3": "3.815.0",
|
||||
"@aws-sdk/lib-storage": "3.815.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@fastify/accepts": "5.0.2",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
|
|
@ -76,22 +76,22 @@
|
|||
"@fastify/express": "4.0.2",
|
||||
"@fastify/http-proxy": "10.0.2",
|
||||
"@fastify/multipart": "9.0.3",
|
||||
"@fastify/static": "8.1.1",
|
||||
"@fastify/static": "8.2.0",
|
||||
"@fastify/view": "10.0.2",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@napi-rs/canvas": "0.1.69",
|
||||
"@nestjs/common": "11.1.0",
|
||||
"@nestjs/core": "11.1.0",
|
||||
"@nestjs/testing": "11.1.0",
|
||||
"@napi-rs/canvas": "0.1.70",
|
||||
"@nestjs/common": "11.1.1",
|
||||
"@nestjs/core": "11.1.1",
|
||||
"@nestjs/testing": "11.1.1",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.55.0",
|
||||
"@sentry/profiling-node": "8.55.0",
|
||||
"@simplewebauthn/server": "12.0.0",
|
||||
"@sinonjs/fake-timers": "11.3.1",
|
||||
"@smithy/node-http-handler": "2.5.0",
|
||||
"@swc/cli": "0.7.3",
|
||||
"@swc/core": "1.11.22",
|
||||
"@swc/cli": "0.7.7",
|
||||
"@swc/core": "1.11.29",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@types/redis-info": "3.0.3",
|
||||
"accepts": "1.3.8",
|
||||
|
|
@ -101,18 +101,18 @@
|
|||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.51.1",
|
||||
"bullmq": "5.53.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.4.1",
|
||||
"chalk-template": "1.1.0",
|
||||
"chokidar": "3.6.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-highlight": "2.1.11",
|
||||
"color-convert": "2.0.1",
|
||||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fastify": "5.3.2",
|
||||
"fastify": "5.3.3",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "19.6.0",
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.4.0",
|
||||
"parse5": "7.3.0",
|
||||
"pg": "8.15.6",
|
||||
"pg": "8.16.0",
|
||||
"pkce-challenge": "4.1.0",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
|
|
@ -159,37 +159,37 @@
|
|||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.21.4",
|
||||
"re2": "1.21.5",
|
||||
"redis-info": "3.1.0",
|
||||
"redis-lock": "0.1.4",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.2",
|
||||
"sanitize-html": "2.16.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"secure-json-parse": "3.0.2",
|
||||
"sharp": "0.33.5",
|
||||
"semver": "7.7.1",
|
||||
"semver": "7.7.2",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.25.11",
|
||||
"systeminformation": "5.26.1",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.3",
|
||||
"tsc-alias": "1.8.15",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typeorm": "0.3.22",
|
||||
"typeorm": "0.3.24",
|
||||
"typescript": "5.8.3",
|
||||
"ulid": "2.4.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.18.1",
|
||||
"ws": "8.18.2",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.17",
|
||||
"@sentry/vue": "9.14.0",
|
||||
"@sentry/vue": "9.22.0",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.38",
|
||||
"@types/accepts": "1.3.7",
|
||||
|
|
@ -208,18 +208,18 @@
|
|||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "22.15.2",
|
||||
"@types/node": "22.15.21",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/oauth": "0.9.6",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.11.14",
|
||||
"@types/pg": "8.15.2",
|
||||
"@types/pug": "2.0.10",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
"@types/rename": "1.0.7",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/simple-oauth2": "5.0.7",
|
||||
"@types/sinonjs__fake-timers": "8.1.5",
|
||||
|
|
@ -229,8 +229,8 @@
|
|||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||
"@typescript-eslint/parser": "8.31.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
|
|
@ -241,6 +241,6 @@
|
|||
"nodemon": "3.1.10",
|
||||
"pid-port": "1.0.2",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.1.0"
|
||||
"supertest": "7.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ type Source = {
|
|||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
disallowExternalApRedirect?: boolean;
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
|
|
@ -100,11 +99,8 @@ type Source = {
|
|||
inboxJobMaxAttempts?: number;
|
||||
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
videoThumbnailGenerator?: string;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
|
||||
perChannelMaxNoteCacheCount?: number;
|
||||
perUserNotificationsMaxCount?: number;
|
||||
deactivateAntennaThreshold?: number;
|
||||
|
|
@ -156,7 +152,6 @@ export type Config = {
|
|||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
disallowExternalApRedirect: boolean;
|
||||
maxFileSize: number;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
|
|
@ -170,8 +165,6 @@ export type Config = {
|
|||
relationshipJobPerSec: number | undefined;
|
||||
deliverJobMaxAttempts: number | undefined;
|
||||
inboxJobMaxAttempts: number | undefined;
|
||||
proxyRemoteFiles: boolean | undefined;
|
||||
signToActivityPubGet: boolean | undefined;
|
||||
logging?: {
|
||||
sql?: {
|
||||
disableQueryTruncation?: boolean,
|
||||
|
|
@ -229,7 +222,7 @@ const dir = `${_dirname}/../../../.config`;
|
|||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.MISSKEY_CONFIG_YML
|
||||
export const path = process.env.MISSKEY_CONFIG_YML
|
||||
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
|
||||
: process.env.NODE_ENV === 'test'
|
||||
? resolve(dir, 'test.yml')
|
||||
|
|
@ -300,7 +293,6 @@ export function loadConfig(): Config {
|
|||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
|
|
@ -313,8 +305,6 @@ export function loadConfig(): Config {
|
|||
relationshipJobPerSec: config.relationshipJobPerSec,
|
||||
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
|
||||
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
|
||||
proxyRemoteFiles: config.proxyRemoteFiles,
|
||||
signToActivityPubGet: config.signToActivityPubGet ?? true,
|
||||
mediaProxy: externalMediaProxy ?? internalMediaProxy,
|
||||
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
|
||||
videoThumbnailGenerator: config.videoThumbnailGenerator ?
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { emojiRegex } from '@/misc/emoji-regex.js';
|
|||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
const MAX_ROOM_MEMBERS = 30;
|
||||
const MAX_ROOM_MEMBERS = 50;
|
||||
const MAX_REACTIONS_PER_MESSAGE = 100;
|
||||
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import * as fs from 'node:fs';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
|
||||
|
|
@ -720,6 +720,21 @@ export class DriveService {
|
|||
return fileObj;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async moveFiles(fileIds: MiDriveFile['id'][], folderId: MiDriveFolder['id'] | null, userId: MiUser['id']) {
|
||||
const folder = folderId ? await this.driveFoldersRepository.findOneByOrFail({
|
||||
id: folderId,
|
||||
userId: userId,
|
||||
}) : null;
|
||||
|
||||
await this.driveFilesRepository.update({
|
||||
id: In(fileIds),
|
||||
userId: userId,
|
||||
}, {
|
||||
folderId: folder ? folder.id : null,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
|
||||
if (file.storedInternal) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import type {
|
|||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export const QUEUE_TYPES = [
|
||||
'system',
|
||||
|
|
@ -774,13 +775,13 @@ export class QueueService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private packJobData(job: Bull.Job) {
|
||||
private packJobData(job: Bull.Job): Packed<'QueueJob'> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : [];
|
||||
stacktrace.reverse();
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
id: job.id!,
|
||||
name: job.name,
|
||||
data: job.data,
|
||||
opts: job.opts,
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export class Resolver {
|
|||
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked');
|
||||
}
|
||||
|
||||
if (this.config.signToActivityPubGet && !this.user) {
|
||||
if (this.meta.signToActivityPubGet && !this.user) {
|
||||
this.user = await this.systemAccountService.fetch('actor');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
|
|
@ -34,6 +34,9 @@ export class DriveFileEntityService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
|
|
@ -95,7 +98,7 @@ export class DriveFileEntityService {
|
|||
return this.getProxiedUrl(file.uri, 'static');
|
||||
}
|
||||
|
||||
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
|
||||
if (file.uri != null && file.isLink && this.meta.proxyRemoteFiles) {
|
||||
// リモートかつ期限切れはローカルプロキシを試みる
|
||||
// 従来は/files/${thumbnailAccessKey}にアクセスしていたが、
|
||||
// /filesはメディアプロキシにリダイレクトするようにしたため直接メディアプロキシを指定する
|
||||
|
|
@ -115,7 +118,7 @@ export class DriveFileEntityService {
|
|||
}
|
||||
|
||||
// リモートかつ期限切れはローカルプロキシを試みる
|
||||
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
|
||||
if (file.uri != null && file.isLink && this.meta.proxyRemoteFiles) {
|
||||
const key = file.webpublicAccessKey;
|
||||
|
||||
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
|
||||
|
|
|
|||
|
|
@ -31,7 +31,11 @@ import { packedChannelSchema } from '@/models/json-schema/channel.js';
|
|||
import { packedAntennaSchema } from '@/models/json-schema/antenna.js';
|
||||
import { packedClipSchema } from '@/models/json-schema/clip.js';
|
||||
import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js';
|
||||
import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
|
||||
import {
|
||||
packedQueueCountSchema,
|
||||
packedQueueMetricsSchema,
|
||||
packedQueueJobSchema,
|
||||
} from '@/models/json-schema/queue.js';
|
||||
import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
|
||||
import {
|
||||
packedEmojiDetailedAdminSchema,
|
||||
|
|
@ -100,6 +104,8 @@ export const refs = {
|
|||
PageBlock: packedPageBlockSchema,
|
||||
Channel: packedChannelSchema,
|
||||
QueueCount: packedQueueCountSchema,
|
||||
QueueMetrics: packedQueueMetricsSchema,
|
||||
QueueJob: packedQueueJobSchema,
|
||||
Antenna: packedAntennaSchema,
|
||||
Clip: packedClipSchema,
|
||||
FederationInstance: packedFederationInstanceSchema,
|
||||
|
|
|
|||
|
|
@ -680,6 +680,21 @@ export class MiMeta {
|
|||
default: false,
|
||||
})
|
||||
public singleUserMode: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public proxyRemoteFiles: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public signToActivityPubGet: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public allowExternalApRedirect: boolean;
|
||||
}
|
||||
|
||||
export type SoftwareSuspension = {
|
||||
|
|
|
|||
|
|
@ -28,3 +28,110 @@ export const packedQueueCountSchema = {
|
|||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Bull.Metrics
|
||||
export const packedQueueMetricsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
meta: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
count: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
prevTS: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
prevCount: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedQueueJobSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
opts: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
timestamp: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
processedOn: {
|
||||
type: 'number',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
processedBy: {
|
||||
type: 'string',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
finishedOn: {
|
||||
type: 'number',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
progress: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
attempts: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
delay: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
failedReason: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
stacktrace: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
returnValue: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isFailed: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
// this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com
|
||||
//
|
||||
// this is not required by standard but protect us from peers that did not validate final URL.
|
||||
if (this.config.disallowExternalApRedirect) {
|
||||
if (!this.meta.allowExternalApRedirect) {
|
||||
const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i;
|
||||
fastify.addHook('onSend', (request, reply, _, done) => {
|
||||
const location = reply.getHeader('location');
|
||||
|
|
@ -133,8 +133,8 @@ export class ServerService implements OnApplicationShutdown {
|
|||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
|
||||
done(null, [
|
||||
"Refusing to relay remote ActivityPub object lookup.",
|
||||
"",
|
||||
'Refusing to relay remote ActivityPub object lookup.',
|
||||
'',
|
||||
`Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
|
||||
].join('\n'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -326,19 +326,15 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
|
||||
if (factor > 0) {
|
||||
// Rate limit
|
||||
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
|
||||
if ('info' in err) {
|
||||
// errはLimiter.LimiterInfoであることが期待される
|
||||
throw new ApiError({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
httpStatusCode: 429,
|
||||
}, err.info);
|
||||
} else {
|
||||
throw new TypeError('information must be a rate-limiter information.');
|
||||
}
|
||||
});
|
||||
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
|
||||
if (rateLimit != null) {
|
||||
throw new ApiError({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
httpStatusCode: 429,
|
||||
}, rateLimit.info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { IEndpointMeta } from './endpoints.js';
|
||||
|
||||
type RateLimitInfo = {
|
||||
code: 'BRIEF_REQUEST_INTERVAL',
|
||||
info: Limiter.LimiterInfo,
|
||||
} | {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
info: Limiter.LimiterInfo,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RateLimiterService {
|
||||
private logger: Logger;
|
||||
|
|
@ -31,77 +39,55 @@ export class RateLimiterService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
|
||||
{
|
||||
if (this.disabled) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
private checkLimiter(options: Limiter.LimiterOption): Promise<Limiter.LimiterInfo> {
|
||||
return new Promise<Limiter.LimiterInfo>((resolve, reject) => {
|
||||
new Limiter(options).get((err, info) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(info);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Short-term limit
|
||||
const min = new Promise<void>((ok, reject) => {
|
||||
const minIntervalLimiter = new Limiter({
|
||||
id: `${actor}:${limitation.key}:min`,
|
||||
duration: limitation.minInterval! * factor,
|
||||
max: 1,
|
||||
db: this.redisClient,
|
||||
});
|
||||
@bindThis
|
||||
public async limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1): Promise<RateLimitInfo | null> {
|
||||
if (this.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
minIntervalLimiter.get((err, info) => {
|
||||
if (err) {
|
||||
return reject({ code: 'ERR', info });
|
||||
}
|
||||
|
||||
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
||||
|
||||
if (info.remaining === 0) {
|
||||
return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
|
||||
} else {
|
||||
if (hasLongTermLimit) {
|
||||
return max.then(ok, reject);
|
||||
} else {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Short-term limit
|
||||
if (limitation.minInterval != null) {
|
||||
const info = await this.checkLimiter({
|
||||
id: `${actor}:${limitation.key}:min`,
|
||||
duration: limitation.minInterval * factor,
|
||||
max: 1,
|
||||
db: this.redisClient,
|
||||
});
|
||||
|
||||
// Long term limit
|
||||
const max = new Promise<void>((ok, reject) => {
|
||||
const limiter = new Limiter({
|
||||
id: `${actor}:${limitation.key}`,
|
||||
duration: limitation.duration! * factor,
|
||||
max: limitation.max! / factor,
|
||||
db: this.redisClient,
|
||||
});
|
||||
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
||||
|
||||
limiter.get((err, info) => {
|
||||
if (err) {
|
||||
return reject({ code: 'ERR', info });
|
||||
}
|
||||
|
||||
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
||||
|
||||
if (info.remaining === 0) {
|
||||
return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
|
||||
} else {
|
||||
return ok();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hasShortTermLimit = typeof limitation.minInterval === 'number';
|
||||
|
||||
const hasLongTermLimit =
|
||||
typeof limitation.duration === 'number' &&
|
||||
typeof limitation.max === 'number';
|
||||
|
||||
if (hasShortTermLimit) {
|
||||
return min;
|
||||
} else if (hasLongTermLimit) {
|
||||
return max;
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
if (info.remaining === 0) {
|
||||
return { code: 'BRIEF_REQUEST_INTERVAL', info };
|
||||
}
|
||||
}
|
||||
|
||||
// Long term limit
|
||||
if (limitation.duration != null && limitation.max != null) {
|
||||
const info = await this.checkLimiter({
|
||||
id: `${actor}:${limitation.key}`,
|
||||
duration: limitation.duration,
|
||||
max: limitation.max / factor,
|
||||
db: this.redisClient,
|
||||
});
|
||||
|
||||
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
||||
|
||||
if (info.remaining === 0) {
|
||||
return { code: 'RATE_LIMIT_EXCEEDED', info };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,10 +89,9 @@ export class SigninApiService {
|
|||
return { error };
|
||||
}
|
||||
|
||||
try {
|
||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||
} catch (err) {
|
||||
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||
if (rateLimit != null) {
|
||||
reply.code(429);
|
||||
return {
|
||||
error: {
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export * as 'drive/files/find' from './endpoints/drive/files/find.js';
|
|||
export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js';
|
||||
export * as 'drive/files/show' from './endpoints/drive/files/show.js';
|
||||
export * as 'drive/files/update' from './endpoints/drive/files/update.js';
|
||||
export * as 'drive/files/move-bulk' from './endpoints/drive/files/move-bulk.js';
|
||||
export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js';
|
||||
export * as 'drive/folders' from './endpoints/drive/folders.js';
|
||||
export * as 'drive/folders/create' from './endpoints/drive/folders/create.js';
|
||||
|
|
|
|||
|
|
@ -555,6 +555,18 @@ export const meta = {
|
|||
enum: ['all', 'local', 'none'],
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
proxyRemoteFiles: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
signToActivityPubGet: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
allowExternalApRedirect: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -702,6 +714,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
|
||||
singleUserMode: instance.singleUserMode,
|
||||
ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor,
|
||||
proxyRemoteFiles: instance.proxyRemoteFiles,
|
||||
signToActivityPubGet: instance.signToActivityPubGet,
|
||||
allowExternalApRedirect: instance.allowExternalApRedirect,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -14,6 +13,15 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:queue',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
optional: false, nullable: false,
|
||||
ref: 'QueueJob',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -14,6 +13,118 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:queue',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: QUEUE_TYPES,
|
||||
},
|
||||
qualifiedName: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
counts: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
additionalProperties: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
isPaused: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
metrics: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
completed: {
|
||||
optional: false, nullable: false,
|
||||
ref: 'QueueMetrics',
|
||||
},
|
||||
failed: {
|
||||
optional: false, nullable: false,
|
||||
ref: 'QueueMetrics',
|
||||
},
|
||||
},
|
||||
},
|
||||
db: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
version: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['cluster', 'standalone', 'sentinel'],
|
||||
},
|
||||
runId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
processId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
os: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
uptime: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
memory: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
total: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
used: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
fragmentationRatio: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
peak: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
clients: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
blocked: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
connected: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -14,6 +13,47 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:queue',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: QUEUE_TYPES,
|
||||
},
|
||||
counts: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
additionalProperties: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
isPaused: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
metrics: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
completed: {
|
||||
optional: false, nullable: false,
|
||||
ref: 'QueueMetrics',
|
||||
},
|
||||
failed: {
|
||||
optional: false, nullable: false,
|
||||
ref: 'QueueMetrics',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -14,6 +13,11 @@ export const meta = {
|
|||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:queue',
|
||||
|
||||
res: {
|
||||
optional: false, nullable: false,
|
||||
ref: 'QueueJob',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
@ -28,7 +32,6 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private moderationLogService: ModerationLogService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,9 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
enum: ['all', 'local', 'none'],
|
||||
},
|
||||
proxyRemoteFiles: { type: 'boolean' },
|
||||
signToActivityPubGet: { type: 'boolean' },
|
||||
allowExternalApRedirect: { type: 'boolean' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
@ -703,6 +706,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor;
|
||||
}
|
||||
|
||||
if (ps.proxyRemoteFiles !== undefined) {
|
||||
set.proxyRemoteFiles = ps.proxyRemoteFiles;
|
||||
}
|
||||
|
||||
if (ps.signToActivityPubGet !== undefined) {
|
||||
set.signToActivityPubGet = ps.signToActivityPubGet;
|
||||
}
|
||||
|
||||
if (ps.allowExternalApRedirect !== undefined) {
|
||||
set.allowExternalApRedirect = ps.allowExternalApRedirect;
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
||||
await this.metaService.update(set);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { DI } from '@/di-symbols.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['drive'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:drive',
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileIds: { type: 'array', uniqueItems: true, minItems: 1, maxItems: 100, items: { type: 'string', format: 'misskey:id' } },
|
||||
folderId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
},
|
||||
required: ['fileIds'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private driveService: DriveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.driveService.moveFiles(ps.fileIds, ps.folderId ?? null, me.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +212,7 @@ export class ClientServerService {
|
|||
instanceUrl: this.config.url,
|
||||
metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)),
|
||||
now: Date.now(),
|
||||
federationEnabled: this.meta.federation !== 'none',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ block meta
|
|||
if note.next
|
||||
link(rel='next' href=`${config.url}/notes/${note.next}`)
|
||||
|
||||
if !user.host
|
||||
link(rel='alternate' href=url type='application/activity+json')
|
||||
if note.uri
|
||||
link(rel='alternate' href=note.uri type='application/activity+json')
|
||||
if federationEnabled
|
||||
if !user.host
|
||||
link(rel='alternate' href=url type='application/activity+json')
|
||||
if note.uri
|
||||
link(rel='alternate' href=note.uri type='application/activity+json')
|
||||
|
|
|
|||
|
|
@ -32,12 +32,13 @@ block meta
|
|||
meta(name='twitter:creator' content=`@${profile.twitter.screenName}`)
|
||||
|
||||
if !sub
|
||||
if !user.host
|
||||
link(rel='alternate' href=`${config.url}/users/${user.id}` type='application/activity+json')
|
||||
if user.uri
|
||||
link(rel='alternate' href=user.uri type='application/activity+json')
|
||||
if profile.url
|
||||
link(rel='alternate' href=profile.url type='text/html')
|
||||
if federationEnabled
|
||||
if !user.host
|
||||
link(rel='alternate' href=`${config.url}/users/${user.id}` type='application/activity+json')
|
||||
if user.uri
|
||||
link(rel='alternate' href=user.uri type='application/activity+json')
|
||||
if profile.url
|
||||
link(rel='alternate' href=profile.url type='text/html')
|
||||
|
||||
each m in me
|
||||
link(rel='me' href=`${m}`)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ proxyBypassHosts:
|
|||
- www.recaptcha.net
|
||||
- hcaptcha.com
|
||||
- challenges.cloudflare.com
|
||||
proxyRemoteFiles: true
|
||||
signToActivityPubGet: true
|
||||
allowedPrivateNetworks:
|
||||
- 127.0.0.1/32
|
||||
- 172.20.0.0/16
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ declare const _VERSION_: string;
|
|||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
|
||||
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
|
||||
|
||||
// for dev-mode
|
||||
declare const _LANGS_FULL_: string[][];
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ export default [
|
|||
_VERSION_: false,
|
||||
_ENV_: false,
|
||||
_PERF_PREFIX_: false,
|
||||
_DATA_TRANSFER_DRIVE_FILE_: false,
|
||||
_DATA_TRANSFER_DRIVE_FOLDER_: false,
|
||||
_DATA_TRANSFER_DECK_COLUMN_: false,
|
||||
},
|
||||
parser,
|
||||
parserOptions: {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@tabler/icons-webfont": "3.31.0",
|
||||
"@tabler/icons-webfont": "3.33.0",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.2.3",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"@vue/compiler-sfc": "3.5.14",
|
||||
"astring": "1.9.0",
|
||||
"buraha": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
|
|
@ -26,42 +26,42 @@
|
|||
"mfm-js": "0.24.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.40.0",
|
||||
"sass": "1.87.0",
|
||||
"shiki": "3.3.0",
|
||||
"rollup": "4.41.0",
|
||||
"sass": "1.89.0",
|
||||
"shiki": "3.4.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.15",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.8.3",
|
||||
"uuid": "11.1.0",
|
||||
"vite": "6.3.4",
|
||||
"vue": "3.5.13"
|
||||
"vite": "6.3.5",
|
||||
"vue": "3.5.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.7",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.15.2",
|
||||
"@types/node": "22.15.21",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||
"@typescript-eslint/parser": "8.31.0",
|
||||
"@vitest/coverage-v8": "3.1.2",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"@vitest/coverage-v8": "3.1.4",
|
||||
"@vue/runtime-core": "3.5.14",
|
||||
"acorn": "8.14.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "10.0.0",
|
||||
"eslint-plugin-vue": "10.1.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.4.4",
|
||||
"happy-dom": "17.4.7",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.7.5",
|
||||
"msw": "2.8.4",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.5.3",
|
||||
"start-server-and-test": "2.0.11",
|
||||
"start-server-and-test": "2.0.12",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "2.2.10",
|
||||
"vue-eslint-parser": "10.1.3",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<ImgWithBlurhash
|
||||
<EmImgWithBlurhash
|
||||
:hash="image.blurhash"
|
||||
:src="hide ? null : url"
|
||||
:forceBlurhash="hide"
|
||||
|
|
@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import ImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
|
||||
import EmImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ declare const _VERSION_: string;
|
|||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
|
||||
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
|
||||
|
||||
// for dev-mode
|
||||
declare const _LANGS_FULL_: string[][];
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ export default [
|
|||
_VERSION_: false,
|
||||
_ENV_: false,
|
||||
_PERF_PREFIX_: false,
|
||||
_DATA_TRANSFER_DRIVE_FILE_: false,
|
||||
_DATA_TRANSFER_DRIVE_FOLDER_: false,
|
||||
_DATA_TRANSFER_DECK_COLUMN_: false,
|
||||
},
|
||||
parser,
|
||||
parserOptions: {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@
|
|||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||
"@typescript-eslint/parser": "8.31.0",
|
||||
"esbuild": "0.25.3",
|
||||
"eslint-plugin-vue": "10.0.0",
|
||||
"@types/node": "22.15.21",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-vue": "10.1.0",
|
||||
"nodemon": "3.1.10",
|
||||
"typescript": "5.8.3",
|
||||
"vue-eslint-parser": "10.1.3"
|
||||
|
|
@ -35,6 +35,6 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"misskey-js": "workspace:*",
|
||||
"vue": "3.5.13"
|
||||
"vue": "3.5.14"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@
|
|||
switchOnFg: '@accent',
|
||||
inputBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
inputBorderHover: 'rgba(255, 255, 255, 0.2)',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
badge: '#31b1ce',
|
||||
messageBg: '@bg',
|
||||
success: '#86b300',
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@
|
|||
switchOnFg: '@fgOnAccent',
|
||||
inputBorder: 'rgba(0, 0, 0, 0.1)',
|
||||
inputBorderHover: 'rgba(0, 0, 0, 0.2)',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
badge: '#31b1ce',
|
||||
messageBg: '@bg',
|
||||
success: '#86b300',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@
|
|||
navIndicator: '@accent',
|
||||
buttonGradateA: '@accent',
|
||||
buttonGradateB: ':hue<-20<@accent',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@
|
|||
inputBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
panelBorder: '" solid 1px var(--MI_THEME-divider)',
|
||||
navIndicator: '@indicator',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
panelBorder: '" solid 1px var(--MI_THEME-divider)',
|
||||
navIndicator: '@indicator',
|
||||
buttonHoverBg: '#0000001a',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@
|
|||
inputBorderHover: 'rgba(0, 0, 0, 0.2)',
|
||||
panelBorder: '" solid 1px var(--MI_THEME-divider)',
|
||||
navIndicator: '@accent',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':darken<3<@fg',
|
||||
fgOnWhite: '@accent',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ declare const _VERSION_: string;
|
|||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
|
||||
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
|
||||
|
||||
// for dev-mode
|
||||
declare const _LANGS_FULL_: string[][];
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -30,9 +30,6 @@ export default [
|
|||
_VERSION_: false,
|
||||
_ENV_: false,
|
||||
_PERF_PREFIX_: false,
|
||||
_DATA_TRANSFER_DRIVE_FILE_: false,
|
||||
_DATA_TRANSFER_DRIVE_FOLDER_: false,
|
||||
_DATA_TRANSFER_DECK_COLUMN_: false,
|
||||
},
|
||||
parser,
|
||||
parserOptions: {
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@
|
|||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@sentry/vue": "9.14.0",
|
||||
"@sentry/vue": "9.22.0",
|
||||
"@syuilo/aiscript": "0.19.0",
|
||||
"@tabler/icons-webfont": "3.31.0",
|
||||
"@tabler/icons-webfont": "3.33.0",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.2.3",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"@vue/compiler-sfc": "3.5.14",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||
"analytics": "0.8.16",
|
||||
"astring": "1.9.0",
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"frontend-shared": "workspace:*",
|
||||
"idb-keyval": "6.2.1",
|
||||
"idb-keyval": "6.2.2",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
|
|
@ -60,84 +60,84 @@
|
|||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.40.0",
|
||||
"sanitize-html": "2.16.0",
|
||||
"sass": "1.87.0",
|
||||
"shiki": "3.3.0",
|
||||
"rollup": "4.41.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.89.0",
|
||||
"shiki": "3.4.2",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.176.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.15",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.8.3",
|
||||
"uuid": "11.1.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "6.3.4",
|
||||
"vue": "3.5.13",
|
||||
"vite": "6.3.5",
|
||||
"vue": "3.5.14",
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@storybook/addon-actions": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-mdx-gfm": "8.6.12",
|
||||
"@storybook/addon-storysource": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/components": "8.6.12",
|
||||
"@storybook/core-events": "8.6.12",
|
||||
"@storybook/manager-api": "8.6.12",
|
||||
"@storybook/preview-api": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@storybook/theming": "8.6.12",
|
||||
"@storybook/types": "8.6.12",
|
||||
"@storybook/vue3": "8.6.12",
|
||||
"@storybook/vue3-vite": "8.6.12",
|
||||
"@storybook/addon-actions": "8.6.14",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/addon-mdx-gfm": "8.6.14",
|
||||
"@storybook/addon-storysource": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/components": "8.6.14",
|
||||
"@storybook/core-events": "8.6.14",
|
||||
"@storybook/manager-api": "8.6.14",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/react-vite": "8.6.14",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@storybook/theming": "8.6.14",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@storybook/vue3": "8.6.14",
|
||||
"@storybook/vue3-vite": "8.6.14",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.7",
|
||||
"@types/matter-js": "0.19.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.15.2",
|
||||
"@types/node": "22.15.21",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||
"@typescript-eslint/parser": "8.31.0",
|
||||
"@vitest/coverage-v8": "3.1.2",
|
||||
"@vue/compiler-core": "3.5.13",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"@vitest/coverage-v8": "3.1.4",
|
||||
"@vue/compiler-core": "3.5.14",
|
||||
"@vue/runtime-core": "3.5.14",
|
||||
"acorn": "8.14.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "14.3.2",
|
||||
"cypress": "14.4.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "10.0.0",
|
||||
"eslint-plugin-vue": "10.1.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.4.4",
|
||||
"happy-dom": "17.4.7",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"minimatch": "10.0.1",
|
||||
"msw": "2.7.5",
|
||||
"msw": "2.8.4",
|
||||
"msw-storybook-addon": "2.0.4",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.11",
|
||||
"storybook": "8.6.12",
|
||||
"start-server-and-test": "2.0.12",
|
||||
"storybook": "8.6.14",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "3.1.2",
|
||||
"vitest": "3.1.4",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "2.2.10",
|
||||
"vue-eslint-parser": "10.1.3",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
|
|||
});
|
||||
return confirm.canceled ? values.FALSE : values.TRUE;
|
||||
}),
|
||||
'Mk:toast': values.FN_NATIVE(async ([text]) => {
|
||||
utils.assertString(text);
|
||||
os.toast(text.value);
|
||||
return values.NULL;
|
||||
}),
|
||||
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
|
||||
utils.assertString(ep);
|
||||
if (ep.value.includes('://') || ep.value.includes('..')) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ const props = withDefaults(defineProps<{
|
|||
thin?: boolean;
|
||||
naked?: boolean;
|
||||
foldable?: boolean;
|
||||
onUnfold?: () => boolean; // return false to prevent unfolding
|
||||
scrollable?: boolean;
|
||||
expanded?: boolean;
|
||||
maxHeight?: number | null;
|
||||
|
|
@ -103,8 +102,6 @@ const omitObserver = new ResizeObserver((entries, observer) => {
|
|||
});
|
||||
|
||||
function showMore() {
|
||||
if (props.onUnfold && !props.onUnfold()) return;
|
||||
|
||||
ignoreOmit.value = true;
|
||||
omitted.value = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.cropImage }}</template>
|
||||
<template #default="{ width, height }">
|
||||
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
|
||||
<Transition name="fade">
|
||||
<div v-if="loading" class="loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="container">
|
||||
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
||||
<div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
|
||||
<Transition name="fade">
|
||||
<div v-if="loading" class="loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="container">
|
||||
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
|
|
@ -35,27 +33,23 @@ import { onMounted, useTemplateRef, ref } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import Cropper from 'cropperjs';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
imageFile: File | Blob;
|
||||
aspectRatio: number | null;
|
||||
uploadFolder?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
|
||||
(ev: 'ok', cropped: File | Blob): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
|
||||
const imgUrl = URL.createObjectURL(props.imageFile);
|
||||
const dialogEl = useTemplateRef('dialogEl');
|
||||
const imgEl = useTemplateRef('imgEl');
|
||||
let cropper: Cropper | null = null;
|
||||
|
|
@ -73,31 +67,10 @@ const ok = async () => {
|
|||
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
|
||||
croppedCanvas?.toBlob(blob => {
|
||||
if (!blob) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('name', `cropped_${props.file.name}`);
|
||||
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
|
||||
if (props.file.comment) { formData.append('comment', props.file.comment);}
|
||||
formData.append('i', $i!.token);
|
||||
if (props.uploadFolder) {
|
||||
formData.append('folderId', props.uploadFolder);
|
||||
} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
|
||||
formData.append('folderId', prefer.s.uploadFolder);
|
||||
}
|
||||
|
||||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
res(f);
|
||||
});
|
||||
res(blob);
|
||||
});
|
||||
});
|
||||
|
||||
os.promiseDialog(promise);
|
||||
|
||||
const f = await promise;
|
||||
|
||||
emit('ok', f);
|
||||
|
|
@ -126,8 +99,8 @@ onMounted(() => {
|
|||
|
||||
const selection = cropper.getCropperSelection()!;
|
||||
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
|
||||
selection.aspectRatio = props.aspectRatio;
|
||||
selection.initialAspectRatio = props.aspectRatio;
|
||||
if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio;
|
||||
selection.initialAspectRatio = props.aspectRatio ?? 1;
|
||||
selection.outlined = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="[$style.root, { [$style.isSelected]: isSelected }]"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
|
|
@ -46,24 +45,18 @@ import * as os from '@/os.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
|
||||
const router = useRouter();
|
||||
import { setDragData } from '@/drag-and-drop.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
folder: Misskey.entities.DriveFolder | null;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(), {
|
||||
isSelected: false,
|
||||
selectMode: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', r: Misskey.entities.DriveFile): void;
|
||||
(ev: 'dragstart'): void;
|
||||
(ev: 'dragstart', dragEvent: DragEvent): void;
|
||||
(ev: 'dragend'): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -71,18 +64,6 @@ const isDragging = ref(false);
|
|||
|
||||
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
if (deviceKind === 'desktop') {
|
||||
router.push(`/my/drive/file/${props.file.id}`);
|
||||
} else {
|
||||
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
|
||||
}
|
||||
|
|
@ -90,11 +71,11 @@ function onContextmenu(ev: MouseEvent) {
|
|||
function onDragstart(ev: DragEvent) {
|
||||
if (ev.dataTransfer) {
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
|
||||
setDragData(ev, 'driveFiles', [props.file]);
|
||||
}
|
||||
isDragging.value = true;
|
||||
|
||||
emit('dragstart');
|
||||
emit('dragstart', ev);
|
||||
}
|
||||
|
||||
function onDragend() {
|
||||
|
|
@ -114,7 +95,7 @@ function onDragend() {
|
|||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b65a5;
|
||||
|
|
@ -132,7 +113,7 @@ function onDragend() {
|
|||
&:active {
|
||||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b588c;
|
||||
|
|
@ -158,19 +139,19 @@ function onDragend() {
|
|||
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
}
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
color: #fff;
|
||||
.name {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
color: #fff;
|
||||
.thumbnail {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -240,8 +221,9 @@ function onDragend() {
|
|||
|
||||
.name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
margin: 8px 0 0 0;
|
||||
padding: 0 2px;
|
||||
font-size: 82%;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--MI_THEME-fg);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="[$style.root, { [$style.draghover]: draghover }]"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@mouseover="onMouseover"
|
||||
@mouseout="onMouseout"
|
||||
|
|
@ -19,14 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<p :class="$style.name">
|
||||
<template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
|
||||
<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
|
||||
{{ folder.name }}
|
||||
</p>
|
||||
<p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
|
||||
<svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none">
|
||||
<path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/>
|
||||
</svg>
|
||||
<div :class="$style.name">{{ folder.name }}</div>
|
||||
<div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
|
||||
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
|
||||
</button>
|
||||
|
|
@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js';
|
|||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
|
||||
import { selectDriveFolder } from '@/utility/drive.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
folder: Misskey.entities.DriveFolder;
|
||||
|
|
@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'unchose', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'move', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
|
||||
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
|
||||
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
|
||||
(ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder);
|
||||
(ev: 'dragstart'): void;
|
||||
(ev: 'dragend'): void;
|
||||
}>();
|
||||
|
|
@ -78,10 +76,6 @@ function checkboxClicked() {
|
|||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
|
@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) {
|
|||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
|
|
@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) {
|
|||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit('upload', file, props.folder);
|
||||
}
|
||||
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit('removeFile', file.id);
|
||||
misskeyApi('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: props.folder.id,
|
||||
});
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFiles');
|
||||
if (droppedData != null) {
|
||||
misskeyApi('drive/files/move-bulk', {
|
||||
fileIds: droppedData.map(f => f.id),
|
||||
folderId: props.folder.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
|
||||
...x,
|
||||
folderId: props.folder.id,
|
||||
folder: props.folder,
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFolders');
|
||||
if (droppedData != null) {
|
||||
const droppedFolder = droppedData[0];
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.id === props.folder.id) return;
|
||||
// 移動先が自分自身ならreject
|
||||
if (droppedFolder.id === props.folder.id) return;
|
||||
|
||||
emit('removeFolder', folder.id);
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder.id,
|
||||
}).then(() => {
|
||||
// noop
|
||||
}).catch(err => {
|
||||
switch (err.code) {
|
||||
case 'RECURSIVE_NESTING':
|
||||
claimAchievement('driveFolderCircularReference');
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: droppedFolder.id,
|
||||
parentId: props.folder.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
|
||||
...x,
|
||||
parentId: props.folder.id,
|
||||
parent: props.folder,
|
||||
})));
|
||||
}).catch(err => {
|
||||
switch (err.code) {
|
||||
case 'RECURSIVE_NESTING':
|
||||
claimAchievement('driveFolderCircularReference');
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) {
|
|||
if (!ev.dataTransfer) return;
|
||||
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
|
||||
setDragData(ev, 'driveFolders', [props.folder]);
|
||||
isDragging.value = true;
|
||||
|
||||
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
|
||||
|
|
@ -211,10 +211,6 @@ function onDragend() {
|
|||
emit('dragend');
|
||||
}
|
||||
|
||||
function go() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFolder,
|
||||
|
|
@ -225,17 +221,28 @@ function rename() {
|
|||
misskeyApi('drive/folders/update', {
|
||||
folderId: props.folder.id,
|
||||
name: name,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [{
|
||||
...props.folder,
|
||||
name: name,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function move() {
|
||||
os.selectDriveFolder(false).then(folder => {
|
||||
selectDriveFolder(null).then(folder => {
|
||||
if (folder[0] && folder[0].id === props.folder.id) return;
|
||||
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: props.folder.id,
|
||||
parentId: folder[0] ? folder[0].id : null,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [{
|
||||
...props.folder,
|
||||
parentId: folder[0] ? folder[0].id : null,
|
||||
parent: folder[0] ?? null,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -247,6 +254,7 @@ function deleteFolder() {
|
|||
if (prefer.s.uploadFolder === props.folder.id) {
|
||||
prefer.commit('uploadFolder', null);
|
||||
}
|
||||
globalEvents.emit('driveFoldersDeleted', [props.folder]);
|
||||
}).catch(err => {
|
||||
switch (err.id) {
|
||||
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
|
||||
|
|
@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) {
|
|||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
height: 64px;
|
||||
background: var(--MI_THEME-driveFolderBg);
|
||||
border-radius: 4px;
|
||||
height: 90px;
|
||||
padding: 24px 16px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
|
||||
&.draghover {
|
||||
|
|
@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
|
|
@ -373,7 +388,6 @@ function onContextmenu(ev: MouseEvent) {
|
|||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
|
@ -384,7 +398,6 @@ function onContextmenu(ev: MouseEvent) {
|
|||
}
|
||||
|
||||
.upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div
|
||||
:class="[$style.root, { [$style.draghover]: draghover }]"
|
||||
@click="onClick"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
|
|
@ -22,6 +21,8 @@ import { ref } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
|
||||
|
||||
const props = defineProps<{
|
||||
folder?: Misskey.entities.DriveFolder;
|
||||
|
|
@ -29,27 +30,11 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'move', v?: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
|
||||
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
|
||||
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
|
||||
(ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const draghover = ref(false);
|
||||
|
||||
function onClick() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function onMouseout() {
|
||||
hover.value = false;
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
|
|
@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) {
|
|||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
|
|
@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) {
|
|||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit('upload', file, props.folder);
|
||||
}
|
||||
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit('removeFile', file.id);
|
||||
misskeyApi('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFiles');
|
||||
if (droppedData != null) {
|
||||
misskeyApi('drive/files/move-bulk', {
|
||||
fileIds: droppedData.map(f => f.id),
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
|
||||
...x,
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
folder: props.folder ?? null,
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
// 移動先が自分自身ならreject
|
||||
if (props.folder && folder.id === props.folder.id) return;
|
||||
emit('removeFolder', folder.id);
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFolders');
|
||||
if (droppedData != null) {
|
||||
const droppedFolder = droppedData[0];
|
||||
// 移動先が自分自身ならreject
|
||||
if (props.folder && droppedFolder.id === props.folder.id) return;
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: droppedFolder.id,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
|
||||
...x,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
parent: props.folder ?? null,
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,5 +3,5 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
|
||||
import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue';
|
||||
void MkDriveSelectDialog;
|
||||
|
|
@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:width="800"
|
||||
:height="500"
|
||||
:withOkButton="true"
|
||||
:okButtonDisabled="(type === 'file') && (selected.length === 0)"
|
||||
:okButtonDisabled="selected.length === 0"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
|
||||
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
|
||||
{{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }}
|
||||
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
|
||||
</template>
|
||||
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
|
||||
<MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDrive from '@/components/MkDrive.vue';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import number from '@/filters/number.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
type?: 'file' | 'folder';
|
||||
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
|
||||
multiple: boolean;
|
||||
}>(), {
|
||||
type: 'file',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
|
||||
const selected = ref<Misskey.entities.DriveFile[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
|
|
@ -57,7 +55,7 @@ function cancel() {
|
|||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
|
||||
function onChangeSelection(v: Misskey.entities.DriveFile[]) {
|
||||
selected.value = v;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -11,15 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.large]: large,
|
||||
}]"
|
||||
>
|
||||
<ImgWithBlurhash
|
||||
v-if="isThumbnailAvailable"
|
||||
<MkImgWithBlurhash
|
||||
v-if="isThumbnailAvailable && prefer.s.enableHighQualityImagePlaceholders"
|
||||
:hash="file.blurhash"
|
||||
:src="file.thumbnailUrl"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
:class="$style.thumbnail"
|
||||
:cover="fit !== 'contain'"
|
||||
:forceBlurhash="forceBlurhash"
|
||||
/>
|
||||
<img
|
||||
v-else-if="isThumbnailAvailable"
|
||||
:src="file.thumbnailUrl"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
:class="$style.thumbnail"
|
||||
:style="{ objectFit: fit }"
|
||||
/>
|
||||
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
|
||||
|
|
@ -36,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
|
|
@ -115,4 +125,8 @@ const isThumbnailAvailable = computed(() => {
|
|||
.large .icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="800"
|
||||
:height="500"
|
||||
:withOkButton="true"
|
||||
:okButtonDisabled="selected.length === 0"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }}
|
||||
<span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
|
||||
</template>
|
||||
<MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
|
||||
multiple?: boolean;
|
||||
}>(), {
|
||||
initialFolder: null,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: Misskey.entities.DriveFolder[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('done');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(v: Misskey.entities.DriveFolder[]) {
|
||||
selected.value = v;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header>
|
||||
{{ i18n.ts.drive }}
|
||||
</template>
|
||||
<XDrive :initialFolder="initialFolder"/>
|
||||
<MkDrive :initialFolder="initialFolder"/>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDrive from '@/components/MkDrive.vue';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder;
|
||||
initialFolder?: Misskey.entities.DriveFolder | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:hasInteractionWithOtherFocusTrappedEls="true"
|
||||
:transparentBg="true"
|
||||
:manualShowing="manualShowing"
|
||||
:src="src"
|
||||
:anchorElement="anchorElement"
|
||||
@click="modal?.close()"
|
||||
@esc="modal?.close()"
|
||||
@opening="opening"
|
||||
|
|
@ -44,7 +44,7 @@ import { prefer } from '@/preferences.js';
|
|||
|
||||
const props = withDefaults(defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
src?: HTMLElement;
|
||||
anchorElement?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
pinnedEmojis?: string[],
|
||||
asReactionPicker?: boolean;
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { getBgColor } from '@/utility/get-bg-color.js';
|
||||
|
||||
const miLocalStoragePrefix = 'ui:folder:' as const;
|
||||
|
|
@ -83,8 +84,19 @@ function afterLeave(el: Element) {
|
|||
el.style.height = '';
|
||||
}
|
||||
|
||||
function updateBgColor() {
|
||||
if (rootEl.value) {
|
||||
parentBg.value = getBgColor(rootEl.value.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
parentBg.value = getBgColor(rootEl.value?.parentElement);
|
||||
updateBgColor();
|
||||
globalEvents.on('themeChanging', updateBgColor);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
globalEvents.off('themeChanging', updateBgColor);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-if="asPage" class="ti ti-chevron-right icon"></i>
|
||||
<i v-else-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
|
||||
<div v-if="asPage">
|
||||
<Teleport v-if="opened" defer :to="`#v-${pageId}-header`">
|
||||
<slot name="label"></slot>
|
||||
</Teleport>
|
||||
<Teleport v-if="opened" defer :to="`#v-${pageId}-body`">
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div v-if="$slots.header" :class="$style.inBodyHeader">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="withSpacer" class="_spacer" :style="{ '--MI_SPACER-min': props.spacerMin + 'px', '--MI_SPACER-max': props.spacerMax + 'px' }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="$slots.footer" :class="$style.inBodyFooter">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<div v-else-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
|
||||
<Transition
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
|
|
@ -70,6 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getBgColor } from '@/utility/get-bg-color.js';
|
||||
import { pageFolderTeleportCount, popup } from '@/os.js';
|
||||
import MkFolderPage from '@/components/MkFolderPage.vue';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
|
|
@ -77,18 +109,21 @@ const props = withDefaults(defineProps<{
|
|||
withSpacer?: boolean;
|
||||
spacerMin?: number;
|
||||
spacerMax?: number;
|
||||
canPage?: boolean;
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
maxHeight: null,
|
||||
withSpacer: true,
|
||||
spacerMin: 14,
|
||||
spacerMax: 22,
|
||||
canPage: true,
|
||||
});
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const asPage = props.canPage && deviceKind === 'smartphone' && prefer.s['experimental.enableFolderPageView'];
|
||||
const bgSame = ref(false);
|
||||
const opened = ref(props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(props.defaultOpen);
|
||||
const opened = ref(asPage ? false : props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(opened.value);
|
||||
|
||||
//#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す)
|
||||
function enter(el: Element) {
|
||||
|
|
@ -126,7 +161,22 @@ function afterLeave(el: Element) {
|
|||
}
|
||||
//#endregion
|
||||
|
||||
function toggle() {
|
||||
let pageId = pageFolderTeleportCount.value;
|
||||
pageFolderTeleportCount.value += 1000;
|
||||
|
||||
async function toggle() {
|
||||
if (asPage && !opened.value) {
|
||||
pageId++;
|
||||
const { dispose } = await popup(MkFolderPage, {
|
||||
pageId,
|
||||
}, {
|
||||
closed: () => {
|
||||
opened.value = false;
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!opened.value) {
|
||||
openedAtLeastOnce.value = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
name="x"
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
|
||||
:duration="300" appear @afterLeave="onClosed"
|
||||
>
|
||||
<div v-show="showing" :class="[$style.root]" :style="{ zIndex }">
|
||||
<div :class="[$style.bg]" :style="{ zIndex }"></div>
|
||||
<div :class="[$style.content]" :style="{ zIndex }">
|
||||
<div :class="$style.header">
|
||||
<button :class="$style.back" class="_button" @click="closePage"><i class="ti ti-chevron-left"></i></button>
|
||||
<div :id="`v-${pageId}-header`" :class="$style.title"></div>
|
||||
<div :class="$style.spacer"></div>
|
||||
</div>
|
||||
<div :id="`v-${pageId}-body`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { claimZIndex } from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pageId: number,
|
||||
}>(), {
|
||||
pageId: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(_: 'closed'): void
|
||||
}>();
|
||||
|
||||
const zIndex = claimZIndex('middle');
|
||||
const showing = ref(true);
|
||||
|
||||
function closePage() {
|
||||
showing.value = false;
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
emit('closed');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_x_enterActive {
|
||||
> .bg {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transition: transform 0.3s cubic-bezier(0,0,.25,1) !important;
|
||||
}
|
||||
}
|
||||
.transition_x_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity 0.3s !important;
|
||||
}
|
||||
|
||||
> .content {
|
||||
transition: transform 0.3s cubic-bezier(0,0,.25,1) !important;
|
||||
}
|
||||
}
|
||||
.transition_x_enterFrom,
|
||||
.transition_x_leaveTo {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .content {
|
||||
pointer-events: none;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--MI_THEME-modalBg);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
background: var(--MI_THEME-bg);
|
||||
container-type: size;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.header {
|
||||
--height: 48px;
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: var(--height);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: color(from var(--MI_THEME-panel) srgb r g b / 0.75);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--height);
|
||||
height: var(--height);
|
||||
font-size: 16px;
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 auto;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: var(--height);
|
||||
height: var(--height);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js';
|
|||
import { computed, ref } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { selectFile } from '@/utility/select-file.js';
|
||||
import { selectFile } from '@/utility/drive.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover">
|
||||
<div class="thumbnail">
|
||||
<Transition>
|
||||
<ImgWithBlurhash
|
||||
<MkImgWithBlurhash
|
||||
class="img layered"
|
||||
:transition="safe ? null : {
|
||||
duration: 500,
|
||||
|
|
@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { computed, ref } from 'vue';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :anchorElement="anchorElement" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
|
||||
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
|
||||
<div class="main">
|
||||
<template v-for="item in items" :key="item.text">
|
||||
|
|
@ -34,7 +34,7 @@ import { deviceKind } from '@/utility/device-kind.js';
|
|||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src?: HTMLElement;
|
||||
anchorElement?: HTMLElement;
|
||||
anchor?: { x: string; y: string; };
|
||||
}>(), {
|
||||
anchor: () => ({ x: 'right', y: 'center' }),
|
||||
|
|
@ -44,7 +44,7 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' :
|
||||
const preferedModalType = (deviceKind === 'desktop' && props.anchorElement != null) ? 'popup' :
|
||||
deviceKind === 'smartphone' ? 'drawer' :
|
||||
'dialog';
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { defineAsyncComponent, ref } from 'vue';
|
|||
import { url as local } from '@@/js/config.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import type { MkABehavior } from '@/components/global/MkA.vue';
|
||||
import { maybeMakeRelative } from '@@/js/url.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { h, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'MarqueeText',
|
||||
props: {
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
repeat: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
paused: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const contentEl = ref<HTMLElement>();
|
||||
|
||||
function calc() {
|
||||
if (contentEl.value == null) return;
|
||||
const eachLength = contentEl.value.offsetWidth / props.repeat;
|
||||
const factor = 3000;
|
||||
const duration = props.duration / ((1 / eachLength) * factor);
|
||||
|
||||
contentEl.value.style.animationDuration = `${duration}s`;
|
||||
}
|
||||
|
||||
watch(() => props.duration, calc);
|
||||
|
||||
onMounted(() => {
|
||||
calc();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
});
|
||||
|
||||
return {
|
||||
contentEl,
|
||||
};
|
||||
},
|
||||
render({
|
||||
$slots, $style, $props: {
|
||||
duration, repeat, paused, reverse,
|
||||
},
|
||||
}) {
|
||||
return h('div', { class: [$style.wrap] }, [
|
||||
h('span', {
|
||||
ref: 'contentEl',
|
||||
class: [
|
||||
paused
|
||||
? $style.paused
|
||||
: undefined,
|
||||
$style.content,
|
||||
],
|
||||
}, Array(repeat).fill(
|
||||
h('span', {
|
||||
class: $style.text,
|
||||
style: {
|
||||
animationDirection: reverse
|
||||
? 'reverse'
|
||||
: undefined,
|
||||
},
|
||||
}, $slots.default()),
|
||||
)),
|
||||
]);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrap {
|
||||
overflow: clip;
|
||||
animation-play-state: running;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
.text {
|
||||
display: inline-block;
|
||||
animation-name: marquee;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: inherit;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
.paused .text {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% { transform:translateX(0); }
|
||||
100% { transform:translateX(-100%); }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrap">
|
||||
<span
|
||||
ref="contentEl"
|
||||
:class="[$style.content, {
|
||||
[$style.paused]: paused,
|
||||
[$style.reverse]: reverse,
|
||||
}]"
|
||||
>
|
||||
<span v-for="key in repeat" :key="key" :class="$style.text">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, useTemplateRef, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
duration?: number;
|
||||
repeat?: number;
|
||||
paused?: boolean;
|
||||
reverse?: boolean;
|
||||
}>(), {
|
||||
duration: 15,
|
||||
repeat: 2,
|
||||
paused: false,
|
||||
reverse: false,
|
||||
});
|
||||
|
||||
const contentEl = useTemplateRef('contentEl');
|
||||
|
||||
function calcDuration() {
|
||||
if (contentEl.value == null) return;
|
||||
const eachLength = contentEl.value.offsetWidth / props.repeat;
|
||||
const factor = 3000;
|
||||
const duration = props.duration / ((1 / eachLength) * factor);
|
||||
contentEl.value.style.animationDuration = `${duration}s`;
|
||||
}
|
||||
|
||||
watch(() => props.duration, calcDuration);
|
||||
|
||||
onMounted(calcDuration);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrap {
|
||||
overflow: clip;
|
||||
animation-play-state: running;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: inline-block;
|
||||
animation-name: marquee;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: inherit;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
|
||||
.paused .text {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.reverse .text {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -17,7 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
style: 'cursor: zoom-in;'
|
||||
}"
|
||||
>
|
||||
<ImgWithBlurhash
|
||||
<MkImgWithBlurhash
|
||||
v-if="prefer.s.enableHighQualityImagePlaceholders"
|
||||
:hash="image.blurhash"
|
||||
:src="(prefer.s.dataSaver.media && hide) ? null : url"
|
||||
:forceBlurhash="hide"
|
||||
|
|
@ -27,6 +28,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:width="image.properties.width"
|
||||
:height="image.properties.height"
|
||||
:style="hide ? 'filter: brightness(0.7);' : null"
|
||||
:class="$style.image"
|
||||
/>
|
||||
<div
|
||||
v-else-if="prefer.s.dataSaver.media || hide"
|
||||
:title="image.comment || image.name"
|
||||
:style="hide ? 'background: #888;' : null"
|
||||
:class="$style.image"
|
||||
></div>
|
||||
<img
|
||||
v-else
|
||||
:src="url"
|
||||
:alt="image.comment || image.name"
|
||||
:title="image.comment || image.name"
|
||||
:class="$style.image"
|
||||
/>
|
||||
</component>
|
||||
<template v-if="hide">
|
||||
|
|
@ -57,7 +72,7 @@ import type { MenuItem } from '@/types/menu.js';
|
|||
import { copyToClipboard } from '@/utility/copy-to-clipboard';
|
||||
import { getStaticImageUrl } from '@/utility/media-proxy.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
|
|
@ -300,4 +315,12 @@ html[data-color-scheme=light] .visible {
|
|||
font-size: 0.8em;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ type ModalTypes = 'popup' | 'dialog' | 'drawer';
|
|||
const props = withDefaults(defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
anchor?: { x: string; y: string; };
|
||||
src?: HTMLElement | null;
|
||||
anchorElement?: HTMLElement | null;
|
||||
preferType?: ModalTypes | 'auto';
|
||||
zPriority?: 'low' | 'middle' | 'high';
|
||||
noOverlap?: boolean;
|
||||
|
|
@ -76,7 +76,7 @@ const props = withDefaults(defineProps<{
|
|||
returnFocusTo?: HTMLElement | null;
|
||||
}>(), {
|
||||
manualShowing: null,
|
||||
src: null,
|
||||
anchorElement: null,
|
||||
anchor: () => ({ x: 'center', y: 'bottom' }),
|
||||
preferType: 'auto',
|
||||
zPriority: 'low',
|
||||
|
|
@ -110,7 +110,7 @@ const type = computed<ModalTypes>(() => {
|
|||
if ((prefer.s.menuStyle === 'drawer') || (prefer.s.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) {
|
||||
return 'drawer';
|
||||
} else {
|
||||
return props.src != null ? 'popup' : 'dialog';
|
||||
return props.anchorElement != null ? 'popup' : 'dialog';
|
||||
}
|
||||
} else {
|
||||
return props.preferType!;
|
||||
|
|
@ -149,7 +149,7 @@ function close(opts: { useSendAnimation?: boolean } = {}) {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
if (props.src) props.src.style.pointerEvents = 'auto';
|
||||
if (props.anchorElement) props.anchorElement.style.pointerEvents = 'auto';
|
||||
showing.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
|
@ -174,13 +174,13 @@ const MARGIN = 16;
|
|||
const SCROLLBAR_THICKNESS = 16;
|
||||
|
||||
const align = () => {
|
||||
if (props.src == null) return;
|
||||
if (props.anchorElement == null) return;
|
||||
if (type.value === 'drawer') return;
|
||||
if (type.value === 'dialog') return;
|
||||
|
||||
if (content.value == null) return;
|
||||
|
||||
const srcRect = props.src.getBoundingClientRect();
|
||||
const anchorRect = props.anchorElement.getBoundingClientRect();
|
||||
|
||||
const width = content.value!.offsetWidth;
|
||||
const height = content.value!.offsetHeight;
|
||||
|
|
@ -188,15 +188,15 @@ const align = () => {
|
|||
let left;
|
||||
let top;
|
||||
|
||||
const x = srcRect.left + (fixed.value ? 0 : window.scrollX);
|
||||
const y = srcRect.top + (fixed.value ? 0 : window.scrollY);
|
||||
const x = anchorRect.left + (fixed.value ? 0 : window.scrollX);
|
||||
const y = anchorRect.top + (fixed.value ? 0 : window.scrollY);
|
||||
|
||||
if (props.anchor.x === 'center') {
|
||||
left = x + (props.src.offsetWidth / 2) - (width / 2);
|
||||
left = x + (props.anchorElement.offsetWidth / 2) - (width / 2);
|
||||
} else if (props.anchor.x === 'left') {
|
||||
// TODO
|
||||
} else if (props.anchor.x === 'right') {
|
||||
left = x + props.src.offsetWidth;
|
||||
left = x + props.anchorElement.offsetWidth;
|
||||
}
|
||||
|
||||
if (props.anchor.y === 'center') {
|
||||
|
|
@ -204,7 +204,7 @@ const align = () => {
|
|||
} else if (props.anchor.y === 'top') {
|
||||
// TODO
|
||||
} else if (props.anchor.y === 'bottom') {
|
||||
top = y + props.src.offsetHeight;
|
||||
top = y + props.anchorElement.offsetHeight;
|
||||
}
|
||||
|
||||
if (fixed.value) {
|
||||
|
|
@ -214,7 +214,7 @@ const align = () => {
|
|||
}
|
||||
|
||||
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - top;
|
||||
const upperSpace = (srcRect.top - MARGIN);
|
||||
const upperSpace = (anchorRect.top - MARGIN);
|
||||
|
||||
// 画面から縦にはみ出る場合
|
||||
if (top + height > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
|
||||
|
|
@ -238,7 +238,7 @@ const align = () => {
|
|||
}
|
||||
|
||||
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY);
|
||||
const upperSpace = (srcRect.top - MARGIN);
|
||||
const upperSpace = (anchorRect.top - MARGIN);
|
||||
|
||||
// 画面から縦にはみ出る場合
|
||||
if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
|
||||
|
|
@ -268,15 +268,15 @@ const align = () => {
|
|||
let transformOriginX = 'center';
|
||||
let transformOriginY = 'center';
|
||||
|
||||
if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) {
|
||||
if (top >= anchorRect.top + props.anchorElement.offsetHeight + (fixed.value ? 0 : window.scrollY)) {
|
||||
transformOriginY = 'top';
|
||||
} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) {
|
||||
} else if ((top + height) <= anchorRect.top + (fixed.value ? 0 : window.scrollY)) {
|
||||
transformOriginY = 'bottom';
|
||||
}
|
||||
|
||||
if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) {
|
||||
if (left >= anchorRect.left + props.anchorElement.offsetWidth + (fixed.value ? 0 : window.scrollX)) {
|
||||
transformOriginX = 'left';
|
||||
} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) {
|
||||
} else if ((left + width) <= anchorRect.left + (fixed.value ? 0 : window.scrollX)) {
|
||||
transformOriginX = 'right';
|
||||
}
|
||||
|
||||
|
|
@ -317,12 +317,12 @@ const alignObserver = new ResizeObserver((entries, observer) => {
|
|||
});
|
||||
|
||||
onMounted(() => {
|
||||
watch(() => props.src, async () => {
|
||||
if (props.src) {
|
||||
watch(() => props.anchorElement, async () => {
|
||||
if (props.anchorElement) {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.src.style.pointerEvents = 'none';
|
||||
props.anchorElement.style.pointerEvents = 'none';
|
||||
}
|
||||
fixed.value = (type.value === 'drawer') || (getFixedContainer(props.src) != null);
|
||||
fixed.value = (type.value === 'drawer') || (getFixedContainer(props.anchorElement) != null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
|
|
@ -339,7 +339,7 @@ onMounted(() => {
|
|||
}
|
||||
} else {
|
||||
releaseFocusTrap?.();
|
||||
focusParent(props.returnFocusTo ?? props.src, true, false);
|
||||
focusParent(props.returnFocusTo ?? props.anchorElement, true, false);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
|
||||
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
|
||||
<div ref="headerEl" :class="$style.header">
|
||||
<div :class="$style.header">
|
||||
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
|
||||
<span :class="$style.title">
|
||||
<slot name="header"></slot>
|
||||
|
|
@ -15,7 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
|
||||
</div>
|
||||
<div :class="$style.body">
|
||||
<slot :width="bodyWidth" :height="bodyHeight"></slot>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" :class="$style.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
|
|
@ -48,10 +51,6 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const modal = useTemplateRef('modal');
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const headerEl = useTemplateRef('headerEl');
|
||||
const bodyWidth = ref(0);
|
||||
const bodyHeight = ref(0);
|
||||
|
||||
function close() {
|
||||
modal.value?.close();
|
||||
|
|
@ -61,23 +60,6 @@ function onBgClick() {
|
|||
emit('click');
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver((entries, observer) => {
|
||||
if (rootEl.value == null || headerEl.value == null) return;
|
||||
bodyWidth.value = rootEl.value.offsetWidth;
|
||||
bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (rootEl.value == null || headerEl.value == null) return;
|
||||
bodyWidth.value = rootEl.value.offsetWidth;
|
||||
bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight;
|
||||
ro.observe(rootEl.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ro.disconnect();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
close,
|
||||
});
|
||||
|
|
@ -143,7 +125,14 @@ defineExpose({
|
|||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--MI_THEME-panel);
|
||||
background: var(--MI_THEME-bg);
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
background: var(--MI_THEME-bg);
|
||||
border-top: 1px solid var(--MI_THEME-divider);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll
|
||||
|
|
@ -234,7 +234,7 @@ import { claimAchievement } from '@/utility/achievements.js';
|
|||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { focusPrev, focusNext } from '@/utility/focus.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
|
@ -410,12 +410,15 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
|
|||
});
|
||||
});
|
||||
|
||||
let subscribeManuallyToNoteCapture: () => void = () => { };
|
||||
|
||||
if (!props.mock) {
|
||||
useNoteCapture({
|
||||
const { subscribe } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
$note: $appearNote,
|
||||
});
|
||||
subscribeManuallyToNoteCapture = subscribe;
|
||||
}
|
||||
|
||||
if (!props.mock) {
|
||||
|
|
@ -472,6 +475,8 @@ function renote(viaKeyboard = false) {
|
|||
os.popupMenu(menu, renoteButton.value, {
|
||||
viaKeyboard,
|
||||
});
|
||||
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
|
|
@ -567,6 +572,11 @@ function undoReact(): void {
|
|||
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: appearNote.id,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
|||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
|
|
@ -397,7 +397,7 @@ const reactionsPagination = computed(() => ({
|
|||
},
|
||||
}));
|
||||
|
||||
useNoteCapture({
|
||||
const { subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
$note: $appearNote,
|
||||
|
|
@ -453,6 +453,9 @@ function renote() {
|
|||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton });
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
|
||||
// リノート後は反応が来る可能性があるので手動で購読する
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
|
|
@ -527,6 +530,11 @@ function undoReact(targetNote: Misskey.entities.Note): void {
|
|||
if (!oldReaction) return;
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: targetNote.id,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ const showingFiles = ref<Set<string>>(new Set());
|
|||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkPagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script lang="ts" setup generic="T extends PagingCtx">
|
||||
import { useTemplateRef } from 'vue';
|
||||
import type { PagingCtx } from '@/composables/use-pagination.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
|
|
@ -41,7 +41,7 @@ import { globalEvents, useGlobalEvent } from '@/events.js';
|
|||
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: PagingCtx;
|
||||
pagination: T;
|
||||
noGap?: boolean;
|
||||
disableAutoLoad?: boolean;
|
||||
pullToRefresh?: boolean;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ 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 : ''"
|
||||
:css="prefer.s.animation"
|
||||
mode="out-in"
|
||||
>
|
||||
<MkLoading v-if="paginator.fetching.value"/>
|
||||
|
|
@ -40,16 +40,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script lang="ts" setup generic="T extends PagingCtx">
|
||||
import type { PagingCtx } from '@/composables/use-pagination.js';
|
||||
import type { UnwrapRef } from 'vue';
|
||||
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';
|
||||
|
||||
type Paginator = ReturnType<typeof usePagination<T['endpoint']>>;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: PagingCtx;
|
||||
pagination: T;
|
||||
disableAutoLoad?: boolean;
|
||||
displayLimit?: number;
|
||||
pullToRefresh?: boolean;
|
||||
|
|
@ -58,7 +61,7 @@ const props = withDefaults(defineProps<{
|
|||
pullToRefresh: true,
|
||||
});
|
||||
|
||||
const paginator = usePagination({
|
||||
const paginator: Paginator = usePagination({
|
||||
ctx: props.pagination,
|
||||
});
|
||||
|
||||
|
|
@ -70,6 +73,11 @@ function appearFetchMore() {
|
|||
paginator.fetchOlder();
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
empty: () => void;
|
||||
default: (props: { items: UnwrapRef<Paginator['items']> }) => void;
|
||||
}>();
|
||||
|
||||
defineExpose({
|
||||
paginator: paginator,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :anchorElement="anchorElement" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
|
||||
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
|
@ -19,7 +19,7 @@ defineProps<{
|
|||
items: MenuItem[];
|
||||
align?: 'center' | string;
|
||||
width?: number;
|
||||
src?: HTMLElement | null;
|
||||
anchorElement?: HTMLElement | null;
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
|
||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||
|
|
@ -120,14 +120,13 @@ import { formatTimeString } from '@/utility/format-time-string.js';
|
|||
import { Autocomplete } from '@/utility/autocomplete.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { selectFiles } from '@/utility/select-file.js';
|
||||
import { selectFiles } from '@/utility/drive.js';
|
||||
import { store } from '@/store.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
|
||||
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
|
||||
import { uploadFile } from '@/utility/upload.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
|
@ -138,6 +137,7 @@ import { prefer } from '@/preferences.js';
|
|||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
|
|
@ -459,18 +459,6 @@ function updateFileName(file, name) {
|
|||
files.value[files.value.findIndex(x => x.id === file.id)].name = name;
|
||||
}
|
||||
|
||||
function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void {
|
||||
files.value[files.value.findIndex(x => x.id === file.id)] = newFile;
|
||||
}
|
||||
|
||||
function upload(file: File, name?: string): void {
|
||||
if (props.mock) return;
|
||||
|
||||
uploadFile(file, prefer.s.uploadFolder, name).then(res => {
|
||||
files.value.push(res);
|
||||
});
|
||||
}
|
||||
|
||||
function setVisibility() {
|
||||
if (props.channel) {
|
||||
visibility.value = 'public';
|
||||
|
|
@ -482,7 +470,7 @@ function setVisibility() {
|
|||
currentVisibility: visibility.value,
|
||||
isSilenced: $i.isSilenced,
|
||||
localOnly: localOnly.value,
|
||||
src: visibilityButton.value,
|
||||
anchorElement: visibilityButton.value,
|
||||
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
|
||||
}, {
|
||||
changeVisibility: v => {
|
||||
|
|
@ -651,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) {
|
|||
if (props.mock) return;
|
||||
if (!ev.clipboardData) return;
|
||||
|
||||
let pastedFiles: File[] = [];
|
||||
for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
const lio = file.name.lastIndexOf('.');
|
||||
const ext = lio >= 0 ? file.name.slice(lio) : '';
|
||||
const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
|
||||
upload(file, formatted);
|
||||
const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
|
||||
const renamedFile = new File([file], formattedName, { type: file.type });
|
||||
pastedFiles.push(renamedFile);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
ev.preventDefault();
|
||||
os.launchUploader(pastedFiles, {}).then(driveFiles => {
|
||||
files.value.push(...driveFiles);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paste = ev.clipboardData.getData('text');
|
||||
|
||||
|
|
@ -693,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) {
|
|||
|
||||
const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
|
||||
const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
|
||||
upload(file, `${fileName}.txt`);
|
||||
os.launchUploader([file], {}).then(driveFiles => {
|
||||
files.value.push(...driveFiles);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -701,8 +700,7 @@ async function onPaste(ev: ClipboardEvent) {
|
|||
function onDragover(ev) {
|
||||
if (!ev.dataTransfer.items[0]) return;
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
if (isFile || isDriveFile) {
|
||||
if (isFile || checkDragDataType(ev, ['driveFiles'])) {
|
||||
ev.preventDefault();
|
||||
draghover.value = true;
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
|
|
@ -738,16 +736,19 @@ function onDrop(ev: DragEvent): void {
|
|||
// ファイルだったら
|
||||
if (ev.dataTransfer && ev.dataTransfer.files.length > 0) {
|
||||
ev.preventDefault();
|
||||
for (const x of Array.from(ev.dataTransfer.files)) upload(x);
|
||||
os.launchUploader(Array.from(ev.dataTransfer.files), {}).then(driveFiles => {
|
||||
files.value.push(...driveFiles);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
files.value.push(file);
|
||||
ev.preventDefault();
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFiles');
|
||||
if (droppedData != null) {
|
||||
files.value.push(...droppedData);
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
|
|
@ -58,7 +59,6 @@ const emit = defineEmits<{
|
|||
(ev: 'detach', id: string): void;
|
||||
(ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void;
|
||||
(ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void;
|
||||
(ev: 'replaceFile', file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void;
|
||||
}>();
|
||||
|
||||
let menuShowing = false;
|
||||
|
|
@ -82,12 +82,13 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
|
|||
type: 'warning',
|
||||
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('drive/files/delete', {
|
||||
await os.apiWithDialog('drive/files/delete', {
|
||||
fileId: file.id,
|
||||
});
|
||||
|
||||
globalEvents.emit('driveFilesDeleted', [file]);
|
||||
}
|
||||
|
||||
function toggleSensitive(file) {
|
||||
|
|
@ -142,13 +143,6 @@ async function describe(file: Misskey.entities.DriveFile) {
|
|||
});
|
||||
}
|
||||
|
||||
async function crop(file: Misskey.entities.DriveFile): Promise<void> {
|
||||
if (mock) return;
|
||||
|
||||
const newFile = await os.cropImage(file, { aspectRatio: NaN });
|
||||
emit('replaceFile', file, newFile);
|
||||
}
|
||||
|
||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void {
|
||||
if (menuShowing) return;
|
||||
|
||||
|
|
@ -172,10 +166,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
|||
|
||||
if (isImage) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.cropImage,
|
||||
icon: 'ti ti-crop',
|
||||
action: () : void => { crop(file); },
|
||||
}, {
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: () => {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
|
||||
</div>
|
||||
<div :class="$style.preview__content1__button">
|
||||
<MkButton inline>This is</MkButton>
|
||||
<MkButton inline primary>the button</MkButton>
|
||||
<MkButton inline>This is</MkButton>
|
||||
<MkButton inline primary>the button</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.preview__content2" style="pointer-events: none;">
|
||||
|
|
@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as config from '@@/js/config.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkRadio from '@/components/MkRadio.vue';
|
||||
import * as os from '@/os.js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { chooseDriveFile } from '@/utility/drive.js';
|
||||
|
||||
const text = ref('');
|
||||
const flag = ref(true);
|
||||
|
|
@ -79,7 +80,9 @@ const openForm = async () => {
|
|||
};
|
||||
|
||||
const openDrive = async () => {
|
||||
await os.selectDriveFile(false);
|
||||
await chooseDriveFile({
|
||||
multiple: false,
|
||||
});
|
||||
};
|
||||
|
||||
const selectUser = async () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { computed, inject, onMounted, useTemplateRef, watch } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { getUnicodeEmoji } from '@@/js/emojilist.js';
|
||||
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
|
||||
import type { MenuItem } from '@/types/menu';
|
||||
import XDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import * as os from '@/os.js';
|
||||
|
|
@ -36,6 +37,7 @@ import { customEmojisMap } from '@/custom-emojis.js';
|
|||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { noteEvents } from '@/composables/use-note-capture.js';
|
||||
import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: Misskey.entities.Note['id'];
|
||||
|
|
@ -63,6 +65,7 @@ const canToggle = computed(() => {
|
|||
return !props.reaction.match(/@\w/) && $i && emoji.value;
|
||||
});
|
||||
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
|
||||
const isLocalCustomEmoji = props.reaction[0] === ':' && props.reaction.includes('@.');
|
||||
|
||||
async function toggleReaction() {
|
||||
if (!canToggle.value) return;
|
||||
|
|
@ -89,8 +92,7 @@ async function toggleReaction() {
|
|||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${props.noteId}`, {
|
||||
userId: $i!.id,
|
||||
reaction: props.reaction,
|
||||
emoji: emoji.value,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
if (oldReaction !== props.reaction) {
|
||||
misskeyApi('notes/reactions/create', {
|
||||
|
|
@ -140,21 +142,55 @@ async function toggleReaction() {
|
|||
}
|
||||
|
||||
async function menu(ev) {
|
||||
if (!canGetInfo.value) return;
|
||||
let menuItems: MenuItem[] = [];
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
|
||||
emoji: await misskeyApiGet('emoji', {
|
||||
name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
|
||||
}),
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
if (canGetInfo.value) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
|
||||
emoji: await misskeyApiGet('emoji', {
|
||||
name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
|
||||
}),
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isEmojiMuted(props.reaction).value) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.emojiUnmute,
|
||||
icon: 'ti ti-mood-smile',
|
||||
action: () => {
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.tsx.unmuteX({ x: isLocalCustomEmoji ? `:${emojiName.value}:` : props.reaction }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
unmuteEmoji(props.reaction);
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
menuItems.push({
|
||||
text: i18n.ts.emojiMute,
|
||||
icon: 'ti ti-mood-off',
|
||||
action: () => {
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.tsx.muteX({ x: isLocalCustomEmoji ? `:${emojiName.value}:` : props.reaction }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
muteEmoji(props.reaction);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function anime() {
|
||||
|
|
|
|||
|
|
@ -21,15 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script lang="ts">
|
||||
export type TlEvent<E = any> = {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
data: E;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup generic="T extends unknown">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
events: {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
data: any;
|
||||
}[];
|
||||
events: TlEvent<T>[];
|
||||
}>();
|
||||
|
||||
const events = computed(() => {
|
||||
|
|
@ -44,12 +48,12 @@ function getDateText(dateInstance: Date) {
|
|||
return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`;
|
||||
}
|
||||
|
||||
const items = computed<({
|
||||
type TlItem<T> = ({
|
||||
id: string;
|
||||
type: 'event';
|
||||
timestamp: number;
|
||||
delta: number;
|
||||
data: any;
|
||||
delta: number
|
||||
data: T;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'date';
|
||||
|
|
@ -57,8 +61,10 @@ const items = computed<({
|
|||
prevText: string;
|
||||
next: Date | null;
|
||||
nextText: string;
|
||||
})[]>(() => {
|
||||
const results = [];
|
||||
});
|
||||
|
||||
const items = computed<TlItem<T>[]>(() => {
|
||||
const results: TlItem<T>[] = [];
|
||||
for (let i = 0; i < events.value.length; i++) {
|
||||
const item = events.value[i];
|
||||
|
||||
|
|
@ -97,19 +103,12 @@ const items = computed<({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 18px 1fr;
|
||||
gap: 0 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
}
|
||||
|
||||
.center {
|
||||
position: relative;
|
||||
|
||||
|
|
@ -140,6 +139,7 @@ const items = computed<({
|
|||
height: 100%;
|
||||
background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%);
|
||||
}
|
||||
|
||||
.centerPoint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,564 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="800"
|
||||
:height="500"
|
||||
@close="cancel()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<i class="ti ti-upload"></i> {{ i18n.tsx.uploadNFiles({ n: files.length }) }}
|
||||
</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
|
||||
|
||||
<div class="_gaps_s _spacer">
|
||||
<div class="_gaps_s">
|
||||
<div
|
||||
v-for="ctx in items"
|
||||
:key="ctx.id"
|
||||
v-panel
|
||||
:class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
|
||||
:style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
|
||||
>
|
||||
<div :class="$style.itemInner">
|
||||
<div :class="$style.itemActionWrapper">
|
||||
<MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton>
|
||||
</div>
|
||||
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
|
||||
<div :class="$style.itemBody">
|
||||
<div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
|
||||
<div :class="$style.itemInfo">
|
||||
<span>{{ ctx.file.type }}</span>
|
||||
<span>{{ bytes(ctx.file.size) }}</span>
|
||||
<span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.itemIconWrapper">
|
||||
<MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/>
|
||||
<MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/>
|
||||
<MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.multiple">
|
||||
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
|
||||
<MkSelect
|
||||
v-if="items.length > 0"
|
||||
v-model="compressionLevel"
|
||||
:items="[
|
||||
{ value: 0, label: i18n.ts.none },
|
||||
{ value: 1, label: i18n.ts.low },
|
||||
{ value: 2, label: i18n.ts.middle },
|
||||
{ value: 3, label: i18n.ts.high },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts.compress }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton>
|
||||
<MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
|
||||
|
||||
<MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
|
||||
<MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
||||
import isAnimated from 'is-file-animated';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const COMPRESSION_SUPPORTED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
const CROPPING_SUPPORTED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const mimeTypeMap = {
|
||||
'image/webp': 'webp',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
} as const;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
files: File[];
|
||||
folderId?: string | null;
|
||||
multiple?: boolean;
|
||||
}>(), {
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'canceled'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const items = ref<{
|
||||
id: string;
|
||||
name: string;
|
||||
progress: { max: number; value: number } | null;
|
||||
thumbnail: string;
|
||||
waiting: boolean;
|
||||
uploading: boolean;
|
||||
uploaded: Misskey.entities.DriveFile | null;
|
||||
uploadFailed: boolean;
|
||||
aborted: boolean;
|
||||
compressedSize?: number | null;
|
||||
compressedImage?: Blob | null;
|
||||
file: File;
|
||||
abort?: (() => void) | null;
|
||||
}[]>([]);
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const firstUploadAttempted = ref(false);
|
||||
const isUploading = computed(() => items.value.some(item => item.uploading));
|
||||
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null));
|
||||
const canDone = computed(() => items.value.some(item => item.uploaded != null));
|
||||
const overallProgress = computed(() => {
|
||||
const max = items.value.length;
|
||||
if (max === 0) return 0;
|
||||
const v = items.value.reduce((acc, item) => {
|
||||
if (item.uploaded) return acc + 1;
|
||||
if (item.progress) return acc + (item.progress.value / item.progress.max);
|
||||
return acc;
|
||||
}, 0);
|
||||
return Math.round((v / max) * 100);
|
||||
});
|
||||
|
||||
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
||||
const compressionSettings = computed(() => {
|
||||
if (compressionLevel.value === 1) {
|
||||
return {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
};
|
||||
} else if (compressionLevel.value === 2) {
|
||||
return {
|
||||
maxWidth: 2000 * 0.75, // =1500
|
||||
maxHeight: 2000 * 0.75, // =1500
|
||||
};
|
||||
} else if (compressionLevel.value === 3) {
|
||||
return {
|
||||
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(items, () => {
|
||||
if (items.value.length === 0) {
|
||||
emit('canceled');
|
||||
dialog.value?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.value.every(item => item.uploaded)) {
|
||||
emit('done', items.value.map(item => item.uploaded!));
|
||||
dialog.value?.close();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
async function cancel() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._uploader.abortConfirm,
|
||||
okText: i18n.ts.yes,
|
||||
cancelText: i18n.ts.no,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
abortAll();
|
||||
emit('canceled');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
async function abortWithConfirm() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._uploader.abortConfirm,
|
||||
okText: i18n.ts.yes,
|
||||
cancelText: i18n.ts.no,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
abortAll();
|
||||
}
|
||||
|
||||
async function done() {
|
||||
if (items.value.some(item => item.uploaded == null)) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._uploader.doneConfirm,
|
||||
okText: i18n.ts.yes,
|
||||
cancelText: i18n.ts.no,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
emit('done', items.value.filter(item => item.uploaded != null).map(item => item.uploaded!));
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !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 });
|
||||
items.value.splice(items.value.indexOf(item), 1, {
|
||||
...item,
|
||||
file: markRaw(cropped),
|
||||
thumbnail: window.URL.createObjectURL(cropped),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.waiting && !item.uploading && !item.uploaded) {
|
||||
menu.push({
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => {
|
||||
items.value.splice(items.value.indexOf(item), 1);
|
||||
},
|
||||
});
|
||||
} else if (item.uploading) {
|
||||
menu.push({
|
||||
icon: 'ti ti-cloud-pause',
|
||||
text: i18n.ts.abort,
|
||||
danger: true,
|
||||
action: () => {
|
||||
if (item.abort != null) {
|
||||
item.abort();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
|
||||
firstUploadAttempted.value = true;
|
||||
|
||||
items.value = items.value.map(item => ({
|
||||
...item,
|
||||
aborted: false,
|
||||
uploadFailed: false,
|
||||
waiting: false,
|
||||
uploading: false,
|
||||
}));
|
||||
|
||||
for (const item of items.value.filter(item => item.uploaded == null)) {
|
||||
// アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック
|
||||
if (item.aborted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
item.waiting = true;
|
||||
item.uploadFailed = false;
|
||||
|
||||
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file));
|
||||
|
||||
if (shouldCompress) {
|
||||
const config = {
|
||||
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
||||
maxWidth: compressionSettings.value.maxWidth,
|
||||
maxHeight: compressionSettings.value.maxHeight,
|
||||
quality: isWebpSupported() ? 0.85 : 0.8,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await readAndCompressImage(item.file, config);
|
||||
if (result.size < item.file.size || item.file.type === 'image/webp') {
|
||||
// The compression may not always reduce the file size
|
||||
// (and WebP is not browser safe yet)
|
||||
item.compressedImage = markRaw(result);
|
||||
item.compressedSize = result.size;
|
||||
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resize image', err);
|
||||
}
|
||||
}
|
||||
|
||||
item.uploading = true;
|
||||
|
||||
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
||||
name: item.name,
|
||||
folderId: props.folderId,
|
||||
onProgress: (progress) => {
|
||||
item.waiting = false;
|
||||
if (item.progress == null) {
|
||||
item.progress = { max: progress.total, value: progress.loaded };
|
||||
} else {
|
||||
item.progress.value = progress.loaded;
|
||||
item.progress.max = progress.total;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
item.abort = () => {
|
||||
item.abort = null;
|
||||
abort();
|
||||
item.uploading = false;
|
||||
item.waiting = false;
|
||||
item.uploadFailed = true;
|
||||
};
|
||||
|
||||
await filePromise.then((file) => {
|
||||
item.uploaded = file;
|
||||
item.abort = null;
|
||||
}).catch(err => {
|
||||
item.uploadFailed = true;
|
||||
item.progress = null;
|
||||
if (!(err instanceof UploadAbortedError)) {
|
||||
throw err;
|
||||
}
|
||||
}).finally(() => {
|
||||
item.uploading = false;
|
||||
item.waiting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function abortAll() {
|
||||
for (const item of items.value) {
|
||||
if (item.uploaded != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.abort != null) {
|
||||
item.abort();
|
||||
}
|
||||
item.aborted = true;
|
||||
item.uploadFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseFile(ev: MouseEvent) {
|
||||
const newFiles = await os.chooseFileFromPc({ multiple: true });
|
||||
|
||||
for (const file of newFiles) {
|
||||
initializeFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFile(file: File) {
|
||||
const id = uuid();
|
||||
const filename = file.name ?? 'untitled';
|
||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||
items.value.push({
|
||||
id,
|
||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||
progress: null,
|
||||
thumbnail: window.URL.createObjectURL(file),
|
||||
waiting: false,
|
||||
uploading: false,
|
||||
aborted: false,
|
||||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
file: markRaw(file),
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
for (const file of props.files) {
|
||||
initializeFile(file);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overallProgress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--op);
|
||||
height: 4px;
|
||||
background: var(--MI_THEME-accent);
|
||||
border-radius: 0 999px 999px 0;
|
||||
transition: width 0.2s ease;
|
||||
|
||||
&.overallProgressError {
|
||||
background: var(--MI_THEME-warn);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
overflow: clip;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--p);
|
||||
height: 100%;
|
||||
background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
||||
transition: width 0.2s ease, left 0.2s ease;
|
||||
}
|
||||
|
||||
&.itemWaiting {
|
||||
&::after {
|
||||
--c: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
|
||||
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
|
||||
background-size: 25px 25px;
|
||||
animation: stripe .8s infinite linear;
|
||||
}
|
||||
}
|
||||
|
||||
&.itemCompleted {
|
||||
&::before {
|
||||
left: 100%;
|
||||
width: var(--p);
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
|
||||
&.itemFailed {
|
||||
.itemBody {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stripe {
|
||||
0% { background-position-x: 0; }
|
||||
100% { background-position-x: -25px; }
|
||||
}
|
||||
|
||||
.itemInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.itemThumbnail {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
font-size: 90%;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
.itemInner {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemActionWrapper {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.itemIconWrapper {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
<div v-else>
|
||||
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="maybeRelativeUrl" rel="nofollow noopener" :target="target" :title="url">
|
||||
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreview ? '' : { backgroundImage: `url('${thumbnail}')` }">
|
||||
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="prefer.s.dataSaver.urlPreviewThumbnail ? '' : { backgroundImage: `url('${thumbnail}')` }">
|
||||
</div>
|
||||
<article :class="$style.body">
|
||||
<header :class="$style.header">
|
||||
|
|
@ -91,7 +91,7 @@ import { i18n } from '@/i18n.js';
|
|||
import * as os from '@/os.js';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { transformPlayerUrl } from '@/utility/player-url-transform.js';
|
||||
import { transformPlayerUrl } from '@/utility/url-preview.js';
|
||||
import { store } from '@/store.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { maybeMakeRelative } from '@@/js/url.js';
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import MkInput from '@/components/MkInput.vue';
|
|||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { chooseFileFromPc } from '@/utility/select-file.js';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
|
|
@ -49,7 +48,7 @@ const description = ref($i.description ?? '');
|
|||
watch(name, () => {
|
||||
os.apiWithDialog('i/update', {
|
||||
// 空文字列をnullにしたいので??は使うな
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
|
||||
name: name.value || null,
|
||||
}, undefined, {
|
||||
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
|
||||
|
|
@ -62,36 +61,37 @@ watch(name, () => {
|
|||
watch(description, () => {
|
||||
os.apiWithDialog('i/update', {
|
||||
// 空文字列をnullにしたいので??は使うな
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
|
||||
description: description.value || null,
|
||||
});
|
||||
});
|
||||
|
||||
function setAvatar(ev) {
|
||||
chooseFileFromPc(false).then(async (files) => {
|
||||
const file = files[0];
|
||||
async function setAvatar(ev) {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
const file = files[0];
|
||||
|
||||
let originalOrCropped = file;
|
||||
let originalOrCropped = file;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.cropImageAsk,
|
||||
okText: i18n.ts.cropYes,
|
||||
cancelText: i18n.ts.cropNo,
|
||||
});
|
||||
|
||||
if (!canceled) {
|
||||
originalOrCropped = await os.cropImage(file, {
|
||||
aspectRatio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const i = await os.apiWithDialog('i/update', {
|
||||
avatarId: originalOrCropped.id,
|
||||
});
|
||||
$i.avatarId = i.avatarId;
|
||||
$i.avatarUrl = i.avatarUrl;
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.cropImageAsk,
|
||||
okText: i18n.ts.cropYes,
|
||||
cancelText: i18n.ts.cropNo,
|
||||
});
|
||||
|
||||
if (!canceled) {
|
||||
originalOrCropped = await os.cropImageFile(file, {
|
||||
aspectRatio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0];
|
||||
|
||||
const i = await os.apiWithDialog('i/update', {
|
||||
avatarId: driveFile.id,
|
||||
});
|
||||
$i.avatarId = i.avatarId;
|
||||
$i.avatarUrl = i.avatarUrl;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
|
||||
<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :anchorElement="anchorElement" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
|
||||
<div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }">
|
||||
<div :class="[$style.label, $style.item]">
|
||||
{{ i18n.ts.visibility }}
|
||||
|
|
@ -53,7 +53,7 @@ const props = withDefaults(defineProps<{
|
|||
currentVisibility: typeof Misskey.noteVisibilities[number];
|
||||
isSilenced: boolean;
|
||||
localOnly: boolean;
|
||||
src?: HTMLElement;
|
||||
anchorElement?: HTMLElement;
|
||||
isReplyVisibilitySpecified?: boolean;
|
||||
}>(), {
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref } from 'vue';
|
||||
import { versatileLang } from '@@/js/intl-const.js';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { transformPlayerUrl } from '@/utility/player-url-transform.js';
|
||||
import { transformPlayerUrl } from '@/utility/url-preview.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
|
||||
<MkImgWithBlurhash v-if="prefer.s.enableHighQualityImagePlaceholders" :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
|
||||
<img v-else :class="$style.inner" :src="url" alt="" decoding="async" style="pointer-events: none;"/>
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<div v-if="user.isCat" :class="[$style.ears]">
|
||||
<div :class="$style.earLeft">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<img
|
||||
v-if="errored && fallbackToImage"
|
||||
v-if="shouldMute"
|
||||
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
|
||||
src="/client-assets/unknown.png"
|
||||
:title="alt"
|
||||
draggable="false"
|
||||
style="-webkit-user-drag: none;"
|
||||
@click="onClick"
|
||||
/>
|
||||
<img
|
||||
v-else-if="errored && fallbackToImage"
|
||||
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
|
||||
src="/client-assets/dummy.png"
|
||||
:title="alt"
|
||||
|
|
@ -40,6 +49,7 @@ import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialo
|
|||
import { $i } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { makeEmojiMuteKey, mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkEmojiMuted } from '@/utility/emoji-mute';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
|
|
@ -51,12 +61,16 @@ const props = defineProps<{
|
|||
menu?: boolean;
|
||||
menuReaction?: boolean;
|
||||
fallbackToImage?: boolean;
|
||||
ignoreMuted?: boolean;
|
||||
}>();
|
||||
|
||||
const react = inject(DI.mfmEmojiReactCallback);
|
||||
|
||||
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
|
||||
const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
|
||||
const emojiCodeToMute = makeEmojiMuteKey(props);
|
||||
const isMuted = checkEmojiMuted(emojiCodeToMute);
|
||||
const shouldMute = computed(() => !props.ignoreMuted && isMuted.value);
|
||||
|
||||
const rawUrl = computed(() => {
|
||||
if (props.url) {
|
||||
|
|
@ -95,14 +109,18 @@ function onClick(ev: MouseEvent) {
|
|||
menuItems.push({
|
||||
type: 'label',
|
||||
text: `:${props.name}:`,
|
||||
}, {
|
||||
text: i18n.ts.copy,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => {
|
||||
copyToClipboard(`:${props.name}:`);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLocal.value) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.copy,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => {
|
||||
copyToClipboard(`:${props.name}:`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (props.menuReaction && react) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.doReaction,
|
||||
|
|
@ -113,21 +131,43 @@ function onClick(ev: MouseEvent) {
|
|||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
|
||||
emoji: await misskeyApiGet('emoji', {
|
||||
name: customEmojiName.value,
|
||||
}),
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
});
|
||||
if (isLocal.value) {
|
||||
menuItems.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
|
||||
emoji: await misskeyApiGet('emoji', {
|
||||
name: customEmojiName.value,
|
||||
}),
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ($i?.isModerator ?? $i?.isAdmin) {
|
||||
if (isMuted.value) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.emojiUnmute,
|
||||
icon: 'ti ti-mood-smile',
|
||||
action: async () => {
|
||||
await unmute();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
menuItems.push({
|
||||
text: i18n.ts.emojiMute,
|
||||
icon: 'ti ti-mood-off',
|
||||
action: async () => {
|
||||
await mute();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (($i?.isModerator ?? $i?.isAdmin) && isLocal.value) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.edit,
|
||||
icon: 'ti ti-pencil',
|
||||
|
|
@ -152,6 +192,36 @@ async function edit(name: string) {
|
|||
});
|
||||
}
|
||||
|
||||
function mute() {
|
||||
const titleEmojiName = isLocal.value
|
||||
? `:${customEmojiName.value}:`
|
||||
: emojiCodeToMute;
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.tsx.muteX({ x: titleEmojiName }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
muteEmoji(emojiCodeToMute);
|
||||
});
|
||||
}
|
||||
|
||||
function unmute() {
|
||||
const titleEmojiName = isLocal.value
|
||||
? `:${customEmojiName.value}:`
|
||||
: emojiCodeToMute;
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.tsx.unmuteX({ x: titleEmojiName }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
unmuteEmoji(emojiCodeToMute);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
|
||||
<img v-if="shouldMute" :class="$style.root" src="/client-assets/unknown.png" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
|
||||
<img v-else-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
|
||||
<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span>
|
||||
</template>
|
||||
|
||||
|
|
@ -18,11 +19,13 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkMutedEmoji } from '@/utility/emoji-mute.js';
|
||||
|
||||
const props = defineProps<{
|
||||
emoji: string;
|
||||
menu?: boolean;
|
||||
menuReaction?: boolean;
|
||||
ignoreMuted?: boolean;
|
||||
}>();
|
||||
|
||||
const react = inject(DI.mfmEmojiReactCallback, null);
|
||||
|
|
@ -32,12 +35,38 @@ const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : cha
|
|||
const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native');
|
||||
const url = computed(() => char2path(props.emoji));
|
||||
const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
|
||||
const isMuted = checkMutedEmoji(props.emoji);
|
||||
const shouldMute = computed(() => isMuted.value && !props.ignoreMuted);
|
||||
|
||||
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
|
||||
function computeTitle(event: PointerEvent): void {
|
||||
(event.target as HTMLElement).title = getEmojiName(props.emoji);
|
||||
}
|
||||
|
||||
function mute() {
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.tsx.muteX({ x: props.emoji }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
muteEmoji(props.emoji);
|
||||
});
|
||||
}
|
||||
|
||||
function unmute() {
|
||||
os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.tsx.unmuteX({ x: props.emoji }),
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
unmuteEmoji(props.emoji);
|
||||
});
|
||||
}
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
if (props.menu) {
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
|
@ -63,6 +92,22 @@ function onClick(ev: MouseEvent) {
|
|||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
type: 'divider',
|
||||
}, isMuted.value ? {
|
||||
text: i18n.ts.emojiUnmute,
|
||||
icon: 'ti ti-mood-smile',
|
||||
action: () => {
|
||||
unmute();
|
||||
},
|
||||
} : {
|
||||
text: i18n.ts.emojiMute,
|
||||
icon: 'ti ti-mood-off',
|
||||
action: () => {
|
||||
mute();
|
||||
},
|
||||
});
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -435,6 +435,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
|
|||
normal: props.plain,
|
||||
host: props.author.host,
|
||||
useOriginalSize: scale >= 2.5,
|
||||
menu: props.enableEmojiMenu,
|
||||
menuReaction: false,
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</svg>
|
||||
<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
|
||||
<path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.animLine]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleSuccess]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
|
||||
<path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.animLine]"/>
|
||||
|
|
@ -28,13 +28,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'waiting'" :class="[$style.icon, $style.waiting]" viewBox="0 0 160 160">
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleWaiting]"/>
|
||||
<circle cx="80" cy="80" r="56" style="opacity: 0.25;" :class="[$style.line]"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'info' | 'question' | 'success' | 'warn' | 'error';
|
||||
type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
|
@ -62,6 +66,10 @@ const props = defineProps<{
|
|||
&.error {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
|
||||
&.waiting {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
|
|
@ -87,11 +95,10 @@ const props = defineProps<{
|
|||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.animCircleSuccess {
|
||||
.animCircleWaiting {
|
||||
stroke-dasharray: var(--l);
|
||||
stroke-dashoffset: var(--l);
|
||||
animation: circleSuccess var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
stroke-dashoffset: calc(var(--l) / 1.5);
|
||||
animation: waiting 0.75s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
|
|
@ -112,16 +119,12 @@ const props = defineProps<{
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes circleSuccess {
|
||||
@keyframes waiting {
|
||||
0% {
|
||||
stroke-dashoffset: var(--l);
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue