diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000000..b54795f8ce93 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,50 @@ +name: Build and Push Image (Docker Buildx; GitHub Actions) + +on: + push: + tags: + - 'v*' + +env: + DOCKER_REGISTRY_NAME: ghcr.io + DOCKER_IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Docker hub + uses: docker/login-action@v1 + with: + registry: ${{ env.DOCKER_REGISTRY_NAME }} + username: ${{ github.repository_owner }} + password: ${{ github.token }} # github.token + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.DOCKER_REGISTRY_NAME }}/${{ env.DOCKER_IMAGE_NAME }} + + - name: Build & Push + uses: docker/build-push-action@v2 + env: + DOCKER_BUILDKIT: 1 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: ${{ env.DOCKER_REGISTRY_NAME }}/${{ env.DOCKER_IMAGE_NAME }}:latest + build-args: BUILDKIT_INLINE_CACHE=1 diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 9575e22d02fd..375676e83443 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -47,6 +47,7 @@ jobs: e2e: runs-on: ubuntu-latest + if: false strategy: fail-fast: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e02508fe6f..00cff6d862ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,15 @@ --> -## 2023.11.1 +## 2023.11.1-kinel.1 + +### General + +### Client + +### Server + +## 2023.11.1 (merged to 2023.11.1-kinel.1) ### General - Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました @@ -45,7 +53,7 @@ - Fix: ActivityPubに関するセキュリティの向上 - Fix: 非公開の投稿に対して返信できないように -## 2023.11.0 +## 2023.11.0 (merged to 2023.11.1-kinel.1) ### Note - iOS 16.4未満を使用している場合はiOS 16.4以上にアップデートをお願いします @@ -121,7 +129,7 @@ - Fix: サーバーサイドからのテスト通知を正しく行えるように修正 - Fix: GTLの「リノートを表示」オプションが機能しないのを修正 #12233 -## 2023.10.2 +## 2023.10.2 (merged to 2023.11.1-kinel.1) ### General - Feat: アンテナでローカルの投稿のみ収集できるようになりました @@ -145,7 +153,7 @@ - Change: ユーザーのisCatがtrueでも、サーバーではnyaizeが行われなくなりました - isCatな場合、クライアントでnyaize処理を行うことを推奨します -## 2023.10.1 +## 2023.10.1 (merged to 2023.11.1-kinel.1) ### General - Enhance: ローカルタイムライン、ソーシャルタイムラインで返信を含むかどうか設定可能に @@ -156,7 +164,7 @@ - Fix: フォローしているユーザーからの自分の投稿への返信がタイムラインに含まれない問題を修正 - Fix: users/notesでセンシティブチャンネルの投稿が含まれる場合がある問題を修正 -## 2023.10.0 +## 2023.10.0 (merged to 2023.11.1-kinel.1) ### NOTE - 2023.9.2で導入されたノート編集機能はクオリティの高い実装が困難であることが判明したため撤回されました - アップデートを行うと、タイムラインが一時的にリセットされます @@ -205,7 +213,30 @@ - Fix: 「ファイル付きのみ」のTLでファイル無しの新着ノートが流れる問題を修正 - Fix: プロセスが終了しない、あるいは非常に時間がかかる問題を修正 -## 2023.9.3 +## 2023.9.3-kinel.4 + +### Client +- Fix: Renoteの省略表示でセンシティブチャンネルの自動CWが効かない問題 + +### Server +- 2023.10.x向けのTLを内部的に構築するようになりました + - これにより、2023.9.3-kinel.4更新後のnoteは2023.10.x以降に更新した後でも見ることが出来ます + - 2023.10.xには、更新以前のnoteをTLで見ることが出来ないという仕様がありました + - 2023.9.xを利用している内は特に影響ありません + +## 2023.9.3-kinel.3 (unreleased) + +### General +- ノート編集機能を削除 + - roleにより禁止されてたためユーザには影響がありません。 +- 様々なカラムでリプライが表示されないのを表示されるように戻しました + +## 2023.9.3-kinel.2 + +### General +- noteがデフォルトで編集できないようになりました。 + +## 2023.9.3-kinel.1 ### General - Enhance: ノートの翻訳機能の利用可否をロールで設定可能に @@ -214,11 +245,15 @@ - Enhance: モデレーションログ機能の強化 - Enhance: ローカリゼーションの更新 +--- + +- Fix: "右クリックでリアクションピッカーを開くようにする"が2つある + ### Server - Fix: Redisに古いバージョンのキャッシュが残っている場合、キャッシュが消えるまでの間通知が届かなくなる問題を修正 - Fix: 後方互換性の修正 -## 2023.9.2 +## 2023.9.2 (merged to 2023.9.3) ### General - Feat: ノートの編集をできるように @@ -240,11 +275,16 @@ - Enhance: MasterプロセスのPIDを書き出せるように - Enhance: admin/ad/createにてレスポンス200、設定した広告情報を返すように -## 2023.9.1 +## 2023.9.1-kinel.1 ### General - Enhance: モデレーションログ機能の強化 +--- + +- 4K/8K画像が2K以下で表示されてしまう問題を修正しました + - 既存のファイルは更新されず、新規アップロード分にのみ適用されます + ### Client - Fix: ノートのメニューにある「詳細」ボタンの表示がログイン/ログアウト状態で統一されていない問題を修正 @@ -252,7 +292,7 @@ - Fix: お知らせのページネーションが機能しない - Fix: 「ユーザーの新規投稿」の通知設定を切り替えるとサーバー内部エラーが出る -## 2023.9.0 +## 2023.9.0 (merged to 2023.9.1) ### Note - meilisearchを使用する場合、v1.2以上が必要です @@ -348,21 +388,59 @@ - Fix: 一部のサーバー内部エラーがスタックトレースを返さないように修正 - Fix: 一部のリモートユーザーをフォローすることができない問題を修正 -## 13.14.2 +## 13.14.2-kinel.4 + +### General +- お知らせ機能の強化 + - ユーザー個別のお知らせを作成可能に + - お知らせのバナー表示やダイアログ表示が可能に + - お知らせのアイコンを設定可能に +- ハイライトからテキストのみのおやすみnoteを除外するようになりました ### Client -- リストTLで、ユーザーが追加・削除されてもTLを初期化しないように -- URL取得変数を関数に変更 CURRENT_URL -> Mk:url() -- Fix: モバイル表示のときページ下部がナビゲーションバーに隠れる問題を修正 -- Fix: 一部モーダルダイアログでスクロールできない問題を修正 -- Fix: Selecting all emojis in Custom emoji is impossible -- Fix: PhotoSwipeによるメモリリークの修正 +- メニューのスイッチの動作を改善 +- Enhance: ユーザーメニューでスイッチでユーザーリストに追加・削除できるように +- 画像の圧縮方法を選択可能にしました + - サイズ変更を行うかを選択可能にしました + - 強制的に非可逆圧縮できるようになりました +- センシティブチャンネルのNoteがたたまれる方法がCWになりました +- About Misskeyにこのforkに付いての情報を追加しました +- Fix: 未読のお知らせの「わかった」をクリック・タップしてもその場で「わかった」が消えない問題を修正 +- Fix: Misskeyプラグインをインストールする際のAiScriptバージョンのチェックが0.14.0以降に対応していない問題を修正 ### Server -- Fix: APIのオフセットが壊れていたせいで「もっと見る」でもっと見れない問題を修正 -- Fix: 外部サーバーの投稿がタイムラインに表示されないことがある問題を修正 +- ファイルアップロード時等にファイル名の拡張子を修正する関数(correctFilename)の挙動を改善 +- fix: muteがapiからのuser list timeline取得で機能しない問題を修正 -## 13.14.1 +## 13.14.2-kinel.3 + +### General +- 更新後初回に表示される`更新情報を見る`ボタンのリンク先を[にりらみすきー部のCHANGELOG.md](https://github.com/niri-la/misskey.niri.la/blob/develop/CHANGELOG.md)に変更しました + +### Client +- 長いnoteなどがたたまれない問題を修正しました +- センシティブチャンネルのNoteがユーザページからたたまれるのを無効化できるようにしました + +### Server + +## 13.14.2-kinel.2 + +### General +- ハイライトからのおはnoteの除外でCWを見忘れていたのを修正 +- チャンネルをセンシティブ指定できるようになりました +- ハイライトから除外するチャンネルをsensitiveチャンネルのみにしました +- センシティブチャンネルのNoteがクローラーによるインデックスを拒否するようになりました + +### Client +- センシティブチャンネルのNoteのReNoteはデフォルトでHome TLに流れるようになりました +- センシティブチャンネルのNoteがユーザページから非表示またはたたまれるようになりました +- Enhance: Renote自体を通報できるように + +### Server +- 通報のメール通知を無効化するオプションを追加 +- 通知の保存上限を変更可能に + +## 13.14.2-kinel.1 ### General - 招待機能を改善しました @@ -373,22 +451,20 @@ - identicon生成を無効にしてパフォーマンスを向上させることができるようになりました - サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました +--- + +- 管理者専用の他人を見るwebhookが増えました +- ハイライトからおはnoteとチャンネル投稿を除外するようになりました + ### Client -- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように - ドライブファイルのメニューで画像をクロップできるように -- 画像を動画と同様に簡単に隠せるように - Enhance: ノートの埋め込みが複数画像と動画を表示されるように - オリジナル画像を保持せずにアップロードする場合webpでアップロードされるように(Safari以外) -- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように - フォルダーやファイルに対しても開発者モード使用時、IDをコピーできるように -- 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように - プロフィールURLをコピーできるボタンを追加 #11190 - `CURRENT_URL`で現在表示中のURLを取得できるように(AiScript) - ユーザーのContextMenuに「アンテナに追加」ボタンを追加 -- フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように -- 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように - オフライン時の画面にリロードボタンを追加 -- Renote時に公開範囲のデフォルト設定が適用されるように - Deckで非ルートページにアクセスした際に簡易UIで表示しない設定を追加 - ロール設定画面でロールIDを確認できるように - コンテキストメニュー表示時のパフォーマンスを改善 @@ -396,16 +472,26 @@ - 本文にMFMが含まれている場合に自動でたたまれる機能が、返信先や引用RNにも適用されるように - position は対象外になりました - AiScriptを0.15.0に更新 +- リストTLで、ユーザーが追加・削除されてもTLを初期化しないように +- URL取得変数を関数に変更 CURRENT_URL -> Mk:url() - Fix: サーバーメトリクスが90度傾いている - Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正 - Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正 - Fix: ZenUIでポップアップの表示位置がおかしい問題を修正 - Fix: ページ遷移でスクロール位置が保持されない問題を修正 - Fix: フォルダーのページネーションが機能しない #11180 -- Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正 - Fix: システムフォント設定が正しく反映されない問題を修正 - Fix: アンケート終了時のプッシュ通知が正しく表示されない問題を修正 - Fix: MasterVolumeが0の時だけでなく各通知音の音量設定が0のときも、HTMLAudioElement.playが実行されないように変更 +- Fix: モバイル表示のときページ下部がナビゲーションバーに隠れる問題を修正 +- Fix: 一部モーダルダイアログでスクロールできない問題を修正 +- Fix: Selecting all emojis in Custom emoji is impossible +- Fix: PhotoSwipeによるメモリリークの修正 + +--- + +- プレビューの表示状態を記憶するように +- 絵文字ピッカーの検索の表示件数を100件に増加 ### Server - JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました @@ -425,6 +511,28 @@ - Fix: インスタンスのアイコンがbase64の場合の挙動を修正 - Fix: ローカルの `Person` を指す `acct` URI を解析するときのバグを修正しました - Fix: 無効化されたアンテナが再度有効化されないことがある問題を修正 +- Fix: APIのオフセットが壊れていたせいで「もっと見る」でもっと見れない問題を修正 +- Fix: 外部サーバーの投稿がタイムラインに表示されないことがある問題を修正 + +--- + +- 通報をDiscordのWebhookに送信できるように + +## 13.13.2-kinel-0.0.1 + +### General + +### Client +- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように +- 画像を動画と同様に簡単に隠せるように +- 見たことのあるRenoteを省略して表示をオンのときに自分のnoteのrenoteを省略するように +- フォローやお気に入り登録をしていないチャンネルを開く時は概要ページを開くように +- 引用対象を「もっと見る」で展開した場合、「閉じる」で畳めるように +- Renote時に公開範囲のデフォルト設定が適用されるように +- 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように +- Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正 + +### Server ## 13.13.2 diff --git a/locales/en-US.yml b/locales/en-US.yml index 09fd726c9f0f..cfd72935efe9 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -118,6 +118,7 @@ pinned: "Pin to profile" you: "You" clickToShow: "Click to show" sensitive: "Sensitive" +sensitiveChannelAutoCW: "Sensitive Channel Auto CW" add: "Add" reaction: "Reactions" reactions: "Reactions" diff --git a/locales/index.d.ts b/locales/index.d.ts index 6baed91c4294..b197c3d0b127 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -121,6 +121,7 @@ export interface Locale { "you": string; "clickToShow": string; "sensitive": string; + "sensitiveChannelAutoCW": string; "add": string; "reaction": string; "reactions": string; @@ -778,6 +779,7 @@ export interface Locale { "publish": string; "inChannelSearch": string; "useReactionPickerForContextMenu": string; + "collapseSensitiveChannel": string; "typingUsers": string; "jumpToSpecifiedDate": string; "showingPastTimeline": string; @@ -1809,6 +1811,9 @@ export interface Locale { "morePatrons": string; "patrons": string; "projectMembers": string; + "forkContributors": string; + "allForkContributors": string; + "forkSource": string; }; "_displayOfSensitiveMedia": { "respect": string; @@ -2377,8 +2382,18 @@ export interface Locale { "renote": string; "reaction": string; "mention": string; + "usersLabel": string; + "usersCaption": string; }; }; + "_imageCompressionMode": { + "title": string; + "description": string; + "resizeCompress": string; + "noResizeCompress": string; + "resizeCompressLossy": string; + "noResizeCompressLossy": string; + }; "_moderationLogTypes": { "createRole": string; "deleteRole": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e59a550df5a6..44830e32f1c6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -118,6 +118,7 @@ pinned: "ピン留め" you: "あなた" clickToShow: "クリックして表示" sensitive: "センシティブ" +sensitiveChannelAutoCW: "センシティブチャンネル自動CW" add: "追加" reaction: "リアクション" reactions: "リアクション" @@ -775,6 +776,7 @@ emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く" +collapseSensitiveChannel: "ユーザページでセンシティブチャンネルの投稿を閉じる" typingUsers: "{users}が入力中" jumpToSpecifiedDate: "特定の日付にジャンプ" showingPastTimeline: "過去のタイムラインを表示しています" @@ -1714,6 +1716,9 @@ _aboutMisskey: morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰" patrons: "支援者" projectMembers: "プロジェクトメンバー" + forkContributors: "このサーバーで使用しているforkの主要な開発者" + allForkContributors: "このサーバーで使用しているforkの全てのコントリビューター" + forkSource: "このサーバーで使用しているforkのソースコード" _displayOfSensitiveMedia: respect: "センシティブ設定されたメディアを隠す" @@ -2279,6 +2284,16 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" + usersLabel: "以下のユーザがnoteしたとき" + usersCaption: "このサーバーのユーザの@に挟まれた部分を改行で区切って指定します" + +_imageCompressionMode: + title: "画像の圧縮形式" + description: "オリジナル画像を保持しない場合に、Web公開用画像の圧縮形式を選択できます。縮小する場合は2048x2048より小さくなるように縮小されます。非可逆圧縮を指定しない場合は、元画像に応じて非可逆圧縮か可逆圧縮かが自動的に選択されます。" + resizeCompress: "縮小して再圧縮する" + noResizeCompress: "縮小せず再圧縮する" + resizeCompressLossy: "縮小して非可逆圧縮する" + noResizeCompressLossy: "縮小せず非可逆圧縮する" _moderationLogTypes: createRole: "ロールを作成" diff --git a/package.json b/package.json index d0fc867b3c5c..03d86f320428 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "misskey", - "version": "2023.11.1", + "version": "2023.11.1-kinel.1", "codename": "nasubi", "repository": { "type": "git", - "url": "https://github.com/misskey-dev/misskey.git" + "url": "https://github.com/niri-la/misskey.niri.la" }, "packageManager": "pnpm@8.10.5", "workspaces": [ diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index b25554b22938..5170b691a609 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -91,6 +91,14 @@ type Source = { perUserNotificationsMaxCount?: number; deactivateAntennaThreshold?: number; pidFile: string; + + nirila?: { + abuseDiscordHook?: string; + disableAbuseRepository?: boolean; + maxWebImageSize?: number; + withRepliesInHomeTL?: boolean; + withRepliesInUserList?: boolean; + } }; export type Config = { @@ -167,6 +175,14 @@ export type Config = { perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; pidFile: string; + + nirila: { + abuseDiscordHook?: string; + disableAbuseRepository?: boolean; + maxWebImageSize?: number; + withRepliesInHomeTL?: boolean, + withRepliesInUserList: boolean, + } }; const _filename = fileURLToPath(import.meta.url); @@ -208,6 +224,10 @@ export function loadConfig(): Config { const redis = convertRedisOptions(config.redis, host); return { + // to avoid merge conflict in the future, this is at top + nirila: Object.assign({ + withRepliesInUserList: true, + }, config.nirila ?? {}), version, url: url.origin, port: config.port ?? parseInt(process.env.PORT ?? '', 10), diff --git a/packages/backend/src/core/AbuseDiscordHookService.ts b/packages/backend/src/core/AbuseDiscordHookService.ts new file mode 100644 index 000000000000..ca9e53432e6c --- /dev/null +++ b/packages/backend/src/core/AbuseDiscordHookService.ts @@ -0,0 +1,40 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiUser } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import type { Config } from '@/config.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; + +@Injectable() +export class AbuseDiscordHookService { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + @bindThis + public send(me: MiUser, user: MiUser, comment: string): void { + const webhookUrl = this.config.nirila?.abuseDiscordHook; + if (webhookUrl) { + setImmediate(async () => { + const content = 'New abuse report created!\n' + + `author: \`@${me.username}${me.host ? `@${me.host}` : ''}\`\n` + + `target user: \`@${user.username}${user.host ? `@${user.host}` : ''}\`\n` + + 'Comment:\n' + + comment; + + await this.httpRequestService.send(webhookUrl, { + method: 'POST', + headers: { + 'User-Agent': 'Niri-la-Misskey-Hooks', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content }), + }); + }); + } + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 9fb29e0e68b5..3002e78ee410 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -132,6 +132,7 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; +import { AbuseDiscordHookService } from './AbuseDiscordHookService.js'; import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) @@ -398,6 +399,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + AbuseDiscordHookService, //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, @@ -656,6 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + AbuseDiscordHookService, //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 484f4fc52e5a..ebd690546359 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -301,19 +301,26 @@ export class DriveService { let img: sharp.Sharp | null = null; let satisfyWebpublic: boolean; let isAnimated: boolean; + let compressedWidth: number; + let compressedHeight: number; try { img = await sharpBmp(path, type); const metadata = await img.metadata(); isAnimated = !!(metadata.pages && metadata.pages > 1); + const maxSize = this.config.nirila?.maxWebImageSize ?? 8192; + // nirila Extension: We want to keep original size as possible + // noinspection PointlessBooleanExpressionJS satisfyWebpublic = !!( type !== 'image/svg+xml' && // security reason type !== 'image/avif' && // not supported by Mastodon and MS Edge !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && - metadata.width && metadata.width <= 2048 && - metadata.height && metadata.height <= 2048 + metadata.width && metadata.width <= maxSize && + metadata.height && metadata.height <= maxSize ); + compressedWidth = metadata.width && metadata.width <= maxSize ? metadata.width : maxSize; + compressedHeight = metadata.height && metadata.height <= maxSize ? metadata.height : maxSize; } catch (err) { this.registerLogger.warn(`sharp failed: ${err}`); return { @@ -330,9 +337,9 @@ export class DriveService { try { if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); + webpublic = await this.imageProcessingService.convertSharpToWebp(img, compressedWidth, compressedHeight); } else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); + webpublic = await this.imageProcessingService.convertSharpToPng(img, compressedWidth, compressedHeight); } else { this.registerLogger.debug('web image not created (not an required image)'); } diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index 9617f83880f1..97d1e4fe4cc4 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -74,6 +74,27 @@ export class FeaturedService { return Array.from(ranking.keys()); } + // TODO: find better place? + @bindThis + public shouldBeIncludedInGlobalOrUserFeatured(note: MiNote): boolean { + if (note.visibility !== 'public') return false; // non-public note + if (note.userHost != null) return false; // remote + if (note.replyId != null) return false; // reply + // Channels are checked outside + + // In nirila misskey, it was very common to notes with `:ohayo_nirila_misskey:` or `:oyasumi_nirila_misskey:` + // Will get many reaction`:ohayo_nirila_misskey:` or `:oyasumi_nirila_misskey:` so exclude them + // if they don't have any images. + if (note.fileIds.length === 0) { + for (const exclusion of ["おはよう", "おやすみ", ":ohayo_nirila_misskey:", ":oyasumi_nirila_misskey:"]) { + if (note.text?.includes(exclusion)) return false; + if (note.cw?.includes(exclusion)) return false; + } + } + + return true; + } + @bindThis public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise { return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 86f220abd093..940604ff4dcf 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -582,7 +582,8 @@ export class NoteCreateService implements OnApplicationShutdown { this.roleService.addNoteToRoleTimeline(noteObj); this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + const userNoteEvent = `note@${user.username}` as const; + webhooks = webhooks.filter(x => (x.userId === user.id && x.on.includes('note')) || x.on.includes(userNoteEvent)); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'note', { note: noteObj, @@ -744,7 +745,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5); } } else { - if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) { + if (this.featuredService.shouldBeIncludedInGlobalOrUserFeatured(renote)) { this.featuredService.updateGlobalNotesRanking(renote.id, 5); this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index be378a899b4e..044d749338f9 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; +import type { MiWebhook, WebhookEventType } from '@/models/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -414,7 +414,7 @@ export class QueueService { } @bindThis - public webhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { + public webhookDeliver(webhook: MiWebhook, type: WebhookEventType, content: unknown) { const data = { type, content, diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 4233b8d4c348..b5a9998b35a5 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -206,7 +206,7 @@ export class ReactionService { this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1); } } else { - if (note.visibility === 'public' && note.userHost == null && note.replyId == null) { + if (this.featuredService.shouldBeIncludedInGlobalOrUserFeatured(note)) { this.featuredService.updateGlobalNotesRanking(note.id, 1); this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1); } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 7aba14068951..1581b46c00a8 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -27,6 +27,7 @@ import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; +import { AbuseDiscordHookService } from '@/core/AbuseDiscordHookService.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -82,6 +83,7 @@ export class ApInboxService { private apPersonService: ApPersonService, private apQuestionService: ApQuestionService, private queueService: QueueService, + private abuseDiscordHookService: AbuseDiscordHookService, ) { this.logger = this.apLoggerService.logger; } @@ -513,15 +515,19 @@ export class ApInboxService { }); if (users.length < 1) return 'skip'; + const comment = `${activity.content}\n${JSON.stringify(uris, null, 2)}`; + await this.abuseUserReportsRepository.insert({ id: this.idService.gen(), targetUserId: users[0].id, targetUserHost: users[0].host, reporterId: actor.id, reporterHost: actor.host, - comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, + comment, }); + this.abuseDiscordHookService.send(actor, users[0], comment); + return 'ok'; } diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts index ec4e13cc76e2..e0fd06f8efc7 100644 --- a/packages/backend/src/models/Webhook.ts +++ b/packages/backend/src/models/Webhook.ts @@ -8,6 +8,7 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; +export type WebhookEventType = (typeof webhookEventTypes)[number] | `note@${string}`; @Entity('webhook') export class MiWebhook { @@ -37,7 +38,7 @@ export class MiWebhook { @Column('varchar', { length: 128, array: true, default: '{}', }) - public on: (typeof webhookEventTypes)[number][]; + public on: WebhookEventType[]; @Column('varchar', { length: 1024, diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index f00dba4a856a..012ced1b33c7 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { IdService } from '@/core/IdService.js'; import type { WebhooksRepository } from '@/models/_.js'; -import { webhookEventTypes } from '@/models/Webhook.js'; +import { webhookEventTypes, WebhookEventType } from '@/models/Webhook.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; @@ -26,6 +26,11 @@ export const meta = { code: 'TOO_MANY_WEBHOOKS', id: '87a9bb19-111e-4e37-81d3-a3e7426453b0', }, + adminWebhookDenied: { + message: 'You cannot create webhook for other users.', + code: 'ADMIN_WEBHOOK_DENIED', + id: '0d3321b1-6f66-41aa-9fbe-233c60ce19b0', + }, }, } as const; @@ -36,7 +41,10 @@ export const paramDef = { url: { type: 'string', minLength: 1, maxLength: 1024 }, secret: { type: 'string', maxLength: 1024, default: '' }, on: { type: 'array', items: { - type: 'string', enum: webhookEventTypes, + oneOf: [ + { type: 'string', enum: webhookEventTypes }, + { type: 'string', pattern: '^note@[a-zA-Z0-9]{1,20}$' }, + ], } }, }, required: ['name', 'url', 'on'], @@ -62,13 +70,19 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.tooManyWebhooks); } + if (ps.on.some(x => !(webhookEventTypes as readonly string[]).includes(x))) { + if (!await this.roleService.isAdministrator(me)) { + throw new ApiError(meta.errors.adminWebhookDenied); + } + } + const webhook = await this.webhooksRepository.insert({ id: this.idService.gen(), userId: me.id, name: ps.name, url: ps.url, secret: ps.secret, - on: ps.on, + on: ps.on as WebhookEventType[], }).then(x => this.webhooksRepository.findOneByOrFail(x.identifiers[0])); this.globalEventService.publishInternalEvent('webhookCreated', webhook); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts index b3e000524d0e..670ed813f0d0 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -6,9 +6,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { WebhooksRepository } from '@/models/_.js'; -import { webhookEventTypes } from '@/models/Webhook.js'; +import { webhookEventTypes, WebhookEventType } from '@/models/Webhook.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -24,6 +25,11 @@ export const meta = { code: 'NO_SUCH_WEBHOOK', id: 'fb0fea69-da18-45b1-828d-bd4fd1612518', }, + adminWebhookDenied: { + message: 'You cannot create webhook for other users.', + code: 'UPDATE_ADMIN_WEBHOOK_DENIED', + id: 'eb43c0c4-24a3-487d-b139-f3e4e58f87a4', + }, }, } as const; @@ -36,7 +42,10 @@ export const paramDef = { url: { type: 'string', minLength: 1, maxLength: 1024 }, secret: { type: 'string', maxLength: 1024, default: '' }, on: { type: 'array', items: { - type: 'string', enum: webhookEventTypes, + oneOf: [ + { type: 'string', enum: webhookEventTypes }, + { type: 'string', pattern: '^note@[a-zA-Z0-9]{1,20}$' }, + ], } }, active: { type: 'boolean' }, }, @@ -52,6 +61,7 @@ export default class extends Endpoint { // eslint- private webhooksRepository: WebhooksRepository, private globalEventService: GlobalEventService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const webhook = await this.webhooksRepository.findOneBy({ @@ -63,11 +73,17 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchWebhook); } + if (ps.on.some(x => !(webhookEventTypes as readonly string[]).includes(x))) { + if (!await this.roleService.isAdministrator(me)) { + throw new ApiError(meta.errors.adminWebhookDenied); + } + } + await this.webhooksRepository.update(webhook.id, { name: ps.name, url: ps.url, secret: ps.secret, - on: ps.on, + on: ps.on as WebhookEventType[], active: ps.active, }); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index c456874309c5..860124a523b3 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index a43e57292217..671af8a870b0 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -53,6 +53,7 @@ export const paramDef = { untilDate: { type: 'integer' }, withFiles: { type: 'boolean', default: false }, excludeNsfw: { type: 'boolean', default: false }, + includeSensitiveChannel: { type: 'boolean', default: false }, }, required: ['userId'], } as const; @@ -150,7 +151,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.withChannelNotes) { - if (!isSelf) query.andWhere(new Brackets(qb => { + if (!ps.includeSensitiveChannel) query.andWhere(new Brackets(qb => { qb.orWhere('note.channelId IS NULL'); qb.orWhere('channel.isSensitive = false'); })); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 3bcf44cc4228..fe6cee1115c4 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -14,6 +14,8 @@ import { EmailService } from '@/core/EmailService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; +import { AbuseDiscordHookService } from '@/core/AbuseDiscordHookService.js'; +import type { Config } from '@/config.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -58,6 +60,8 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.config) + private config: Config, private idService: IdService, private metaService: MetaService, @@ -65,6 +69,7 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, private roleService: RoleService, private globalEventService: GlobalEventService, + private abuseDiscordHookService: AbuseDiscordHookService, ) { super(meta, paramDef, async (ps, me) => { // Lookup user @@ -104,12 +109,14 @@ export default class extends Endpoint { // eslint- } const meta = await this.metaService.fetch(); - if (meta.email) { + if (meta.email && !config.nirila?.disableAbuseRepository) { this.emailService.sendEmail(meta.email, 'New abuse report', sanitizeHtml(ps.comment), sanitizeHtml(ps.comment)); } }); + + this.abuseDiscordHookService.send(me, user, ps.comment); }); } } diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index 9bc652b6a108..f708508e4087 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -28,7 +28,7 @@ block og // FIXME: add embed player for Twitter if images.length meta(property='twitter:card' content='summary_large_image') - each image in images + each image in images meta(property='og:image' content= image.url) else meta(property='twitter:card' content='summary') @@ -36,7 +36,8 @@ block og block meta - if user.host || isRenote || profile.noCrawle + // TODO: make user configurable if sensitive channel note should be indexed or not + if user.host || isRenote || profile.noCrawle || (note.channel && note.channel.isSensitive) meta(name='robots' content='noindex') if profile.preventAiLearning meta(name='robots' content='noimageai') diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index e300ef88a50e..e0c6369c2f8f 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -52,11 +52,11 @@ SPDX-License-Identifier: AGPL-3.0-only
-

- +

+

-
+
({{ i18n.ts.private }}) @@ -194,6 +194,7 @@ const emit = defineEmits<{ (ev: 'removeReaction', emoji: string): void; }>(); +const collapseSensitiveChannel = inject('collapseSensitiveChannel', false); const inChannel = inject('inChannel', null); const currentClip = inject | null>('currentClip', null); @@ -232,12 +233,14 @@ const renoteTime = shallowRef(); const reactButton = shallowRef(); const clipButton = shallowRef(); let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); +const sensitiveChannelCW = collapseSensitiveChannel && appearNote.cw == null && appearNote.channel?.isSensitive; +const cwExists = sensitiveChannelCW || appearNote.cw != null; const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null); -const isLong = shouldCollapsed(appearNote, urls ?? []); -const collapsed = ref(appearNote.cw == null && isLong); +const isLong = shouldCollapsed(appearNote, urls ?? []) && !cwExists; +const collapsed = ref(!cwExists && isLong); const isDeleted = ref(false); const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); const translation = ref(null); diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 04716ebab27e..3db2834c3ae9 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -40,7 +40,7 @@ const props = withDefaults(defineProps<{ onlyFiles?: boolean; }>(), { withRenotes: true, - withReplies: false, + withReplies: true, onlyFiles: false, }); diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index 699d7af33e82..6b743ba59566 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -27,7 +27,7 @@ const modal = shallowRef>(); const whatIsNew = () => { modal.value.close(); - window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank'); + window.open(`https://github.com/niri-la/misskey.niri.la/blob/develop/CHANGELOG.md#${version.replace(/\./g, '')}`, '_blank'); }; onMounted(() => { diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 9fe7f7f79c6b..76f6d99cebe5 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -34,6 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._aboutMisskey.source }} + + + {{ i18n.ts._aboutMisskey.forkSource }} + + {{ i18n.ts._aboutMisskey.translation }} @@ -103,6 +108,24 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + +
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 01a07116825e..ed5c420e1b74 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -44,6 +44,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + @@ -71,6 +79,7 @@ import MkChart from '@/components/MkChart.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; +import MkSelect from '@/components/MkSelect.vue'; const fetching = ref(true); const usage = ref(null); @@ -91,6 +100,7 @@ const meterStyle = computed(() => { }); const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); +const imageCompressionMode = computed(defaultStore.makeGetterSetter('imageCompressionMode')); os.api('drive').then(info => { capacity.value = info.capacity; diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 06d3789829a6..e3130e236ff5 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.enableAnimatedMfm }} {{ i18n.ts.showGapBetweenNotesInTimeline }} {{ i18n.ts.loadRawImages }} + {{ i18n.ts.collapseSensitiveChannel }} @@ -248,6 +249,7 @@ const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCa const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); +const collapseSensitiveChannel = computed(defaultStore.makeGetterSetter('collapseSensitiveChannel')); const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations')); const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 3301732c884d..7b8bffadfcbe 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -29,6 +29,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._webhookSettings._events.renote }} {{ i18n.ts._webhookSettings._events.reaction }} {{ i18n.ts._webhookSettings._events.mention }} + + + + +
@@ -51,6 +56,8 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { useRouter } from '@/router.js'; +import { $i } from '@/account.js'; +import MkTextarea from '@/components/MkTextarea.vue'; const router = useRouter(); @@ -74,9 +81,10 @@ let event_reply = $ref(webhook.on.includes('reply')); let event_renote = $ref(webhook.on.includes('renote')); let event_reaction = $ref(webhook.on.includes('reaction')); let event_mention = $ref(webhook.on.includes('mention')); +let users = $ref((webhook.on as string[]).filter(x => x.startsWith('note@')).map(x => x.substring('note@'.length)).join('\n')); async function save(): Promise { - const events = []; + const events: string[] = []; if (event_follow) events.push('follow'); if (event_followed) events.push('followed'); if (event_note) events.push('note'); @@ -84,6 +92,7 @@ async function save(): Promise { if (event_renote) events.push('renote'); if (event_reaction) events.push('reaction'); if (event_mention) events.push('mention'); + if (users !== '') events.push(...users.split('\n').filter(x => x).map(x => `note@${x}`)); os.apiWithDialog('i/webhooks/update', { name, @@ -118,3 +127,11 @@ definePageMetadata({ icon: 'ti ti-webhook', }); + + diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index ed56126548d2..995490590590 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -29,6 +29,11 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._webhookSettings._events.renote }} {{ i18n.ts._webhookSettings._events.reaction }} {{ i18n.ts._webhookSettings._events.mention }} + + + + +
@@ -47,6 +52,8 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { $i } from '@/account.js'; +import MkTextarea from '@/components/MkTextarea.vue'; let name = $ref(''); let url = $ref(''); @@ -59,9 +66,10 @@ let event_reply = $ref(true); let event_renote = $ref(true); let event_reaction = $ref(true); let event_mention = $ref(true); +let users = $ref(''); async function create(): Promise { - const events = []; + const events: string[] = []; if (event_follow) events.push('follow'); if (event_followed) events.push('followed'); if (event_note) events.push('note'); @@ -69,6 +77,7 @@ async function create(): Promise { if (event_renote) events.push('renote'); if (event_reaction) events.push('reaction'); if (event_mention) events.push('mention'); + if (users !== '') events.push(...users.split('\n').filter(x => x).map(x => `note@${x}`)); os.apiWithDialog('i/webhooks/create', { name, diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 2e5dd705d0bc..75df1f666bed 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -146,7 +146,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index d0753872ffc0..c0edce79e8d8 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -93,7 +93,7 @@ export async function getNoteClipMenu(props: { }]; } -export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): MenuItem { +export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem { return { icon: 'ti ti-exclamation-circle', text, @@ -107,7 +107,7 @@ export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): Men }; } -export function getCopyNoteLinkMenu(note: misskey.entities.Note, text: string): MenuItem { +export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem { return { icon: 'ti ti-link', text, @@ -345,7 +345,7 @@ export function getNoteMenu(props: { }] : [] ),*/ - ...(appearNote.userId !== $i.id ? [ + ...(appearNote.userId !== $i.id || (isRenote && props.note.userId !== $i.id) ? [ null, appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined, ] diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts index 1fd9f04d4616..2b0c74173cfd 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -10,7 +10,7 @@ import { i18n } from '@/i18n.js'; * 投稿を表す文字列を取得します。 * @param {*} note (packされた)投稿 */ -export const getNoteSummary = (note: Misskey.entities.Note): string => { +export const getNoteSummary = (note: Misskey.entities.Note, sensitiveChannelCW?: boolean): string => { if (note.deletedAt) { return `(${i18n.ts.deletedNote})`; } @@ -19,6 +19,10 @@ export const getNoteSummary = (note: Misskey.entities.Note): string => { return `(${i18n.ts.invisibleNote})`; } + if (sensitiveChannelCW) { + return i18n.ts.sensitiveChannelAutoCW; + } + let summary = ''; // 本文 diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts index 8fe64c8b76b9..a22c259d2716 100644 --- a/packages/frontend/src/scripts/upload/compress-config.ts +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -6,31 +6,105 @@ import isAnimated from 'is-file-animated'; import { isWebpSupported } from './isWebpSupported'; import type { BrowserImageResizerConfig } from 'browser-image-resizer'; +import { defaultStore } from '@/store'; const compressTypeMap = { - 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, - 'image/png': { quality: 1, mimeType: 'image/webp' }, - 'image/webp': { quality: 0.90, mimeType: 'image/webp' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/webp' }, + 'lossy': { quality: 0.90, mimeType: 'image/webp' }, + 'lossless': { quality: 1, mimeType: 'image/webp' }, } as const; const compressTypeMapFallback = { - 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/png': { quality: 1, mimeType: 'image/png' }, - 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, + 'lossy': { quality: 0.85, mimeType: 'image/jpeg' }, + 'lossless': { quality: 1, mimeType: 'image/png' }, } as const; +const inputCompressKindMap = { + 'image/jpeg': 'lossy', + 'image/png': 'lossless', + 'image/webp': 'lossy', + 'image/svg+xml': 'lossless', +} as const; + +const resizeSizeConfig = { maxWidth: 2048, maxHeight: 2048 } as const; +const noResizeSizeConfig = { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER } as const; + +async function isLosslessWebp(file: Blob): Promise { + // file header + // 'RIFF': u32 @ 0x00 + // file size: u32 @ 0x04 + // 'WEBP': u32 @ 0x08 + // for simple lossless + // 'VP8L': u32 @ 0x0C + // so read 16 bytes and check those three magic numbers + const buffer = new Uint8Array(await file.slice(0, 16).arrayBuffer()); + + const header = 'RIFF\x00\x00\x00\x00WEBPVP8L'; + for (let i = 0; i < header.length; i++) { + const code = header.charCodeAt(i); + if (code === 0) continue; + if (buffer[i] !== code) return false; + } + return true; +} + +async function inputImageKind(file: File): Promise<'lossy' | 'lossless' | undefined> { + let compressKind: 'lossy' | 'lossless' | undefined = inputCompressKindMap[file.type]; + if (!compressKind) return undefined; // unknown image format + if (await isAnimated(file)) return undefined; // animated image format + // WEBPs can be lossless + if (await isLosslessWebp(file)) compressKind = 'lossless'; + return compressKind; +} + export async function getCompressionConfig(file: File): Promise { - const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; - if (!imgConfig || await isAnimated(file)) { - return; + const inputCompressKind = await inputImageKind(file); + if (!inputCompressKind) return undefined; + + let compressKind: 'lossy' | 'lossless'; + let resize: boolean; + + switch (defaultStore.state.imageCompressionMode) { + case 'resizeCompress': + case null: + default: + resize = true; + compressKind = inputCompressKind; + break; + case 'noResizeCompress': + resize = false; + compressKind = inputCompressKind; + break; + case 'resizeCompressLossy': + resize = true; + compressKind = 'lossy'; + break; + case 'noResizeCompressLossy': + resize = false; + compressKind = 'lossy'; + break; + } + + const webpSupported = isWebpSupported(); + + const imgFormatConfig = (webpSupported ? compressTypeMap : compressTypeMapFallback)[compressKind]; + const sizeConfig = resize ? resizeSizeConfig : noResizeSizeConfig; + + if (!resize) { + // we don't resize images so we may omit recompression + if (imgFormatConfig.mimeType === file.type && inputCompressKind === compressKind) { + // we don't have to recompress already compressed to preferred image format. + return undefined; + } + + if (!webpSupported && file.type === 'image/webp' && compressKind === 'lossless') { + // lossless webp -> png recompression likely to increase image size so don't recompress + return undefined; + } } return { - maxWidth: 2048, - maxHeight: 2048, debug: true, - ...imgConfig, + ...imgFormatConfig, + ...sizeConfig, }; } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 6d95ddba35d1..369470be78f0 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -94,6 +94,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: false, }, + imageCompressionMode: { + where: 'account', + default: 'resizeCompress' as 'resizeCompress' | 'noResizeCompress' | 'resizeCompressLossy' | 'noResizeCompressLossy' | null, + }, memo: { where: 'account', default: null, @@ -182,6 +186,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'respect' as 'respect' | 'force' | 'ignore', }, + collapseSensitiveChannel: { + where: 'device', + default: true, + }, highlightSensitiveMedia: { where: 'device', default: false,