Merge branch 'develop' into deps-update-openapits

This commit is contained in:
kakkokari-gtyih 2025-05-22 19:44:22 +09:00
commit 02b5dce8d7
184 changed files with 7003 additions and 5788 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'
]

View File

@ -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

View File

@ -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'
#]

154
locales/index.d.ts vendored
View File

@ -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;

View File

@ -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: "一部の拡張機能はクライアントの動作に干渉しパフォーマンスに影響を及ぼすことがあります。ブラウザの拡張機能を無効にして改善するか確認してください。"

View File

@ -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"

View File

@ -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"`);
}
}

View File

@ -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;
}

View File

@ -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"
}
}

View File

@ -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 ?

View File

@ -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+-]+)(?:@\.)?:$/;

View File

@ -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) {

View File

@ -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,

View File

@ -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');
}

View File

@ -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('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外

View File

@ -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,

View File

@ -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 = {

View File

@ -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;

View File

@ -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'));
});

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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: {

View File

@ -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';

View File

@ -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,
};
});
}

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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) => {

View File

@ -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);

View File

@ -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);
});
}
}

View File

@ -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',
};
}

View File

@ -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')

View File

@ -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}`)

View File

@ -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

View File

@ -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[][];

View File

@ -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: {

View File

@ -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",

View File

@ -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<{

View File

@ -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[][];

View File

@ -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: {

View File

@ -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"
}
}

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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

View File

@ -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: {

View File

@ -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",

View File

@ -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('..')) {

View File

@ -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;
}

View File

@ -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(() => {

View File

@ -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);

View File

@ -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;
}

View File

@ -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

View File

@ -3,5 +3,5 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue';
void MkDriveSelectDialog;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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<{

View File

@ -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;

View File

@ -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>

View File

@ -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-sizeTODO:
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;
}

View File

@ -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>

View File

@ -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<{

View File

@ -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<{

View File

@ -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';

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 });

View File

@ -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>

View File

@ -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,
});
});
}

View File

@ -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,
});
});
}

View File

@ -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);

View File

@ -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;

View File

@ -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,
});

View File

@ -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;
}>();

View File

@ -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
}

View File

@ -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: () => {

View File

@ -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 () => {

View File

@ -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() {

View File

@ -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;

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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;
}>(), {
});

View File

@ -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<{

View File

@ -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">

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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,
})];
}
}

View File

@ -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