mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-31 20:35:52 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37fe7a9634 | |||
| 729abbef62 | |||
| e74ab35de3 | |||
| 4ba18690d7 | |||
| 26c8914a26 | |||
| 119423e3ae | |||
| 3de1ce63cd | |||
| 18fbc9bb05 | |||
| 37a21cf54e | |||
| 290fd8c7cc | |||
| 0edb0133fc | |||
| 0abe021640 | |||
| 344f50d538 | |||
| 1cebad0ddb | |||
| 3e34e87a59 | |||
| b9713259a7 | |||
| e15b8b7fa3 | |||
| ca89c86426 | |||
| 6bce19655b | |||
| 78ed024b0b | |||
| 6f76b598a1 | |||
| a888f2863b | |||
| a892bbcce5 | |||
| 6571c87e14 | |||
| 9e0f18a705 | |||
| 3dfca2d61f | |||
| f1578c282e | |||
| 231a6877be | |||
| d0a5ccc1ec | |||
| 4e16e23acd | |||
| 17da44078b | |||
| 1f29fb4e40 | |||
| 0343b4e689 | |||
| a76a1a6305 |
@@ -5,7 +5,7 @@
|
||||
"workspaceFolder": "/workspace",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22.15.0"
|
||||
"version": "24.10.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {
|
||||
"version": "10.10.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Reply
|
||||
uses: actions/github-script@v6
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22.15.0
|
||||
24.10.0
|
||||
|
||||
+21
-6
@@ -1,16 +1,31 @@
|
||||
## 2025.10.2
|
||||
## 2025.11.0
|
||||
|
||||
### General
|
||||
-
|
||||
- Feat: チャンネルミュート機能の実装 #10649
|
||||
- チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列)
|
||||
- Enhance: Node.js 24.10.0をサポートするようになりました
|
||||
- Enhance: DockerのNode.jsが24.10.0に更新されました
|
||||
|
||||
### Client
|
||||
- Feat: 画像にメタデータを含むフレームをつけられる機能
|
||||
- Enhance: プリセットを作成しなくても画像にウォーターマークを付与できるように
|
||||
- Enhance: 管理しているチャンネルの見分けがつきやすくなるように
|
||||
- Enhance: プロフィールへのリンクをユーザーポップアップのアバターに追加
|
||||
- Enhance: ユーザーのノート、フォロー、フォロワーページへのリンクをユーザーポップアップに追加
|
||||
- Enhance: プッシュ通知を行うための権限確認をより確実に行うように
|
||||
- Fix: 紙吹雪エフェクトがアニメーション設定を考慮せず常に表示される問題を修正
|
||||
- Fix: ナビゲーションバーのリアルタイムモード切替ボタンの状態をよりわかりやすく表示するように
|
||||
- Fix: ページのタイトルが長いとき、はみ出る問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: Remote Notes Cleaningが複雑度が高いノートの処理を中断せずに次のノートから再開するように
|
||||
|
||||
## 2025.10.2
|
||||
|
||||
### Client
|
||||
- Fix: アプリ内からキャッシュをクリアするとテーマ再適用するまでレンダリングが正しく行われない問題を修正
|
||||
- Fix: 期限が無期限のアンケートに投票できない問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
|
||||
## 2025.10.1
|
||||
|
||||
### General
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.4
|
||||
|
||||
ARG NODE_VERSION=22.15.0-bookworm
|
||||
ARG NODE_VERSION=24.10.0-bookworm
|
||||
|
||||
# build assets & compile TypeScript
|
||||
|
||||
|
||||
Vendored
+212
-2
@@ -747,7 +747,7 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"flagShowTimelineRepliesDescription": string;
|
||||
/**
|
||||
* フォロー中ユーザーからのフォロリクを自動承認
|
||||
* フォロー中ユーザーからのフォロー申請を自動承認
|
||||
*/
|
||||
"autoAcceptFollowed": string;
|
||||
/**
|
||||
@@ -1226,6 +1226,10 @@ export interface Locale extends ILocale {
|
||||
* 既読
|
||||
*/
|
||||
"messageRead": string;
|
||||
/**
|
||||
* すべてのメッセージを既読にする
|
||||
*/
|
||||
"readAllChatMessages": string;
|
||||
/**
|
||||
* これより過去の履歴はありません
|
||||
*/
|
||||
@@ -4106,6 +4110,18 @@ export interface Locale extends ILocale {
|
||||
* 端末の電池消費量が増加する可能性があります。
|
||||
*/
|
||||
"sendPushNotificationReadMessageCaption": string;
|
||||
/**
|
||||
* ブラウザの通知設定を許可してください
|
||||
*/
|
||||
"pleaseAllowPushNotification": string;
|
||||
/**
|
||||
* 通知の送信権限の取得に失敗しました
|
||||
*/
|
||||
"browserPushNotificationDisabled": string;
|
||||
/**
|
||||
* {serverName}から通知を送信する権限がありません。ブラウザの設定から通知を許可して再度お試しください。
|
||||
*/
|
||||
"browserPushNotificationDisabledDescription": ParameterizedString<"serverName">;
|
||||
/**
|
||||
* 最大化
|
||||
*/
|
||||
@@ -5605,6 +5621,176 @@ export interface Locale extends ILocale {
|
||||
* 技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。
|
||||
*/
|
||||
"deviceInfoDescription": string;
|
||||
/**
|
||||
* あなたは管理者です
|
||||
*/
|
||||
"youAreAdmin": string;
|
||||
/**
|
||||
* フレーム
|
||||
*/
|
||||
"frame": string;
|
||||
/**
|
||||
* プリセット
|
||||
*/
|
||||
"presets": string;
|
||||
/**
|
||||
* ゼロ埋め
|
||||
*/
|
||||
"zeroPadding": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
* ファイルのキャプション
|
||||
*/
|
||||
"caption": string;
|
||||
/**
|
||||
* ファイル名
|
||||
*/
|
||||
"filename": string;
|
||||
/**
|
||||
* 拡張子無しファイル名
|
||||
*/
|
||||
"filename_without_ext": string;
|
||||
/**
|
||||
* 撮影年
|
||||
*/
|
||||
"year": string;
|
||||
/**
|
||||
* 撮影月
|
||||
*/
|
||||
"month": string;
|
||||
/**
|
||||
* 撮影日
|
||||
*/
|
||||
"day": string;
|
||||
/**
|
||||
* 撮影した時刻(時)
|
||||
*/
|
||||
"hour": string;
|
||||
/**
|
||||
* 撮影した時刻(分)
|
||||
*/
|
||||
"minute": string;
|
||||
/**
|
||||
* 撮影した時刻(秒)
|
||||
*/
|
||||
"second": string;
|
||||
/**
|
||||
* カメラ名
|
||||
*/
|
||||
"camera_model": string;
|
||||
/**
|
||||
* レンズ名
|
||||
*/
|
||||
"camera_lens_model": string;
|
||||
/**
|
||||
* 焦点距離
|
||||
*/
|
||||
"camera_mm": string;
|
||||
/**
|
||||
* 焦点距離(35mm判換算)
|
||||
*/
|
||||
"camera_mm_35": string;
|
||||
/**
|
||||
* 絞り
|
||||
*/
|
||||
"camera_f": string;
|
||||
/**
|
||||
* シャッタースピード
|
||||
*/
|
||||
"camera_s": string;
|
||||
/**
|
||||
* ISO感度
|
||||
*/
|
||||
"camera_iso": string;
|
||||
/**
|
||||
* 緯度
|
||||
*/
|
||||
"gps_lat": string;
|
||||
/**
|
||||
* 経度
|
||||
*/
|
||||
"gps_long": string;
|
||||
};
|
||||
};
|
||||
"_imageFrameEditor": {
|
||||
/**
|
||||
* フレームの編集
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* 画像にフレームやメタデータを含んだラベルを追加して装飾できます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
* ヘッダー
|
||||
*/
|
||||
"header": string;
|
||||
/**
|
||||
* フッター
|
||||
*/
|
||||
"footer": string;
|
||||
/**
|
||||
* フチの幅
|
||||
*/
|
||||
"borderThickness": string;
|
||||
/**
|
||||
* ラベルの幅
|
||||
*/
|
||||
"labelThickness": string;
|
||||
/**
|
||||
* ラベルのスケール
|
||||
*/
|
||||
"labelScale": string;
|
||||
/**
|
||||
* 中央揃え
|
||||
*/
|
||||
"centered": string;
|
||||
/**
|
||||
* キャプション(大)
|
||||
*/
|
||||
"captionMain": string;
|
||||
/**
|
||||
* キャプション(小)
|
||||
*/
|
||||
"captionSub": string;
|
||||
/**
|
||||
* 利用可能な変数
|
||||
*/
|
||||
"availableVariables": string;
|
||||
/**
|
||||
* 二次元コード
|
||||
*/
|
||||
"withQrCode": string;
|
||||
/**
|
||||
* 背景色
|
||||
*/
|
||||
"backgroundColor": string;
|
||||
/**
|
||||
* 文字色
|
||||
*/
|
||||
"textColor": string;
|
||||
/**
|
||||
* フォント
|
||||
*/
|
||||
"font": string;
|
||||
/**
|
||||
* セリフ
|
||||
*/
|
||||
"fontSerif": string;
|
||||
/**
|
||||
* サンセリフ
|
||||
*/
|
||||
"fontSansSerif": string;
|
||||
/**
|
||||
* 保存せずに終了しますか?
|
||||
*/
|
||||
"quitWithoutSaveConfirm": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
};
|
||||
"_compression": {
|
||||
"_quality": {
|
||||
/**
|
||||
@@ -5997,6 +6183,10 @@ export interface Locale extends ILocale {
|
||||
* ページのタブバーを下部に表示
|
||||
*/
|
||||
"showPageTabBarBottom": string;
|
||||
/**
|
||||
* 絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。
|
||||
*/
|
||||
"emojiPaletteBanner": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* 送信者の名前を表示
|
||||
@@ -6059,6 +6249,10 @@ export interface Locale extends ILocale {
|
||||
* 設定のバックアップが見つかりました
|
||||
*/
|
||||
"backupFound": string;
|
||||
/**
|
||||
* 設定の強制バックアップ
|
||||
*/
|
||||
"forceBackup": string;
|
||||
};
|
||||
"_accountSettings": {
|
||||
/**
|
||||
@@ -10890,6 +11084,14 @@ export interface Locale extends ILocale {
|
||||
};
|
||||
};
|
||||
"_moderationLogTypes": {
|
||||
/**
|
||||
* ジョブキューをクリア
|
||||
*/
|
||||
"clearQueue": string;
|
||||
/**
|
||||
* キューのジョブを再試行
|
||||
*/
|
||||
"promoteQueue": string;
|
||||
/**
|
||||
* ロールを作成
|
||||
*/
|
||||
@@ -12318,7 +12520,7 @@ export interface Locale extends ILocale {
|
||||
"defaultPreset": string;
|
||||
"_watermarkEditor": {
|
||||
/**
|
||||
* 画像にクレジット情報などのウォーターマークを追加することができます。
|
||||
* 画像にクレジット情報などのウォーターマークを追加できます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
@@ -12433,6 +12635,10 @@ export interface Locale extends ILocale {
|
||||
* 空欄にするとアカウントのURLになります
|
||||
*/
|
||||
"leaveBlankToAccountUrl": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
};
|
||||
"_imageEffector": {
|
||||
/**
|
||||
@@ -12451,6 +12657,10 @@ export interface Locale extends ILocale {
|
||||
* 設定項目はありません
|
||||
*/
|
||||
"nothingToConfigure": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
"_fxs": {
|
||||
/**
|
||||
* 色収差
|
||||
|
||||
+58
-2
@@ -182,7 +182,7 @@ flagAsCat: "にゃああああああああああああああ!!!!!!
|
||||
flagAsCatDescription: "にゃにゃにゃ??"
|
||||
flagShowTimelineReplies: "タイムラインにノートへの返信を表示する"
|
||||
flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。"
|
||||
autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
|
||||
autoAcceptFollowed: "フォロー中ユーザーからのフォロー申請を自動承認"
|
||||
addAccount: "アカウントを追加"
|
||||
reloadAccountsList: "アカウントリストの情報を更新"
|
||||
loginFailed: "ログインに失敗しました"
|
||||
@@ -302,6 +302,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか
|
||||
uploadNFiles: "{n}個のファイルをアップロード"
|
||||
explore: "みつける"
|
||||
messageRead: "既読"
|
||||
readAllChatMessages: "すべてのメッセージを既読にする"
|
||||
noMoreHistory: "これより過去の履歴はありません"
|
||||
startChat: "メッセージを送る"
|
||||
nUsersRead: "{n}人が読みました"
|
||||
@@ -1022,6 +1023,9 @@ pushNotificationAlreadySubscribed: "プッシュ通知は有効です"
|
||||
pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応"
|
||||
sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する"
|
||||
sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。"
|
||||
pleaseAllowPushNotification: "ブラウザの通知設定を許可してください"
|
||||
browserPushNotificationDisabled: "通知の送信権限の取得に失敗しました"
|
||||
browserPushNotificationDisabledDescription: "{serverName}から通知を送信する権限がありません。ブラウザの設定から通知を許可して再度お試しください。"
|
||||
windowMaximize: "最大化"
|
||||
windowMinimize: "最小化"
|
||||
windowRestore: "元に戻す"
|
||||
@@ -1396,6 +1400,52 @@ scheduled: "予約"
|
||||
widgets: "ウィジェット"
|
||||
deviceInfo: "デバイス情報"
|
||||
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
||||
youAreAdmin: "あなたは管理者です"
|
||||
frame: "フレーム"
|
||||
presets: "プリセット"
|
||||
zeroPadding: "ゼロ埋め"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "ファイルのキャプション"
|
||||
filename: "ファイル名"
|
||||
filename_without_ext: "拡張子無しファイル名"
|
||||
year: "撮影年"
|
||||
month: "撮影月"
|
||||
day: "撮影日"
|
||||
hour: "撮影した時刻(時)"
|
||||
minute: "撮影した時刻(分)"
|
||||
second: "撮影した時刻(秒)"
|
||||
camera_model: "カメラ名"
|
||||
camera_lens_model: "レンズ名"
|
||||
camera_mm: "焦点距離"
|
||||
camera_mm_35: "焦点距離(35mm判換算)"
|
||||
camera_f: "絞り"
|
||||
camera_s: "シャッタースピード"
|
||||
camera_iso: "ISO感度"
|
||||
gps_lat: "緯度"
|
||||
gps_long: "経度"
|
||||
|
||||
_imageFrameEditor:
|
||||
title: "フレームの編集"
|
||||
tip: "画像にフレームやメタデータを含んだラベルを追加して装飾できます。"
|
||||
header: "ヘッダー"
|
||||
footer: "フッター"
|
||||
borderThickness: "フチの幅"
|
||||
labelThickness: "ラベルの幅"
|
||||
labelScale: "ラベルのスケール"
|
||||
centered: "中央揃え"
|
||||
captionMain: "キャプション(大)"
|
||||
captionSub: "キャプション(小)"
|
||||
availableVariables: "利用可能な変数"
|
||||
withQrCode: "二次元コード"
|
||||
backgroundColor: "背景色"
|
||||
textColor: "文字色"
|
||||
font: "フォント"
|
||||
fontSerif: "セリフ"
|
||||
fontSansSerif: "サンセリフ"
|
||||
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_compression:
|
||||
_quality:
|
||||
@@ -1503,6 +1553,7 @@ _settings:
|
||||
showUrlPreview: "URLプレビューを表示する"
|
||||
showAvailableReactionsFirstInNote: "利用できるリアクションを先頭に表示"
|
||||
showPageTabBarBottom: "ページのタブバーを下部に表示"
|
||||
emojiPaletteBanner: "絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。"
|
||||
|
||||
_chat:
|
||||
showSenderName: "送信者の名前を表示"
|
||||
@@ -1523,6 +1574,7 @@ _preferencesBackup:
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
|
||||
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
|
||||
backupFound: "設定のバックアップが見つかりました"
|
||||
forceBackup: "設定の強制バックアップ"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
@@ -2886,6 +2938,8 @@ _abuseReport:
|
||||
deleteConfirm: "通知先を削除しますか?"
|
||||
|
||||
_moderationLogTypes:
|
||||
clearQueue: "ジョブキューをクリア"
|
||||
promoteQueue: "キューのジョブを再試行"
|
||||
createRole: "ロールを作成"
|
||||
deleteRole: "ロールを削除"
|
||||
updateRole: "ロールを更新"
|
||||
@@ -3298,7 +3352,7 @@ _userLists:
|
||||
watermark: "ウォーターマーク"
|
||||
defaultPreset: "デフォルトのプリセット"
|
||||
_watermarkEditor:
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加できます。"
|
||||
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||
driveFileTypeWarn: "このファイルは対応していません"
|
||||
driveFileTypeWarnDescription: "画像ファイルを選択してください"
|
||||
@@ -3327,12 +3381,14 @@ _watermarkEditor:
|
||||
polkadotSubDotRadius: "サブドットの大きさ"
|
||||
polkadotSubDotDivisions: "サブドットの数"
|
||||
leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_imageEffector:
|
||||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_fxs:
|
||||
chromaticAberration: "色収差"
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.10.2-beta.0",
|
||||
"version": "2025.11.0-alpha.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -68,7 +68,7 @@
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "22.18.10",
|
||||
"@types/node": "24.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -85,6 +85,9 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@aiscript-dev/aiscript-languageserver": "-"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@sentry-internal/node-cpu-profiler"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const base = require('./jest.config.cjs')
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
globalSetup: "<rootDir>/test/jest.setup.unit.cjs",
|
||||
testMatch: [
|
||||
"<rootDir>/test/unit/**/*.ts",
|
||||
"<rootDir>/src/**/*.test.ts",
|
||||
|
||||
@@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const args = [];
|
||||
args.push(...[
|
||||
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [],
|
||||
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0 || ^24.10.0') ? ['--no-experimental-require-module'] : [],
|
||||
'--experimental-vm-modules',
|
||||
'--experimental-import-meta-resolve',
|
||||
path.join(__dirname, 'node_modules/jest/bin/jest.js'),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddChannelMuting1761569941833 {
|
||||
name = 'AddChannelMuting1761569941833'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "channel_muting" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "channelId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aec842e98f332ebd8e12f85bad6" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_34415e3062ae7a94617496e81c" ON "channel_muting" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_4d534d7177fc59879d942e96d0" ON "channel_muting" ("channelId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_6dd314e96806b7df65ddadff72" ON "channel_muting" ("expiresAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_b96870ed326ccc7fa243970965" ON "channel_muting" ("userId", "channelId") `);
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD "renoteChannelId" character varying(32)`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_34415e3062ae7a94617496e81c5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_4d534d7177fc59879d942e96d03" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_4d534d7177fc59879d942e96d03"`);
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_34415e3062ae7a94617496e81c5"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "renoteChannelId"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_b96870ed326ccc7fa243970965"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_6dd314e96806b7df65ddadff72"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_4d534d7177fc59879d942e96d0"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_34415e3062ae7a94617496e81c"`);
|
||||
await queryRunner.query(`DROP TABLE "channel_muting"`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.15.0"
|
||||
"node": "^22.15.0 || ^24.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./built/boot/entry.js",
|
||||
@@ -87,8 +87,8 @@
|
||||
"@nestjs/core": "11.1.6",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.55.0",
|
||||
"@sentry/profiling-node": "8.55.0",
|
||||
"@sentry/node": "10.20.0",
|
||||
"@sentry/profiling-node": "10.20.0",
|
||||
"@simplewebauthn/server": "12.0.0",
|
||||
"@sinonjs/fake-timers": "11.3.1",
|
||||
"@smithy/node-http-handler": "2.5.0",
|
||||
@@ -117,7 +117,7 @@
|
||||
"fastify": "5.6.1",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "19.6.0",
|
||||
"file-type": "21.0.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.4",
|
||||
"got": "14.5.0",
|
||||
@@ -191,7 +191,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.20",
|
||||
"@sentry/vue": "9.46.0",
|
||||
"@sentry/vue": "10.20.0",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.39",
|
||||
"@types/accepts": "1.3.7",
|
||||
@@ -210,7 +210,7 @@
|
||||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "22.18.10",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/nodemailer": "6.4.20",
|
||||
"@types/oauth": "0.9.6",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ChannelFollowingsRepository } from '@/models/_.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js';
|
||||
import { MiChannel } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
@@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
private idService: IdService,
|
||||
@@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||
onModuleInit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* フォローしているチャンネルの一覧を取得する.
|
||||
* @param params
|
||||
* @param [opts]
|
||||
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
|
||||
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
*/
|
||||
@bindThis
|
||||
public async list(
|
||||
params: {
|
||||
requestUserId: MiUser['id'],
|
||||
},
|
||||
opts?: {
|
||||
idOnly?: boolean;
|
||||
joinUser?: boolean;
|
||||
joinBannerFile?: boolean;
|
||||
},
|
||||
): Promise<MiChannel[]> {
|
||||
if (opts?.idOnly) {
|
||||
const q = this.channelFollowingsRepository.createQueryBuilder('channel_following')
|
||||
.select('channel_following.followeeId')
|
||||
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
|
||||
|
||||
return q
|
||||
.getRawMany<{ channel_following_followeeId: string }>()
|
||||
.then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel)));
|
||||
} else {
|
||||
const q = this.channelsRepository.createQueryBuilder('channel')
|
||||
.innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id')
|
||||
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
|
||||
|
||||
if (opts?.joinUser) {
|
||||
q.innerJoinAndSelect('channel.user', 'user');
|
||||
}
|
||||
|
||||
if (opts?.joinBannerFile) {
|
||||
q.leftJoinAndSelect('channel.banner', 'drive_file');
|
||||
}
|
||||
|
||||
return q.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async follow(
|
||||
requestUser: MiLocalUser,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelMutingService {
|
||||
public mutingChannelsCache: RedisKVCache<Set<string>>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
@Inject(DI.channelMutingRepository)
|
||||
private channelMutingRepository: ChannelMutingRepository,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.mutingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'channelMutingChannels', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (userId) => this.channelMutingRepository.find({
|
||||
where: { userId: userId },
|
||||
select: ['channelId'],
|
||||
}).then(xs => new Set(xs.map(x => x.channelId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* ミュートしているチャンネルの一覧を取得する.
|
||||
* @param params
|
||||
* @param [opts]
|
||||
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
|
||||
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
*/
|
||||
@bindThis
|
||||
public async list(
|
||||
params: {
|
||||
requestUserId: MiUser['id'],
|
||||
},
|
||||
opts?: {
|
||||
idOnly?: boolean;
|
||||
joinUser?: boolean;
|
||||
joinBannerFile?: boolean;
|
||||
},
|
||||
): Promise<MiChannel[]> {
|
||||
if (opts?.idOnly) {
|
||||
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
|
||||
.select('channel_muting.channelId')
|
||||
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.where('channel_muting.expiresAt IS NULL')
|
||||
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
|
||||
}));
|
||||
|
||||
return q
|
||||
.getRawMany<{ channel_muting_channelId: string }>()
|
||||
.then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel)));
|
||||
} else {
|
||||
const q = this.channelsRepository.createQueryBuilder('channel')
|
||||
.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id')
|
||||
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.where('channel_muting.expiresAt IS NULL')
|
||||
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
|
||||
}));
|
||||
|
||||
if (opts?.joinUser) {
|
||||
q.innerJoinAndSelect('channel.user', 'user');
|
||||
}
|
||||
|
||||
if (opts?.joinBannerFile) {
|
||||
q.leftJoinAndSelect('channel.banner', 'drive_file');
|
||||
}
|
||||
|
||||
return q.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 期限切れのチャンネルミュート情報を取得する.
|
||||
*
|
||||
* @param [opts]
|
||||
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
* @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
*/
|
||||
public async findExpiredMutings(opts?: {
|
||||
joinUser?: boolean;
|
||||
joinChannel?: boolean;
|
||||
}): Promise<MiChannelMuting[]> {
|
||||
const now = new Date();
|
||||
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
|
||||
.where('channel_muting.expiresAt < :now', { now });
|
||||
|
||||
if (opts?.joinUser) {
|
||||
q.innerJoinAndSelect('channel_muting.user', 'user');
|
||||
}
|
||||
|
||||
if (opts?.joinChannel) {
|
||||
q.leftJoinAndSelect('channel_muting.channel', 'channel');
|
||||
}
|
||||
|
||||
return q.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 既にミュートされているかどうかをキャッシュから取得する.
|
||||
* @param params
|
||||
* @param params.requestUserId
|
||||
*/
|
||||
@bindThis
|
||||
public async isMuted(params: {
|
||||
requestUserId: MiUser['id'],
|
||||
targetChannelId: MiChannel['id'],
|
||||
}): Promise<boolean> {
|
||||
const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId);
|
||||
return (mutedChannels?.has(params.targetChannelId) ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* チャンネルをミュートする.
|
||||
* @param params
|
||||
* @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限.
|
||||
*/
|
||||
@bindThis
|
||||
public async mute(params: {
|
||||
requestUserId: MiUser['id'],
|
||||
targetChannelId: MiChannel['id'],
|
||||
expiresAt?: Date | null,
|
||||
}): Promise<void> {
|
||||
await this.channelMutingRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
expiresAt: params.expiresAt,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('muteChannel', {
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* チャンネルのミュートを解除する.
|
||||
* @param params
|
||||
*/
|
||||
@bindThis
|
||||
public async unmute(params: {
|
||||
requestUserId: MiUser['id'],
|
||||
targetChannelId: MiChannel['id'],
|
||||
}): Promise<void> {
|
||||
await this.channelMutingRepository.delete({
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('unmuteChannel', {
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 期限切れのチャンネルミュート情報を削除する.
|
||||
*/
|
||||
@bindThis
|
||||
public async eraseExpiredMutings(): Promise<void> {
|
||||
const expiredMutings = await this.findExpiredMutings();
|
||||
await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) });
|
||||
|
||||
const userIds = [...new Set(expiredMutings.map(x => x.userId))];
|
||||
for (const userId of userIds) {
|
||||
this.mutingChannelsCache.refresh(userId).then();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'muteChannel': {
|
||||
this.mutingChannelsCache.refresh(body.userId).then();
|
||||
break;
|
||||
}
|
||||
case 'unmuteChannel': {
|
||||
this.mutingChannelsCache.delete(body.userId).then();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.mutingChannelsCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { FlashService } from '@/core/FlashService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
@@ -225,6 +226,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
|
||||
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
||||
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
||||
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
||||
const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService };
|
||||
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
|
||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||
@@ -378,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChannelMutingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
@@ -527,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChannelMutingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
@@ -677,6 +681,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChannelMutingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
@@ -824,6 +829,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChannelMutingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||
|
||||
type NoteFilter = (note: MiNote) => boolean;
|
||||
|
||||
@@ -35,6 +37,7 @@ type TimelineOptions = {
|
||||
ignoreAuthorFromBlock?: boolean;
|
||||
ignoreAuthorFromMute?: boolean;
|
||||
ignoreAuthorFromInstanceBlock?: boolean;
|
||||
ignoreAuthorChannelFromMute?: boolean;
|
||||
excludeNoFiles?: boolean;
|
||||
excludeReplies?: boolean;
|
||||
excludePureRenotes: boolean;
|
||||
@@ -55,6 +58,7 @@ export class FanoutTimelineEndpointService {
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private utilityService: UtilityService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -111,11 +115,13 @@ export class FanoutTimelineEndpointService {
|
||||
userIdsWhoMeMutingRenotes,
|
||||
userIdsWhoBlockingMe,
|
||||
userMutedInstances,
|
||||
userMutedChannels,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(ps.me.id),
|
||||
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
|
||||
this.cacheService.userBlockedCache.fetch(ps.me.id),
|
||||
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
|
||||
this.channelMutingService.mutingChannelsCache.fetch(me.id),
|
||||
]);
|
||||
|
||||
const parentFilter = filter;
|
||||
@@ -126,6 +132,7 @@ export class FanoutTimelineEndpointService {
|
||||
if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
|
||||
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
|
||||
if (isInstanceMuted(note, userMutedInstances)) return false;
|
||||
if (isChannelRelated(note, userMutedChannels, ps.ignoreAuthorChannelFromMute)) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ import { AiService } from '@/core/AiService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { PredictionType } from 'nsfwjs';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import type { PredictionType } from 'nsfwjs';
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
@@ -339,7 +339,7 @@ export class FileInfoService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public fixMime(mime: string | fileType.MimeType): string {
|
||||
public fixMime(mime: string): string {
|
||||
// see https://github.com/misskey-dev/misskey/pull/10686
|
||||
if (mime === 'audio/x-flac') {
|
||||
return 'audio/flac';
|
||||
|
||||
@@ -255,6 +255,8 @@ export interface InternalEventTypes {
|
||||
metaUpdated: { before?: MiMeta; after: MiMeta; };
|
||||
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
updateUserProfile: MiUserProfile;
|
||||
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
|
||||
@@ -604,6 +604,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
replyUserHost: data.reply ? data.reply.userHost : null,
|
||||
renoteUserId: data.renote ? data.renote.userId : null,
|
||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||
renoteChannelId: data.renote ? data.renote.channelId : null,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteChannelId: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,36 +4,40 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js';
|
||||
import type {
|
||||
ChannelFavoritesRepository,
|
||||
ChannelFollowingsRepository, ChannelMutingRepository,
|
||||
ChannelsRepository,
|
||||
DriveFilesRepository,
|
||||
MiDriveFile,
|
||||
MiNote,
|
||||
NotesRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { NoteEntityService } from './NoteEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelEntityService {
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFavoritesRepository)
|
||||
private channelFavoritesRepository: ChannelFavoritesRepository,
|
||||
|
||||
@Inject(DI.channelMutingRepository)
|
||||
private channelMutingRepository: ChannelMutingRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
@@ -45,31 +49,59 @@ export class ChannelEntityService {
|
||||
src: MiChannel['id'] | MiChannel,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
detailed?: boolean,
|
||||
opts?: {
|
||||
bannerFiles?: Map<MiDriveFile['id'], MiDriveFile>;
|
||||
followings?: Set<MiChannel['id']>;
|
||||
favorites?: Set<MiChannel['id']>;
|
||||
muting?: Set<MiChannel['id']>;
|
||||
pinnedNotes?: Map<MiNote['id'], MiNote>;
|
||||
},
|
||||
): Promise<Packed<'Channel'>> {
|
||||
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
|
||||
const meId = me ? me.id : null;
|
||||
|
||||
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
|
||||
let bannerFile: MiDriveFile | null = null;
|
||||
if (channel.bannerId) {
|
||||
bannerFile = opts?.bannerFiles?.get(channel.bannerId)
|
||||
?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId });
|
||||
}
|
||||
|
||||
const isFollowing = meId ? await this.channelFollowingsRepository.exists({
|
||||
where: {
|
||||
followerId: meId,
|
||||
followeeId: channel.id,
|
||||
},
|
||||
}) : false;
|
||||
let isFollowing = false;
|
||||
let isFavorited = false;
|
||||
let isMuting = false;
|
||||
if (me) {
|
||||
isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
followeeId: channel.id,
|
||||
},
|
||||
});
|
||||
|
||||
const isFavorited = meId ? await this.channelFavoritesRepository.exists({
|
||||
where: {
|
||||
userId: meId,
|
||||
channelId: channel.id,
|
||||
},
|
||||
}) : false;
|
||||
isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({
|
||||
where: {
|
||||
userId: me.id,
|
||||
channelId: channel.id,
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: {
|
||||
id: In(channel.pinnedNoteIds),
|
||||
},
|
||||
}) : [];
|
||||
isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({
|
||||
where: {
|
||||
userId: me.id,
|
||||
channelId: channel.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const pinnedNotes = Array.of<MiNote>();
|
||||
if (channel.pinnedNoteIds.length > 0) {
|
||||
pinnedNotes.push(
|
||||
...(
|
||||
opts?.pinnedNotes
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null)
|
||||
: await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) })
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
@@ -78,7 +110,7 @@ export class ChannelEntityService {
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
userId: channel.userId,
|
||||
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
|
||||
bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null,
|
||||
pinnedNoteIds: channel.pinnedNoteIds,
|
||||
color: channel.color,
|
||||
isArchived: channel.isArchived,
|
||||
@@ -90,6 +122,7 @@ export class ChannelEntityService {
|
||||
...(me ? {
|
||||
isFollowing,
|
||||
isFavorited,
|
||||
isMuting,
|
||||
hasUnreadNote: false, // 後方互換性のため
|
||||
} : {}),
|
||||
|
||||
@@ -98,5 +131,72 @@ export class ChannelEntityService {
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
src: MiChannel['id'][] | MiChannel[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
detailed?: boolean,
|
||||
): Promise<Packed<'Channel'>[]> {
|
||||
// IDのみの要素がある場合、DBからオブジェクトを取得して補う
|
||||
const channels = src.filter(it => typeof it === 'object') as MiChannel[];
|
||||
channels.push(
|
||||
...(await this.channelsRepository.find({
|
||||
where: {
|
||||
id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]),
|
||||
},
|
||||
})),
|
||||
);
|
||||
channels.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const bannerFiles = await this.driveFilesRepository
|
||||
.findBy({
|
||||
id: In(channels.map(it => it.bannerId).filter(it => it != null)),
|
||||
})
|
||||
.then(it => new Map(it.map(it => [it.id, it])));
|
||||
|
||||
const followings = me
|
||||
? await this.channelFollowingsRepository
|
||||
.findBy({
|
||||
followerId: me.id,
|
||||
followeeId: In(channels.map(it => it.id)),
|
||||
})
|
||||
.then(it => new Set(it.map(it => it.followeeId)))
|
||||
: new Set<MiChannel['id']>();
|
||||
|
||||
const favorites = me
|
||||
? await this.channelFavoritesRepository
|
||||
.findBy({
|
||||
userId: me.id,
|
||||
channelId: In(channels.map(it => it.id)),
|
||||
})
|
||||
.then(it => new Set(it.map(it => it.channelId)))
|
||||
: new Set<MiChannel['id']>();
|
||||
|
||||
const muting = me
|
||||
? await this.channelMutingRepository
|
||||
.findBy({
|
||||
userId: me.id,
|
||||
channelId: In(channels.map(it => it.id)),
|
||||
})
|
||||
.then(it => new Set(it.map(it => it.channelId)))
|
||||
: new Set<MiChannel['id']>();
|
||||
|
||||
const pinnedNotes = await this.notesRepository
|
||||
.find({
|
||||
where: {
|
||||
id: In(channels.flatMap(it => it.pinnedNoteIds)),
|
||||
},
|
||||
})
|
||||
.then(it => new Map(it.map(it => [it.id, it])));
|
||||
|
||||
return Promise.all(channels.map(it => this.pack(it, me, detailed, {
|
||||
bannerFiles,
|
||||
followings,
|
||||
favorites,
|
||||
muting,
|
||||
pinnedNotes,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export const DI = {
|
||||
channelsRepository: Symbol('channelsRepository'),
|
||||
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
|
||||
channelFavoritesRepository: Symbol('channelFavoritesRepository'),
|
||||
channelMutingRepository: Symbol('channelMutingRepository'),
|
||||
registryItemsRepository: Symbol('registryItemsRepository'),
|
||||
webhooksRepository: Symbol('webhooksRepository'),
|
||||
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
/**
|
||||
* {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。
|
||||
* 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。
|
||||
*
|
||||
* @param note 確認対象のノート
|
||||
* @param channelIds 確認対象のチャンネルID一覧
|
||||
* @param ignoreAuthor trueの場合、ノートの所属チャンネルが{@link channelIds}に含まれていても無視します(デフォルトはfalse)
|
||||
*/
|
||||
export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set<string>, ignoreAuthor = false): boolean {
|
||||
// ノートの所属チャンネルが確認対象のチャンネルID一覧に含まれている場合
|
||||
if (!ignoreAuthor && note.channelId && channelIds.has(note.channelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const renoteChannelId = note.renote?.channelId;
|
||||
if (renoteChannelId != null && renoteChannelId !== note.channelId && channelIds.has(renoteChannelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので)
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiChannel } from './Channel.js';
|
||||
|
||||
@Entity('channel_muting')
|
||||
@Index(['userId', 'channelId'], {})
|
||||
export class MiChannelMuting {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public channelId: MiChannel['id'];
|
||||
|
||||
@ManyToOne(type => MiChannel, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: MiChannel | null;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public expiresAt: Date | null;
|
||||
}
|
||||
@@ -248,6 +248,14 @@ export class MiNote {
|
||||
})
|
||||
public renoteUserHost: string | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
})
|
||||
public renoteChannelId: MiChannel['id'] | null;
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MiChannel,
|
||||
MiChannelFavorite,
|
||||
MiChannelFollowing,
|
||||
MiChannelMuting,
|
||||
MiClip,
|
||||
MiClipFavorite,
|
||||
MiClipNote,
|
||||
@@ -429,6 +430,12 @@ const $channelFavoritesRepository: Provider = {
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelMutingRepository: Provider = {
|
||||
provide: DI.channelMutingRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository<MiChannelMuting>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $registryItemsRepository: Provider = {
|
||||
provide: DI.registryItemsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository<MiRegistryItem>),
|
||||
@@ -597,6 +604,7 @@ const $reversiGamesRepository: Provider = {
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelFavoritesRepository,
|
||||
$channelMutingRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$systemWebhooksRepository,
|
||||
@@ -674,6 +682,7 @@ const $reversiGamesRepository: Provider = {
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelFavoritesRepository,
|
||||
$channelMutingRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$systemWebhooksRepository,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiChannel } from '@/models/Channel.js';
|
||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiChatMessage } from '@/models/ChatMessage.js';
|
||||
import { MiChatRoom } from '@/models/ChatRoom.js';
|
||||
@@ -172,6 +173,7 @@ export {
|
||||
MiBlocking,
|
||||
MiChannelFollowing,
|
||||
MiChannelFavorite,
|
||||
MiChannelMuting,
|
||||
MiClip,
|
||||
MiClipNote,
|
||||
MiClipFavorite,
|
||||
@@ -251,6 +253,7 @@ export type AuthSessionsRepository = Repository<MiAuthSession> & MiRepository<Mi
|
||||
export type BlockingsRepository = Repository<MiBlocking> & MiRepository<MiBlocking>;
|
||||
export type ChannelFollowingsRepository = Repository<MiChannelFollowing> & MiRepository<MiChannelFollowing>;
|
||||
export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepository<MiChannelFavorite>;
|
||||
export type ChannelMutingRepository = Repository<MiChannelMuting> & MiRepository<MiChannelMuting>;
|
||||
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
|
||||
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
|
||||
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
|
||||
|
||||
@@ -80,6 +80,10 @@ export const packedChannelSchema = {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
isMuting: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
pinnedNotes: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MiAuthSession } from '@/models/AuthSession.js';
|
||||
import { MiBlocking } from '@/models/Blocking.js';
|
||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||
import { MiClip } from '@/models/Clip.js';
|
||||
import { MiClipNote } from '@/models/ClipNote.js';
|
||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||
@@ -239,6 +240,7 @@ export const entities = [
|
||||
MiChannel,
|
||||
MiChannelFollowing,
|
||||
MiChannelFavorite,
|
||||
MiChannelMuting,
|
||||
MiRegistryItem,
|
||||
MiAd,
|
||||
MiPasswordResetRequest,
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MutingsRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserMutingService } from '@/core/UserMutingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CheckExpiredMutingsProcessorService {
|
||||
@@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService {
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private userMutingService: UserMutingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
|
||||
@@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService {
|
||||
await this.userMutingService.unmute(expired);
|
||||
}
|
||||
|
||||
await this.channelMutingService.eraseExpiredMutings();
|
||||
|
||||
this.logger.succ('All expired mutings checked.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,17 @@ export class CleanRemoteNotesProcessorService {
|
||||
skipped: boolean;
|
||||
transientErrors: number;
|
||||
}> {
|
||||
const getConfig = () => {
|
||||
return {
|
||||
enabled: this.meta.enableRemoteNotesCleaning,
|
||||
maxDuration: this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000, // Convert minutes to milliseconds
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
newestLimit: this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)),
|
||||
};
|
||||
};
|
||||
|
||||
const initialConfig = getConfig();
|
||||
if (!this.meta.enableRemoteNotesCleaning) {
|
||||
this.logger.info('Remote notes cleaning is disabled, skipping...');
|
||||
return {
|
||||
@@ -64,13 +75,9 @@ export class CleanRemoteNotesProcessorService {
|
||||
|
||||
this.logger.info('cleaning remote notes...');
|
||||
|
||||
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
|
||||
const startAt = Date.now();
|
||||
|
||||
//#region queries
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
||||
|
||||
// The condition for removing the notes.
|
||||
// The note must be:
|
||||
@@ -92,7 +99,7 @@ export class CleanRemoteNotesProcessorService {
|
||||
const minId = (await this.notesRepository.createQueryBuilder('note')
|
||||
.select('MIN(note.id)', 'minId')
|
||||
.where({
|
||||
id: LessThan(newestLimit),
|
||||
id: LessThan(initialConfig.newestLimit),
|
||||
userHost: Not(IsNull()),
|
||||
replyId: IsNull(),
|
||||
renoteId: IsNull(),
|
||||
@@ -155,12 +162,12 @@ export class CleanRemoteNotesProcessorService {
|
||||
// | fff | fff | TRUE |
|
||||
// | ggg | ggg | FALSE |
|
||||
//
|
||||
const candidateNotesQuery = this.db.createQueryBuilder()
|
||||
const candidateNotesQuery = ({ limit }: { limit: number }) => this.db.createQueryBuilder()
|
||||
.select(`"${candidateNotesCteName}"."id"`, 'id')
|
||||
.addSelect('unremovable."id" IS NULL', 'isRemovable')
|
||||
.addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
|
||||
.addCommonTableExpression(
|
||||
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
|
||||
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(limit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
|
||||
candidateNotesCteName,
|
||||
{ recursive: true },
|
||||
)
|
||||
@@ -178,6 +185,11 @@ export class CleanRemoteNotesProcessorService {
|
||||
let lowThroughputWarned = false;
|
||||
let transientErrors = 0;
|
||||
for (;;) {
|
||||
const { enabled, maxDuration, newestLimit } = getConfig();
|
||||
if (!enabled) {
|
||||
this.logger.info('Remote notes cleaning is disabled, processing stopped...');
|
||||
break;
|
||||
}
|
||||
//#region check time
|
||||
const batchBeginAt = Date.now();
|
||||
|
||||
@@ -205,13 +217,38 @@ export class CleanRemoteNotesProcessorService {
|
||||
let noteIds = null;
|
||||
|
||||
try {
|
||||
noteIds = await candidateNotesQuery.setParameters(
|
||||
noteIds = await candidateNotesQuery({ limit: currentLimit }).setParameters(
|
||||
{ newestLimit, cursorLeft },
|
||||
).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
|
||||
} catch (e) {
|
||||
if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') {
|
||||
// Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again
|
||||
// continuous failures will eventually converge to currentLimit == minimumLimit and then throw
|
||||
if (e instanceof QueryFailedError && e.driverError?.code === '57014') {
|
||||
// Statement timeout (maybe suddenly hit a large note tree), if possible, reduce the limit and try again
|
||||
// if not possible, skip the current batch of notes and find the next root note
|
||||
if (currentLimit <= minimumLimit) {
|
||||
job.log('Local note tree complexity is too high, finding next root note...');
|
||||
|
||||
const idWindow = await this.notesRepository.createQueryBuilder('note')
|
||||
.select('id')
|
||||
.where('note.id > :cursorLeft')
|
||||
.andWhere(removalCriteria)
|
||||
.andWhere({ replyId: IsNull(), renoteId: IsNull() })
|
||||
.orderBy('note.id', 'ASC')
|
||||
.limit(minimumLimit + 1)
|
||||
.setParameters({ cursorLeft, newestLimit })
|
||||
.getRawMany<{ id?: MiNote['id'] }>();
|
||||
|
||||
job.log(`Skipped note IDs: ${idWindow.slice(0, minimumLimit).map(id => id.id).join(', ')}`);
|
||||
|
||||
const lastId = idWindow.at(minimumLimit)?.id;
|
||||
|
||||
if (!lastId) {
|
||||
job.log('No more notes to clean.');
|
||||
break;
|
||||
}
|
||||
|
||||
cursorLeft = lastId;
|
||||
continue;
|
||||
}
|
||||
currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { UserService } from '@/core/UserService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/Connection.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
@@ -39,6 +40,7 @@ export class StreamingApiServerService {
|
||||
private notificationService: NotificationService,
|
||||
private usersService: UserService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -97,6 +99,7 @@ export class StreamingApiServerService {
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
this.channelFollowingService,
|
||||
this.channelMutingService,
|
||||
user, app,
|
||||
);
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ export * as 'channels/timeline' from './endpoints/channels/timeline.js';
|
||||
export * as 'channels/unfavorite' from './endpoints/channels/unfavorite.js';
|
||||
export * as 'channels/unfollow' from './endpoints/channels/unfollow.js';
|
||||
export * as 'channels/update' from './endpoints/channels/update.js';
|
||||
export * as 'channels/mute/create' from './endpoints/channels/mute/create.js';
|
||||
export * as 'channels/mute/delete' from './endpoints/channels/mute/delete.js';
|
||||
export * as 'channels/mute/list' from './endpoints/channels/mute/list.js';
|
||||
export * as 'charts/active-users' from './endpoints/charts/active-users.js';
|
||||
export * as 'charts/ap-request' from './endpoints/charts/ap-request.js';
|
||||
export * as 'charts/drive' from './endpoints/charts/drive.js';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
@@ -14,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -69,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -108,6 +111,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
// -- ミュートされたチャンネル対策
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
|
||||
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'mute'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such Channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: '7174361e-d58f-31d6-2e7c-6fb830786a3f',
|
||||
},
|
||||
|
||||
alreadyMuting: {
|
||||
message: 'You are already muting that user.',
|
||||
code: 'ALREADY_MUTING_CHANNEL',
|
||||
id: '5a251978-769a-da44-3e89-3931e43bb592',
|
||||
},
|
||||
|
||||
expiresAtIsPast: {
|
||||
message: 'Cannot set past date to "expiresAt".',
|
||||
code: 'EXPIRES_AT_IS_PAST',
|
||||
id: '42b32236-df2c-a45f-fdbf-def67268f749',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: { type: 'string', format: 'misskey:id' },
|
||||
expiresAt: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
|
||||
},
|
||||
},
|
||||
required: ['channelId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check if exists the channel
|
||||
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||
if (!targetChannel) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
// Check if already muting
|
||||
const exist = await this.channelMutingService.isMuted({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
if (exist) {
|
||||
throw new ApiError(meta.errors.alreadyMuting);
|
||||
}
|
||||
|
||||
// Check if expiresAt is past
|
||||
if (ps.expiresAt && ps.expiresAt <= Date.now()) {
|
||||
throw new ApiError(meta.errors.expiresAtIsPast);
|
||||
}
|
||||
|
||||
await this.channelMutingService.mute({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'mute'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such Channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'e7998769-6e94-d9c2-6b8f-94a527314aba',
|
||||
},
|
||||
|
||||
notMuting: {
|
||||
message: 'You are not muting that channel.',
|
||||
code: 'NOT_MUTING_CHANNEL',
|
||||
id: '14d55962-6ea8-d990-1333-d6bef78dc2ab',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['channelId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check if exists the channel
|
||||
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||
if (!targetChannel) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
// Check muting
|
||||
const exist = await this.channelMutingService.isMuted({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
if (!exist) {
|
||||
throw new ApiError(meta.errors.notMuting);
|
||||
}
|
||||
|
||||
await this.channelMutingService.unmute({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'mute'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'read:channels',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private channelEntityService: ChannelEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const mutings = await this.channelMutingService.list({
|
||||
requestUserId: me.id,
|
||||
});
|
||||
return await this.channelEntityService.packMany(mutings, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -70,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -98,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
useDbFallback: true,
|
||||
redisTimelines: [`channelTimeline:${channel.id}`],
|
||||
excludePureRenotes: false,
|
||||
ignoreAuthorChannelFromMute: true,
|
||||
dbFallback: async (untilId, sinceId, limit) => {
|
||||
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
||||
},
|
||||
@@ -122,6 +125,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
if (me) {
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id).filter(x => x !== ps.channelId));
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
||||
@@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -46,7 +48,7 @@ export const meta = {
|
||||
bothWithRepliesAndWithFiles: {
|
||||
message: 'Specifying both withReplies and withFiles is not supported',
|
||||
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
||||
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f'
|
||||
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -79,9 +81,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
@@ -89,6 +88,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private cacheService: CacheService,
|
||||
private queryService: QueryService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
@@ -196,11 +197,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
withReplies: boolean,
|
||||
}, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
const followingChannelIds = await this.channelFollowingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
@@ -219,9 +222,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followingChannels.length > 0) {
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
|
||||
if (followingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
@@ -230,6 +231,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
if (!ps.withReplies) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
|
||||
@@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -76,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -157,7 +159,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
const mutedChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutedChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL')
|
||||
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
|
||||
import type { NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
@@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -61,15 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
@@ -140,11 +141,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
const followingChannelIds = await this.channelFollowingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
@@ -154,15 +157,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followees.length > 0 && followingChannels.length > 0) {
|
||||
if (followees.length > 0 && followingChannelIds.length > 0) {
|
||||
// ユーザー・チャンネルともにフォローあり
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb2 => {
|
||||
qb2
|
||||
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
||||
.andWhere('note.channelId IS NULL');
|
||||
}))
|
||||
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||
@@ -170,22 +172,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
} else if (followees.length > 0) {
|
||||
// ユーザーフォローのみ(チャンネルフォローなし)
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
query
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||
} else if (followingChannels.length > 0) {
|
||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||
if (mutingChannelIds.length > 0) {
|
||||
qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}
|
||||
}));
|
||||
} else if (followingChannelIds.length > 0) {
|
||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// renoteChannelIdは見る必要が無い
|
||||
// ・HTLに流れてくるチャンネル=フォローしているチャンネル
|
||||
// ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ
|
||||
// つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い
|
||||
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
|
||||
.orWhere('note.userId = :meId', { meId: me.id });
|
||||
}));
|
||||
} else {
|
||||
// フォローなし
|
||||
query
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
}));
|
||||
}
|
||||
|
||||
query.andWhere(new Brackets(qb => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -84,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -187,6 +189,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
// -- ミュートされたチャンネルのリノート対策
|
||||
const mutedChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutedChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL')
|
||||
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository, RolesRepository } from '@/models/_.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
@@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -68,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -101,6 +104,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
// -- ミュートされたチャンネル対策
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users', 'notes'],
|
||||
@@ -77,12 +78,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -165,6 +166,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
withFiles: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser | null) {
|
||||
const mutingChannelIds = me
|
||||
? await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id))
|
||||
: [];
|
||||
const isSelf = me && (me.id === ps.userId);
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
@@ -177,14 +183,30 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (ps.withChannelNotes) {
|
||||
if (!isSelf) query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('channel.isSensitive = false');
|
||||
query.andWhere(new Brackets(qb => {
|
||||
if (mutingChannelIds.length > 0) {
|
||||
qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds });
|
||||
}
|
||||
|
||||
if (!isSelf) {
|
||||
qb.andWhere(new Brackets(qb2 => {
|
||||
qb2.orWhere('note.channelId IS NULL');
|
||||
qb2.orWhere('channel.isSensitive = false');
|
||||
}));
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
// -- ミュートされたチャンネルのリノート対策
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me, {
|
||||
excludeAuthor: true,
|
||||
|
||||
@@ -11,8 +11,9 @@ import type { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
||||
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import type { ChannelsService } from './ChannelsService.js';
|
||||
@@ -35,6 +36,7 @@ export default class Connection {
|
||||
public userProfile: MiUserProfile | null = null;
|
||||
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
public followingChannels: Set<string> = new Set();
|
||||
public mutingChannels: Set<string> = new Set();
|
||||
public userIdsWhoMeMuting: Set<string> = new Set();
|
||||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
||||
@@ -46,7 +48,7 @@ export default class Connection {
|
||||
private notificationService: NotificationService,
|
||||
private cacheService: CacheService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
|
||||
private channelMutingService: ChannelMutingService,
|
||||
user: MiUser | null | undefined,
|
||||
token: MiAccessToken | null | undefined,
|
||||
) {
|
||||
@@ -57,10 +59,19 @@ export default class Connection {
|
||||
@bindThis
|
||||
public async fetch() {
|
||||
if (this.user == null) return;
|
||||
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
|
||||
const [
|
||||
userProfile,
|
||||
following,
|
||||
followingChannels,
|
||||
mutingChannels,
|
||||
userIdsWhoMeMuting,
|
||||
userIdsWhoBlockingMe,
|
||||
userIdsWhoMeMutingRenotes,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userProfileCache.fetch(this.user.id),
|
||||
this.cacheService.userFollowingsCache.fetch(this.user.id),
|
||||
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
|
||||
this.channelMutingService.mutingChannelsCache.fetch(this.user.id),
|
||||
this.cacheService.userMutingsCache.fetch(this.user.id),
|
||||
this.cacheService.userBlockedCache.fetch(this.user.id),
|
||||
this.cacheService.renoteMutingsCache.fetch(this.user.id),
|
||||
@@ -68,6 +79,7 @@ export default class Connection {
|
||||
this.userProfile = userProfile;
|
||||
this.following = following;
|
||||
this.followingChannels = followingChannels;
|
||||
this.mutingChannels = mutingChannels;
|
||||
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
|
||||
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
||||
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
|
||||
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import type Connection from './Connection.js';
|
||||
@@ -55,6 +56,10 @@ export default abstract class Channel {
|
||||
return this.connection.followingChannels;
|
||||
}
|
||||
|
||||
protected get mutingChannels() {
|
||||
return this.connection.mutingChannels;
|
||||
}
|
||||
|
||||
protected get subscriber() {
|
||||
return this.connection.subscriber;
|
||||
}
|
||||
@@ -74,6 +79,9 @@ export default abstract class Channel {
|
||||
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||
|
||||
// 流れてきたNoteがミュートしているチャンネルと関わる
|
||||
if (isChannelRelated(note, this.mutingChannels)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
|
||||
@@ -19,7 +21,6 @@ class ChannelChannel extends Channel {
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
@@ -52,6 +53,35 @@ class ChannelChannel extends Channel {
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
/*
|
||||
* ミュートとブロックされてるを処理する
|
||||
*/
|
||||
protected override isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
|
||||
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
|
||||
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return true;
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わる
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
|
||||
// 流れてきたNoteがブロックされているユーザーが関わる
|
||||
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true;
|
||||
|
||||
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||
|
||||
// このソケットで見ているチャンネルがミュートされていたとしても、チャンネルを直接見ている以上は流すようにしたい
|
||||
// ただし、他のミュートしているチャンネルは流さないようにもしたい
|
||||
// ノート自体のチャンネルIDはonNoteでチェックしているので、ここではリノートのチャンネルIDをチェックする
|
||||
if (
|
||||
(note.renote) &&
|
||||
(note.renote.channelId !== this.channelId) &&
|
||||
(note.renote.channelId && this.mutingChannels.has(note.renote.channelId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
// Unsubscribe events
|
||||
|
||||
@@ -44,7 +44,10 @@ class HomeTimelineChannel extends Channel {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
if (note.channelId) {
|
||||
if (!this.followingChannels.has(note.channelId)) return;
|
||||
// そのチャンネルをフォローしていない
|
||||
if (!this.followingChannels.has(note.channelId)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||
|
||||
@@ -53,16 +53,25 @@ class HybridTimelineChannel extends Channel {
|
||||
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
// チャンネルの投稿ではなく、自分自身の投稿 または
|
||||
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
|
||||
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
|
||||
// フォローしているチャンネルの投稿 の場合だけ
|
||||
if (!(
|
||||
(note.channelId == null && isMe) ||
|
||||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
|
||||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
|
||||
(note.channelId != null && this.followingChannels.has(note.channelId))
|
||||
)) return;
|
||||
if (!note.channelId) {
|
||||
// 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする)
|
||||
// - 自分自身の投稿
|
||||
// - その投稿のユーザーをフォローしている
|
||||
// - 全体公開のローカルの投稿
|
||||
if (!(
|
||||
isMe ||
|
||||
Object.hasOwn(this.following, note.userId) ||
|
||||
(note.user.host == null && note.visibility === 'public')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする)
|
||||
// - フォローしているチャンネルの投稿
|
||||
if (!this.followingChannels.has(note.channelId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||
|
||||
@@ -69,6 +69,9 @@ describe('アンテナ', () => {
|
||||
let userMutingAlice: User;
|
||||
let userMutedByAlice: User;
|
||||
|
||||
let testChannel: misskey.entities.Channel;
|
||||
let testMutedChannel: misskey.entities.Channel;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await signup({ username: 'root' });
|
||||
alice = await signup({ username: 'alice' });
|
||||
@@ -120,6 +123,10 @@ describe('アンテナ', () => {
|
||||
userMutedByAlice = await signup({ username: 'userMutedByAlice' });
|
||||
await post(userMutedByAlice, { text: 'test' });
|
||||
await api('mute/create', { userId: userMutedByAlice.id }, alice);
|
||||
|
||||
testChannel = (await api('channels/create', { name: 'test' }, root)).body;
|
||||
testMutedChannel = (await api('channels/create', { name: 'test-muted' }, root)).body;
|
||||
await api('channels/mute/create', { channelId: testMutedChannel.id }, alice);
|
||||
}, 1000 * 60 * 10);
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -605,6 +612,20 @@ describe('アンテナ', () => {
|
||||
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'チャンネルノートも含む',
|
||||
parameters: () => ({ src: 'all' }),
|
||||
posts: [
|
||||
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}`, channelId: testChannel.id }), included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ミュートしてるチャンネルは含まない',
|
||||
parameters: () => ({ src: 'all' }),
|
||||
posts: [
|
||||
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}`, channelId: testMutedChannel.id }) },
|
||||
],
|
||||
},
|
||||
])('が取得できること($label)', async ({ parameters, posts }) => {
|
||||
const antenna = await successfulApiCall({
|
||||
endpoint: 'antennas/create',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
module.exports = async () => {
|
||||
// DBはUTC(っぽい)ので、テスト側も合わせておく
|
||||
process.env.TZ = 'UTC';
|
||||
process.env.NODE_ENV = 'test';
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import {
|
||||
type ChannelFollowingsRepository,
|
||||
ChannelsRepository,
|
||||
DriveFilesRepository,
|
||||
MiChannel,
|
||||
MiChannelFollowing,
|
||||
MiDriveFile,
|
||||
MiUser,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ChannelFollowingService } from "@/core/ChannelFollowingService.js";
|
||||
import { MiLocalUser } from "@/models/User.js";
|
||||
|
||||
describe('ChannelFollowingService', () => {
|
||||
let app: TestingModule;
|
||||
let service: ChannelFollowingService;
|
||||
let channelsRepository: ChannelsRepository;
|
||||
let channelFollowingsRepository: ChannelFollowingsRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let userProfilesRepository: UserProfilesRepository;
|
||||
let driveFilesRepository: DriveFilesRepository;
|
||||
let idService: IdService;
|
||||
|
||||
let alice: MiLocalUser;
|
||||
let bob: MiLocalUser;
|
||||
let channel1: MiChannel;
|
||||
let channel2: MiChannel;
|
||||
let channel3: MiChannel;
|
||||
let driveFile1: MiDriveFile;
|
||||
let driveFile2: MiDriveFile;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
username: 'username',
|
||||
usernameLower: 'username',
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await userProfilesRepository.insert({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createChannel(data: Partial<MiChannel> = {}) {
|
||||
return await channelsRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function createChannelFollowing(data: Partial<MiChannelFollowing> = {}) {
|
||||
return await channelFollowingsRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelFollowingsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function fetchChannelFollowing() {
|
||||
return await channelFollowingsRepository.findBy({});
|
||||
}
|
||||
|
||||
async function createDriveFile(data: Partial<MiDriveFile> = {}) {
|
||||
return await driveFilesRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
md5: 'md5',
|
||||
name: 'name',
|
||||
size: 0,
|
||||
type: 'type',
|
||||
storedInternal: false,
|
||||
url: 'url',
|
||||
...data,
|
||||
})
|
||||
.then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
CoreModule,
|
||||
],
|
||||
providers: [
|
||||
GlobalEventService,
|
||||
IdService,
|
||||
ChannelFollowingService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
service = app.get<ChannelFollowingService>(ChannelFollowingService);
|
||||
idService = app.get<IdService>(IdService);
|
||||
channelsRepository = app.get<ChannelsRepository>(DI.channelsRepository);
|
||||
channelFollowingsRepository = app.get<ChannelFollowingsRepository>(DI.channelFollowingsRepository);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
|
||||
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
alice = { ...await createUser({ username: 'alice' }), host: null, uri: null };
|
||||
bob = { ...await createUser({ username: 'bob' }), host: null, uri: null };
|
||||
driveFile1 = await createDriveFile();
|
||||
driveFile2 = await createDriveFile();
|
||||
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });
|
||||
channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id });
|
||||
channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await channelFollowingsRepository.deleteAll();
|
||||
await channelsRepository.deleteAll();
|
||||
await userProfilesRepository.deleteAll();
|
||||
await usersRepository.deleteAll();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
test('default', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[0].userId).toBe(alice.id);
|
||||
expect(followings[0].user).toBeFalsy();
|
||||
expect(followings[0].bannerId).toBe(driveFile1.id);
|
||||
expect(followings[0].banner).toBeFalsy();
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
expect(followings[1].userId).toBe(alice.id);
|
||||
expect(followings[1].user).toBeFalsy();
|
||||
expect(followings[1].bannerId).toBe(driveFile2.id);
|
||||
expect(followings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('idOnly', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id }, { idOnly: true });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
});
|
||||
|
||||
test('joinUser', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id }, { joinUser: true });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[0].user).toEqual(alice);
|
||||
expect(followings[0].banner).toBeFalsy();
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
expect(followings[1].user).toEqual(alice);
|
||||
expect(followings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('joinBannerFile', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[0].user).toBeFalsy();
|
||||
expect(followings[0].banner).toEqual(driveFile1);
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
expect(followings[1].user).toBeFalsy();
|
||||
expect(followings[1].banner).toEqual(driveFile2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('follow', () => {
|
||||
test('default', async () => {
|
||||
await service.follow(alice, channel1);
|
||||
|
||||
const followings = await fetchChannelFollowing();
|
||||
|
||||
expect(followings).toHaveLength(1);
|
||||
expect(followings[0].followeeId).toBe(channel1.id);
|
||||
expect(followings[0].followerId).toBe(alice.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unfollow', () => {
|
||||
test('default', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
|
||||
await service.unfollow(alice, channel1);
|
||||
|
||||
const followings = await fetchChannelFollowing();
|
||||
|
||||
expect(followings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import {
|
||||
ChannelMutingRepository,
|
||||
ChannelsRepository,
|
||||
DriveFilesRepository,
|
||||
MiChannel,
|
||||
MiChannelMuting,
|
||||
MiDriveFile,
|
||||
MiUser,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
describe('ChannelMutingService', () => {
|
||||
let app: TestingModule;
|
||||
let service: ChannelMutingService;
|
||||
let channelsRepository: ChannelsRepository;
|
||||
let channelMutingRepository: ChannelMutingRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let userProfilesRepository: UserProfilesRepository;
|
||||
let driveFilesRepository: DriveFilesRepository;
|
||||
let idService: IdService;
|
||||
|
||||
let alice: MiUser;
|
||||
let bob: MiUser;
|
||||
let channel1: MiChannel;
|
||||
let channel2: MiChannel;
|
||||
let channel3: MiChannel;
|
||||
let driveFile1: MiDriveFile;
|
||||
let driveFile2: MiDriveFile;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
username: 'username',
|
||||
usernameLower: 'username',
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await userProfilesRepository.insert({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createChannel(data: Partial<MiChannel> = {}) {
|
||||
return await channelsRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function createChannelMuting(data: Partial<MiChannelMuting> = {}) {
|
||||
return await channelMutingRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelMutingRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function fetchChannelMuting() {
|
||||
return await channelMutingRepository.findBy({});
|
||||
}
|
||||
|
||||
async function createDriveFile(data: Partial<MiDriveFile> = {}) {
|
||||
return await driveFilesRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
md5: 'md5',
|
||||
name: 'name',
|
||||
size: 0,
|
||||
type: 'type',
|
||||
storedInternal: false,
|
||||
url: 'url',
|
||||
...data,
|
||||
})
|
||||
.then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
CoreModule,
|
||||
],
|
||||
providers: [
|
||||
GlobalEventService,
|
||||
IdService,
|
||||
ChannelMutingService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
service = app.get<ChannelMutingService>(ChannelMutingService);
|
||||
idService = app.get<IdService>(IdService);
|
||||
channelsRepository = app.get<ChannelsRepository>(DI.channelsRepository);
|
||||
channelMutingRepository = app.get<ChannelMutingRepository>(DI.channelMutingRepository);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
|
||||
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
alice = await createUser({ username: 'alice' });
|
||||
bob = await createUser({ username: 'bob' });
|
||||
driveFile1 = await createDriveFile();
|
||||
driveFile2 = await createDriveFile();
|
||||
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });
|
||||
channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id });
|
||||
channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await channelMutingRepository.deleteAll();
|
||||
await channelsRepository.deleteAll();
|
||||
await userProfilesRepository.deleteAll();
|
||||
await usersRepository.deleteAll();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
test('default', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[0].userId).toBe(alice.id);
|
||||
expect(mutings[0].user).toBeFalsy();
|
||||
expect(mutings[0].bannerId).toBe(driveFile1.id);
|
||||
expect(mutings[0].banner).toBeFalsy();
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
expect(mutings[1].userId).toBe(alice.id);
|
||||
expect(mutings[1].user).toBeFalsy();
|
||||
expect(mutings[1].bannerId).toBe(driveFile2.id);
|
||||
expect(mutings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('withoutExpires', async () => {
|
||||
const now = new Date();
|
||||
const past = new Date(now);
|
||||
const future = new Date(now);
|
||||
past.setMinutes(past.getMinutes() - 1);
|
||||
future.setMinutes(future.getMinutes() + 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel2.id);
|
||||
expect(mutings[1].id).toBe(channel3.id);
|
||||
});
|
||||
|
||||
test('idOnly', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
});
|
||||
|
||||
test('withoutExpires-idOnly', async () => {
|
||||
const now = new Date();
|
||||
const past = new Date(now);
|
||||
const future = new Date(now);
|
||||
past.setMinutes(past.getMinutes() - 1);
|
||||
future.setMinutes(future.getMinutes() + 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel2.id);
|
||||
expect(mutings[1].id).toBe(channel3.id);
|
||||
});
|
||||
|
||||
test('joinUser', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { joinUser: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[0].user).toEqual(alice);
|
||||
expect(mutings[0].banner).toBeFalsy();
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
expect(mutings[1].user).toEqual(alice);
|
||||
expect(mutings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('joinBannerFile', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[0].user).toBeFalsy();
|
||||
expect(mutings[0].banner).toEqual(driveFile1);
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
expect(mutings[1].user).toBeFalsy();
|
||||
expect(mutings[1].banner).toEqual(driveFile2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExpiredMutings', () => {
|
||||
test('default', async () => {
|
||||
const now = new Date();
|
||||
const future = new Date(now);
|
||||
const past = new Date(now);
|
||||
future.setMinutes(now.getMinutes() + 1);
|
||||
past.setMinutes(now.getMinutes() - 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past });
|
||||
|
||||
const mutings = await service.findExpiredMutings();
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].channelId).toBe(channel1.id);
|
||||
expect(mutings[1].channelId).toBe(channel3.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMuted', () => {
|
||||
test('isMuted: true', async () => {
|
||||
// キャッシュを読むのでServiceの機能を使って登録し、キャッシュを作成する
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id });
|
||||
|
||||
await setTimeout(500);
|
||||
|
||||
const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('isMuted: false', async () => {
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id });
|
||||
|
||||
await setTimeout(500);
|
||||
|
||||
const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mute', () => {
|
||||
test('default', async () => {
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
const muting = await fetchChannelMuting();
|
||||
expect(muting).toHaveLength(1);
|
||||
expect(muting[0].channelId).toBe(channel1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmute', () => {
|
||||
test('default', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
|
||||
let muting = await fetchChannelMuting();
|
||||
expect(muting).toHaveLength(1);
|
||||
expect(muting[0].channelId).toBe(channel1.id);
|
||||
|
||||
await service.unmute({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
muting = await fetchChannelMuting();
|
||||
expect(muting).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eraseExpiredMutings', () => {
|
||||
test('default', async () => {
|
||||
const now = new Date();
|
||||
const future = new Date(now);
|
||||
const past = new Date(now);
|
||||
future.setMinutes(now.getMinutes() + 1);
|
||||
past.setMinutes(now.getMinutes() - 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past });
|
||||
|
||||
await service.eraseExpiredMutings();
|
||||
|
||||
const mutings = await fetchChannelMuting();
|
||||
expect(mutings).toHaveLength(1);
|
||||
expect(mutings[0].channelId).toBe(channel2.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ describe('NoteCreateService', () => {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteChannelId: null,
|
||||
};
|
||||
|
||||
const poll: IPoll = {
|
||||
|
||||
@@ -44,6 +44,7 @@ const base: MiNote = {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteChannelId: null,
|
||||
};
|
||||
|
||||
describe('misc:is-renote', () => {
|
||||
|
||||
@@ -316,8 +316,12 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
||||
: new URL(path, new URL('resources/', import.meta.url));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob ??
|
||||
new File([new Uint8Array(await readFile(absPath))], basename(absPath.toString())));
|
||||
formData.append(
|
||||
'file',
|
||||
blob ?? new Blob([new Uint8Array(await readFile(absPath))]),
|
||||
basename(absPath.toString()),
|
||||
);
|
||||
|
||||
formData.append('force', 'true');
|
||||
if (name) {
|
||||
formData.append('name', name);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/node": "22.18.10",
|
||||
"@types/node": "24.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"rollup": "4.52.4",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.18.10",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
|
||||
@@ -20,3 +20,4 @@ export const instanceName = (siteName === 'Misskey' || siteName == null) ? host
|
||||
export const ui = localStorage.getItem('ui');
|
||||
export const debug = localStorage.getItem('debug') === 'true';
|
||||
export const isSafeMode = localStorage.getItem('isSafeMode') === 'true';
|
||||
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion)').matches;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.18.10",
|
||||
"@types/node": "24.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"esbuild": "0.25.10",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -24,7 +24,7 @@
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@sentry/vue": "10.19.0",
|
||||
"@sentry/vue": "10.20.0",
|
||||
"@syuilo/aiscript": "1.1.2",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
@@ -48,6 +48,7 @@
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"execa": "9.6.0",
|
||||
"exifreader": "4.32.0",
|
||||
"frontend-shared": "workspace:*",
|
||||
"icons-subsetter": "workspace:*",
|
||||
"idb-keyval": "6.2.2",
|
||||
@@ -109,7 +110,7 @@
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.18.10",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
|
||||
@@ -303,13 +303,6 @@ export async function mainBoot() {
|
||||
});
|
||||
}
|
||||
|
||||
if ('Notification' in window) {
|
||||
// 許可を得ていなかったらリクエスト
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
if (store.s.realtimeMode) {
|
||||
const stream = useStream();
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="$i != null && $i.id === channel.userId" style="color: var(--MI_THEME-warn)">
|
||||
<i class="ti ti-user-star ti-fw"></i>
|
||||
<span style="margin-left: 4px;">{{ i18n.ts.youAreAdmin }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article v-if="channel.description">
|
||||
@@ -48,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
@drop.prevent.stop="onDrop"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
|
||||
<div :class="$style.tipContainer">
|
||||
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
|
||||
</div>
|
||||
|
||||
<div :class="$style.folders">
|
||||
<XFolder
|
||||
@@ -802,6 +804,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.tipContainer:not(:empty) {
|
||||
padding: 16px 32px;
|
||||
}
|
||||
|
||||
.folders,
|
||||
.files {
|
||||
display: grid;
|
||||
@@ -811,6 +817,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@container (max-width: 600px) {
|
||||
.tipContainer:not(:empty) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.folders,
|
||||
.files {
|
||||
padding: 16px;
|
||||
|
||||
@@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
|
||||
<div
|
||||
:class="$style.embedCodeGenPreviewRoot"
|
||||
>
|
||||
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
|
||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
@@ -91,20 +89,18 @@ import { url } from '@@/js/config.js';
|
||||
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
@@ -314,10 +310,19 @@ onUnmounted(() => {
|
||||
|
||||
.embedCodeGenPreviewRoot {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
cursor: not-allowed;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewWrapper {
|
||||
|
||||
@@ -539,46 +539,44 @@ defineExpose({
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 5;
|
||||
}
|
||||
|
||||
&.w2 {
|
||||
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 6;
|
||||
}
|
||||
|
||||
&.w3 {
|
||||
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 7;
|
||||
}
|
||||
|
||||
&.w4 {
|
||||
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 8;
|
||||
}
|
||||
|
||||
&.w5 {
|
||||
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 9;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
|
||||
--rows: 4;
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--rows: 6;
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--rows: 8;
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
|
||||
--rows: 10;
|
||||
}
|
||||
|
||||
width: calc((var(--eachSize) * var(--columns)) + (#{$pad} * 2));
|
||||
height: calc((var(--eachSize) * var(--rows)) + (#{$pad} * 2));
|
||||
|
||||
&.asDrawer {
|
||||
width: 100% !important;
|
||||
|
||||
@@ -593,7 +591,7 @@ defineExpose({
|
||||
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
font-size: 30px;
|
||||
|
||||
> .config {
|
||||
@@ -635,7 +633,7 @@ defineExpose({
|
||||
::v-deep(section) {
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
font-size: 30px;
|
||||
|
||||
> .item {
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<MkFolder :defaultOpen="true" :canPage="false">
|
||||
<template #label>{{ fx.name }}</template>
|
||||
<template #label>{{ fx.uiDefinition.name }}</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
|
||||
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
@@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
|
||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
|
||||
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
|
||||
const fx = FXS.find((fx) => fx.id === layer.value.fxId);
|
||||
const fx = FXS[layer.value.fxId];
|
||||
if (fx == null) {
|
||||
throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'del'): void;
|
||||
(e: 'swapUp'): void;
|
||||
(e: 'swapDown'): void;
|
||||
(ev: 'del'): void;
|
||||
(ev: 'swapUp'): void;
|
||||
(ev: 'swapDown'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
@@ -64,6 +64,7 @@ import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
image: File;
|
||||
@@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]);
|
||||
|
||||
watch(layers, async () => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(layers);
|
||||
renderer.render(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
function addEffect(ev: MouseEvent) {
|
||||
os.popupMenu(FXS.map((fx) => ({
|
||||
text: fx.name,
|
||||
os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({
|
||||
text: fx.uiDefinition.name,
|
||||
action: () => {
|
||||
layers.push({
|
||||
id: genId(),
|
||||
fxId: fx.id,
|
||||
params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
|
||||
});
|
||||
fxId: id as keyof typeof FXS,
|
||||
params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])),
|
||||
} as ImageEffectorLayer);
|
||||
},
|
||||
})), ev.currentTarget ?? ev.target);
|
||||
}
|
||||
@@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
let renderer: ImageEffector<typeof FXS> | null = null;
|
||||
let renderer: ImageEffector | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -146,31 +147,36 @@ onMounted(async () => {
|
||||
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
try {
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
let w = imageBitmap.width;
|
||||
let h = imageBitmap.height;
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
let w = imageBitmap.width;
|
||||
let h = imageBitmap.height;
|
||||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
canvas: canvasEl.value,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.render(layers);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._imageEffector.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
canvas: canvasEl.value,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
fxs: FXS,
|
||||
});
|
||||
|
||||
await renderer.setLayers(layers);
|
||||
|
||||
renderer.render();
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
@@ -196,7 +202,7 @@ async function save() {
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
|
||||
renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
canvasEl.value.toBlob((blob) => {
|
||||
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
|
||||
dialog.value?.close();
|
||||
@@ -208,11 +214,10 @@ const enabled = ref(true);
|
||||
watch(enabled, () => {
|
||||
if (renderer != null) {
|
||||
if (enabled.value) {
|
||||
renderer.setLayers(layers);
|
||||
renderer.render(layers);
|
||||
} else {
|
||||
renderer.setLayers([]);
|
||||
renderer.render([]);
|
||||
}
|
||||
renderer.render();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
angle: 0,
|
||||
opacity: 1,
|
||||
color: [1, 1, 1],
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'blur') {
|
||||
@@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
radius: 3,
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'pixelate') {
|
||||
@@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
strength: 0.2,
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
const scaleY = Math.abs(y - startY);
|
||||
|
||||
const layerIndex = layers.findIndex((l) => l.id === id);
|
||||
const layer = layerIndex !== -1 ? layers[layerIndex] : null;
|
||||
const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null;
|
||||
if (layer != null) {
|
||||
layer.params.offsetX = (x + startX) - 1;
|
||||
layer.params.offsetY = (y + startY) - 1;
|
||||
@@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="1000"
|
||||
:height="600"
|
||||
:scroll="false"
|
||||
:withOkButton="true"
|
||||
@close="cancel()"
|
||||
@ok="save()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSelect
|
||||
v-model="params.font" :items="[
|
||||
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
|
||||
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelTop.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelTop.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelBottom.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelBottom.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkInfo>
|
||||
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
|
||||
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
|
||||
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
|
||||
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
|
||||
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
|
||||
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
|
||||
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
|
||||
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
|
||||
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
|
||||
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
|
||||
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
|
||||
</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
|
||||
import ExifReader from 'exifreader';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const props = defineProps<{
|
||||
presetEditMode?: boolean;
|
||||
preset?: ImageFramePreset | null;
|
||||
params?: ImageFrameParams | null;
|
||||
image?: File | null;
|
||||
imageCaption?: string | null;
|
||||
imageFilename?: string | null;
|
||||
}>();
|
||||
|
||||
const preset = deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
};
|
||||
|
||||
const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
|
||||
borderThickness: 0.05,
|
||||
borderRadius: 0,
|
||||
labelTop: {
|
||||
enabled: false,
|
||||
scale: 1.0,
|
||||
padding: 0.2,
|
||||
textBig: '',
|
||||
textSmall: '',
|
||||
centered: false,
|
||||
withQrCode: false,
|
||||
},
|
||||
labelBottom: {
|
||||
enabled: true,
|
||||
scale: 1.0,
|
||||
padding: 0.2,
|
||||
textBig: '{year}/{0month}/{0day}',
|
||||
textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
|
||||
centered: false,
|
||||
withQrCode: true,
|
||||
},
|
||||
bgColor: [1, 1, 1],
|
||||
fgColor: [0, 0, 0],
|
||||
font: 'sans-serif',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', frame: ImageFrameParams): void;
|
||||
(ev: 'presetOk', preset: ImageFramePreset): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
async function cancel() {
|
||||
if (props.presetEditMode) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
const updateThrottled = throttle(50, () => {
|
||||
if (renderer != null) {
|
||||
renderer.render(params);
|
||||
}
|
||||
});
|
||||
|
||||
watch(params, async (newValue, oldValue) => {
|
||||
updateThrottled();
|
||||
}, { deep: true });
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const sampleImage_3_2 = new Image();
|
||||
sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
|
||||
const sampleImage_3_2_loading = new Promise<void>(resolve => {
|
||||
sampleImage_3_2.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImage_2_3 = new Image();
|
||||
sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
|
||||
const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
||||
sampleImage_2_3.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (sampleImageType.value === 'provided') return;
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
});
|
||||
|
||||
let imageFile = props.image;
|
||||
|
||||
async function choiceImage() {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
if (files.length === 0) return;
|
||||
imageFile = files[0];
|
||||
sampleImageType.value = 'provided';
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: ImageFrameRenderer | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
async function initRenderer() {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
if (sampleImageType.value === '3_2') {
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage_3_2,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
} else if (sampleImageType.value === '2_3') {
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage_2_3,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
} else if (imageFile != null) {
|
||||
imageBitmap = await window.createImageBitmap(imageFile);
|
||||
|
||||
const exif = ExifReader.load(await imageFile.arrayBuffer());
|
||||
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: imageBitmap,
|
||||
exif: exif,
|
||||
caption: props.imageCaption ?? null,
|
||||
filename: props.imageFilename ?? null,
|
||||
renderAsPreview: true,
|
||||
});
|
||||
}
|
||||
|
||||
await renderer!.render(params);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const closeWaiting = os.waiting();
|
||||
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
try {
|
||||
await initRenderer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._imageFrameEditor.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.close();
|
||||
imageBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (props.presetEditMode) {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
preset.name = name || '';
|
||||
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('presetOk', {
|
||||
...preset,
|
||||
params: deepClone(params),
|
||||
});
|
||||
} else {
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('ok', params);
|
||||
}
|
||||
}
|
||||
|
||||
function getHex(c: [number, number, number]) {
|
||||
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function getRgb(hex: string | number): [number, number, number] | null {
|
||||
if (
|
||||
typeof hex === 'number' ||
|
||||
typeof hex !== 'string' ||
|
||||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
|
||||
if (m == null) return [0, 0, 0];
|
||||
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.previewControls {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.previewControlsButton {
|
||||
&.active {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.previewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -42,10 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { instanceName } from '@@/js/config.js';
|
||||
import { $i } from '@/i.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { apiWithDialog, promiseDialog } from '@/os.js';
|
||||
import { apiWithDialog, promiseDialog, alert } from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getAccounts } from '@/accounts.js';
|
||||
@@ -72,11 +73,28 @@ const supported = ref(false);
|
||||
const pushSubscription = ref<PushSubscription | null>(null);
|
||||
const pushRegistrationInServer = ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>();
|
||||
|
||||
function subscribe() {
|
||||
async function subscribe() {
|
||||
if (!registration.value || !supported.value || !instance.swPublickey) return;
|
||||
|
||||
if ('Notification' in window) {
|
||||
let permission = Notification.permission;
|
||||
|
||||
if (Notification.permission === 'default') {
|
||||
permission = await promiseDialog(Notification.requestPermission(), null, null, i18n.ts.pleaseAllowPushNotification);
|
||||
}
|
||||
|
||||
if (permission !== 'granted') {
|
||||
alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.browserPushNotificationDisabled,
|
||||
text: i18n.tsx.browserPushNotificationDisabledDescription({ serverName: instanceName }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
|
||||
return promiseDialog(registration.value.pushManager.subscribe({
|
||||
await promiseDialog(registration.value.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(instance.swPublickey),
|
||||
})
|
||||
|
||||
@@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div class="_panel" :class="$style.root">
|
||||
<div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''"></div>
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
<MkA :to="userPage(user)">
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
</MkA>
|
||||
<div :class="$style.title">
|
||||
<MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
|
||||
<p :class="$style.username"><MkAcct :user="user"/></p>
|
||||
@@ -19,15 +21,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
|
||||
</div>
|
||||
<div :class="$style.status">
|
||||
<div :class="$style.statusItem">
|
||||
<MkA :class="$style.statusItem" :to="userPage(user, 'notes')">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span>
|
||||
</div>
|
||||
<div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem">
|
||||
</MkA>
|
||||
<MkA v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'following')">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ number(user.followingCount) }}</span>
|
||||
</div>
|
||||
<div v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem">
|
||||
</MkA>
|
||||
<MkA v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'followers')">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span>
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<MkFollowButton v-if="user.id != $i?.id" :class="$style.follow" :user="user" mini/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<path d="M64,32C81.661,32 96,46.339 96,64C95.891,72.184 104,72 104,72C104,72 74.096,80 64,80C52.755,80 24,72 24,72C24,72 31.854,72.018 32,64C32,46.339 46.339,32 64,32Z" style="fill: var(--MI_THEME-popup);"/>
|
||||
</g>
|
||||
</svg>
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
<MkA :to="userPage(user)">
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
</MkA>
|
||||
<div :class="$style.title">
|
||||
<MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
|
||||
<div :class="$style.username"><MkAcct :user="user"/></div>
|
||||
@@ -32,18 +34,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
|
||||
</div>
|
||||
<div :class="$style.status">
|
||||
<div :class="$style.statusItem">
|
||||
<MkA :class="$style.statusItem" :to="userPage(user, 'notes')">
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
|
||||
<div>{{ number(user.notesCount) }}</div>
|
||||
</div>
|
||||
<div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem">
|
||||
</MkA>
|
||||
<MkA v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'following')">
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div>
|
||||
<div>{{ number(user.followingCount) }}</div>
|
||||
</div>
|
||||
<div v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem">
|
||||
</MkA>
|
||||
<MkA v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem" :to="userPage(user, 'followers')">
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div>
|
||||
<div>{{ number(user.followersCount) }}</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<button class="_button" :class="$style.menu" @click="showMenu"><i class="ti ti-dots"></i></button>
|
||||
<MkFollowButton v-if="$i && user.id != $i.id" v-model:user="user" :class="$style.follow" mini/>
|
||||
|
||||
@@ -345,7 +345,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
||||
@@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<template #label>
|
||||
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
||||
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
|
||||
@@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<XLayer
|
||||
v-model:layer="preset.layers[i]"
|
||||
v-model:layer="layers[i]"
|
||||
></XLayer>
|
||||
</MkFolder>
|
||||
|
||||
@@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
@@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
@@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
presetEditMode?: boolean;
|
||||
preset?: WatermarkPreset | null;
|
||||
layers?: WatermarkLayers | null;
|
||||
image?: File | null;
|
||||
}>();
|
||||
|
||||
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
||||
const preset = deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
layers: [],
|
||||
});
|
||||
};
|
||||
|
||||
const layers = reactive<WatermarkLayers>(props.layers ?? []);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', preset: WatermarkPreset): void;
|
||||
(ev: 'ok', layers: WatermarkLayers): void;
|
||||
(ev: 'presetOk', preset: WatermarkPreset): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
@@ -180,19 +186,21 @@ const emit = defineEmits<{
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
async function cancel() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (props.presetEditMode) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
emit('cancel');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
watch(layers, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(preset.layers);
|
||||
renderer.render(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
@@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
||||
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (sampleImageType.value === 'provided') return;
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
@@ -219,6 +228,20 @@ watch(sampleImageType, async () => {
|
||||
}
|
||||
});
|
||||
|
||||
let imageFile = props.image;
|
||||
|
||||
async function choiceImage() {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
if (files.length === 0) return;
|
||||
imageFile = files[0];
|
||||
sampleImageType.value = 'provided';
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: WatermarkRenderer | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
@@ -239,8 +262,8 @@ async function initRenderer() {
|
||||
renderHeight: 1500,
|
||||
image: sampleImage_2_3,
|
||||
});
|
||||
} else if (props.image != null) {
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
} else if (imageFile != null) {
|
||||
imageBitmap = await window.createImageBitmap(imageFile);
|
||||
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
@@ -249,8 +272,8 @@ async function initRenderer() {
|
||||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new WatermarkRenderer({
|
||||
@@ -261,9 +284,7 @@ async function initRenderer() {
|
||||
});
|
||||
}
|
||||
|
||||
await renderer!.setLayers(preset.layers);
|
||||
|
||||
renderer!.render();
|
||||
await renderer!.render(layers);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -274,7 +295,15 @@ onMounted(async () => {
|
||||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
await initRenderer();
|
||||
try {
|
||||
await initRenderer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._watermarkEditor.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
@@ -291,77 +320,93 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (props.presetEditMode) {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
preset.name = name || '';
|
||||
preset.name = name || '';
|
||||
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('presetOk', {
|
||||
...preset,
|
||||
layers: deepClone(layers),
|
||||
});
|
||||
} else {
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('ok', layers);
|
||||
}
|
||||
|
||||
emit('ok', preset);
|
||||
}
|
||||
|
||||
function addLayer(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._watermarkEditor.text,
|
||||
action: () => {
|
||||
preset.layers.push(createTextLayer());
|
||||
layers.push(createTextLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.image,
|
||||
action: () => {
|
||||
preset.layers.push(createImageLayer());
|
||||
layers.push(createImageLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.qr,
|
||||
action: () => {
|
||||
preset.layers.push(createQrLayer());
|
||||
layers.push(createQrLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.stripe,
|
||||
action: () => {
|
||||
preset.layers.push(createStripeLayer());
|
||||
layers.push(createStripeLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.polkadot,
|
||||
action: () => {
|
||||
preset.layers.push(createPolkadotLayer());
|
||||
layers.push(createPolkadotLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.checker,
|
||||
action: () => {
|
||||
preset.layers.push(createCheckerLayer());
|
||||
layers.push(createCheckerLayer());
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index > 0) {
|
||||
const tmp = preset.layers[index - 1];
|
||||
preset.layers[index - 1] = preset.layers[index];
|
||||
preset.layers[index] = tmp;
|
||||
const tmp = layers[index - 1];
|
||||
layers[index - 1] = layers[index];
|
||||
layers[index] = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
function swapDownLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||
if (index < preset.layers.length - 1) {
|
||||
const tmp = preset.layers[index + 1];
|
||||
preset.layers[index + 1] = preset.layers[index];
|
||||
preset.layers[index] = tmp;
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index < layers.length - 1) {
|
||||
const tmp = layers[index + 1];
|
||||
layers[index + 1] = layers[index];
|
||||
layers[index] = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
preset.layers = preset.layers.filter(l => l.id !== layer.id);
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index !== -1) {
|
||||
layers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
|
||||
@@ -47,9 +47,11 @@ defineProps<{
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
|
||||
<MkAvatar :class="$style.avatar" :user="$i"/>
|
||||
</div>
|
||||
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttonsLeft"/>
|
||||
<div v-else-if="!thin_ && narrow && !hideTitle" :class="[$style.buttons, $style.buttonsLeft]"></div>
|
||||
|
||||
<template v-if="pageMetadata">
|
||||
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
||||
@@ -18,9 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<i v-else-if="pageMetadata.icon" :class="[$style.titleIcon, pageMetadata.icon]"></i>
|
||||
|
||||
<div :class="$style.title">
|
||||
<div class="_nowrap" :class="$style.title">
|
||||
<MkUserName v-if="pageMetadata.userName" :user="pageMetadata.userName" :nowrap="true"/>
|
||||
<div v-else-if="pageMetadata.title">{{ pageMetadata.title }}</div>
|
||||
<div v-else-if="pageMetadata.title" class="_nowrap">{{ pageMetadata.title }}</div>
|
||||
<div v-if="pageMetadata.subtitle" :class="$style.subtitle">
|
||||
{{ pageMetadata.subtitle }}
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
|
||||
</template>
|
||||
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight">
|
||||
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="[$style.buttons, $style.buttonsRight]">
|
||||
<template v-for="action in actions">
|
||||
<button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
|
||||
</template>
|
||||
@@ -155,8 +155,10 @@ onUnmounted(() => {
|
||||
|
||||
.upper {
|
||||
--height: 50px;
|
||||
--margin: var(--MI-margin);
|
||||
display: flex;
|
||||
gap: var(--MI-margin);
|
||||
gap: var(--margin);
|
||||
align-items: center;
|
||||
height: var(--height);
|
||||
|
||||
.tabs:first-child {
|
||||
@@ -169,6 +171,7 @@ onUnmounted(() => {
|
||||
|
||||
&.thin {
|
||||
--height: 40px;
|
||||
--margin: 8px;
|
||||
|
||||
> .buttons {
|
||||
> .button {
|
||||
@@ -179,12 +182,8 @@ onUnmounted(() => {
|
||||
|
||||
&.slim {
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
|
||||
.tabs:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
> .titleContainer {
|
||||
.titleContainer {
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -197,7 +196,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.buttons {
|
||||
--margin: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: var(--height);
|
||||
@@ -207,16 +206,6 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsLeft {
|
||||
composes: buttons;
|
||||
margin: 0 var(--margin) 0 0;
|
||||
}
|
||||
|
||||
.buttonsRight {
|
||||
composes: buttons;
|
||||
margin: 0 0 0 var(--margin);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
$size: 32px;
|
||||
display: inline-block;
|
||||
@@ -231,7 +220,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--height);
|
||||
width: calc(var(--height) - (var(--margin)));
|
||||
width: calc(var(--height) - 8px);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
@@ -254,6 +243,7 @@ onUnmounted(() => {
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: min(30vw, 400px);
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
@@ -287,9 +277,6 @@ onUnmounted(() => {
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import isAnimated from 'is-file-animated';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
@@ -16,7 +18,6 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
|
||||
export type UploaderFeatures = {
|
||||
imageEditing?: boolean;
|
||||
@@ -28,13 +29,7 @@ const THUMBNAIL_SUPPORTED_TYPES = [
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
const IMAGE_COMPRESSION_SUPPORTED_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
const IMAGE_EDITING_SUPPORTED_TYPES = [
|
||||
@@ -49,11 +44,7 @@ const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO
|
||||
'video/x-matroska',
|
||||
];
|
||||
|
||||
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||
|
||||
const IMAGE_PREPROCESS_NEEDED_TYPES = [
|
||||
...WATERMARK_SUPPORTED_TYPES,
|
||||
...IMAGE_COMPRESSION_SUPPORTED_TYPES,
|
||||
...IMAGE_EDITING_SUPPORTED_TYPES,
|
||||
];
|
||||
|
||||
@@ -83,7 +74,9 @@ export type UploaderItem = {
|
||||
compressedSize?: number | null;
|
||||
preprocessedFile?: Blob | null;
|
||||
file: File;
|
||||
watermarkPresetId: string | null;
|
||||
watermarkPreset: WatermarkPreset | null;
|
||||
watermarkLayers: WatermarkLayers | null;
|
||||
imageFrameParams: ImageFrameParams | null;
|
||||
isSensitive?: boolean;
|
||||
caption?: string | null;
|
||||
abort?: (() => void) | null;
|
||||
@@ -135,6 +128,7 @@ export function useUploader(options: {
|
||||
const id = genId();
|
||||
const filename = file.name ?? 'untitled';
|
||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
||||
const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null;
|
||||
items.value.push({
|
||||
id,
|
||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
||||
@@ -146,8 +140,10 @@ export function useUploader(options: {
|
||||
aborted: false,
|
||||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
|
||||
watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null,
|
||||
compressionLevel: IMAGE_EDITING_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
|
||||
watermarkPreset,
|
||||
watermarkLayers: watermarkPreset?.layers ?? null,
|
||||
imageFrameParams: null,
|
||||
file: markRaw(file),
|
||||
});
|
||||
const reactiveItem = items.value.at(-1)!;
|
||||
@@ -253,7 +249,7 @@ export function useUploader(options: {
|
||||
},
|
||||
},*/ {
|
||||
icon: 'ti ti-sparkles',
|
||||
text: i18n.ts._imageEffector.title + ' (BETA)',
|
||||
text: i18n.ts._imageEffector.title,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
|
||||
image: item.file,
|
||||
@@ -280,13 +276,14 @@ export function useUploader(options: {
|
||||
if (
|
||||
uploaderFeatures.value.watermark &&
|
||||
$i.policies.watermarkAvailable &&
|
||||
WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
) {
|
||||
function changeWatermarkPreset(presetId: string | null) {
|
||||
item.watermarkPresetId = presetId;
|
||||
function change(layers: WatermarkLayers | null, preset?: WatermarkPreset | null) {
|
||||
item.watermarkPreset = preset ?? null;
|
||||
item.watermarkLayers = layers;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
@@ -295,43 +292,109 @@ export function useUploader(options: {
|
||||
menu.push({
|
||||
icon: 'ti ti-copyright',
|
||||
text: i18n.ts.watermark,
|
||||
caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name),
|
||||
caption: computed(() => item.watermarkPreset != null ? item.watermarkPreset.name : item.watermarkLayers != null ? i18n.ts.custom : null),
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'radioOption',
|
||||
text: i18n.ts.none,
|
||||
active: computed(() => item.watermarkPresetId == null),
|
||||
action: () => changeWatermarkPreset(null),
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, ...prefer.s.watermarkPresets.map(preset => ({
|
||||
type: 'radioOption' as const,
|
||||
text: preset.name,
|
||||
active: computed(() => item.watermarkPresetId === preset.id),
|
||||
action: () => changeWatermarkPreset(preset.id),
|
||||
})), ...(prefer.s.watermarkPresets.length > 0 ? [{
|
||||
type: 'divider' as const,
|
||||
}] : []), {
|
||||
type: 'button',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.add,
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.edit,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||
layers: item.watermarkLayers,
|
||||
image: item.file,
|
||||
}, {
|
||||
ok: (preset) => {
|
||||
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||
changeWatermarkPreset(preset.id);
|
||||
ok: (layers) => {
|
||||
change(layers);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => change(null),
|
||||
}, {
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'label',
|
||||
text: i18n.ts.presets,
|
||||
}, ...prefer.s.watermarkPresets.map(preset => ({
|
||||
type: 'radioOption' as const,
|
||||
text: preset.name,
|
||||
active: computed(() => item.watermarkPreset?.id === preset.id),
|
||||
action: () => change(preset.layers, preset),
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
|
||||
uploaderFeatures.value.imageEditing &&
|
||||
IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
) {
|
||||
function change(params: ImageFrameParams | null) {
|
||||
item.imageFrameParams = params;
|
||||
preprocess(item).then(() => {
|
||||
triggerRef(items);
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
icon: 'ti ti-device-ipad-horizontal',
|
||||
text: i18n.ts.frame,
|
||||
type: 'parent' as const,
|
||||
children: [{
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.edit,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
|
||||
params: item.imageFrameParams,
|
||||
image: item.file,
|
||||
imageCaption: item.caption ?? null,
|
||||
imageFilename: item.name,
|
||||
}, {
|
||||
ok: (params) => {
|
||||
change(params);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}, ...(item.imageFrameParams != null ? [{
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-x',
|
||||
text: i18n.ts.remove,
|
||||
action: () => change(null),
|
||||
}] : []), {
|
||||
type: 'divider' as const,
|
||||
}, {
|
||||
type: 'label' as const,
|
||||
text: i18n.ts.presets,
|
||||
}, ...prefer.s.imageFramePresets.map(preset => ({
|
||||
type: 'button' as const,
|
||||
text: preset.name,
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
|
||||
params: preset.params,
|
||||
image: item.file,
|
||||
imageCaption: item.caption ?? null,
|
||||
imageFilename: item.name,
|
||||
}, {
|
||||
ok: (params) => {
|
||||
change(params);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}))],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
@@ -545,10 +608,10 @@ export function useUploader(options: {
|
||||
|
||||
let preprocessedFile: Blob | File = item.file;
|
||||
|
||||
const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
|
||||
const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
|
||||
if (needsWatermark && preset != null) {
|
||||
const needsWatermark = item.watermarkLayers != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && $i.policies.watermarkAvailable;
|
||||
if (needsWatermark && item.watermarkLayers != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const WatermarkRenderer = await import('@/utility/watermark/WatermarkRenderer.js').then(x => x.WatermarkRenderer);
|
||||
const renderer = new WatermarkRenderer({
|
||||
canvas: canvas,
|
||||
renderWidth: imageBitmap.width,
|
||||
@@ -556,9 +619,7 @@ export function useUploader(options: {
|
||||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.setLayers(preset.layers);
|
||||
|
||||
renderer.render();
|
||||
await renderer.render(item.watermarkLayers);
|
||||
|
||||
preprocessedFile = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
@@ -571,8 +632,35 @@ export function useUploader(options: {
|
||||
});
|
||||
}
|
||||
|
||||
const needsImageFrame = item.imageFrameParams != null && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type);
|
||||
if (needsImageFrame && item.imageFrameParams != null) {
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const ExifReader = await import('exifreader');
|
||||
const exif = await ExifReader.load(await item.file.arrayBuffer());
|
||||
const ImageFrameRenderer = await import('@/utility/image-frame-renderer/ImageFrameRenderer.js').then(x => x.ImageFrameRenderer);
|
||||
const frameRenderer = new ImageFrameRenderer({
|
||||
canvas: canvas,
|
||||
image: await window.createImageBitmap(preprocessedFile),
|
||||
exif,
|
||||
caption: item.caption ?? null,
|
||||
filename: item.name,
|
||||
});
|
||||
|
||||
await frameRenderer.render(item.imageFrameParams);
|
||||
|
||||
preprocessedFile = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob == null) {
|
||||
throw new Error('Failed to convert canvas to blob');
|
||||
}
|
||||
resolve(blob);
|
||||
frameRenderer.destroy();
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
const compressionSettings = getCompressionSettings(item.compressionLevel);
|
||||
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));
|
||||
const needsCompress = item.compressionLevel !== 0 && compressionSettings && IMAGE_EDITING_SUPPORTED_TYPES.includes(preprocessedFile.type) && !(await isAnimated(preprocessedFile));
|
||||
|
||||
if (needsCompress) {
|
||||
const config = {
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createTexture, initShaderProgram } from '../utility/webgl.js';
|
||||
|
||||
export type ImageCompositorFunctionParams = Record<string, any>;
|
||||
|
||||
export type ImageCompositorFunction<PS extends ImageCompositorFunctionParams = ImageCompositorFunctionParams> = {
|
||||
shader: string;
|
||||
main: (ctx: {
|
||||
gl: WebGL2RenderingContext;
|
||||
program: WebGLProgram;
|
||||
params: PS;
|
||||
u: Record<string, WebGLUniformLocation>;
|
||||
width: number;
|
||||
height: number;
|
||||
textures: Map<string, { texture: WebGLTexture; width: number; height: number; }>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export type ImageCompositorLayer<FNS extends Record<string, ImageCompositorFunction> = any> = {
|
||||
[K in keyof FNS]: {
|
||||
id: string;
|
||||
functionId: K;
|
||||
params: Parameters<FNS[K]['main']>[0]['params'];
|
||||
};
|
||||
}[keyof FNS];
|
||||
|
||||
export function defineImageCompositorFunction<PS extends ImageCompositorFunctionParams>(fn: ImageCompositorFunction<PS>) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
// TODO: per layer cache
|
||||
|
||||
export class ImageCompositor<FNS extends Record<string, ImageCompositorFunction<any>>> {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private renderWidth: number;
|
||||
private renderHeight: number;
|
||||
private baseTexture: WebGLTexture;
|
||||
private shaderCache: Map<string, WebGLProgram> = new Map();
|
||||
private perLayerResultTextures: Map<string, WebGLTexture> = new Map();
|
||||
private perLayerResultFrameBuffers: Map<string, WebGLFramebuffer> = new Map();
|
||||
private nopProgram: WebGLProgram;
|
||||
private registeredTextures: Map<string, { texture: WebGLTexture; width: number; height: number; }> = new Map();
|
||||
private registeredFunctions: Map<string, ImageCompositorFunction & { id: string; uniforms: string[] }> = new Map();
|
||||
|
||||
constructor(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
renderWidth: number;
|
||||
renderHeight: number;
|
||||
image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | null;
|
||||
functions: FNS;
|
||||
}) {
|
||||
this.canvas = options.canvas;
|
||||
this.renderWidth = options.renderWidth;
|
||||
this.renderHeight = options.renderHeight;
|
||||
|
||||
this.canvas.width = this.renderWidth;
|
||||
this.canvas.height = this.renderHeight;
|
||||
|
||||
const gl = this.canvas.getContext('webgl2', {
|
||||
preserveDrawingBuffer: false,
|
||||
alpha: true,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
|
||||
if (gl == null) throw new Error('Failed to initialize WebGL2 context');
|
||||
|
||||
this.gl = gl;
|
||||
|
||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
|
||||
|
||||
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
if (options.image != null) {
|
||||
this.baseTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, options.image.width, options.image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, options.image);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
} else {
|
||||
this.baseTexture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
}
|
||||
|
||||
this.nopProgram = initShaderProgram(this.gl, `#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position * vec2(1.0, -1.0), 0.0, 1.0);
|
||||
}
|
||||
`, `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D u_texture;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = texture(u_texture, in_uv);
|
||||
}
|
||||
`);
|
||||
|
||||
// レジスタ番号はシェーダープログラムに属しているわけではなく、独立の存在なので、とりあえず nopProgram を使って設定する(その後は効果が持続する)
|
||||
// ref. https://qiita.com/emadurandal/items/5966c8374f03d4de3266
|
||||
const positionLocation = gl.getAttribLocation(this.nopProgram, 'position');
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
|
||||
for (const [id, fn] of Object.entries(options.functions)) {
|
||||
const uniforms = this.extractUniformNamesFromShader(fn.shader);
|
||||
this.registeredFunctions.set(id, { ...fn, id, uniforms });
|
||||
}
|
||||
}
|
||||
|
||||
private extractUniformNamesFromShader(shader: string): string[] {
|
||||
const uniformRegex = /uniform\s+\w+\s+(\w+)\s*;/g;
|
||||
const uniforms: string[] = [];
|
||||
let match;
|
||||
while ((match = uniformRegex.exec(shader)) !== null) {
|
||||
uniforms.push(match[1].replace(/^u_/, ''));
|
||||
}
|
||||
return uniforms;
|
||||
}
|
||||
|
||||
private renderLayer(layer: ImageCompositorLayer, preTexture: WebGLTexture, invert = false) {
|
||||
const gl = this.gl;
|
||||
|
||||
const fn = this.registeredFunctions.get(layer.functionId);
|
||||
if (fn == null) return;
|
||||
|
||||
const cachedShader = this.shaderCache.get(fn.id);
|
||||
const shaderProgram = cachedShader ?? initShaderProgram(this.gl, `#version 300 es
|
||||
in vec2 position;
|
||||
uniform bool u_invert;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = u_invert ? vec4(position * vec2(1.0, -1.0), 0.0, 1.0) : vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`, fn.shader);
|
||||
if (cachedShader == null) {
|
||||
this.shaderCache.set(fn.id, shaderProgram);
|
||||
}
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
|
||||
gl.uniform2fv(in_resolution, [this.renderWidth, this.renderHeight]);
|
||||
|
||||
const u_invert = gl.getUniformLocation(shaderProgram, 'u_invert');
|
||||
gl.uniform1i(u_invert, invert ? 1 : 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, preTexture);
|
||||
const in_texture = gl.getUniformLocation(shaderProgram, 'in_texture');
|
||||
gl.uniform1i(in_texture, 0);
|
||||
|
||||
fn.main({
|
||||
gl: gl,
|
||||
program: shaderProgram,
|
||||
params: layer.params,
|
||||
u: Object.fromEntries(fn.uniforms.map(u => [u, gl.getUniformLocation(shaderProgram, 'u_' + u)!])),
|
||||
width: this.renderWidth,
|
||||
height: this.renderHeight,
|
||||
textures: this.registeredTextures,
|
||||
});
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
public render(layers: (ImageCompositorLayer<FNS>)[]) {
|
||||
const gl = this.gl;
|
||||
|
||||
// 入力をそのまま出力
|
||||
if (layers.length === 0) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.baseTexture);
|
||||
|
||||
gl.useProgram(this.nopProgram);
|
||||
gl.uniform1i(gl.getUniformLocation(this.nopProgram, 'u_texture')!, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
let preTexture = this.baseTexture;
|
||||
|
||||
for (const layer of layers) {
|
||||
const isLast = layer === layers.at(-1);
|
||||
|
||||
const cachedResultTexture = this.perLayerResultTextures.get(layer.id);
|
||||
const resultTexture = cachedResultTexture ?? createTexture(gl);
|
||||
if (cachedResultTexture == null) {
|
||||
this.perLayerResultTextures.set(layer.id, resultTexture);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, resultTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.renderWidth, this.renderHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
if (isLast) {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
} else {
|
||||
const cachedResultFrameBuffer = this.perLayerResultFrameBuffers.get(layer.id);
|
||||
const resultFrameBuffer = cachedResultFrameBuffer ?? gl.createFramebuffer();
|
||||
if (cachedResultFrameBuffer == null) {
|
||||
this.perLayerResultFrameBuffers.set(layer.id, resultFrameBuffer);
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, resultFrameBuffer);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, resultTexture, 0);
|
||||
}
|
||||
|
||||
this.renderLayer(layer, preTexture, isLast);
|
||||
|
||||
preTexture = resultTexture;
|
||||
}
|
||||
}
|
||||
|
||||
public registerTexture(key: string, image: ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
|
||||
const gl = this.gl;
|
||||
|
||||
const existing = this.registeredTextures.get(key);
|
||||
if (existing != null) {
|
||||
gl.deleteTexture(existing.texture);
|
||||
this.registeredTextures.delete(key);
|
||||
}
|
||||
|
||||
const texture = createTexture(gl);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||||
|
||||
this.registeredTextures.set(key, {
|
||||
texture: texture,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
public unregisterTexture(key: string) {
|
||||
const gl = this.gl;
|
||||
|
||||
const existing = this.registeredTextures.get(key);
|
||||
if (existing != null) {
|
||||
gl.deleteTexture(existing.texture);
|
||||
this.registeredTextures.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
public hasTexture(key: string) {
|
||||
return this.registeredTextures.has(key);
|
||||
}
|
||||
|
||||
public getKeysOfRegisteredTextures() {
|
||||
return this.registeredTextures.keys();
|
||||
}
|
||||
|
||||
public changeResolution(width: number, height: number) {
|
||||
if (this.renderWidth === width && this.renderHeight === height) return;
|
||||
|
||||
this.renderWidth = width;
|
||||
this.renderHeight = height;
|
||||
if (this.canvas) {
|
||||
this.canvas.width = this.renderWidth;
|
||||
this.canvas.height = this.renderHeight;
|
||||
}
|
||||
this.gl.viewport(0, 0, this.renderWidth, this.renderHeight);
|
||||
}
|
||||
|
||||
/*
|
||||
* disposeCanvas = true だとloseContextを呼ぶため、コンストラクタで渡されたcanvasも再利用不可になるので注意
|
||||
*/
|
||||
public destroy(disposeCanvas = true) {
|
||||
this.gl.deleteProgram(this.nopProgram);
|
||||
|
||||
for (const shader of this.shaderCache.values()) {
|
||||
this.gl.deleteProgram(shader);
|
||||
}
|
||||
this.shaderCache.clear();
|
||||
|
||||
for (const texture of this.perLayerResultTextures.values()) {
|
||||
this.gl.deleteTexture(texture);
|
||||
}
|
||||
this.perLayerResultTextures.clear();
|
||||
|
||||
for (const framebuffer of this.perLayerResultFrameBuffers.values()) {
|
||||
this.gl.deleteFramebuffer(framebuffer);
|
||||
}
|
||||
this.perLayerResultFrameBuffers.clear();
|
||||
|
||||
for (const texture of this.registeredTextures.values()) {
|
||||
this.gl.deleteTexture(texture.texture);
|
||||
}
|
||||
this.registeredTextures.clear();
|
||||
|
||||
this.gl.deleteTexture(this.baseTexture);
|
||||
|
||||
if (disposeCanvas) {
|
||||
const loseContextExt = this.gl.getExtension('WEBGL_lose_context');
|
||||
if (loseContextExt) loseContextExt.loseContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
// PIZZAX --- A lightweight store
|
||||
|
||||
// TODO: Misskeyのドメイン知識があるのでutilityなどに移動する
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
import { BroadcastChannel } from 'broadcast-channel';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
@@ -137,6 +137,7 @@ import FormSplit from '@/components/form/split.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
const initStats = () => misskeyApi('stats', {});
|
||||
</script>
|
||||
|
||||
@@ -6,9 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<PageWithHeader>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 500px;">
|
||||
<div class="_gaps">
|
||||
<div v-if="instance.ads.length > 0" class="_gaps">
|
||||
<MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/>
|
||||
</div>
|
||||
<MkResult v-else type="empty"/>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="$style.bannerStatus">
|
||||
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
|
||||
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
|
||||
<div v-if="$i != null && channel != null && $i.id === channel.userId" style="color: var(--MI_THEME-warn)"><i class="ti ti-user-star ti-fw"></i><span style="margin-left: 4px;">{{ i18n.ts.youAreAdmin }}</span></div>
|
||||
</div>
|
||||
<div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div>
|
||||
<div :class="$style.bannerFade"></div>
|
||||
@@ -128,24 +129,25 @@ useInterval(() => {
|
||||
});
|
||||
|
||||
watch(() => props.channelId, async () => {
|
||||
channel.value = await misskeyApi('channels/show', {
|
||||
const _channel = await misskeyApi('channels/show', {
|
||||
channelId: props.channelId,
|
||||
});
|
||||
if (channel.value == null) return; // TSを黙らすため
|
||||
|
||||
favorited.value = channel.value.isFavorited ?? false;
|
||||
if (favorited.value || channel.value.isFollowing) {
|
||||
favorited.value = _channel.isFavorited ?? false;
|
||||
if (favorited.value || _channel.isFollowing) {
|
||||
tab.value = 'timeline';
|
||||
}
|
||||
|
||||
if ((favorited.value || channel.value.isFollowing) && channel.value.lastNotedAt) {
|
||||
const lastReadedAt: number = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.value.id}`) ?? 0;
|
||||
const lastNotedAt = Date.parse(channel.value.lastNotedAt);
|
||||
if ((favorited.value || _channel.isFollowing) && _channel.lastNotedAt) {
|
||||
const lastReadedAt: number = miLocalStorage.getItemAsJson(`channelLastReadedAt:${_channel.id}`) ?? 0;
|
||||
const lastNotedAt = Date.parse(_channel.lastNotedAt);
|
||||
|
||||
if (lastNotedAt > lastReadedAt) {
|
||||
miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, lastNotedAt);
|
||||
miLocalStorage.setItemAsJson(`channelLastReadedAt:${_channel.id}`, lastNotedAt);
|
||||
}
|
||||
}
|
||||
|
||||
channel.value = _channel;
|
||||
}, { immediate: true });
|
||||
|
||||
function edit() {
|
||||
@@ -189,6 +191,53 @@ async function unfavorite() {
|
||||
});
|
||||
}
|
||||
|
||||
async function mute() {
|
||||
if (!channel.value) return;
|
||||
const _channel = channel.value;
|
||||
|
||||
const { canceled, result: period } = await os.select({
|
||||
title: i18n.ts.mutePeriod,
|
||||
items: [{
|
||||
value: 'indefinitely', text: i18n.ts.indefinitely,
|
||||
}, {
|
||||
value: 'tenMinutes', text: i18n.ts.tenMinutes,
|
||||
}, {
|
||||
value: 'oneHour', text: i18n.ts.oneHour,
|
||||
}, {
|
||||
value: 'oneDay', text: i18n.ts.oneDay,
|
||||
}, {
|
||||
value: 'oneWeek', text: i18n.ts.oneWeek,
|
||||
}],
|
||||
default: 'indefinitely',
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
const expiresAt = period === 'indefinitely' ? null
|
||||
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
|
||||
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
|
||||
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
|
||||
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
|
||||
: null;
|
||||
|
||||
os.apiWithDialog('channels/mute/create', {
|
||||
channelId: _channel.id,
|
||||
expiresAt,
|
||||
}).then(() => {
|
||||
_channel.isMuting = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function unmute() {
|
||||
if (!channel.value) return;
|
||||
const _channel = channel.value;
|
||||
|
||||
os.apiWithDialog('channels/mute/delete', {
|
||||
channelId: _channel.id,
|
||||
}).then(() => {
|
||||
_channel.isMuting = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!channel.value) return;
|
||||
|
||||
@@ -242,6 +291,24 @@ const headerActions = computed(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!channel.value.isMuting) {
|
||||
headerItems.push({
|
||||
icon: 'ti ti-volume',
|
||||
text: i18n.ts.mute,
|
||||
handler: async (): Promise<void> => {
|
||||
await mute();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
headerItems.push({
|
||||
icon: 'ti ti-volume-off',
|
||||
text: i18n.ts.unmute,
|
||||
handler: async (): Promise<void> => {
|
||||
await unmute();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (($i && $i.id === channel.value.userId) || iAmModerator) {
|
||||
headerItems.push({
|
||||
icon: 'ti ti-settings',
|
||||
|
||||
@@ -44,8 +44,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'owned'">
|
||||
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
|
||||
<div v-else-if="tab === 'owned'" class="_gaps">
|
||||
<MkButton link primary rounded to="/channels/new"><i class="ti ti-plus"></i> {{ i18n.ts.createNew }}</MkButton>
|
||||
<MkPagination v-slot="{items}" :paginator="ownedPaginator">
|
||||
<div :class="$style.root">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
|
||||
@@ -126,15 +126,7 @@ async function search() {
|
||||
key.value = query + type;
|
||||
}
|
||||
|
||||
function create() {
|
||||
router.push('/channels/new');
|
||||
}
|
||||
|
||||
const headerActions = computed(() => [{
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.create,
|
||||
handler: create,
|
||||
}]);
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'search',
|
||||
|
||||
@@ -5,9 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div class="_gaps_s">
|
||||
<div v-if="roles != null && roles.length > 0" class="_gaps_s">
|
||||
<MkRolePreview v-for="role in roles" :key="role.id" :role="role" :forModeration="false"/>
|
||||
</div>
|
||||
<MkLoading v-else-if="loading" />
|
||||
<MkResult v-else type="empty" :text="i18n.ts.noRole"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,12 +17,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
const roles = ref<Misskey.entities.Role[] | null>(null);
|
||||
const loading = ref(true);
|
||||
|
||||
misskeyApi('roles/list').then(res => {
|
||||
roles.value = res.filter(x => x.target === 'manual').sort((a, b) => b.displayOrder - a.displayOrder);
|
||||
}).finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkFolder :defaultOpen="false" :canPage="false">
|
||||
<template #icon><i class="ti ti-pencil"></i></template>
|
||||
<template #label>{{ i18n.ts.preset }}: {{ preset.name === '' ? '(' + i18n.ts.noName + ')' : preset.name }}</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
|
||||
<MkButton danger iconOnly style="margin-left: auto;" @click="del"><i class="ti ti-trash"></i></MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
preset: ImageFramePreset;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'updatePreset', preset: ImageFramePreset): void,
|
||||
(ev: 'del'): void,
|
||||
}>();
|
||||
|
||||
async function edit() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkImageFrameEditorDialog.vue')), {
|
||||
presetEditMode: true,
|
||||
preset: deepClone(props.preset),
|
||||
params: deepClone(props.preset.params),
|
||||
}, {
|
||||
presetOk: (preset) => {
|
||||
emit('updatePreset', preset);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function del(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.delete,
|
||||
action: () => {
|
||||
emit('del');
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const sampleImage = new Image();
|
||||
sampleImage.src = '/client-assets/sample/3-2.jpg';
|
||||
|
||||
let renderer: ImageFrameRenderer | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
sampleImage.onload = async () => {
|
||||
watch(canvasEl, async () => {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
|
||||
await renderer.render(props.preset.params);
|
||||
}, { immediate: true });
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.preset, async () => {
|
||||
if (renderer != null) {
|
||||
await renderer.render(props.preset.params);
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.previewCanvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 200px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -22,8 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
@@ -41,9 +41,11 @@ const emit = defineEmits<{
|
||||
|
||||
async function edit() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWatermarkEditorDialog.vue')), {
|
||||
presetEditMode: true,
|
||||
preset: deepClone(props.preset),
|
||||
layers: deepClone(props.preset.layers),
|
||||
}, {
|
||||
ok: (preset) => {
|
||||
presetOk: (preset) => {
|
||||
emit('updatePreset', preset);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
@@ -78,9 +80,7 @@ onMounted(() => {
|
||||
image: sampleImage,
|
||||
});
|
||||
|
||||
await renderer.setLayers(props.preset.layers);
|
||||
|
||||
renderer.render();
|
||||
await renderer.render(props.preset.layers);
|
||||
}, { immediate: true });
|
||||
};
|
||||
});
|
||||
@@ -94,8 +94,7 @@ onUnmounted(() => {
|
||||
|
||||
watch(() => props.preset, async () => {
|
||||
if (renderer != null) {
|
||||
await renderer.setLayers(props.preset.layers);
|
||||
renderer.render();
|
||||
await renderer.render(props.preset.layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
@@ -124,6 +124,34 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['label', 'frame', 'credit', 'metadata']">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-device-ipad-horizontal"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.frame }}</SearchLabel></template>
|
||||
<template #caption>{{ i18n.ts._imageFrameEditor.tip }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
<XImageFrameItem
|
||||
v-for="(preset, i) in prefer.r.imageFramePresets.value"
|
||||
:key="preset.id"
|
||||
:preset="preset"
|
||||
@updatePreset="onUpdateImageFramePreset(preset.id, $event)"
|
||||
@del="onDeleteImageFramePreset(preset.id)"
|
||||
/>
|
||||
|
||||
<MkButton iconOnly rounded style="margin: 0 auto;" @click="addImageFramePreset"><i class="ti ti-plus"></i></MkButton>
|
||||
|
||||
<SearchMarker :keywords="['sync', 'frame', 'label', 'preset', 'devices']">
|
||||
<MkSwitch :modelValue="imageFramePresetsSyncEnabled" @update:modelValue="changeImageFramePresetsSyncEnabled">
|
||||
<template #label><i class="ti ti-cloud-cog"></i> <SearchLabel>{{ i18n.ts.syncBetweenDevices }}</SearchLabel></template>
|
||||
</MkSwitch>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['default', 'image', 'compression']">
|
||||
<MkPreferenceContainer k="defaultImageCompressionLevel">
|
||||
<MkSelect
|
||||
@@ -175,7 +203,9 @@ import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import XWatermarkItem from './drive.WatermarkItem.vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import XImageFrameItem from './drive.ImageFrameItem.vue';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
@@ -195,6 +225,7 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
||||
import { selectDriveFolder } from '@/utility/drive.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
@@ -236,6 +267,20 @@ function changeWatermarkPresetsSyncEnabled(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const imageFramePresetsSyncEnabled = ref(prefer.isSyncEnabled('imageFramePresets'));
|
||||
|
||||
function changeImageFramePresetsSyncEnabled(value: boolean) {
|
||||
if (value) {
|
||||
prefer.enableSync('imageFramePresets').then((res) => {
|
||||
if (res == null) return;
|
||||
if (res.enabled) imageFramePresetsSyncEnabled.value = true;
|
||||
});
|
||||
} else {
|
||||
prefer.disableSync('imageFramePresets');
|
||||
imageFramePresetsSyncEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
misskeyApi('drive').then(info => {
|
||||
capacity.value = info.capacity;
|
||||
usage.value = info.usage;
|
||||
@@ -266,8 +311,11 @@ function chooseUploadFolder() {
|
||||
|
||||
async function addWatermarkPreset() {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
|
||||
presetEditMode: true,
|
||||
preset: null,
|
||||
layers: [],
|
||||
}, {
|
||||
ok: (preset: WatermarkPreset) => {
|
||||
presetOk: (preset) => {
|
||||
prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
@@ -299,6 +347,40 @@ function onDeleteWatermarkPreset(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateImageFramePreset(id: string, preset: ImageFramePreset) {
|
||||
const index = prefer.s.imageFramePresets.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
prefer.commit('imageFramePresets', [
|
||||
...prefer.s.imageFramePresets.slice(0, index),
|
||||
preset,
|
||||
...prefer.s.imageFramePresets.slice(index + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteImageFramePreset(id: string) {
|
||||
const index = prefer.s.imageFramePresets.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
prefer.commit('imageFramePresets', [
|
||||
...prefer.s.imageFramePresets.slice(0, index),
|
||||
...prefer.s.imageFramePresets.slice(index + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function addImageFramePreset() {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageFrameEditorDialog.vue').then(x => x.default), {
|
||||
presetEditMode: true,
|
||||
preset: null,
|
||||
params: null,
|
||||
}, {
|
||||
presetOk: (preset) => {
|
||||
prefer.commit('imageFramePresets', [...prefer.s.imageFramePresets, preset]);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
misskeyApi('i/update', {
|
||||
alwaysMarkNsfw: !!alwaysMarkNsfw.value,
|
||||
|
||||
@@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<SearchMarker path="/settings/emoji-palette" :label="i18n.ts.emojiPalette" :keywords="['emoji', 'palette']" icon="ti ti-mood-happy">
|
||||
<div class="_gaps_m">
|
||||
<MkFeatureBanner icon="/client-assets/artist_palette_3d.png" color="#ff9100">
|
||||
<SearchText>{{ i18n.ts._settings.emojiPaletteBanner }}</SearchText>
|
||||
</MkFeatureBanner>
|
||||
|
||||
<FormSection first>
|
||||
<template #label>{{ i18n.ts._emojiPalette.palettes }}</template>
|
||||
|
||||
@@ -123,6 +127,7 @@ import { computed, ref, watch } from 'vue';
|
||||
import XPalette from './emoji-palette.palette.vue';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
|
||||
@@ -54,7 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #icon><SearchIcon><i class="ti ti-badges"></i></SearchIcon></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.rolesAssignedToMe }}</SearchLabel></template>
|
||||
|
||||
<MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/>
|
||||
<div class="_gaps_s">
|
||||
<MkRolePreview v-for="role in $i.roles" :key="role.id" :role="role" :forModeration="false"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
@@ -134,16 +136,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<hr>
|
||||
|
||||
<MkButton @click="forceCloudBackup">Force cloud backup</MkButton>
|
||||
|
||||
<hr>
|
||||
|
||||
<template v-if="$i.policies.chatAvailability !== 'unavailable'">
|
||||
<MkButton @click="readAllChatMessages">Read all chat messages</MkButton>
|
||||
<MkButton @click="readAllChatMessages">{{ i18n.ts.readAllChatMessages }}</MkButton>
|
||||
|
||||
<hr>
|
||||
</template>
|
||||
|
||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||
|
||||
<FormSlot>
|
||||
<MkButton danger @click="migrate"><i class="ti ti-refresh"></i> {{ i18n.ts.migrateOldSettings }}</MkButton>
|
||||
<template #caption>{{ i18n.ts.migrateOldSettings_description }}</template>
|
||||
|
||||
@@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</dl>
|
||||
</div>
|
||||
<div class="status">
|
||||
<MkA :to="userPage(user)">
|
||||
<MkA :to="userPage(user, 'notes')">
|
||||
<b>{{ number(user.notesCount) }}</b>
|
||||
<span>{{ i18n.ts.notes }}</span>
|
||||
</MkA>
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { hemisphere } from '@@/js/intl-const.js';
|
||||
import { prefersReducedMotion } from '@@/js/config.js';
|
||||
import { definePreferences } from './manager.js';
|
||||
import type { Theme } from '@/theme.js';
|
||||
import type { SoundType } from '@/utility/sound.js';
|
||||
import type { Plugin } from '@/plugin.js';
|
||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||
import type { DeckProfile } from '@/deck.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark.js';
|
||||
import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import type { ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js';
|
||||
import { deepEqual } from '@/utility/deep-equal.js';
|
||||
@@ -211,10 +213,10 @@ export const PREF_DEF = definePreferences({
|
||||
default: false,
|
||||
},
|
||||
animation: {
|
||||
default: !window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
default: !prefersReducedMotion,
|
||||
},
|
||||
animatedMfm: {
|
||||
default: !window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
default: !prefersReducedMotion,
|
||||
},
|
||||
advancedMfm: {
|
||||
default: true,
|
||||
@@ -232,7 +234,7 @@ export const PREF_DEF = definePreferences({
|
||||
default: false,
|
||||
},
|
||||
disableShowingAnimatedImages: {
|
||||
default: window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
default: prefersReducedMotion,
|
||||
},
|
||||
emojiStyle: {
|
||||
default: 'twemoji', // twemoji / fluentEmoji / native
|
||||
@@ -436,6 +438,26 @@ export const PREF_DEF = definePreferences({
|
||||
accountDependent: true,
|
||||
default: null as WatermarkPreset['id'] | null,
|
||||
},
|
||||
imageFramePresets: {
|
||||
accountDependent: true,
|
||||
default: [] as ImageFramePreset[],
|
||||
mergeStrategy: (a, b) => {
|
||||
const mergedItems = [] as typeof a;
|
||||
for (const x of a.concat(b)) {
|
||||
const sameIdItem = mergedItems.find(y => y.id === x.id);
|
||||
if (sameIdItem != null) {
|
||||
if (deepEqual(x, sameIdItem)) { // 完全な重複は無視
|
||||
continue;
|
||||
} else { // IDは同じなのに内容が違う場合はマージ不可とする
|
||||
throw new Error();
|
||||
}
|
||||
} else {
|
||||
mergedItems.push(x);
|
||||
}
|
||||
}
|
||||
return mergedItems;
|
||||
},
|
||||
},
|
||||
defaultImageCompressionLevel: {
|
||||
default: 2 as 0 | 1 | 2 | 3,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import lightTheme from '@@/themes/l-light.json5';
|
||||
import darkTheme from '@@/themes/d-green-lime.json5';
|
||||
import { prefersReducedMotion } from '@@/js/config.js';
|
||||
import { hemisphere } from '@@/js/intl-const.js';
|
||||
import type { DeviceKind } from '@/utility/device-kind.js';
|
||||
import type { Plugin } from '@/plugin.js';
|
||||
@@ -220,11 +221,11 @@ export const store = markRaw(new Pizzax('base', {
|
||||
},
|
||||
animation: {
|
||||
where: 'device',
|
||||
default: !window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
default: !prefersReducedMotion,
|
||||
},
|
||||
animatedMfm: {
|
||||
where: 'device',
|
||||
default: !window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
default: !prefersReducedMotion,
|
||||
},
|
||||
advancedMfm: {
|
||||
where: 'device',
|
||||
@@ -248,7 +249,7 @@ export const store = markRaw(new Pizzax('base', {
|
||||
},
|
||||
disableShowingAnimatedImages: {
|
||||
where: 'device',
|
||||
default: window.matchMedia('(prefers-reduced-motion)').matches,
|
||||
default: prefersReducedMotion,
|
||||
},
|
||||
emojiStyle: {
|
||||
where: 'device',
|
||||
|
||||
@@ -146,17 +146,26 @@ export function applyTheme(theme: Theme, persist = true) {
|
||||
|
||||
if (window.document.startViewTransition != null) {
|
||||
window.document.documentElement.classList.add('_themeChanging_');
|
||||
window.document.startViewTransition(async () => {
|
||||
applyThemeInternal(theme, persist);
|
||||
await nextTick();
|
||||
}).finished.then(() => {
|
||||
try {
|
||||
window.document.startViewTransition(async () => {
|
||||
applyThemeInternal(theme, persist);
|
||||
await nextTick();
|
||||
}).finished.then(() => {
|
||||
window.document.documentElement.classList.remove('_themeChanging_');
|
||||
globalEvents.emit('themeChanged');
|
||||
});
|
||||
} catch (err) {
|
||||
// 様々な理由により startViewTransition は失敗することがある
|
||||
// ref. https://github.com/misskey-dev/misskey/issues/16562
|
||||
|
||||
console.error(err);
|
||||
|
||||
window.document.documentElement.classList.remove('_themeChanging_');
|
||||
// 色計算など再度行えるようにクライアント全体に通知
|
||||
applyThemeInternal(theme, persist);
|
||||
globalEvents.emit('themeChanged');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
applyThemeInternal(theme, persist);
|
||||
// 色計算など再度行えるようにクライアント全体に通知
|
||||
globalEvents.emit('themeChanged');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/>
|
||||
</button>
|
||||
<button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode">
|
||||
<i class="ti ti-bolt ti-fw"></i>
|
||||
<i v-if="store.r.realtimeMode.value" class="ti ti-bolt ti-fw"></i>
|
||||
<i v-else class="ti ti-bolt-off ti-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="$style.middle">
|
||||
@@ -54,7 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<i class="ti ti-apps ti-fw"></i>
|
||||
</button>
|
||||
<button v-if="iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode">
|
||||
<i class="ti ti-bolt ti-fw"></i>
|
||||
<i v-if="store.r.realtimeMode.value" class="ti ti-bolt ti-fw"></i>
|
||||
<i v-else class="ti ti-bolt-off ti-fw"></i>
|
||||
</button>
|
||||
<button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }">
|
||||
<i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span>
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
|
||||
import _confetti from 'canvas-confetti';
|
||||
import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
export function confetti(options: { duration?: number; } = {}) {
|
||||
if (!prefer.s.animation) return;
|
||||
|
||||
const duration = options.duration ?? 1000 * 4;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: os.claimZIndex('high') };
|
||||
const defaults = {
|
||||
startVelocity: 30,
|
||||
spread: 360,
|
||||
ticks: 60,
|
||||
zIndex: os.claimZIndex('high'),
|
||||
} satisfies _confetti.Options;
|
||||
|
||||
function randomInRange(min, max) {
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
|
||||
+34
-25
@@ -5,14 +5,42 @@
|
||||
|
||||
import seedrandom from 'seedrandom';
|
||||
import shader from './blockNoise.glsl';
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import type { ImageEffectorUiDefinition } from '../image-effector/ImageEffector.js';
|
||||
import { defineImageCompositorFunction } from '@/lib/ImageCompositor.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const FX_blockNoise = defineImageEffectorFx({
|
||||
id: 'blockNoise',
|
||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
|
||||
export const fn = defineImageCompositorFunction<{
|
||||
amount: number;
|
||||
strength: number;
|
||||
width: number;
|
||||
height: number;
|
||||
channelShift: number;
|
||||
seed: number;
|
||||
}>({
|
||||
shader,
|
||||
uniforms: ['amount', 'channelShift'] as const,
|
||||
main: ({ gl, program, u, params }) => {
|
||||
gl.uniform1i(u.amount, params.amount);
|
||||
gl.uniform1f(u.channelShift, params.channelShift);
|
||||
|
||||
const margin = 0;
|
||||
|
||||
const rnd = seedrandom(params.seed.toString());
|
||||
|
||||
for (let i = 0; i < params.amount; i++) {
|
||||
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
|
||||
gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
|
||||
|
||||
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
|
||||
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
|
||||
|
||||
const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
|
||||
gl.uniform2f(sizes, params.width, params.height);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const uiDefinition = {
|
||||
name: i18n.ts._imageEffector._fxs.glitch + ': ' + i18n.ts._imageEffector._fxs.blockNoise,
|
||||
params: {
|
||||
amount: {
|
||||
label: i18n.ts._imageEffector._fxProps.amount,
|
||||
@@ -64,23 +92,4 @@ export const FX_blockNoise = defineImageEffectorFx({
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
main: ({ gl, program, u, params }) => {
|
||||
gl.uniform1i(u.amount, params.amount);
|
||||
gl.uniform1f(u.channelShift, params.channelShift);
|
||||
|
||||
const margin = 0;
|
||||
|
||||
const rnd = seedrandom(params.seed.toString());
|
||||
|
||||
for (let i = 0; i < params.amount; i++) {
|
||||
const o = gl.getUniformLocation(program, `u_shiftOrigins[${i.toString()}]`);
|
||||
gl.uniform2f(o, (rnd() * (1 + (margin * 2))) - margin, (rnd() * (1 + (margin * 2))) - margin);
|
||||
|
||||
const s = gl.getUniformLocation(program, `u_shiftStrengths[${i.toString()}]`);
|
||||
gl.uniform1f(s, (1 - (rnd() * 2)) * params.strength);
|
||||
|
||||
const sizes = gl.getUniformLocation(program, `u_shiftSizes[${i.toString()}]`);
|
||||
gl.uniform2f(sizes, params.width, params.height);
|
||||
}
|
||||
},
|
||||
});
|
||||
} satisfies ImageEffectorUiDefinition<typeof fn>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user