diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.md b/.github/ISSUE_TEMPLATE/01_bug-report.md deleted file mode 100644 index 3421b0b69a..0000000000 --- a/.github/ISSUE_TEMPLATE/01_bug-report.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: 🐛 Bug Report -about: Create a report to help us improve -title: '' -labels: ⚠️bug? -assignees: '' - ---- - - - -## 💡 Summary - - - -## 🥰 Expected Behavior - - - -## 🤬 Actual Behavior - - - -## 📝 Steps to Reproduce - -1. -2. -3. - -## 📌 Environment - - - - -### 💻 Frontend -* Model and OS of the device(s): - -* Browser: - -* Server URL: - -* CherryPick: - 13.x.x-cp-4.x.x - -### 🛰 Backend (for server admin) - - -* Installation Method or Hosting Service: -* CherryPick: 13.x.x-cp-4.x.x -* Node: 20.x.x -* PostgreSQL: 15.x.x -* Redis: 7.x.x -* OS and Architecture: diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.yml b/.github/ISSUE_TEMPLATE/01_bug-report.yml new file mode 100644 index 0000000000..03618103dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_bug-report.yml @@ -0,0 +1,91 @@ +name: 🐛 Bug Report +description: Create a report to help us improve +labels: ["⚠️bug?"] + +body: + - type: markdown + attributes: + value: | + Thanks for reporting! + First, in order to avoid duplicate Issues, please search to see if the problem you found has already been reported. + Also, If you are NOT owner/admin of server, PLEASE DONT REPORT SERVER SPECIFIC ISSUES TO HERE! (e.g. feature XXX is not working in cherrypick.example) Please try with another CherryPick servers, and if your issue is only reproducible with specific server, contact your server's owner/admin first. + + - type: textarea + attributes: + label: 💡 Summary + description: Tell us what the bug is + validations: + required: true + + - type: textarea + attributes: + label: 🥰 Expected Behavior + description: Tell us what should happen + validations: + required: true + + - type: textarea + attributes: + label: 🤬 Actual Behavior + description: | + Tell us what happens instead of the expected behavior. + Please include errors from the developer console and/or server log files if you have access to them. + validations: + required: true + + - type: textarea + attributes: + label: 📝 Steps to Reproduce + placeholder: | + 1. + 2. + 3. + validations: + required: false + + - type: textarea + attributes: + label: 💻 Frontend Environment + description: | + Tell us where on the platform it happens + DO NOT WRITE "latest". Please provide the specific version. + + Examples: + * Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4 + * Browser: Chrome 113.0.5672.126 + * Server URL: kokonect.link + * CherryPick: 4.x.x (Misskey: 2023.x.x) + value: | + * Model and OS of the device(s): + * Browser: + * Server URL: + * CherryPick: + render: markdown + validations: + required: false + + - type: textarea + attributes: + label: 🛰 Backend Environment (for server admin) + description: | + Tell us where on the platform it happens + DO NOT WRITE "latest". Please provide the specific version. + If you are using a managed service, put that after the version. + + Examples: + * Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "CherryPick install shell script", development environment + * CherryPick: 4.x.x (Misskey: 2023.x.x) + * Node: 20.x.x + * PostgreSQL: 15.x.x + * Redis: 7.x.x + * OS and Architecture: Ubuntu 22.04.2 LTS aarch64 + value: | + * Installation Method or Hosting Service: + * CherryPick: + * Node: + * PostgreSQL: + * Redis: + * OS and Architecture: + render: markdown + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/02_feature-request.md b/.github/ISSUE_TEMPLATE/02_feature-request.md deleted file mode 100644 index 5045b17712..0000000000 --- a/.github/ISSUE_TEMPLATE/02_feature-request.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: ✨ Feature Request -about: Suggest an idea for this project -title: '' -labels: ✨Feature -assignees: '' - ---- - -## Summary - - diff --git a/.github/ISSUE_TEMPLATE/02_feature-request.yml b/.github/ISSUE_TEMPLATE/02_feature-request.yml new file mode 100644 index 0000000000..17926412ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02_feature-request.yml @@ -0,0 +1,11 @@ +name: ✨ Feature Request +description: Suggest an idea for this project +labels: ["✨Feature"] + +body: + - type: textarea + attributes: + label: Summary + description: Tell us what the suggestion is + validations: + required: true diff --git a/.github/workflows/api-cherrypick-js.yml b/.github/workflows/api-cherrypick-js.yml index 7c688dc30b..4a9cdd847f 100644 --- a/.github/workflows/api-cherrypick-js.yml +++ b/.github/workflows/api-cherrypick-js.yml @@ -14,7 +14,7 @@ jobs: - run: corepack enable - name: Setup Node.js - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index ae63cca851..1aefda730c 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -1,4 +1,5 @@ -name: Report API Diff +# this name is used in report-api-diff.yml so be careful when change name +name: Get api.json from CherryPick on: pull_request: @@ -43,7 +44,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -125,7 +126,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -171,55 +172,15 @@ jobs: - name: Kill CherryPick Job run: screen -S cherrypick -X quit - compare-diff: + save-pr-number: runs-on: ubuntu-latest - if: success() - needs: [get-base, get-head] - permissions: - pull-requests: write - steps: - - name: Download Artifact - uses: actions/download-artifact@v3 - with: - name: api-artifact - path: ./artifacts - - name: Output base - run: cat ./artifacts/api-base.json - - name: Output head - run: cat ./artifacts/api-head.json - - name: Arrange json files + - name: Save PR number + env: + PR_NUMBER: ${{ github.event.number }} run: | - jq '.' ./artifacts/api-base.json > ./api-base.json - jq '.' ./artifacts/api-head.json > ./api-head.json - - name: Get diff of 2 files - run: diff -u --label=base --label=head ./api-base.json ./api-head.json | cat > api.json.diff - - name: Get full diff - run: diff --label=base --label=head --new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L' ./api-base.json ./api-head.json | cat > api-full.json.diff - - name: Echo full diff - run: cat ./api-full.json.diff - - name: Upload full diff to Artifact - uses: actions/upload-artifact@v3 + echo "$PR_NUMBER" > ./pr_number + - uses: actions/upload-artifact@v3 with: name: api-artifact - path: api-full.json.diff - - id: out-diff - name: Build diff Comment - run: | - cat <<- EOF > ./output.md - このPRによるapi.jsonの差分 -
- 差分はこちら - - \`\`\`diff - $(cat ./api.json.diff) - \`\`\` -
- - [Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) - EOF - - name: Write diff comment - uses: thollander/actions-comment-pull-request@v2 - with: - comment_tag: show_diff - filePath: ./output.md + path: pr_number diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0ed67a578d..534856004c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: with: version: 8 run_install: false - - uses: actions/setup-node@v3.8.1 + - uses: actions/setup-node@v4.0.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -46,7 +46,7 @@ jobs: with: version: 7 run_install: false - - uses: actions/setup-node@v3.8.1 + - uses: actions/setup-node@v4.0.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -72,7 +72,7 @@ jobs: with: version: 7 run_install: false - - uses: actions/setup-node@v3.8.1 + - uses: actions/setup-node@v4.0.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/report-api-diff.yml b/.github/workflows/report-api-diff.yml new file mode 100644 index 0000000000..ca400ec0df --- /dev/null +++ b/.github/workflows/report-api-diff.yml @@ -0,0 +1,85 @@ +name: Report API Diff + +on: + workflow_run: + types: [completed] + workflows: + - Get api.json from CherryPick # get-api-diff.yml + +jobs: + compare-diff: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + pull-requests: write + +# api-artifact + steps: + - name: Download artifact + uses: actions/github-script@v6 + with: + script: | + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name == "api-artifact" + })[0]; + let download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + let fs = require('fs'); + fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/api-artifact.zip`, Buffer.from(download.data)); + - name: Extract artifact + run: unzip api-artifact.zip -d artifacts + - name: Load PR Number + id: load-pr-num + run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT" + + - name: Output base + run: cat ./artifacts/api-base.json + - name: Output head + run: cat ./artifacts/api-head.json + - name: Arrange json files + run: | + jq '.' ./artifacts/api-base.json > ./api-base.json + jq '.' ./artifacts/api-head.json > ./api-head.json + - name: Get diff of 2 files + run: diff -u --label=base --label=head ./api-base.json ./api-head.json | cat > api.json.diff + - name: Get full diff + run: diff --label=base --label=head --new-line-format='+%L' --old-line-format='-%L' --unchanged-line-format=' %L' ./api-base.json ./api-head.json | cat > api-full.json.diff + - name: Echo full diff + run: cat ./api-full.json.diff + - name: Upload full diff to Artifact + uses: actions/upload-artifact@v3 + with: + name: api-artifact + path: | + api-full.json.diff + api-base.json + api-head.json + - id: out-diff + name: Build diff Comment + run: | + cat <<- EOF > ./output.md + 이 PR에 의한 api.json 차이 +
+ 차이점은 여기에서 볼 수 있음 + + \`\`\`diff + $(cat ./api.json.diff) + \`\`\` +
+ + [Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) + EOF + - uses: thollander/actions-comment-pull-request@v2 + with: + pr_number: ${{ steps.load-pr-num.outputs.pr-number }} + comment_tag: show_diff + filePath: ./output.md diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 19c90a9e28..b161b4f877 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -38,7 +38,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-cherrypick-js.yml b/.github/workflows/test-cherrypick-js.yml index b1d24f47ae..e740fc70e8 100644 --- a/.github/workflows/test-cherrypick-js.yml +++ b/.github/workflows/test-cherrypick-js.yml @@ -26,7 +26,7 @@ jobs: - run: corepack enable - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index f7ae1574bf..44f721ad2d 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -25,7 +25,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -83,7 +83,7 @@ jobs: version: 7 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 6399f10255..34289c9797 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -28,7 +28,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.8.1 + uses: actions/setup-node@v4.0.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/CHANGELOG.md b/CHANGELOG.md index 28eadd76ae..9c125200f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,83 @@ --> -## 2023.x.x (unreleased) +## 2023.11.0 + +### Note +- iOS 16.4未満を使用している場合はiOS 16.4以上にアップデートをお願いします + +### General +- Feat: アイコンデコレーション機能 + - サーバーで用意された画像をアイコンに重ねることができます + - 画像のテンプレートはこちらです: https://misskey-hub.net/avatar-decoration-template.png + - 最大でも黄色いエリア内にデコレーションを収めることを推奨します。 + - 画像は512x512pxを推奨します。 +- Feat: チャンネル設定にリノート/引用リノートの可否を設定できる項目を追加 +- Enhance: アカウント登録時のメールアドレス認証に30分の有効期限を設定 + - 有効期限が切れた後であれば、登録時に使用した招待コードを再度利用できるように変更しました。 + - ユーザーが誤ったメールアドレスを入力した場合に招待コードが失効してしまう問題が解消されます。 +- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように +- Enhance: 未読の通知数を表示できるように +- Enhance: 通知されず、確認の必要もないお知らせ(silence)を作成可能になりました +- Enhance: ローカリゼーションの更新 +- Enhance: 依存関係の更新 +- Change: CWを使用する場合、注釈を空にすることは許可されなくなりました + +### Client +- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました + - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください + https://misskey-hub.net/docs/advanced/publish-on-your-website.html +- Feat: 通知をグルーピングして表示するオプション(オプトアウト) +- Feat: Misskeyの基本的なチュートリアルを実装 +- Feat: スワイプしてタイムラインを再読込できるように + - PCの場合は右上のボタンからでも再読込できます +- Enhance: タイムラインの自動更新を無効にできるように +- Enhance: コードのシンタックスハイライトエンジンをShikiに変更 + - AiScriptのシンタックスハイライトに対応 + - MFMでAiScriptをハイライトする場合、コードブロックの開始部分を ` ```is ` もしくは ` ```aiscript ` としてください +- Enhance: データセーバー有効時はアニメーション付きのアバター画像が停止するように +- Enhance: プラグインを削除した際には、使用されていたアクセストークンも同時に削除されるようになりました +- Enhance: プラグインで`Plugin:register_note_view_interruptor`を用いてnoteの代わりにnullを返却することでノートを非表示にできるようになりました +- Enhance: AiScript関数`Mk:nyaize()`が追加されました +- Enhance: 情報→ツール はナビゲーションバーにツールとして独立した項目になりました +- Enhance: ノート内の絵文字をクリックすることで、コピーおよびリアクションができるように +- Enhance: その他細かなブラッシュアップ +- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正 +- Fix: ユーザーページの ノート > ファイル付き タブにリプライが表示されてしまう +- Fix: 「検索」MFMにおいて一部の検索キーワードが正しく認識されない問題を修正 +- Fix: 一部の言語でMisskey Webがクラッシュする問題を修正 +- Fix: チャンネルの作成・更新時に失敗した場合何も表示されない問題を修正 #11983 +- Fix: 個人カードのemojiがバッテリーになっている問題を修正 +- Fix: 標準テーマと同じIDを使用してインストールできてしまう問題を修正 +- Fix: 絵文字ピッカーでバッテリーの絵文字が複数表示される問題を修正 #12197 +- Fix: 11以上されているリアクションにおいてツールチップで示されるリアクション数が本来よりも1多い問題を修正 #12174 +- Fix: サイレンス状態で公開範囲のパブリックを選択できてしまう問題を修正 #12224 +- Fix: In deck layout, replies option is not saved after refresh +- Fix: アーカイブしたお知らせがコントロールパネルに表示される問題を修正 +- Note: アップデート後、サウンドに関する設定が初期化されます + +### Server +- Feat: Registry APIがサードパーティから利用可能になりました +- Enhance: RedisへのTLのキャッシュ(FTT)をオフにできるように +- Enhance: フォローしているチャンネルをフォロー解除した時(またはその逆)、タイムラインに反映される間隔を改善 +- Enhance: プロフィールの自己紹介欄のMFMが連合するようになりました + - 相手がMisskey v2023.11.0以降である必要があります +- Enhance: チャンネル取得時のパフォーマンスを向上 +- Enhance: AP: ApplicationタイプのアカウントをisBotとして扱うように +- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正 +- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正 +- Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正 +- Fix: RedisへのTLキャッシュが有効の場合にHTL/LTL/STLが空になることがある問題を修正 +- Fix: STLでフォローしていないチャンネルが取得される問題を修正 +- Fix: `hashtags/trend`にてRedisからトレンドの情報が取得できない際にInternal Server Errorになる問題を修正 +- Fix: HTLをリロードまたは遡行したとき、フォローしているチャンネルのノートが含まれない問題を修正 #11765 #12181 +- Fix: リノートをリノートできるのを修正 +- Fix: アクセストークンを削除すると、通知が取得できなくなる場合がある問題を修正 +- Fix: 自身の宛先なしダイレクト投稿がストリーミングで流れてこない問題を修正 +- Fix: サーバーサイドからのテスト通知を正しく行えるように修正 +- Fix: GTLの「リノートを表示」オプションが機能しないのを修正 #12233 + +## 2023.10.2 ### General - Feat: アンテナでローカルの投稿のみ収集できるようになりました @@ -26,6 +102,7 @@ ### Client - Enhance: TLの返信表示オプションを記憶するように - Enhance: 投稿されてから時間が経過しているノートであることを視覚的に分かりやすく +- Feat: 絵文字ピッカーのカテゴリに「/」を入れることでフォルダ分け表示できるように ### Server - Enhance: タイムライン取得時のパフォーマンスを向上 diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index ea6d43ec98..4df7159f37 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -23,15 +23,71 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023xx](CHANGE # 릴리즈 노트 이 문서는 CherryPick의 변경 사항만 포함합니다. +> Misskey 또는 CherryPick v4.3.0 이전 버전에서 마이그레이션 하는 경우, 버전 관리 방식의 차이 때문에 기존 버전보다 낮은 것으로 인식되어 마이그레이션 이후 업데이트 관련 대화 상자가 표시되지 않을 수 있습니다. +> +> 또한, 일부 locale이 누락되거나 기능이 정상적으로 작동하지 않는 등의 문제가 발생할 수 있으나 이는 정상적인 동작으로, +> 문제가 발생하면 '설정 - 캐시 비우기'를 진행하거나, 브라우저 캐시를 삭제하십시오. + +## 4.5.0 +출시일: 2023/11/16
+기반 Misskey 버전: 2023.11.0
+Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023110](CHANGELOG.md#2023110) 문서를 참고하십시오. + +### General +- Change: 프로필과 노트를 번역할 때 nyaize를 사용하지 않음 + +### Client +- Feat: (Friendly) 모바일 환경의 플로팅 네비게이션 버튼에서 아이콘 장식 표시 여부를 선택할 수 있음 + - 시인성 문제로 기본적으로 비활성화 상태임 +- Feat: 본문 미리보기의 프로필을 표시하지 않도록 설정할 수 있음 +- Feat: 이모티콘 피커의 카테고리를 다중 계층 폴더로 분류할 수 있음 (misskey-dev/misskey#12132) +- Feat: 열람 주의로 설정된 미디어를 두 번 탭 하여 표시하도록 할 수 있음 #392 +- Feat: 노트의 텍스트 소스를 볼 수 있음 +- Feat: 고양이로 설정된 유저의 노트를 nyaize로 표시하지 않고 볼 수 있음 +- Enhance: 스와이프하여 타임라인을 다시 불러올 수 있음 (misskey-dev/misskey#12113) + - PC의 경우 오른쪽 상단의 버튼을 통해서도 다시 불러올 수 있습니다 +- Enhance: 타임라인 자동 업데이트를 비활성화할 수 있음 (misskey-dev/misskey#12113) +- Enhance: AiScript 함수 `Mk:nyaize()`가 추가됨 (misskey-dev/misskey#12136) +- Enhance: 노트 작성 폼에서 노트를 게시한 뒤에 textarea의 높이를 원래대로 되돌림 +- Enhance: 노트 상세 페이지의 답글 목록 개선 +- Enhance: 유저 페이지 개선 + - 요약 탭의 하이라이트를 제거 & 노트 탭으로 하이라이트를 이동 + - 요약 탭의 리액션을 제거 & 노트 탭으로 리액션을 이동 +- Enhance: 노트 편집 시 관련 안내 추가 +- Enhance: 계정을 고양이로 설정하면 자동으로 노트 작성 버튼을 '노트'에서 '냥!'으로 변경함 + - 임의로 해당 옵션을 조작한 경우에는 설정을 변경하지 않음 +- Enhance: 노트 메뉴를 보기 쉽도록 자주 사용하지 않는 메뉴 이동 +- chore: 이모티콘 이름 필드에서 autocapitalize를 끄기 (misskey-dev/misskey#12139) +- Fix: 외부 리소스 설치 페이지에서 페이지 캐시가 작동하는 문제 수정 (misskey-dev/misskey#12105) +- Fix: 채널 생성/업데이트 시 실패하면 아무 것도 표시되지 않는 문제 수정 misskey-dev/misskey#11983 (misskey-dev/misskey#12142) +- Fix: 유저 페이지의 미디어 타임라인에서 미디어가 없는 답글이 표시됨 #388 +- Fix: Friendly UI가 아닌 경우 헤더 디자인이 잘못 표시되는 문제 + - 헤더의 액션 항목이 여러 개 일 때 왼쪽으로 타이틀이 치우칠 수 있음 + - 특정 조건에서 헤더의 왼쪽에 여백이 발생할 수 있음 + - 일부 페이지에서 잘못된 디자인이 표시됨 + - 일부 페이지에서 액션 항목이 존재해도 버튼이 표시되지 않을 수 있음 +- Fix: 네비게이션 메뉴의 하단 프로필 영역이 잘못된 디자인으로 표시됨 +- Fix: 노트를 인용할 때 입력란에 자동으로 포커스가 맞춰지지 않음 +- Fix: '모든 미디어 노트 간략화하기' 옵션을 활성화하면 미디어가 아닌 노트에도 '닫기' 버튼이 표시될 수 있음 +- Fix: 유저 프로필에서 헤더 디자인이 잘못 표시되는 문제 +- Fix: 비로그인 상태에서 노트 번역을 시도할 수 있음 +- Fix: 클립이 없는 상태에서 노트 메뉴의 클립 추가 버튼 위에 줄이 표시됨 + +### Server +- Feat: 연합에서 노트 수정이 반영됨 (libnare/cp-castella#1) +- Feat: 리모트 유저의 아바타 장식이 반영됨 ([libnare/cp-castella@7891331](https://github.com/libnare/cp-castella/commit/7891331321e2fbaf4ec5f5c9d4e51b116948d564), [libnare/cp-castella@ae4004c](https://github.com/libnare/cp-castella/commit/ae4004cd41c85f56716a4100c2eb0d8410fbd20a), [libnare/cp-castella@135aa97](https://github.com/libnare/cp-castella/commit/135aa97046548ba5929c04e412622c979a2cad09)) +- Enhance: 사용자 차단 개선 (Renote Part) (misskey-dev/misskey#12089) +- Fix: 장시간 기다려도 마이그레이션이 완료되지 않을 수 있음 +- Fix: Redis 에서 TL 캐시를 반환하지 않으면 '고양이만 보기'가 작동하지 않을 수 있음 +- Fix: latestRequestReceivedAt이 제대로 반영되지 않음 (misskey-dev/misskey#12270) + +--- + ## 4.4.1 출시일: 2023/10/21
기반 Misskey 버전: 2023.10.2
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023102](CHANGELOG.md#2023102) 문서를 참고하십시오. -> 버전 관리 방식이 변경되었기 때문에, 기존 버전보다 낮은 것으로 인식되어 업데이트 대화 상자가 표시되지 않을 수 있습니다. -> 또한, 일부 locale이 누락되거나 기능이 정상적으로 작동하지 않는 등의 문제가 발생할 수 있습니다. -> 문제가 발생하면 '설정 - 캐시 비우기'를 진행하거나, 브라우저 캐시를 삭제하십시오. - ### Client - Feat: 노트 편집 시 토스트 알림을 표시하고 사운드를 재생 - Feat: PostForm 접두사에 현재 공개 범위 표시 ([tanukey-dev/tanukey@1cc0071](https://github.com/tanukey-dev/tanukey/commit/1cc0071bbd424949d9305bcec554f5d755a73554)) @@ -57,10 +113,6 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023102](CHANG 기반 Misskey 버전: 2023.10.1
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023101](CHANGELOG.md#2023101) 문서를 참고하십시오. -> 버전 관리 방식이 변경되었기 때문에, 기존 버전보다 낮은 것으로 인식되어 업데이트 대화 상자가 표시되지 않을 수 있습니다. -> 또한, 일부 locale이 누락되거나 기능이 정상적으로 작동하지 않는 등의 문제가 발생할 수 있습니다. -> 문제가 발생하면 '설정 - 캐시 비우기'를 진행하거나, 브라우저 캐시를 삭제하십시오. - ## NOTE - Misskey 2023.10.0 에서 제거된 노트 편집 기능이 계속 유지됩니다. @@ -116,10 +168,6 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2023101](CHANG 기반 Misskey 버전: 2023.9.3
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#202393](CHANGELOG.md#202393) 문서를 참고하십시오. -> 버전 관리 방식이 변경되었기 때문에, 기존 버전보다 낮은 것으로 인식되어 업데이트 대화 상자가 표시되지 않을 수 있습니다. -> 또한, 일부 locale이 누락되거나 기능이 정상적으로 작동하지 않는 등의 문제가 발생할 수 있습니다. -> 문제가 발생하면 '설정 - 캐시 비우기'를 진행하거나, 브라우저 캐시를 삭제하십시오. - ### Server - Fix: 마이그레이션 문제 - Misskey에서 CherryPick으로 마이그레이션하면 오류가 발생함 @@ -131,10 +179,6 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#202393](CHANGE 기반 Misskey 버전: 2023.9.3
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#202393](CHANGELOG.md#202393) 문서를 참고하십시오. -> 버전 관리 방식이 변경되었기 때문에, 기존 버전보다 낮은 것으로 인식되어 업데이트 대화 상자가 표시되지 않을 수 있습니다. -> 또한, 일부 locale이 누락되거나 기능이 정상적으로 작동하지 않는 등의 문제가 발생할 수 있습니다. -> 문제가 발생하면 '설정 - 캐시 비우기'를 진행하거나, 브라우저 캐시를 삭제하십시오. - ### General - Feat: 편집한 노트의 기록을 확인할 수 있음 (misskey-dev/misskey#11938) @@ -163,10 +207,6 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#202393](CHANGE 기반 Misskey 버전: 2023.9.2
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#202392](CHANGELOG.md#202392) 문서를 참고하십시오. -> 버전 관리 방식이 변경되었기 때문에, 기존 버전보다 낮은 것으로 인식되어 업데이트 대화 상자가 표시되지 않을 수 있습니다. -> 또한, 일부 locale이 누락되거나 기능이 정상적으로 작동하지 않는 등의 문제가 발생할 수 있습니다. -> 문제가 발생하면 '설정 - 캐시 비우기'를 진행하거나, 브라우저 캐시를 삭제하십시오. - ### General - 미디어, 고양이 타임라인 개선 - [misskey-dev/misskey@eb740e2](https://github.com/misskey-dev/misskey/commit/eb740e2c72ae6854b244ad099c927c069008720e) 이 추가됨에 따라, 해당 기능에 병합하고 기존 미디어 및 고양이 타임라인을 제거함 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e471e4b8ce..303fd9e495 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/kokonect-link/cherrypick/discussions) or [Discord](https://discord.gg/V8qghB28Aj). + - Please ask questions or troubleshooting in [GitHub Discussions](https://github.com/kokonect-link/cherrypick/discussions) or [Discord](https://discord.gg/V8qghB28Aj). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. @@ -40,7 +40,7 @@ Thank you for your PR! Before creating a PR, please check the following: - `fix` / `refactor` / `feat` / `enhance` / `perf` / `chore` etc - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR. - If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text. -- Please add the summary of the changes to [`CHANGELOG.md`](/CHANGELOG.md). However, this is not necessary for changes that do not affect the users, such as refactoring. +- Please add the summary of the changes to [`CHANGELOG_CHERRYPICK.md`](/CHANGELOG_CHERRYPICK.md). However, this is not necessary for changes that do not affect the users, such as refactoring. - Check if there are any documents that need to be created or updated due to this change. - If you have added a feature or fixed a bug, please add a test case if possible. - Please make sure that tests and Lint are passed in advance. diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 9669ae33ac..87a951ac80 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1013,6 +1013,7 @@ expired: "منتهية صلاحيته" icon: "الصورة الرمزية" replies: "رد" renotes: "أعد النشر" +flip: "اقلب" _initialAccountSetting: accountCreated: "نجح إنشاء حسابك!" letsStartAccountSetup: "إذا كنت جديدًا لنعدّ حسابك الشخصي." @@ -1277,9 +1278,6 @@ _time: minute: "د" hour: "سا" day: "ي" -_timelineTutorial: - title: "كيف تستخدم CherryPick" - step3_1: "هل نشرت ملاحظتك الأولى؟" _2fa: alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين." step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})." diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 70d40a4e35..f15aa16a70 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -853,6 +853,7 @@ youFollowing: "অনুসরণ করা হচ্ছে" icon: "প্রোফাইল ছবি" replies: "জবাব" renotes: "রিনোট" +flip: "উল্টান" _role: priority: "অগ্রাধিকার" _priority: diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 7bcf9418c9..18519bd2a4 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1096,6 +1096,7 @@ iHaveReadXCarefullyAndAgree: "Přečetl jsem si text \"{x}\" a souhlasím s ním icon: "Avatar" replies: "Odpovědět" renotes: "Přeposlat" +flip: "Otočit" _initialAccountSetting: accountCreated: "Váš účet byl úspěšně vytvořen!" letsStartAccountSetup: "Pro začátek si nastavte svůj profil." @@ -1108,7 +1109,6 @@ _initialAccountSetting: pushNotificationDescription: "Povolení push oznámení vám umožní přijímat oznámení od {name} přímo ve vašem zařízení." initialAccountSettingCompleted: "Nastavení profilu dokončeno!" haveFun: "Užívejte {name}!" - ifYouNeedLearnMore: "Pokud se chcete dozvědět více o tom, jak používat {name} (Misskey), navštivte {link}." skipAreYouSure: "Opravdu chcete přeskočit nastavení profilu?" laterAreYouSure: "Opravdu chcete provést nastavení profilu později?" _serverRules: @@ -1659,16 +1659,6 @@ _time: minute: "Minut" hour: "Hodin" day: "Dnů" -_timelineTutorial: - title: "Jak používat Misskey" - step1_1: "Toto je \"časová osa\". Zde se chronologicky zobrazují všechny \"poznámky\" odeslané na {name}." - step1_2: "Existuje několik různých časových plánů. Například \"Domácí časová osa\" bude obsahovat poznámky uživatelů, které sledujete, a \"Místní časová osa\" bude obsahovat poznámky všech uživatelů {name}." - step2_1: "Zkusme zveřejnit poznámku. Můžete tak učinit stisknutím tlačítka s ikonou tužky." - step2_2: "Co takhle napsat sebepředstavení, nebo jen \"Ahoj {name}!\", pokud se vám nechce?" - step3_1: "Dokončil jsi svou první poznámku?" - step3_2: "Na časové ose by se nyní měla zobrazit vaše první poznámka." - step4_1: "K poznámkám můžete také připojit \"Reakce\"." - step4_2: "Chcete-li připojit reakci, stiskněte na poznámce znaménko \"+\" a vyberte emoji, kterým chcete reagovat." _2fa: alreadyRegistered: "Již jste zaregistrovali dvoufaktorové ověřovací zařízení." registerTOTP: "Registrovat aplikaci autentizátoru" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 77cf4bd59c..41497e4d1c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -993,6 +993,7 @@ assign: "Zuweisen" unassign: "Entfernen" color: "Farbe" manageCustomEmojis: "Kann benutzerdefinierte Emojis verwalten" +manageAvatarDecorations: "Profilbilddekorationen verwalten" youCannotCreateAnymore: "Du hast das Erstellungslimit erreicht." cannotPerformTemporary: "Vorübergehend nicht verfügbar" cannotPerformTemporaryDescription: "Diese Aktion ist wegen des Überschreitenes des Ausführungslimits temporär nicht verfügbar. Bitte versuche es nach einiger Zeit erneut." @@ -1146,6 +1147,10 @@ mutualFollow: "Gegenseitig gefolgt" fileAttachedOnly: "Nur Notizen mit Dateien" showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen" hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen" +showRepliesToOthersInTimelineAll: "Antworten von allen momentan gefolgten Benutzern in Chronik anzeigen" +hideRepliesToOthersInTimelineAll: "Antworten von allen momentan gefolgten Benutzern nicht in Chronik anzeigen" +confirmShowRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten von allen momentan gefolgten Benutzern in der Chronik anzeigen?" +confirmHideRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten von allen momentan gefolgten Benutzern nicht in der Chronik anzeigen?" externalServices: "Externe Dienste" impressum: "Impressum" impressumUrl: "Impressums-URL" @@ -1153,6 +1158,18 @@ impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, privacyPolicy: "Datenschutzerklärung" privacyPolicyUrl: "Datenschutzerklärungs-URL" tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung" +avatarDecorations: "Profilbilddekoration" +attach: "Anbringen" +detach: "Entfernen" +angle: "Winkel" +flip: "Umdrehen" +showAvatarDecorations: "Profilbilddekoration anzeigen" +releaseToRefresh: "Zum Aktualisieren loslassen" +refreshing: "Wird aktualisiert..." +pullDownToRefresh: "Zum Aktualisieren ziehen" +disableStreamingTimeline: "Echtzeitaktualisierung der Chronik deaktivieren" +useGroupedNotifications: "Benachrichtigungen gruppieren" +cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden." _announcement: forExistingUsers: "Nur für existierende Nutzer" forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." @@ -1174,7 +1191,6 @@ _initialAccountSetting: pushNotificationDescription: "Durch die Aktivierung von Push-Benachrichtigungen kannst du von {name} Benachrichtigungen direkt auf dein Gerät erhalten." initialAccountSettingCompleted: "Kontoeinrichtung abgeschlossen!" haveFun: "Viel Spaß mit {name}!" - ifYouNeedLearnMore: "Besuche {link}, falls du mehr über {name} (CherryPick) lernen möchtest." skipAreYouSure: "Die Kontoeinrichtung wirklich überspringen?" laterAreYouSure: "Die Kontoeinrichtung wirklich später erledigen?" _serverRules: @@ -1188,6 +1204,7 @@ _serverSettings: manifestJsonOverride: "Überschreiben von manifest.json" shortName: "Abkürzung" shortNameDescription: "Ein Kürzel für den Namen der Instanz, der angezeigt werden kann, falls der volle Instanzname lang ist." + fanoutTimelineDescription: "Ist diese Option aktiviert, kann eine erhebliche Verbesserung im Abrufen von Chroniken und eine Reduzierung der Datenbankbelastung erzielt werden, im Gegenzug zu einer Steigerung in der Speichernutzung von Redis. Bei geringem Serverspeicher oder Serverinstabilität kann diese Option deaktiviert werden." _accountMigration: moveFrom: "Von einem anderen Konto zu diesem migrieren" moveFromSub: "Alias für ein anderes Konto erstellen" @@ -1489,6 +1506,7 @@ _role: inviteLimitCycle: "Zyklus des Einladungslimits" inviteExpirationTime: "Gültigkeitsdauer von Einladungen" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" + canManageAvatarDecorations: "Profilbilddekorationen verwalten" driveCapacity: "Drive-Kapazität" alwaysMarkNsfw: "Dateien immer als NSFW markieren" pinMax: "Maximale Anzahl an angehefteten Notizen" @@ -1608,6 +1626,7 @@ _aboutMisskey: donate: "An Misskey spenden" morePatrons: "Wir schätzen ebenso die Unterstützung vieler anderer hier nicht gelisteter Personen sehr. Danke! 🥰" patrons: "UnterstützerInnen" + projectMembers: "Projektmitglieder" _displayOfSensitiveMedia: respect: "Sensible Medien verbergen" ignore: "Sensible Medien anzeigen" @@ -1807,16 +1826,6 @@ _time: minute: "Minute(n)" hour: "Stunde(n)" day: "Tag(en)" -_timelineTutorial: - title: "Wie du CherryPick verwendest" - step1_1: "Dieser Bildschirm ist die \"Chronik\". Hier werden alle \"Notizen\" von {name} angezeigt." - step1_2: "Es gibt einige verschiedene Chroniken. Beispielsweise werden in der \"Startseite\" alle Notizen von Nutzern, denen du folgst, angezeigt, und in der \"Lokalen Chronik\" werden Notizen aller Nutzer auf {name} angezeigt." - step2_1: "Lass uns als nächstes versuchen, eine Notiz zu schreiben. Dies kannst du tun, indem du auf den Knopf mit dem Stift-Icon drückst." - step2_2: "Stell dich den anderen vor oder schreibe einfach \"Hallo {name}!\", wenn du darauf keine Lust hast oder dir nichts einfällt." - step3_1: "Fertig mit dem Senden deiner ersten Notiz?" - step3_2: "Falls deine Notiz nun in deiner Chronik auftaucht, hast du alles richtig gemacht." - step4_1: "Notizen können zusätzlich mit \"Reaktionen\" ausgestattet werden." - step4_2: "Um eine Reaktion anzufügen, klicke auf das „+“-Symbol einer Notiz und wähle ein Emoji aus, mit dem du reagieren möchtest." _2fa: alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung registriert." registerTOTP: "Authentifizierungs-App registrieren" @@ -2131,6 +2140,9 @@ _notification: checkNotificationBehavior: "Aussehen von Benachrichtigungen überprüfen" sendTestNotification: "Testbenachrichtigung senden" notificationWillBeDisplayedLikeThis: "Benachrichtigungen sehen so aus" + reactedBySomeUsers: "{n} Benutzer haben eine Reaktion geschickt" + renotedBySomeUsers: "Renote von {n} Benutzern" + followedBySomeUsers: "Von {n} Benutzern gefolgt" _types: all: "Alle" note: "Neue Notizen" @@ -2235,6 +2247,9 @@ _moderationLogTypes: createAd: "Werbung erstellt" deleteAd: "Werbung gelöscht" updateAd: "Werbung aktualisiert" + createAvatarDecoration: "Profilbilddekoration erstellt" + updateAvatarDecoration: "Profilbilddekoration aktualisiert" + deleteAvatarDecoration: "Profilbilddekoration gelöscht" _fileViewer: title: "Dateiinformationen" type: "Dateityp" @@ -2243,3 +2258,44 @@ _fileViewer: uploadedAt: "Hochgeladen am" attachedNotes: "Zugehörige Notizen" thisPageCanBeSeenFromTheAuthor: "Nur der Benutzer, der diese Datei hochgeladen hat, kann diese Seite sehen." +_externalResourceInstaller: + title: "Von externer Seite installieren" + checkVendorBeforeInstall: "Überprüfe vor Installation die Vertrauenswürdigkeit des Vertreibers." + _plugin: + title: "Möchtest du dieses Plugin installieren?" + metaTitle: "Plugininformation" + _theme: + title: "Möchten du dieses Farbschema installieren?" + metaTitle: "Farbschemainfo" + _meta: + base: "Farbschemavorlage" + _vendorInfo: + title: "Vertreiber" + endpoint: "Referenzierter Endpunkt" + hashVerify: "Hash-Verifikation" + _errors: + _invalidParams: + title: "Ungültige Parameter" + description: "Es fehlen Informationen zum Laden der externen Ressource. Überprüfe die übergebene URL." + _resourceTypeNotSupported: + title: "Diese Ressource wird nicht unterstützt" + description: "Dieser Ressourcentyp wird nicht unterstützt. Bitte kontaktiere den Seitenbesitzer." + _failedToFetch: + title: "Fehler beim Abrufen der Daten" + fetchErrorDescription: "Während der Kommunikation mit der externen Seite ist ein Fehler aufgetreten. Kontaktiere den Seitenbesitzer, falls ein erneutes Probieren dieses Problem nicht löst." + parseErrorDescription: "Während dem Auslesen der externen Daten ist ein Fehler aufgetreten. Kontaktiere den Seitenbesitzer." + _hashUnmatched: + title: "Datenverifizierung fehlgeschlagen" + description: "Die Integritätsprüfung der geladenen Daten ist fehlgeschlagen. Aus Sicherheitsgründen kann die Installation nicht fortgesetzt werden. Kontaktiere den Seitenbesitzer." + _pluginParseFailed: + title: "AiScript-Fehler" + description: "Die angeforderten Daten wurden erfolgreich abgerufen, jedoch trat während des AiScript-Parsings ein Fehler auf. Kontaktiere den Autor des Plugins. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." + _pluginInstallFailed: + title: "Das Plugin konnte nicht installiert werden" + description: "Während der Installation des Plugin ist ein Problem aufgetreten. Bitte versuche es erneut. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." + _themeParseFailed: + title: "Parsing des Farbschemas fehlgeschlagen" + description: "Die angeforderten Daten wurden erfolgreich abgerufen, jedoch trat während des Farbschema-Parsings ein Fehler auf. Kontaktiere den Autor des Farbschemas. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." + _themeInstallFailed: + title: "Das Farbschema konnte nicht installiert werden" + description: "Während der Installation des Farbschemas ist ein Problem aufgetreten. Bitte versuche es erneut. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." diff --git a/locales/en-US.yml b/locales/en-US.yml index 62226be2d3..f1688db7b4 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1,5 +1,13 @@ --- _lang_: "English" +noNyaization: "Show content without Nyaization" +revertNoNyaization: "Show content including Nyaization" +viewTextSource: "View text source" +disableNoteEditConfirm: "Really continue editing the note?" +disableNoteEditConfirmWarn: "Only software that supports note editing(Mastodon, CherryPick, FireFish, etc.) will be able to see the edits and history.\nSoftware that doesn't support note editing will only show what was in the note before you edited it, so if you want it to reflect your edits across all federated servers, rewrite the note with \"delete and edit\"." +disableNoteEditOk: "Edit a note" +nsfwOpenBehavior: "NSFW media open behavior" +previewNoteProfile: "Show profile" noteEdited: "Note are now edited." removeModalBgColorForBlur: "Remove modal background color" skipThisVersion: "Skip this release" @@ -99,7 +107,7 @@ copyLinkRenote: "Copy renote link" delete: "Delete" deleteAndEdit: "Delete and edit" deleteAndEditConfirm: "Are you sure you want to redraft this note? This means you will lose all reactions, renotes, and replies to it." -copyAndEdit: "Copy and edit" +copyAndEdit: "Copy and edit content" copyAndEditConfirm: "Are you sure you want to copy this note and edit it? The media included in the notes are also copied." addToList: "Add to list" addToAntenna: "Add to antenna" @@ -171,6 +179,7 @@ pinnedNote: "Pinned note" pinned: "Pin to profile" you: "You" clickToShow: "Click to show" +doubleClickToShow: "Double click to show" sensitive: "Sensitive" add: "Add" reaction: "Reactions" @@ -366,6 +375,7 @@ folderName: "Folder name" createFolder: "Create a folder" renameFolder: "Rename this folder" deleteFolder: "Delete this folder" +folder: "Folder" addFile: "Add a file" emptyDrive: "Your Drive is empty" emptyFolder: "This folder is empty" @@ -1058,6 +1068,7 @@ assign: "Assign" unassign: "Unassign" color: "Color" manageCustomEmojis: "Manage Custom Emojis" +manageAvatarDecorations: "Manage avatar decorations" youCannotCreateAnymore: "You've hit the creation limit." cannotPerformTemporary: "Temporarily unavailable" cannotPerformTemporaryDescription: "This action cannot be performed temporarily due to exceeding the execution limit. Please wait for a while and then try again." @@ -1218,6 +1229,10 @@ mutualFollow: "Mutual follow" fileAttachedOnly: "Only notes with files" showRepliesToOthersInTimeline: "Show replies to others in timeline" hideRepliesToOthersInTimeline: "Hide replies to others from timeline" +showRepliesToOthersInTimelineAll: "Show replies to others from everyone you follow in timeline" +hideRepliesToOthersInTimelineAll: "Hide replies to others from everyone you follow in timeline" +confirmShowRepliesAll: "This operation is irreversible. Would you really like to show replies to others from everyone you follow in your timeline?" +confirmHideRepliesAll: "This operation is irreversible. Would you really like to hide replies to others from everyone you follow in your timeline?" externalServices: "External Services" impressum: "Impressum" impressumUrl: "Impressum URL" @@ -1225,12 +1240,29 @@ impressumDescription: "In some countries, like germany, the inclusion of operato privacyPolicy: "Privacy Policy" privacyPolicyUrl: "Privacy Policy URL" tosAndPrivacyPolicy: "Terms of Service and Privacy Policy" -showUnreadNotificationCount: "Show the number of unread notifications" +avatarDecorations: "Avatar decorations" +attach: "Attach" +detach: "Remove" +angle: "Angle" +flip: "Flip" +showAvatarDecorations: "Show avatar decorations" +releaseToRefresh: "Release to refresh" +refreshing: "Refreshing..." +pullDownToRefresh: "Pull down to refresh" +disableStreamingTimeline: "Disable real-time timeline updates" +useGroupedNotifications: "Display grouped notifications" +signupPendingError: "There was a problem verifying the email address. The link may have expired." +cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided." +doReaction: "Add reaction" +showUnreadNotificationsCount: "Show the number of unread notifications" showCatOnly: "Show only cats" additionalPermissionsForFlash: "Allow to add permission to Play" thisFlashRequiresTheFollowingPermissions: "This Play requires the following permissions" doYouWantToAllowThisPlayToAccessYourAccount: "Do you want to allow this Play to access your account?" translateProfile: "Translate profile" +_nsfwOpenBehavior: + click: "Click to open" + doubleClick: "Double click to open" _vibrations: click: "When an element is clicked" note: "When a new note is posted on the timeline" @@ -1244,11 +1276,6 @@ _showingAnimatedImages: inactive: "Stop after a certain amount of time" _messaging: direct: "Direct Message" -_tlTutorial: - step1_1: 'The {icon} Home timeline is where you can see posts from the accounts you follow.' - step1_2: 'The {icon} Local timeline is where you can see posts from everyone else on this server.' - step1_3: 'The {icon} Social timeline is a combination of the Home and Local timelines.' - step1_4: 'The {icon} Global timeline is where you can see posts from every other connected server.' _announcement: forExistingUsers: "Existing users only" forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it." @@ -1258,6 +1285,8 @@ _announcement: tooManyActiveAnnouncementDescription: "Having too many active announcements may worsen the user experience. Please consider archiving announcements that have become obsolete." readConfirmTitle: "Mark as read?" readConfirmText: "This will mark the contents of \"{title}\" as read." + shouldNotBeUsedToPresentPermanentInfo: "As it may significantly impact the user experience for new users, it is recommended to use notifications in the flow information rather than stock information." + dialogAnnouncementUxWarn: "Having two or more dialog-style notifications simultaneously can significantly impact the user experience, so please use them carefully." _group: leader: "Group owner" banish: "Banish" @@ -1289,6 +1318,7 @@ _cherrypick: renameTheButtonInPostFormToNya: "Change the \"Note\" button on the note-posting form to \"Nyan!\"" renameTheButtonInPostFormToNyaDescription: "Outside of the note-posting form, they are still as \"Note\"." enableLongPressOpenAccountMenu: "Press and hold to open the account menu." + friendlyShowAvatarDecorationsInNavBtn: "Show avatar decorations on floating buttons" _bannerDisplay: all: "All" topBottom: "Top and Bottom" @@ -1314,10 +1344,80 @@ _initialAccountSetting: pushNotificationDescription: "Enabling push notifications will allow you to receive notifications from {name} directly on your device." initialAccountSettingCompleted: "Profile setup complete!" haveFun: "Enjoy {name}!" - ifYouNeedLearnMore: "If you'd like to learn more about how to use {name} (CherryPick), please visit {link}." + youCanContinueTutorial: "You can proceed to a tutorial on how to use {name} (CherryPick) or you can exit the setup here and start using it immediately." + startTutorial: "Start Tutorial" skipAreYouSure: "Really skip profile setup?" skipAreYouSureDescription: "If you interrupt initialization now, you can resume it at [More! - Help - Replay initial setting]." laterAreYouSure: "Really do profile setup later?" +_initialTutorial: + launchTutorial: "Start Tutorial" + title: "Tutorial" + wellDone: "Well done!" + skipAreYouSure: "Quit Tutorial?" + _landing: + title: "Welcome to the Tutorial" + description: "Here, you can learn the basics of using CherryPick and its features." + _note: + title: "What is a Note?" + description: "Posts on CherryPick are called 'Notes.' Notes are arranged chronologically on the timeline and are updated in real-time." + reply: "Click on this button to reply to a message. It's also possible to reply to replies, continuing the conversation like a thread." + renote: "You can share that note to your own timeline. You can also quote them with your comments." + like: "You can add heart reactions to the Note. which is useful if you want to quickly leave a \"like!\"." + reaction: "You can add reactions to the Note. More details will be explained on the next page." + quote: "You can add a quote. Which is useful if you want to add a comment based on something." + menu: "You can view Note details, copy links, and perform various other actions." + _reaction: + title: "What are Reactions?" + description: "Notes can be reacted to with various emojis. Reactions allow you to express nuances that may not be conveyed with just a 'like.'" + letsTryReacting: "Reactions can be added by clicking the '+' button on the note. Try reacting to this sample note!" + reactToContinue: "Add a reaction to proceed." + reactNotification: "You'll receive real-time notifications when someone reacts to your note." + reactDone: "You can undo a reaction by pressing the '-' button." + _timeline: + title: "The Concept of Timelines" + description1: "CherryPick provides multiple timelines based on usage (some may not be available depending on the server's policies)." + home: "You can view notes from accounts you follow." + local: "You can view notes from all users on this server." + social: "Notes from the Home and Local timelines will be displayed." + global: "You can view notes from all connected servers." + description2: "You can switch between timelines at the top of the screen at any time." + description3: "Additionally, there are list timelines and channel timelines. For more details, please refer to {link}." + _postNote: + title: "Note Posting Settings" + description1: "When posting a note on CherryPick, various options are available. The posting form looks like this." + _visibility: + description: "You can limit who can view your note." + public: "Your note will be visible for all users." + home: "Public only on the Home timeline. People visiting your profile, via followers, and through renotes can see it." + followers: "Visible to followers only. Only followers can see it and no one else, and it cannot be renoted by others." + direct: "Visible only to specified users, and the recipient will be notified. It can be used as an alternative to direct messaging." + doNotSendConfidencialOnDirect1: "Be careful when sending sensitive information!" + doNotSendConfidencialOnDirect2: "Administrators of the server can see what you write. Be careful with sensitive information when sending direct notes to users on untrusted servers." + localOnly: "Posting with this flag will not federate the note to other servers. Users on other servers will not be able to view these notes directly, regardless of the display settings above." + _cw: + title: "Content Warning" + description: "Instead of the body, the content written in 'comments' field will be displayed. Pressing \"read more\" will reveal the body." + _exampleNote: + cw: "This will surely make you hungry!" + note: "Just had a chocolate-glazed donut 🍩😋" + useCases: "This is used when following the server guidelines for necessary notes or for self-restriction of spoiler or sensitive text." + _howToMakeAttachmentsSensitive: + title: "How to Mark Attachments as Sensitive?" + description: "For attachments that are required by server guidelines or that should not be left intact, add a \"sensitive\" flag." + tryThisFile: "Try marking the image attached in this form as sensitive!" + _exampleNote: + note: "Oops, messed up opening the natto lid..." + method: "To mark an attachment as sensitive, click the file thumbnail, open the menu, and click \"Mark as Sensitive.\"" + sensitiveSucceeded: "When attaching files, please set sensitivities in accordance with the server guidelines." + doItToContinue: "Mark the attachment file as sensitive to proceed." + _done: + title: "The tutorial is complete! 🎉" + description: "The functions introduced here are just a small part. For a more detailed understanding of using CherryPick, please refer to {link}." +_timelineDescription: + home: "In the Home timeline, you can see notes from accounts you follow." + local: "In the Local timeline, you can see notes from all users on this server." + social: "The Social timeline displays notes from both the Home and Local timelines." + global: "In the Global timeline, you can see notes from all connected servers." _serverRules: description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended." _event: @@ -1355,6 +1455,7 @@ _serverSettings: manifestJsonOverride: "manifest.json Override" shortName: "Short name" shortNameDescription: "A shorthand for the instance's name that can be displayed if the full official name is long." + fanoutTimelineDescription: "Greatly increases performance of timeline retrieval and reduces load on the database when enabled. In exchange, memory usage of Redis will increase. Consider disabling this in case of low server memory or server instability." _accountMigration: moveFrom: "Migrate another account to this one" moveFromSub: "Create alias to another account" @@ -1612,6 +1713,9 @@ _achievements: _smashTestNotificationButton: title: "Test overflow" description: "Trigger the notification test repeatedly within an extremely short time" + _tutorialCompleted: + title: "CherryPick Elementary Course Diploma" + description: "Tutorial completed" _role: new: "New role" edit: "Edit role" @@ -1656,6 +1760,7 @@ _role: inviteLimitCycle: "Invite limit cooldown" inviteExpirationTime: "Invite expiration interval" canManageCustomEmojis: "Can manage custom emojis" + canManageAvatarDecorations: "Manage avatar decorations" driveCapacity: "Drive capacity" alwaysMarkNsfw: "Always mark files as NSFW" pinMax: "Maximum number of pinned notes" @@ -1779,6 +1884,7 @@ _aboutMisskey: donate: "Donate to Misskey" morePatrons: "We also appreciate the support of many other helpers not listed here. Thank you! 🥰" patrons: "Patrons" + projectMembers: "Project members" _kokonect: serverStatus: "Server Status" donate: "Donate to Kokonect" @@ -1891,6 +1997,7 @@ _channel: notesCount: "{n} Notes" nameAndDescription: "Name and description" nameOnly: "Name only" + allowRenoteToExternal: "Allow renote and quote outside the channel" _menuDisplay: sideFull: "Side" sideIcon: "Side (Icons)" @@ -2001,16 +2108,6 @@ _time: minute: "Minute(s)" hour: "Hour(s)" day: "Day(s)" -_timelineTutorial: - title: "How to use CherryPick" - step1_1: "This is the \"timeline\". All \"notes\" submitted on {name} will be chronologically displayed here." - step1_2: "There are a few different timelines. For example, the \"Home timeline\" will contain notes of users you follow, and the \"Local timeline\" will contain notes from all users of {name}." - step2_1: "Let's try posting a note next. You can do so by pressing the button with a pencil icon." - step2_2: "How about writing a self-introduction, or just \"Hello {name}!\" if you don't feel like it?" - step3_1: "Finished posting your first note?" - step3_2: "Your first note should now be displayed on your timeline." - step4_1: "You can also attach \"Reactions\" to notes." - step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with." _2fa: alreadyRegistered: "You have already registered a 2-factor authentication device." registerTOTP: "Register authenticator app" @@ -2325,6 +2422,9 @@ _notification: checkNotificationBehavior: "Check notification appearance" sendTestNotification: "Send test notification" notificationWillBeDisplayedLikeThis: "Notifications look like this" + reactedBySomeUsers: "{n} users reacted" + renotedBySomeUsers: "Renote from {n} users" + followedBySomeUsers: "Followed by {n} users" _types: all: "All" note: "New notes" @@ -2429,6 +2529,9 @@ _moderationLogTypes: createAd: "Ad created" deleteAd: "Ad deleted" updateAd: "Ad updated" + createAvatarDecoration: "Avatar decoration created" + updateAvatarDecoration: "Avatar decoration updated" + deleteAvatarDecoration: "Avatar decoration deleted" _fileViewer: title: "File details" type: "File type" @@ -2437,6 +2540,47 @@ _fileViewer: uploadedAt: "Uploaded at" attachedNotes: "Attached notes" thisPageCanBeSeenFromTheAuthor: "This page can only be seen by the user who uploaded this file." +_externalResourceInstaller: + title: "Install from external site" + checkVendorBeforeInstall: "Make sure the distributor of this resource is trustworthy before installation." + _plugin: + title: "Do you want to install this plugin?" + metaTitle: "Plugin information" + _theme: + title: "Do you want to install this theme?" + metaTitle: "Theme information" + _meta: + base: "Base color scheme" + _vendorInfo: + title: "Distributor information" + endpoint: "Referenced endpoint" + hashVerify: "Hash verification" + _errors: + _invalidParams: + title: "Invalid parameters" + description: "There is not enough information to load data from an external site. Please confirm the entered URL." + _resourceTypeNotSupported: + title: "This external resource is not supported" + description: "The type of this external resource is not supported. Please contact the site administrator." + _failedToFetch: + title: "Failed to fetch data" + fetchErrorDescription: "An error occurred communicating with the external site. If trying again does not fix this issue, please contact the site administrator." + parseErrorDescription: "An error occurred processing the data loaded from the external site. Please contact the site administrator." + _hashUnmatched: + title: "Data verification failed" + description: "An error occurred verifying the integrity of the fetched data. As a security measure, installation cannot continue. Please contact the site administrator." + _pluginParseFailed: + title: "AiScript Error" + description: "The requested data was fetched successfully, but an error occurred during AiScript parsing. Please contact the plugin author. Error details can be viewed in the Javascript console." + _pluginInstallFailed: + title: "Plugin installation failed" + description: "A problem occurred during plugin installation. Please try again. Error details can be viewed in the Javascript console." + _themeParseFailed: + title: "Theme parsing failed" + description: "The requested data was fetched successfully, but an error occurred during theme parsing. Please contact the theme author. Error details can be viewed in the Javascript console." + _themeInstallFailed: + title: "Failed to install theme" + description: "A problem occurred during theme installation. Please try again. Error details can be viewed in the Javascript console." _abuse: _resolver: 1hour: "one hour" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index a68c24aad7..6ed9ee2bc8 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -1153,6 +1153,7 @@ impressumDescription: "En algunos países, como Alemania, la inclusión del oper privacyPolicy: "Política de Privacidad" privacyPolicyUrl: "URL de la Política de Privacidad" tosAndPrivacyPolicy: "Condiciones de Uso y Política de Privacidad" +flip: "Echar de un capirotazo" _announcement: forExistingUsers: "Solo para usuarios registrados" forExistingUsersDescription: "Este anuncio solo se mostrará a aquellos usuarios registrados en el momento de su publicación. Si se deshabilita esta opción, aquellos usuarios que se registren tras su publicación también lo verán." @@ -1174,7 +1175,6 @@ _initialAccountSetting: pushNotificationDescription: "Habilitar las notificaciones push te permitirá recibir notificaciones de {name} directamente en tu dispositivo." initialAccountSettingCompleted: "¡Configuración del perfil completada!" haveFun: "¡Disfruta de {name}!" - ifYouNeedLearnMore: "Si quieres aprender cómo usar {name} (CherryPick), por favor, visita {link}." skipAreYouSure: "¿Realmente quieres saltarte la configuración del perfil?" laterAreYouSure: "¿Realmente quieres configurar tu perfil después?" _serverRules: @@ -1806,16 +1806,6 @@ _time: minute: "Minutos" hour: "Horas" day: "Días" -_timelineTutorial: - title: "Cómo usar CherryPick" - step1_1: "Ésta es la \"línea de tiempo\". Todas las \"notas\" que sean publicadas en {name} serán mostradas cronológicamente aquí." - step1_2: "Hay varias líneas de tiempo. Por ejemplo, la línea temporal \"Inicio\" contiene las notas de otros usuarios que sigues, y la línea \"Local\" contandrá las notas de todos los usuarios de {name}." - step2_1: "Ahora probemos publicar una nota. Puedes hacerlo presionando el botón que tiene un ícono de lápiz." - step2_2: "¿Qué tal si escribimos una introducción? o sólo un \"¡Hola {name}!\" ¿No te apetece?" - step3_1: "¿Terminaste de publicar tu primera nota?" - step3_2: "Tu primera nota ahora se mostrará en tu línea de tiempo." - step4_1: "También puedes añadir \"Reacciones\" a notas." - step4_2: "Para añadir una reacción selecciona el botón \"+\" en la nota y escoge el emoji que quieras para reaccionar." _2fa: alreadyRegistered: "Ya has completado la configuración." registerTOTP: "Registrar aplicación autenticadora" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 7af2539402..772edca4b8 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -540,6 +540,7 @@ objectStorageSetPublicRead: "Régler sur « public » lors de l'envoi" serverLogs: "Journal du serveur" deleteAll: "Supprimer tout" showFixedPostForm: "Afficher le formulaire de publication en haut du fil d'actualité" +withRepliesByDefaultForNewlyFollowed: "Afficher les réponses des nouvelles personnes que vous suivez dans le fil par défaut" newNoteRecived: "Voir les nouvelles notes" sounds: "Sons" sound: "Sons" @@ -622,7 +623,7 @@ permission: "Autorisations " enableAll: "Tout activer" disableAll: "Tout désactiver" tokenRequested: "Autoriser l'accès au compte" -pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici." +pluginTokenRequestedDescription: "Cette extension pourra utiliser les autorisations définies ici." notificationType: "Type de notifications" edit: "Editer" emailServer: "Serveur de messagerie" @@ -704,7 +705,7 @@ repliesCount: "Nombre de réponses envoyées" renotesCount: "Nombre de notes que vous avez renotées" repliedCount: "Nombre de réponses reçues" renotedCount: "Nombre de vos notes renotées" -followingCount: "Nombre de comptes suivis" +followingCount: "Nombre d'abonnements" followersCount: "Nombre d'abonnés" sentReactionsCount: "Nombre de réactions envoyées" receivedReactionsCount: "Nombre de réactions reçues" @@ -792,7 +793,7 @@ addDescription: "Ajouter une description" userPagePinTip: "Vous pouvez afficher des notes ici en sélectionnant l'option « Épingler au profil » dans le menu de chaque note." notSpecifiedMentionWarning: "Vous avez mentionné des utilisateur·rice·s qui ne font pas partie de la liste des destinataires" info: "Informations" -userInfo: "Informations sur l'utilisateur" +userInfo: "Informations sur l'utilisateur·rice" unknown: "Inconnu" onlineStatus: "Statut" hideOnlineStatus: "Se rendre invisible" @@ -973,7 +974,7 @@ numberOfLikes: "Favoris" show: "Affichage" neverShow: "Ne plus afficher" remindMeLater: "Peut-être plus tard" -didYouLikeMisskey: "Avez-vous aimé Misskey ?" +didYouLikeMisskey: "Avez-vous aimé CherryPick ?" roles: "Rôles" role: "Rôles" noRole: "Aucun rôle" @@ -983,9 +984,11 @@ assign: "Attribuer" unassign: "Retirer" color: "Couleur" manageCustomEmojis: "Gestion des émojis personnalisés" +manageAvatarDecorations: "Gérer les décorations d'avatar" youCannotCreateAnymore: "Vous avez atteint la limite de création." cannotPerformTemporary: "Temporairement indisponible" invalidParamError: "Paramètres invalides" +permissionDeniedError: "Opération refusée" preset: "Préréglage" selectFromPresets: "Sélectionner à partir des préréglages" achievements: "Accomplissements" @@ -1034,10 +1037,14 @@ continue: "Continuer" preservedUsernames: "Noms d'utilisateur·rice réservés" archive: "Archive" displayOfNote: "Affichage de la note" -initialAccountSetting: "Réglage initial du profil" +initialAccountSetting: "Configuration initiale du profil" youFollowing: "Abonné·e" preventAiLearning: "Refuser l'usage dans l'apprentissage automatique d'IA générative" +preventAiLearningDescription: "Demander aux robots d'indexation de ne pas utiliser le contenu publié, tel que les notes et les images, dans l'apprentissage automatique d'IA générative. Cela est réalisé en incluant le drapeau « noai » dans la réponse HTML. Une prévention complète n'est toutefois pas possible, car il est au robot d'indexation de respecter cette demande." options: "Options" +specifyUser: "Spécifier l'utilisateur·rice" +failedToPreviewUrl: "Aperçu d'URL échoué" +update: "Mettre à jour" later: "Plus tard" goToMisskey: "Retour vers CherryPick" expirationDate: "Date d’expiration" @@ -1060,17 +1067,64 @@ pinnedList: "Liste épinglée" notifyNotes: "Notifier à propos des nouvelles notes" authentication: "Authentification" authenticationRequiredToContinue: "Veuillez vous authentifier pour continuer" +dateAndTime: "Date et heure" showRenotes: "Afficher les renotes" +edited: "Modifié" +notificationRecieveConfig: "Paramètres des notifications" +mutualFollow: "Abonnement mutuel" +showRepliesToOthersInTimeline: "Afficher les réponses aux autres dans le fil" +hideRepliesToOthersInTimeline: "Masquer les réponses aux autres dans le fil" +showRepliesToOthersInTimelineAll: "Afficher les réponses de toutes les personnes que vous suivez dans le fil" +hideRepliesToOthersInTimelineAll: "Masquer les réponses de toutes les personnes que vous suivez dans le fil" +confirmShowRepliesAll: "Cette opération est irréversible. Voulez-vous vraiment afficher les réponses de toutes les personnes que vous suivez dans le fil ?" +confirmHideRepliesAll: "Cette opération est irréversible. Voulez-vous vraiment masquer les réponses de toutes les personnes que vous suivez dans le fil ?" +externalServices: "Services externes" +impressum: "Impressum" +impressumUrl: "URL de l'impressum" +impressumDescription: "Dans certains pays comme l'Allemagne, il est obligatoire d'afficher les informations sur l'opérateur d'un site (un impressum)." +privacyPolicy: "Politique de confidentialité" +privacyPolicyUrl: "URL de la politique de confidentialité" +tosAndPrivacyPolicy: "Conditions d'utilisation et politique de confidentialité" +avatarDecorations: "Décorations d'avatar" +attach: "Mettre" +detach: "Enlever" +angle: "Angle" +flip: "Inverser" +showAvatarDecorations: "Afficher les décorations d'avatar" +releaseToRefresh: "Relâcher pour rafraîchir" +refreshing: "Rafraîchissement..." +pullDownToRefresh: "Tirer vers le bas pour rafraîchir" +disableStreamingTimeline: "Désactiver les mises à jour en temps réel de la ligne du temps" +useGroupedNotifications: "Grouper les notifications" _announcement: readConfirmTitle: "Marquer comme lu ?" _initialAccountSetting: profileSetting: "Paramètres du profil" privacySetting: "Paramètres de confidentialité" initialAccountSettingCompleted: "Configuration du profil terminée avec succès !" - ifYouNeedLearnMore: "Si vous voulez en savoir plus comment utiliser {name}(Misskey), veuillez visiter {link}." - skipAreYouSure: "Désirez-vous ignorer la configuration du profile ?" + startTutorial: "Démarrer le tutoriel" + skipAreYouSure: "Désirez-vous ignorer la configuration du profil ?" +_initialTutorial: + title: "Tutoriel" + wellDone: "Bien joué !" + skipAreYouSure: "Quitter le tutoriel ?" + _landing: + title: "Bienvenue dans le tutoriel" + description: "Ici, vous pouvez apprendre l'utilisation de base de CherryPick et ses fonctionnalités." + _note: + title: "Qu'est-ce que les notes ?" + description: "Les messages sur CherryPick sont appelés des « notes » . Les notes sont classées par ordre chronologique sur le fil et sont mises à jour en temps réel." + reply: "Vous pouvez répondre aux messages. Vous pouvez également répondre aux réponses et poursuivre la conversation comme un fil de discussion." + renote: "Vous pouvez partager cette note sur votre propre fil. Vous pouvez aussi ajouter du texte en citant." + reaction: "Vous pouvez ajouter des réactions. Les détails sont expliqués à la page suivante." + menu: "Vous pouvez afficher les détails de la note, copier le lien et effectuer d'autres actions." + _reaction: + title: "Qu'est-ce que les réactions ?" + description: "Vous pouvez ajouter des « réactions » aux notes. Les réactions vous permettent d'exprimer à l'aise des nuances qui ne peuvent pas être exprimées par des mentions j'aime." + letsTryReacting: "Des réactions peuvent être ajoutées en cliquant sur le bouton « + » de la note. Essayez d'ajouter une réaction à cet exemple de note !" _serverSettings: iconUrl: "URL de l’icône" + fanoutTimelineDescription: "Si activée, la performance de la récupération de la chronologie augmentera considérablement et la charge sur la base de données sera réduite. En revanche, l'utilisation de la mémoire de Redis augmentera. Considérez désactiver cette option si le serveur est bas en mémoire ou instable." _accountMigration: moveFrom: "Migrer un autre compte vers le présent compte" moveFromSub: "Créer un alias vers un autre compte" @@ -1137,9 +1191,16 @@ _achievements: description: "Rendre votre compte comme un chat" flavor: "Je n'ai pas encore de nom" _following1: - title: "Vous suivez votre premier utilisateur·rice" + title: "Vous suivez votre premier·ère utilisateur·rice" + _following10: + description: "S'abonner à plus de 10 utilisateur·rice·s" _following50: title: "Beaucoup d'amis" + description: "S'abonner à plus de 50 utilisateur·rice·s" + _following100: + description: "S'abonner à plus de 100 utilisateur·rice·s" + _following300: + description: "S'abonner à plus de 300 utilisateur·rice·s" _followers10: title: "Abonnez-moi !" description: "Obtenir plus de 10 abonné·e·s" @@ -1195,7 +1256,7 @@ _achievements: _cookieClicked: flavor: "Attendez une minute, vous êtes sur le mauvais site web ?" _brainDiver: - flavor: "Misskey-Misskey La-Tu-Ma" + flavor: "CherryPick-CherryPick La-Tu-Ma" _role: new: "Nouveau rôle" edit: "Modifier le rôle" @@ -1219,6 +1280,7 @@ _role: high: "Haute" _options: canManageCustomEmojis: "Gestion des émojis personnalisés" + canManageAvatarDecorations: "Gestion des décorations d'avatar" wordMuteMax: "Nombre maximal de caractères dans le filtre de mots" _sensitiveMediaDetection: description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement." @@ -1253,6 +1315,10 @@ _ad: back: "Retour" reduceFrequencyOfThisAd: "Voir cette publicité moins souvent" hide: "Cacher " + adsSettings: "Paramètres des publicités" + notesPerOneAd: "Intervalle de diffusion de publicités lors de la mise à jour en temps réel (nombre de notes par publicité)" + setZeroToDisable: "Mettre cette valeur à 0 pour désactiver la diffusion de publicités lors de la mise à jour en temps réel" + adsTooClose: "L'expérience de l'utilisateur peut être gravement compromise par un intervalle de diffusion de publicités extrêmement court." _forgotPassword: enterEmail: "Entrez ici l'adresse e-mail que vous avez enregistrée pour votre compte. Un lien vous permettant de réinitialiser votre mot de passe sera envoyé à cette adresse." ifNoEmail: "Si vous n'avez pas enregistré d'adresse e-mail, merci de contacter l'administrateur·rice de votre instance." @@ -1268,9 +1334,9 @@ _email: _receiveFollowRequest: title: "Vous avez reçu une demande de suivi" _plugin: - install: "Installation de plugin" + install: "Installation d'extensions" installWarn: "N’installez que des extensions provenant de sources de confiance." - manage: "Gestion des plugins" + manage: "Gestion des extensions" viewSource: "Afficher la source" _preferencesBackups: list: "Sauvegardes créées" @@ -1305,6 +1371,7 @@ _aboutMisskey: donate: "Soutenir Misskey" morePatrons: "Nous apprécions vraiment le soutien de nombreuses autres personnes non mentionnées ici. Merci à toutes et à tous ! 🥰" patrons: "Contributeurs" + projectMembers: "Membres du projet" _displayOfSensitiveMedia: force: "Masquer tous les médias" _mfm: @@ -1502,9 +1569,6 @@ _time: minute: "min" hour: "h" day: "j" -_timelineTutorial: - title: "Comment utiliser CherryPick" - step3_1: "Avez-vous publié votre première note ?" _2fa: alreadyRegistered: "Configuration déjà achevée." step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil." @@ -1669,6 +1733,7 @@ _exportOrImport: userLists: "Listes" excludeMutingUsers: "Exclure les utilisateur·rice·s mis en sourdine" excludeInactiveUsers: "Exclure les utilisateur·rice·s inactifs" + withReplies: "Inclure les réponses des utilisateur·rice·s importé·e·s dans le fil" _charts: federation: "Fédération" apRequest: "Requêtes" @@ -1768,7 +1833,7 @@ _notification: youRenoted: "{name} vous a Renoté" youGotMessagingMessageFromUser: "{name} vous envoyé un message" youGotMessagingMessageFromGroup: "Un message a été envoyé au groupe {name}" - youWereFollowed: "Vous suit" + youWereFollowed: "s'est abonné·e à vous" youReceivedFollowRequest: "Vous avez reçu une demande d’abonnement" yourFollowRequestAccepted: "Votre demande d’abonnement a été accepté" youWereInvitedToGroup: "Invité·e au groupe" @@ -1776,6 +1841,9 @@ _notification: unreadAntennaNote: "Antenne {name}" emptyPushNotificationMessage: "Les notifications push ont été mises à jour" achievementEarned: "Accomplissement" + reactedBySomeUsers: "{n} utilisateur·rice·s ont réagi" + renotedBySomeUsers: "{n} utilisateur·rice·s ont renoté" + followedBySomeUsers: "{n} utilisateur·rice·s se sont abonné·e·s à vous" _types: all: "Toutes" follow: "Nouvel·le abonné·e" @@ -1825,5 +1893,85 @@ _webhookSettings: name: "Nom" active: "Activé" _moderationLogTypes: - suspend: "Suspendre" - resetPassword: "Réinitialiser le mot de passe" + createRole: "Rôle créé" + deleteRole: "Rôle supprimé" + updateRole: "Rôle mis à jour" + assignRole: "Rôle attribué" + unassignRole: "Rôle enlevé" + suspend: "Utilisateur suspendu" + unsuspend: "Suspension d'un utilisateur levée" + addCustomEmoji: "Émoji personnalisé ajouté" + updateCustomEmoji: "Émoji personnalisé mis à jour" + deleteCustomEmoji: "Émoji personnalisé supprimé" + updateServerSettings: "Paramètres du serveur mis à jour" + updateUserNote: "Note de modération mise à jour" + deleteDriveFile: "Fichier supprimé" + deleteNote: "Note supprimée" + createGlobalAnnouncement: "Annonce globale créée" + createUserAnnouncement: "Annonce individuelle créée" + updateGlobalAnnouncement: "Annonce globale mise à jour" + updateUserAnnouncement: "Annonce individuelle mise à jour" + deleteGlobalAnnouncement: "Annonce globale supprimée" + deleteUserAnnouncement: "Annonce individuelle supprimée" + resetPassword: "Mot de passe réinitialisé" + suspendRemoteInstance: "Instance distante suspendue" + unsuspendRemoteInstance: "Suspension d'une instance distante levée" + markSensitiveDriveFile: "Fichier marqué comme sensible" + unmarkSensitiveDriveFile: "Marquage du fichier comme sensible enlevé" + resolveAbuseReport: "Signalement résolu" + createInvitation: "Code d'invitation créé" + createAd: "Publicité créée" + deleteAd: "Publicité supprimée" + updateAd: "Publicité mise à jour" + createAvatarDecoration: "Décoration d'avatar créée" + updateAvatarDecoration: "Décoration d'avatar mise à jour" + deleteAvatarDecoration: "Décoration d'avatar supprimée" +_fileViewer: + title: "Détails du fichier" + type: "Type du fichier" + size: "Taille du fichier" + url: "URL" + uploadedAt: "Date de téléversement" + attachedNotes: "Notes avec ce fichier" + thisPageCanBeSeenFromTheAuthor: "Cette page ne peut être vue que par l'utilisateur qui a téléversé ce fichier." +_externalResourceInstaller: + title: "Installer depuis un site externe" + checkVendorBeforeInstall: "Veuillez confirmer que le distributeur est fiable avant l'installation." + _plugin: + title: "Voulez-vous installer cette extension ?" + metaTitle: "Informations sur l'extension" + _theme: + title: "Voulez-vous installer ce thème ?" + metaTitle: "Informations sur le thème" + _meta: + base: "Palette de couleurs de base" + _vendorInfo: + title: "Informations sur le distributeur" + endpoint: "Point de terminaison référencé" + hashVerify: "Vérification de l'intégrité du fichier" + _errors: + _invalidParams: + title: "Paramètres invalides" + description: "Il y a un manque d'informations nécessaires pour obtenir des données à partir de sites externes. Veuillez vérifier l'URL." + _resourceTypeNotSupported: + title: "Cette ressource externe n'est pas prise en charge." + description: "Le type de ressource obtenue à partir de ce site externe n'est pas pris en charge. Veuillez contacter l'administrateur du site." + _failedToFetch: + title: "Échec de récupération des données" + fetchErrorDescription: "La communication avec le site externe a échoué. Si vous réessayez et que cela ne s'améliore pas, veuillez contacter l'administrateur du site." + parseErrorDescription: "Les données obtenues à partir du site externe n'ont pas pu être parsées. Veuillez contacter l'administrateur du site." + _hashUnmatched: + title: "Échec de vérification des données" + description: "La vérification de l'intégrité des données fournies a échoué. Pour des raisons de sécurité, l'installation ne peut pas continuer. Veuillez contacter l'administrateur du site." + _pluginParseFailed: + title: "Erreur d'AiScript" + description: "Bien que les données aient été obtenues, elles n'ont pas pu être lues, car il y a eu une erreur lors du parsage d'AiScript. Veuillez contacter l'auteur de l'extension. Pour plus de détails sur l'erreur, veuillez consulter la console JavaScript." + _pluginInstallFailed: + title: "Échec d'installation de l'extension" + description: "Il y a eu un problème lors de l'installation de l'extension. Veuillez réessayer. Pour plus de détails sur l'erreur, veuillez consulter la console JavaScript." + _themeParseFailed: + title: "Erreur de parsage du thème" + description: "Bien que les données aient été obtenues, elles n'ont pas pu être lues, car il y a eu une erreur lors du parsage du fichier du thème. Veuillez contacter l'auteur du thème. Pour plus de détails sur l'erreur, veuillez consulter la console JavaScript." + _themeInstallFailed: + title: "Échec d'installation du thème" + description: "Il y a eu un problème lors de l'installation du thème. Veuillez réessayer. Pour plus de détails sur l'erreur, veuillez consulter la console JavaScript." diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 177a3e168b..75b09bf880 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -45,6 +45,7 @@ pin: "Sematkan ke profil" unpin: "Lepas sematan dari profil" copyContent: "Salin konten" copyLink: "Salin tautan" +copyLinkRenote: "Salin tautan renote" delete: "Hapus" deleteAndEdit: "Hapus dan sunting" deleteAndEditConfirm: "Apakah kamu yakin ingin menghapus note ini dan menyuntingnya? Kamu akan kehilangan semua reaksi, renote dan balasan di note ini." @@ -156,6 +157,7 @@ addEmoji: "Tambahkan emoji" settingGuide: "Pengaturan rekomendasi" cacheRemoteFiles: "Tembolokkan berkas dari instansi luar" cacheRemoteFilesDescription: "Ketika pengaturan ini dinonaktifkan, berkas dari instansi luar akan dimuat langsung. Menonaktifkan ini akan mengurangi penggunaan penyimpanan peladen, namun dapat menyebabkan peningkatan lalu lintas bandwidth, karena keluku tidak dihasilkan." +youCanCleanRemoteFilesCache: "Kamu dapat mengosongkan tembolok dengan mengeklik tombol 🗑️ pada layar manajemen berkas." cacheRemoteSensitiveFiles: "Tembolokkan berkas dari instansi luar" cacheRemoteSensitiveFilesDescription: "Menonaktifkan pengaturan ini menyebabkan berkas sensitif dari instansi luar ditautkan secara langsung, bukan ditembolok." flagAsBot: "Atur akun ini sebagai Bot" @@ -193,6 +195,7 @@ perHour: "per Jam" perDay: "per Hari" stopActivityDelivery: "Berhenti mengirim aktivitas" blockThisInstance: "Blokir instansi ini" +silenceThisInstance: "Senyapkan instansi ini" operations: "Tindakan" software: "Perangkat lunak" version: "Versi" @@ -212,6 +215,8 @@ clearCachedFiles: "Hapus tembolok" clearCachedFilesConfirm: "Apakah kamu yakin ingin menghapus seluruh tembolok berkas instansi luar?" blockedInstances: "Instansi terblokir" blockedInstancesDescription: "Daftar nama host dari instansi yang diperlukan untuk diblokir. Instansi yang didaftarkan tidak akan dapat berkomunikasi dengan instansi ini." +silencedInstances: "Instansi yang disenyapkan" +silencedInstancesDescription: "Daftar nama host dari instansi yang ingin kamu senyapkan. Semua akun dari instansi yang terdaftar akan diperlakukan sebagai disenyapkan. Hal ini membuat akun hanya dapat membuat permintaan mengikuti, dan tidak dapat menyebutkan akun lokal apabila tidak mengikuti. Hal ini tidak akan mempengaruhi instansi yang diblokir." muteAndBlock: "Bisukan / Blokir" mutedUsers: "Pengguna yang dibisukan" blockedUsers: "Pengguna yang diblokir" @@ -409,10 +414,14 @@ aboutMisskey: "Tentang CherryPick" administrator: "Admin" token: "Token" 2fa: "Autentikasi 2-faktor" +setupOf2fa: "Atur autentikasi 2-faktor" totp: "Aplikasi autentikator" totpDescription: "Gunakan aplikasi autentikator untuk mendapatkan kata sandi sekali pakai" moderator: "Moderator" moderation: "Moderasi" +moderationNote: "Catatan moderasi" +addModerationNote: "Tambahkan catatan moderasi" +moderationLogs: "Log moderasi" nUsersMentioned: "{n} pengguna disebut" securityKeyAndPasskey: "Security key dan passkey" securityKey: "Kunci keamanan" @@ -440,7 +449,7 @@ groups: "Grup" createGroup: "Buat grup" ownedGroups: "Grup yang dimiliki" joinedGroups: "Grup yang diikuti" -invites: "Undang" +invites: "Undangan" groupName: "Nama grup" members: "Anggota" transfer: "Transfer" @@ -459,7 +468,7 @@ noMessagesYet: "Tidak ada pesan" newMessageExists: "Kamu mendapatkan pesan baru" onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" signinRequired: "Silahkan login" -invitations: "Undang" +invitations: "Undangan" invitationCode: "Kode undangan" checking: "Memeriksa" available: "Tersedia" @@ -518,7 +527,7 @@ showFeaturedNotesInTimeline: "Tampilkan catatan yang diunggulkan di lini masa" objectStorage: "Object Storage" useObjectStorage: "Gunakan object storage" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "Prefix URL digunakan untuk mengkonstruksi URL ke object (media) referencing. Tentukan URL jika kamu menggunakan CDN atau Proxy, jika tidak tentukan alamat yang dapat diakses secara publik sesuai dengan panduan dari layanan yang akan kamu gunakan, contohnya. 'https://.s3.amazonaws.com' untuk AWS S3, dan 'https://storage.googleapis.com/' untuk GCS." +objectStorageBaseUrlDesc: "Prefix URL digunakan untuk mengonstruksi URL ke object (media) referencing. Tentukan URL jika kamu menggunakan CDN atau Proxy. Jika tidak, tentukan alamat yang dapat diakses secara publik sesuai dengan panduan dari layanan yang akan kamu gunakan. Contohnya: 'https://.s3.amazonaws.com' untuk AWS S3, dan 'https://storage.googleapis.com/' untuk GCS." objectStorageBucket: "Bucket" objectStorageBucketDesc: "Mohon tentukan nama bucket yang digunakan pada layanan yang telah dikonfigurasi." objectStoragePrefix: "Prefix" @@ -535,8 +544,9 @@ objectStorageSetPublicRead: "Setel \"public-read\" disaat mengunggah" s3ForcePathStyleDesc: "Jika s3ForcePathStyle dinyalakan, nama bucket harus dimasukkan dalam path URL dan bukan URL nama host tersebut. Kamu perlu menyalakan pengaturan ini jika menggunakan layanan seperti instansi Minio yang self-hosted." serverLogs: "Log Peladen" deleteAll: "Hapus semua" -showFixedPostForm: "Tampilkan form posting di atas lini masa." +showFixedPostForm: "Tampilkan form posting di atas lini masa" showFixedPostFormInChannel: "Tampilkan form posting di atas lini masa (Kanal)" +withRepliesByDefaultForNewlyFollowed: "Termasuk balasan dari pengguna baru yang diikuti pada lini masa secara bawaan" newNoteRecived: "Kamu mendapat catatan baru" sounds: "Bunyi" sound: "Bunyi" @@ -639,7 +649,7 @@ testEmail: "Tes pengiriman surel" wordMute: "Bisukan kata" regexpError: "Kesalahan ekspresi reguler" regexpErrorDescription: "Galat terjadi pada baris {line} ekspresi reguler dari {tab} kata yang dibisukan:" -instanceMute: "Bisuka instansi" +instanceMute: "Bisukan instansi" userSaysSomething: "{name} mengatakan sesuatu" makeActive: "Aktifkan" display: "Tampilkan" @@ -664,6 +674,7 @@ behavior: "Perilaku" sample: "Contoh" abuseReports: "Laporkan" reportAbuse: "Laporkan" +reportAbuseRenote: "Laporkan renote" reportAbuseOf: "Laporkan {name}" fillAbuseReportDescription: "Mohon isi rincian laporan. Jika laporan ini mengenai catatan yang spesifik, mohon lampirkan serta URL catatan tersebut." abuseReported: "Laporan kamu telah dikirimkan. Terima kasih." @@ -716,6 +727,7 @@ lockedAccountInfo: "Kecuali kamu menyetel visibilitas catatan milikmu ke \"Hanya alwaysMarkSensitive: "Tandai media dalam catatan sebagai media sensitif" loadRawImages: "Tampilkan lampiran gambar secara penuh daripada thumbnail" disableShowingAnimatedImages: "Jangan mainkan gambar bergerak" +highlightSensitiveMedia: "Sorot media sensitif" verificationEmailSent: "Surel verifikasi telah dikirimkan. Mohon akses tautan yang telah disertakan untuk menyelesaikan verifikasi." notSet: "Tidak disetel" emailVerified: "Surel telah diverifikasi" @@ -1032,6 +1044,7 @@ retryAllQueuesConfirmText: "Hal ini akan meningkatkan beban sementara ke peladen enableChartsForRemoteUser: "Buat bagan data pengguna instansi luar" enableChartsForFederatedInstances: "Buat bagan data peladen instansi luar" showClipButtonInNoteFooter: "Tambahkan \"Klip\" ke menu aksi catatan" +reactionsDisplaySize: "Ukuran tampilan reaksi" noteIdOrUrl: "ID catatan atau URL" video: "Video" videos: "Video" @@ -1112,9 +1125,44 @@ icon: "Avatar" forYou: "Untuk Anda" currentAnnouncements: "Pengumuman Saat Ini" pastAnnouncements: "Pengumuman Terdahulu" +youHaveUnreadAnnouncements: "Terdapat pengumuman yang belum dibaca" +useSecurityKey: "Mohon ikuti instruksi peramban atau perangkat kamu untuk menggunakan kunci pengaman atau passkey." replies: "Balas" renotes: "Renote" +loadReplies: "Tampilkan balasan" +loadConversation: "Tampilkan percakapan" +pinnedList: "Daftar yang dipin" +keepScreenOn: "Biarkan layar tetap menyala" +verifiedLink: "Tautan kepemilikan telah diverifikasi" +notifyNotes: "Beritahu mengenai catatan baru" +unnotifyNotes: "Berhenti memberitahu mengenai catatan baru" +authentication: "Autentikasi" +authenticationRequiredToContinue: "Mohon autentikasikan terlebih dahulu sebelum melanjutkan" dateAndTime: "Tanggal dan Waktu" +showRenotes: "Tampilkan renote" +edited: "Telah disunting" +notificationRecieveConfig: "Pengaturan notifikasi" +mutualFollow: "Saling mengikuti" +fileAttachedOnly: "Hanya catatan dengan berkas" +showRepliesToOthersInTimeline: "Tampilkan balasan ke pengguna lain dalam lini masa" +hideRepliesToOthersInTimeline: "Sembunyikan balasan ke orang lain dari lini masa" +externalServices: "Layanan eksternal" +impressum: "Impressum" +impressumUrl: "Tautan Impressum" +impressumDescription: "Pada beberapa negara seperti Jerman, inklusi dari informasi kontak operator (sebuah Impressum) diperlukan secara legal untuk situs web komersil." +privacyPolicy: "Kebijakan Privasi" +privacyPolicyUrl: "Tautan Kebijakan Privasi" +tosAndPrivacyPolicy: "Syarat dan Ketentuan serta Kebijakan Privasi" +flip: "Balik" +_announcement: + forExistingUsers: "Hanya pengguna yang telah ada" + forExistingUsersDescription: "Pengumuman ini akan dimunculkan ke pengguna yang sudah ada dari titik waktu publikasi jika dinyalakan. Apabila dimatikan, mereka yang baru mendaftar setelah publikasi ini akan juga melihatnya." + needConfirmationToRead: "Membutuhkan konfirmasi terpisah bahwa telah dibaca" + needConfirmationToReadDescription: "Permintaan terpisah untuk mengonfirmasi menandai pengumuman ini telah dibaca akan ditampilkan apabila fitur ini dinyalakan. Pengumuman ini juga akan dikecualikan dari fungsi \"Tandai semua telah dibaca\"." + end: "Arsipkan pengumuman" + tooManyActiveAnnouncementDescription: "Terlalu banyak pengumuman dapat memperburuk pengalaman pengguna. Mohon pertimbangkan untuk mengarsipkan pengumuman yang sudah usang/tidak relevan." + readConfirmTitle: "Tandai telah dibaca?" + readConfirmText: "Aksi ini akan menandai konten dari \"{title}\" telah dibaca." _initialAccountSetting: accountCreated: "Akun kamu telah sukses dibuat!" letsStartAccountSetup: "Untuk pemula, ayo atur profilmu dulu." @@ -1127,13 +1175,19 @@ _initialAccountSetting: pushNotificationDescription: "Menyalakan notifikasi dorong akan membuatmu menerima notifikasi dari {name} secara langsung ke perangkatmu." initialAccountSettingCompleted: "Pengaturan profil selesai!" haveFun: "Selamat menikmati, {name}!" - ifYouNeedLearnMore: "Kalau kamu ingin mempelajari lebih lanjut bagaimana cara menggunakan {name} (CherryPick), silahkan kunjungi {link}." skipAreYouSure: "Yakin melewati atur profil?" laterAreYouSure: "Yakin banget untuk atur profil nanti?" _serverRules: description: "Daftar peraturan akan ditampilkan sebelum pendaftaran. Mengatur ringkasan dari Syarat dan Ketentuan sangat direkomendasikan." _serverSettings: iconUrl: "URL ikon" + appIconDescription: "Tentukan ikon yang digunakan ketika {host} ditampilkan sebagai aplikasi." + appIconUsageExample: "Contoh: Sebagai PWA, atau ketika ditampilkan sebagai markah layar beranda pada ponsel" + appIconStyleRecommendation: "Karena ikon berkemungkinan dipotong menjadi persegi atau lingkaran, ikon dengan margin terwanai di sekeliling konten sangat direkomendasikan." + appIconResolutionMustBe: "Minimum resolusi adalah {resolution}." + manifestJsonOverride: "Ambil alih manifest.json" + shortName: "Nama pendek" + shortNameDescription: "Inisial untuk nama instansi yang dapat ditampilkan apabila nama lengkap resmi terlalu panjang." _accountMigration: moveFrom: "Pindahkan akun lain ke akun ini" moveFromSub: "Buat alias ke akun lain" @@ -1388,6 +1442,9 @@ _achievements: title: "Brain Diver" description: "Posting tautan mengenai Brain Diver" flavor: "CherryPick-CherryPick La-Tu-Ma" + _smashTestNotificationButton: + title: "Tes overflow" + description: "Picu tes notifikasi secara berulang dalam waktu yang sangat pendek" _role: new: "Buat peran" edit: "Sunting peran" @@ -1445,6 +1502,7 @@ _role: descriptionOfRateLimitFactor: "Batas kecepatan yang rendah tidak begitu membatasi, batas kecepatan tinggi lebih membatasi. " canHideAds: "Dapat menyembunyikan iklan" canSearchNotes: "Penggunaan pencarian catatan" + canUseTranslator: "Penggunaan penerjemah" _condition: isLocal: "Pengguna lokal" isRemote: "Pengguna remote" @@ -1493,6 +1551,10 @@ _ad: reduceFrequencyOfThisAd: "Tampilkan iklan ini lebih sedikit" hide: "Jangan tampilkan" timezoneinfo: "Hari dalam satu minggu ditentukan dari zona waktu peladen." + adsSettings: "Pengaturan iklan" + notesPerOneAd: "Interval penempatan pemutakhiran iklan secara real-time (catatan per iklan)" + setZeroToDisable: "Atur nilai ini ke 0 untuk menonaktifkan pemutakhiran iklan secara real-time" + adsTooClose: "Interval iklan saat ini kemungkinan memperburuk pengalaman pengguna secara signifikan karena diatur pada nilai yang terlalu rendah." _forgotPassword: enterEmail: "Masukkan alamat surel yang kamu gunakan pada saat mendaftar. Sebuah tautan untuk mengatur ulang kata sandi kamu akan dikirimkan ke alamat surel tersebut." ifNoEmail: "Apabila kamu tidak menggunakan surel pada saat pendaftaran, mohon hubungi admin segera." @@ -1742,28 +1804,20 @@ _time: minute: "menit" hour: "jam" day: "hari" -_timelineTutorial: - title: "Bagaimana cara menggunakan CherryPick" - step1_1: "Ini adalah \"lini masa\". Semua \"catatan\" yang dikirimkan oleh {name} akan dimunculkan secara kronologis di sini." - step1_2: "Ada beberapa lini masa yang berbeda. Seperti contoh, \"Lini masa Beranda\" berisi catatan dari pengguna yang kamu ikuti, dan \"Lini masa lokal\" berisi catatan dari semua pengguna dari {name}." - step2_1: "Selanjutnya, mari kita coba memposting sebuah catatan. Kamu dapat melakukanya dengan menekan tombol dengan ikon pensil." - step2_2: "Bagaimana dengan menuliskan sedikit perkenalan diri, atau hanya \"Hello {name}\" kalau kamu lagi ngga feeling?" - step3_1: "Udah selesai memposting catatan pertamamu?" - step3_2: "Catatan pertamamu seharusnya sekarang sudah tampil di lini masa kamu." - step4_1: "Kamu dapat menyisipkan \"Reaksi\" ke dalam catatan." - step4_2: "Untuk menyisipkan reaksi, tekan tanda \"+\" dalam catatan dan pilih emoji yang kamu suka untuk mereaksi catatan tersebut." _2fa: - alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor." + alreadyRegistered: "Kamu telah mendaftarkan perangkat autentikasi 2-faktor." registerTOTP: "Daftarkan aplikasi autentikator" - step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat kamu." + step1: "Pertama, pasang aplikasi autentikasi (seperti {a} atau {b}) di perangkat kamu." step2: "Lalu, pindai kode QR yang ada di layar." step2Click: "Mengeklik kode QR ini akan membolehkanmu untuk mendaftarkan 2FA ke security-key atau aplikasi autentikator ponsel." + step2Uri: "Masukkan URI berikut jika kamu menggunakan program desktop" step3Title: "Masukkan kode autentikasi" step3: "Masukkan token yang telah disediakan oleh aplikasimu untuk menyelesaikan pemasangan." - step4: "Mulai sekarang, upaya login apapun akan meminta token login dari aplikasi otentikasi kamu." + setupCompleted: "Penyetelan autentikasi 2-faktor selesai" + step4: "Mulai sekarang, upaya login apapun akan meminta token login dari aplikasi autentikasi kamu." securityKeyNotSupported: "Peramban kamu tidak mendukung security key." registerTOTPBeforeKey: "Mohon atur aplikasi autentikator untuk mendaftarkan security key atau passkey." - securityKeyInfo: "Kamu dapat memasang otentikasi WebAuthN untuk mengamankan proses login lebih lanjut dengan tidak hanya perangkat keras kunci keamanan yang mendukung FIDO2, namun juga sidik jari atau otentikasi PIN pada perangkatmu." + securityKeyInfo: "Kamu dapat memasang autentikasi WebAuthN untuk mengamankan proses login lebih lanjut dengan tidak hanya perangkat keras kunci keamanan yang mendukung FIDO2, namun juga sidik jari atau autentikasi PIN pada perangkatmu." registerSecurityKey: "Daftarkan security key atau passkey." securityKeyName: "Masukkan nama key." tapSecurityKey: "Mohon ikuti peramban kamu untuk mendaftarkan security key atau passkey" @@ -1774,7 +1828,11 @@ _2fa: renewTOTPConfirm: "Hal ini akan menyebabkan kode verifikasi dari aplikasi autentikator sebelumnya berhenti bekerja" renewTOTPOk: "Atur ulang" renewTOTPCancel: "Tidak sekarang." + checkBackupCodesBeforeCloseThisWizard: "Sebelum kamu menutup jendela ini, pastikan untuk memperhatikan dan mencadangkan kode cadangan berikut." backupCodes: "Kode Pencadangan" + backupCodesDescription: "Kamu dapat menggunakan kode ini untuk mendapatkan akses ke akun kamu apabila berada dalam situasi tidak dapat menggunakan aplikasi autentikasi 2-faktor yang kamu miliki. Setiap kode hanya dapat digunakan satu kali. Mohon simpan kode ini di tempat yang aman." + backupCodeUsedWarning: "Kode cadangan telah digunakan. Mohon mengatur ulang autentikasi 2-faktor secepatnya apabila kamu sudah tidak dapat menggunakannya lagi." + backupCodesExhaustedWarning: "Semua kode cadangan telah digunakan. Apabila kamu kehilangan akses pada aplikasi autentikasi 2-faktor milikmu, kamu tidak dapat mengakses akun ini lagi. Mohon atur ulang autentikasi 2-faktor kamu." _permissions: "read:account": "Lihat informasi akun" "write:account": "Sunting informasi akun" @@ -1808,6 +1866,10 @@ _permissions: "write:gallery": "Sunting galeri" "read:gallery-likes": "Lihat daftar postingan galeri yang disukai" "write:gallery-likes": "Sunting daftar postingan galeri yang disukai" + "read:flash": "Lihat Play" + "write:flash": "Sunting Play" + "read:flash-likes": "Lihat daftar Play yang disukai" + "write:flash-likes": "Sunting daftar Play yang disukai" _auth: shareAccessTitle: "Mendapatkan ijin akses aplikasi" shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" @@ -1824,6 +1886,7 @@ _antennaSources: users: "Catatan dari pengguna tertentu" userList: "Catatan dari daftar tertentu" userGroup: "Catatan dari pengguna dalam grup yang ditentukan" + userBlacklist: "Semua catatan kecuali untuk satu pengguna atau lebih yang telah ditentukan" _weekday: sunday: "Minggu" monday: "Senin" @@ -1923,6 +1986,7 @@ _profile: metadataContent: "Isi" changeAvatar: "Ubah avatar" changeBanner: "Ubah header" + verifiedLinkDescription: "Dengan memasukkan URL yang mengandung tautan ke profil kamu di sini, ikon verifikasi kepemilikan dapat ditampilkan di sebelah kolom ini." _exportOrImport: allNotes: "Semua catatan" favoritedNotes: "Catatan favorit" @@ -1932,6 +1996,7 @@ _exportOrImport: userLists: "Daftar" excludeMutingUsers: "Kecualikan pengguna yang dibisukan" excludeInactiveUsers: "Kecualikan pengguna tidak aktif" + withReplies: "Termasuk balasan dari pengguna yang diimpor ke dalam lini masa" _charts: federation: "Federasi" apRequest: "Permintaan" @@ -2045,11 +2110,17 @@ _notification: yourFollowRequestAccepted: "Permintaan mengikuti kamu telah diterima" youWereInvitedToGroup: "Telah diundang ke grup" pollEnded: "Hasil Kuesioner telah keluar" + newNote: "Catatan baru" unreadAntennaNote: "Antena {name}" emptyPushNotificationMessage: "Pembaruan notifikasi dorong" achievementEarned: "Pencapaian didapatkan" + testNotification: "Tes notifikasi" + checkNotificationBehavior: "Cek tampilan notifikasi" + sendTestNotification: "Kirim tes notifikasi" + notificationWillBeDisplayedLikeThis: "Notifikasi akan terlihat seperti ini" _types: all: "Semua" + note: "Catatan baru" follow: "Ikuti" mention: "Sebut" reply: "Balasan" @@ -2084,6 +2155,8 @@ _deck: introduction2: "Klik \"+\" pada kanan layar untuk menambahkan kolom baru kapanpun yang kamu mau." widgetsIntroduction: "Mohon pilih \"Sunting gawit\" pada menu kolom dan tambahkan gawit." useSimpleUiForNonRootPages: "Gunakan antarmuka sederhana ke halaman yang dituju" + usedAsMinWidthWhenFlexible: "Lebar minimum akan digunakan untuk ini ketika opsi \"Atur-otomatis lebar\" dinyalakan" + flexible: "Atur-otomatis lebar" _columns: main: "Utama" widgets: "Widget" @@ -2119,6 +2192,41 @@ _webhookSettings: reaction: "Ketika menerima reaksi" mention: "Ketika sedang disebut" _moderationLogTypes: + createRole: "Peran telah dibuat" + deleteRole: "Peran telah dihapus" + updateRole: "Peran telah diperbaharui" + assignRole: "Yang ditugaskan dalam peran" + unassignRole: "Dihapus dari peran" suspend: "Tangguhkan" + unsuspend: "Batal ditangguhkan" + addCustomEmoji: "Emoji kustom ditambahkan" + updateCustomEmoji: "Emoji kustom diperbaharui" + deleteCustomEmoji: "Emoji kustom dihapus" + updateServerSettings: "Pengaturan peladen diperbaharui" + updateUserNote: "Catatan moderasi diperbaharui" + deleteDriveFile: "Berkas dihapus" + deleteNote: "Catatan dihapus" + createGlobalAnnouncement: "Pengumuman global dibuat" + createUserAnnouncement: "Pengumuman pengguna dibuat" + updateGlobalAnnouncement: "Pengumuman global diperbaharui" + updateUserAnnouncement: "Pengumuman pengguna diperbaharui" + deleteGlobalAnnouncement: "Pengumuman global telah dihapus" + deleteUserAnnouncement: "Pengumuman pengguna telah dihapus." resetPassword: "Atur ulang kata sandi" + suspendRemoteInstance: "Instansi luar telah ditangguhkan" + unsuspendRemoteInstance: "Instansi luar batal ditangguhkan" + markSensitiveDriveFile: "Berkas ditandai sensitif" + unmarkSensitiveDriveFile: "Berkas batal ditandai sensitif" + resolveAbuseReport: "Laporan terselesaikan" createInvitation: "Buat kode undangan" + createAd: "Iklan telah dibuat" + deleteAd: "Iklan telah dihapus" + updateAd: "Iklan telah diperbaharui" +_fileViewer: + title: "Rincian berkas" + type: "Jenis berkas" + size: "Ukuran berkas" + url: "URL" + uploadedAt: "Diunggah pada" + attachedNotes: "Catatan yang dilampirkan" + thisPageCanBeSeenFromTheAuthor: "Halaman ini hanya dapat dilihat oleh pengguna yang mengunggah bekas ini." diff --git a/locales/index.d.ts b/locales/index.d.ts index 0d282a4be2..011da81988 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -3,6 +3,14 @@ // Do not edit this file directly. export interface Locale { "_lang_": string; + "noNyaization": string; + "revertNoNyaization": string; + "viewTextSource": string; + "disableNoteEditConfirm": string; + "disableNoteEditConfirmWarn": string; + "disableNoteEditOk": string; + "nsfwOpenBehavior": string; + "previewNoteProfile": string; "noteEdited": string; "removeModalBgColorForBlur": string; "skipThisVersion": string; @@ -174,6 +182,7 @@ export interface Locale { "pinned": string; "you": string; "clickToShow": string; + "doubleClickToShow": string; "sensitive": string; "add": string; "reaction": string; @@ -369,6 +378,7 @@ export interface Locale { "createFolder": string; "renameFolder": string; "deleteFolder": string; + "folder": string; "addFile": string; "emptyDrive": string; "emptyFolder": string; @@ -1061,6 +1071,7 @@ export interface Locale { "unassign": string; "color": string; "manageCustomEmojis": string; + "manageAvatarDecorations": string; "youCannotCreateAnymore": string; "cannotPerformTemporary": string; "cannotPerformTemporaryDescription": string; @@ -1221,6 +1232,10 @@ export interface Locale { "fileAttachedOnly": string; "showRepliesToOthersInTimeline": string; "hideRepliesToOthersInTimeline": string; + "showRepliesToOthersInTimelineAll": string; + "hideRepliesToOthersInTimelineAll": string; + "confirmShowRepliesAll": string; + "confirmHideRepliesAll": string; "externalServices": string; "impressum": string; "impressumUrl": string; @@ -1228,12 +1243,30 @@ export interface Locale { "privacyPolicy": string; "privacyPolicyUrl": string; "tosAndPrivacyPolicy": string; - "showUnreadNotificationCount": string; + "avatarDecorations": string; + "attach": string; + "detach": string; + "angle": string; + "flip": string; + "showAvatarDecorations": string; + "releaseToRefresh": string; + "refreshing": string; + "pullDownToRefresh": string; + "disableStreamingTimeline": string; + "useGroupedNotifications": string; + "signupPendingError": string; + "cwNotationRequired": string; + "doReaction": string; + "showUnreadNotificationsCount": string; "showCatOnly": string; "additionalPermissionsForFlash": string; "thisFlashRequiresTheFollowingPermissions": string; "doYouWantToAllowThisPlayToAccessYourAccount": string; "translateProfile": string; + "_nsfwOpenBehavior": { + "click": string; + "doubleClick": string; + }; "_vibrations": { "click": string; "note": string; @@ -1250,12 +1283,6 @@ export interface Locale { "_messaging": { "direct": string; }; - "_tlTutorial": { - "step1_1": string; - "step1_2": string; - "step1_3": string; - "step1_4": string; - }; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1265,6 +1292,10 @@ export interface Locale { "tooManyActiveAnnouncementDescription": string; "readConfirmTitle": string; "readConfirmText": string; + "shouldNotBeUsedToPresentPermanentInfo": string; + "dialogAnnouncementUxWarn": string; + "silence": string; + "silenceDescription": string; }; "_group": { "leader": string; @@ -1299,6 +1330,7 @@ export interface Locale { "renameTheButtonInPostFormToNya": string; "renameTheButtonInPostFormToNyaDescription": string; "enableLongPressOpenAccountMenu": string; + "friendlyShowAvatarDecorationsInNavBtn": string; }; "_bannerDisplay": { "all": string; @@ -1327,11 +1359,94 @@ export interface Locale { "pushNotificationDescription": string; "initialAccountSettingCompleted": string; "haveFun": string; - "ifYouNeedLearnMore": string; + "youCanContinueTutorial": string; + "startTutorial": string; "skipAreYouSure": string; "skipAreYouSureDescription": string; "laterAreYouSure": string; }; + "_initialTutorial": { + "launchTutorial": string; + "title": string; + "wellDone": string; + "skipAreYouSure": string; + "_landing": { + "title": string; + "description": string; + }; + "_note": { + "title": string; + "description": string; + "reply": string; + "renote": string; + "like": string; + "reaction": string; + "quote": string; + "menu": string; + }; + "_reaction": { + "title": string; + "description": string; + "letsTryReacting": string; + "reactToContinue": string; + "reactNotification": string; + "reactDone": string; + }; + "_timeline": { + "title": string; + "description1": string; + "home": string; + "local": string; + "social": string; + "global": string; + "description2": string; + "description3": string; + }; + "_postNote": { + "title": string; + "description1": string; + "_visibility": { + "description": string; + "public": string; + "home": string; + "followers": string; + "direct": string; + "doNotSendConfidencialOnDirect1": string; + "doNotSendConfidencialOnDirect2": string; + "localOnly": string; + }; + "_cw": { + "title": string; + "description": string; + "_exampleNote": { + "cw": string; + "note": string; + }; + "useCases": string; + }; + }; + "_howToMakeAttachmentsSensitive": { + "title": string; + "description": string; + "tryThisFile": string; + "_exampleNote": { + "note": string; + }; + "method": string; + "sensitiveSucceeded": string; + "doItToContinue": string; + }; + "_done": { + "title": string; + "description": string; + }; + }; + "_timelineDescription": { + "home": string; + "local": string; + "social": string; + "global": string; + }; "_serverRules": { "description": string; }; @@ -1371,6 +1486,7 @@ export interface Locale { "manifestJsonOverride": string; "shortName": string; "shortNameDescription": string; + "fanoutTimelineDescription": string; }; "_accountMigration": { "moveFrom": string; @@ -1705,6 +1821,10 @@ export interface Locale { "title": string; "description": string; }; + "_tutorialCompleted": { + "title": string; + "description": string; + }; }; }; "_role": { @@ -1752,6 +1872,7 @@ export interface Locale { "inviteLimitCycle": string; "inviteExpirationTime": string; "canManageCustomEmojis": string; + "canManageAvatarDecorations": string; "driveCapacity": string; "alwaysMarkNsfw": string; "pinMax": string; @@ -1893,6 +2014,7 @@ export interface Locale { "donate": string; "morePatrons": string; "patrons": string; + "projectMembers": string; "_kokonect": { "serverStatus": string; "donate": string; @@ -2012,6 +2134,7 @@ export interface Locale { "notesCount": string; "nameAndDescription": string; "nameOnly": string; + "allowRenoteToExternal": string; }; "_menuDisplay": { "sideFull": string; @@ -2131,17 +2254,6 @@ export interface Locale { "hour": string; "day": string; }; - "_timelineTutorial": { - "title": string; - "step1_1": string; - "step1_2": string; - "step2_1": string; - "step2_2": string; - "step3_1": string; - "step3_2": string; - "step4_1": string; - "step4_2": string; - }; "_2fa": { "alreadyRegistered": string; "registerTOTP": string; @@ -2478,6 +2590,9 @@ export interface Locale { "checkNotificationBehavior": string; "sendTestNotification": string; "notificationWillBeDisplayedLikeThis": string; + "reactedBySomeUsers": string; + "renotedBySomeUsers": string; + "followedBySomeUsers": string; "_types": { "all": string; "note": string; @@ -2592,6 +2707,9 @@ export interface Locale { "createAd": string; "deleteAd": string; "updateAd": string; + "createAvatarDecoration": string; + "updateAvatarDecoration": string; + "deleteAvatarDecoration": string; }; "_fileViewer": { "title": string; diff --git a/locales/index.js b/locales/index.js index ea61832a93..9706034abc 100644 --- a/locales/index.js +++ b/locales/index.js @@ -54,6 +54,19 @@ const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g') const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); +// 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す +const removeEmpty = (obj) => { + for (const [k, v] of Object.entries(obj)) { + if (v === '') { + delete obj[k]; + } else if (typeof v === 'object') { + removeEmpty(v); + } + } + return obj; +}; +removeEmpty(locales); + export default Object.entries(locales) .reduce((a, [k ,v]) => (a[k] = (() => { const [lang] = k.split('-'); @@ -65,7 +78,7 @@ export default Object.entries(locales) locales['ja-JP'], locales['ko-KR'], locales['en-US'], - locales[`${lang}-${primaries[lang]}`] || {}, + locales[`${lang}-${primaries[lang]}`] ?? {}, v ); } diff --git a/locales/it-IT.yml b/locales/it-IT.yml index cd22acaea5..6aa4b6074e 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -162,8 +162,8 @@ cacheRemoteSensitiveFiles: "Copia nella cache locale i file espliciti remoti" cacheRemoteSensitiveFilesDescription: "Disattivando questa opzione, i file espliciti verranno richiesti direttamente all'istanza remota senza essere salvati nel server locale." flagAsBot: "Io sono un robot" flagAsBotDescription: "Attiva questo campo se il profilo esegue principalmente operazioni automatiche. L'attivazione segnala agli altri sviluppatori come comportarsi per evitare catene d’interazione infinite con altri bot. I sistemi interni di CherryPick si adegueranno al fine di trattare questo profilo come bot." -flagAsCat: "Sono un gatto" -flagAsCatDescription: "La modalità \"sono un gatto\" aggiunge le orecchie al tuo profilo" +flagAsCat: "MIIaaaoo!!! (Io sono un gatto è un romanzo del 1905, il primo dello scrittore giapponese Natsume Sōseki)" +flagAsCatDescription: "Miaoo mia miao mi miao?" flagShowTimelineReplies: "Mostra le risposte alle note sulla timeline." flagShowTimelineRepliesDescription: "Attivando, la timeline mostra le Note del profilo ed anche le risposte ad altre Note" autoAcceptFollowed: "Accetta automaticamente le richieste di follow da profili che già segui" @@ -250,7 +250,7 @@ newPassword: "Nuova Password" newPasswordRetype: "Conferma password" attachFile: "Allega file" more: "Di più!" -featured: "Tendenze" +featured: "In evidenza" usernameOrUserId: "Nome utente o ID" noSuchUser: "Profilo non trovato" lookup: "Ricerca remota" @@ -326,9 +326,9 @@ avatar: "Foto del profilo" banner: "Intestazione" displayOfSensitiveMedia: "Visibilità dei media espliciti" whenServerDisconnected: "Quando la connessione col server è persa" -disconnectedFromServer: "Il server si è disconnesso" +disconnectedFromServer: "Connessione persa" reload: "Ricarica" -doNothing: "Nessun'azione" +doNothing: "Ignora" reloadConfirm: "Vuoi ricaricare?" watch: "Osserva" unwatch: "Smetti di Osserva" @@ -602,7 +602,7 @@ invisibleNote: "Nota invisibile" enableInfiniteScroll: "Abilita scorrimento infinito" visibility: "Visibilità" poll: "Sondaggio" -useCw: "Content Warning" +useCw: "Contenuto esplicito" enablePlayer: "Visualizza" disablePlayer: "Chiudi" expandTweet: "Espandi tweet" @@ -665,7 +665,7 @@ notificationSetting: "Impostazioni notifiche" notificationSettingDesc: "Seleziona il tipo di notifiche da visualizzare." useGlobalSetting: "Usa impostazioni generali" useGlobalSettingDesc: "Quando attiva, verranno utilizzate le impostazioni notifiche del profilo. Altrimenti si possono segliere impostazioni personalizzate." -other: "Avanzate" +other: "Ulteriori" regenerateLoginToken: "Genera di nuovo un token di connessione" regenerateLoginTokenDescription: "Genera un nuovo token di autenticazione. Solitamente questa operazione non è necessaria: quando si genera un nuovo token, tutti i dispositivi vanno disconnessi." setMultipleBySeparatingWithSpace: "È possibile creare multiple voci separate da spazi." @@ -820,8 +820,8 @@ user: "Profilo" administration: "Gestione" accounts: "Profilo" switch: "Cambia" -noMaintainerInformationWarning: "Le informazioni amministratore non sono impostate." -noBotProtectionWarning: "Nessuna protezione impostata contro i bot." +noMaintainerInformationWarning: "Mancano le informazioni sull'amministratore." +noBotProtectionWarning: "Non è stata impostata alcuna protezione dai Bot" configure: "Imposta" postToGallery: "Pubblicare nella galleria" postToHashtag: "Pubblica a questo hashtag" @@ -859,7 +859,7 @@ accountDeletionInProgress: "È in corso l'eliminazione del profilo" usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito." aiChanMode: "Modalità Ai" devMode: "Modalità sviluppatori" -keepCw: "Mantieni il Content Warning" +keepCw: "Mostra i contenuti espliciti" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" @@ -993,6 +993,7 @@ assign: "Assegna" unassign: "Disassegna" color: "Colore" manageCustomEmojis: "Gestisci le emoji personalizzate" +manageAvatarDecorations: "Gestire le decorazioni di foto del profilo" youCannotCreateAnymore: "Non puoi creare, hai raggiunto il limite." cannotPerformTemporary: "Indisponibilità temporanea" cannotPerformTemporaryDescription: "L'attività non può essere svolta, poiché si è raggiunto il limite di esecuzioni possibili. Per favore, riprova più tardi." @@ -1146,13 +1147,31 @@ mutualFollow: "Follow reciproco" fileAttachedOnly: "Solo con allegati" showRepliesToOthersInTimeline: "Risposte altrui nella TL" hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL" +showRepliesToOthersInTimelineAll: "Mostra le risposte dei tuoi follow nella TL" +hideRepliesToOthersInTimelineAll: "Nascondi le risposte dei tuoi follow nella TL" +confirmShowRepliesAll: "Questa è una attività irreversibile. Vuoi davvero includere tutte le risposte dei following in TL?" +confirmHideRepliesAll: "Questa è una attività irreversibile. Vuoi davvero escludere tutte le risposte dei following in TL?" externalServices: "Servizi esterni" impressum: "Dichiarazione di proprietà" impressumUrl: "URL della dichiarazione di proprietà" impressumDescription: "La dichiarazione di proprietà, è obbligatoria in alcuni paesi come la Germania (Impressum)." -privacyPolicy: "Informativa privacy ai sensi del Regolamento UE 2016/679 (GDPR)" +privacyPolicy: "Informativa ai sensi del Reg. UE 2016/679 (GDPR)" privacyPolicyUrl: "URL della informativa privacy" tosAndPrivacyPolicy: "Condizioni d'uso e informativa privacy" +avatarDecorations: "Decorazioni foto profilo" +attach: "Applica" +detach: "Rimuovi" +angle: "Angolo" +flip: "Inverti" +showAvatarDecorations: "Mostra decorazione della foto profilo" +releaseToRefresh: "Rilascia per aggiornare" +refreshing: "Aggiornamento..." +pullDownToRefresh: "Trascina per aggiornare" +disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale" +useGroupedNotifications: "Mostra le notifiche raggruppate" +signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo." +cwNotationRequired: "Devi indicare perché il contenuto è indicato come esplicito." +doReaction: "Reagisci" _announcement: forExistingUsers: "Solo ai profili attuali" forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." @@ -1162,6 +1181,8 @@ _announcement: tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi." readConfirmTitle: "Segnare come già letto?" readConfirmText: "Hai già letto \"{title}˝?" + shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte." + dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte." _initialAccountSetting: accountCreated: "Il tuo profilo è stato creato!" letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo." @@ -1174,9 +1195,77 @@ _initialAccountSetting: pushNotificationDescription: "Attivare le notifiche push ti permettera di ricevere informazioni sulla attività di {name} direttamente sul tuo dispositivo." initialAccountSettingCompleted: "Hai completato la configurazione iniziale!" haveFun: "Divertiti con {name}!" - ifYouNeedLearnMore: "Per saperne di più su come usare {name} (CherryPick), visita la pagina {link}" + youCanContinueTutorial: "Puoi continuare con l'esercitazione su come usare {name} (CherryPick), oppure interrompere, iniziando subito a usarlo." + startTutorial: "Avvia l'esercitazione" skipAreYouSure: "Vuoi davvero saltare la configurazione iniziale?" laterAreYouSure: "Vuoi davvero rimandare la configurazione iniziale?" +_initialTutorial: + launchTutorial: "Guarda il tutorial" + title: "Tutorial" + wellDone: "Ottimo lavoro!" + skipAreYouSure: "Vuoi davvero interrompere il tutorial?" + _landing: + title: "Eccoci nel tutorial" + description: "Qui puoi verificare l'uso delle funzionalità base di CherryPick." + _note: + title: "Cosa sono le Note?" + description: "Gli status su CherryPick sono chiamati \"Note\". Le Note sono elencate in ordine cronologico nelle timeline e vengono aggiornate in tempo reale." + reply: "Puoi rispondere alle Note. Puoi anche rispondere alle risposte e continuare i dialoghi come un conversazioni." + renote: "Puoi ri-condividere le Note, facendole rifluire sulla Timeline. Puoi anche aggiungere testo e citare altri profili." + reaction: "Puoi aggiungere una reazione. Nella pagina successiva spiegheremo i dettagli." + menu: "Puoi svolgere varie attività, come visualizzare i dettagli delle Note o copiare i collegamenti." + _reaction: + title: "Cosa sono le Reazioni?" + description: "Puoi reagire alle Note. Le sensazioni che non si riescono a trasmettere con i \"Mi piace\" si possono esprimere facilmente inviando una reazione." + letsTryReacting: "Puoi aggiungere una Reazione cliccando il bottone \"+\" (più) della relativa Nota. Prova ad aggiungerne una a questa Nota di esempio!" + reactToContinue: "Aggiungere la Reazione ti consentirà di procedere col tutorial." + reactNotification: "Quando qualcuno reagisce alle tue Note, ricevi una notifica in tempo reale." + reactDone: "Puoi annullare la tua Reazione premendo il bottone \"ー\" (meno)" + _timeline: + title: "Come funziona la Timeline" + description1: "CherryPick fornisce alcune Timeline (sequenze cronologiche di Note). Una di queste potrebbe essere stata disattivata dagli amministratori." + home: "Puoi vedere le Note provenienti dai profili che segui (follow)." + local: "Puoi vedere tutte le Note pubblicate dai profili di questa istanza." + social: "Puoi vedere sia le Note della Timeline Home che quelle della Timeline Locale, insieme!" + global: "Puoi vedere le Note da pubblicate da tutte le altre istanze federate con la nostra." + description2: "Nella parte superiore dello schermo, puoi scegliere una Timeline o l'altra in qualsiasi momento." + description3: "Ci sono anche sequenze temporali di elenchi, sequenze temporali di canali, ecc. Per ulteriori dettagli, consultare il {link}.\nPuoi vedere anche Timeline delle liste di profili (se ne hai create), canali, ecc... Per i dettagli, visita {link}." + _postNote: + title: "La Nota e le sue impostazioni" + description1: "Quando scrivi una Nota su CherryPick, hai a disposizione varie opzioni. Il modulo di invio è simile a questo." + _visibility: + description: "Puoi limitare chi può vedere la tua Nota." + public: "Visibile a tutti." + home: "Pubblicato solo sulla Timeline Home (personale). Visibile anche da profili remoti follower, visitatori del tuo profilo e tramite i Rinota dei follower." + followers: "Visibile solo ai profili tuoi follower (locali o remoti). Nessun altro oltre a te può \"Rinotare\"." + direct: "Visibile solo ai profili specificati, i quali riceveranno una notifica. Puoi usarlo come se fossero messaggi diretti." + doNotSendConfidencialOnDirect1: "Attenzione, quando si inviano informazioni confidenziali." + doNotSendConfidencialOnDirect2: "Poiché le Note non sono crittografate, l'amministratore del server di destinazione potrebbe leggere cosa è stato scritto, quindi se spedisci una Nota diretta a un profilo che risiede su un server non attendibile, evita di scrivere informazioni riservate." + localOnly: "Indipendentemente dalla visualizzazione sopra indicata, i profili su altri server non saranno in grado di visualizzare la Nota, se questa impostazione è attivata. Non non verrà comunicata ad altri server." + _cw: + title: "Nascondere il contenuto esplicito" + description: "Verrà visualizzato il testo scritto nel campo \"Annotazione preventiva\" al posto del testo principale della Nota. Premere il bottone \"Continua la lettura\" se si intende davvero leggere il testo." + _exampleNote: + cw: "Attenzione: contiene zuccheri" + note: "Ho appena mangiato una ciambella ricoperta di cioccolato 🍩😋" + useCases: "Utilizzalo per chiarire il contenuto della Nota, prima che sia letta. Come richiesto dal regolamento del server o per autoregolamentare spoiler e testi troppo espliciti." + _howToMakeAttachmentsSensitive: + title: "Come indicare che gli allegati sono espliciti?" + description: "Contrassegnare gli allegati come espliciti, va fatto quando è richiesto dal regolamento del server o quando gli allegati non devono essere immediatamente visibili." + tryThisFile: "Prova a rendere esplicite le immagini allegate a questo modulo!" + _exampleNote: + note: "Ho fatto un errore aprendo il coperchio del natto... (fagioli di soia fermentati, particolarmente appiccicosi)" + method: "Per indicare che un allegato è esplicito, tocca il file per aprirne il menu e scegliere la voce \"Segna come esplicito\"." + sensitiveSucceeded: "Quando alleghi file, assicurati di indicare se è materiale esplicito, in modo appropriato, in base al regolamento del tuo server." + doItToContinue: "Impostando l'immagine come esplicita, potrai procedere col tutorial." + _done: + title: "Il tutorial è finito! 🎉" + description: "Queste sono solamente alcune delle funzionalità principali di CherryPick. Per ulteriori informazioni, {link}." +_timelineDescription: + home: "Nella Timeline Home, la tua cronologia principale, puoi vedere le Note provenienti dai profili che segui (follow)." + local: "La Timeline Locale, è una cronologia di Note pubblicate da tutti i profili iscritti su questo server." + social: "La Timeline Sociale, unisce in ordine cronologico l'elenco di Note presenti nella Timeline Home e quella Locale." + global: "La Timeline Federata ti consente di vedere le Note pubblicate dai profili di tutti gli altri server federati a questo." _serverRules: description: "In Europa è necessario mostrare l'informativa sul trattamento dei dati personali, prima della registrazione al servizio." _serverSettings: @@ -1188,6 +1277,7 @@ _serverSettings: manifestJsonOverride: "Sostituire il file manifest.json" shortName: "Abbreviazione" shortNameDescription: "Un'abbreviazione o un nome comune che può essere visualizzato al posto del nome ufficiale lungo del server." + fanoutTimelineDescription: "Attivando questa funzionalità migliori notevolmente la capacità delle Timeline di collezionare Note, riducendo il carico sul database. Tuttavia, aumenterà l'impiego di memoria RAM per Redis. Disattiva se il tuo server ha poca RAM o la funzionalità è irregolare." _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" moveFromSub: "Crea un alias verso un altro profilo remoto" @@ -1445,6 +1535,9 @@ _achievements: _smashTestNotificationButton: title: "Prove eccessive" description: "Hai provato le notifiche consecutivamente in un periodo di tempo molto breve" + _tutorialCompleted: + title: "Attestato di partecipazione al corso per principianti di CherryPick" + description: "Ha completato il tutorial" _role: new: "Nuovo ruolo" edit: "Modifica ruolo" @@ -1488,6 +1581,7 @@ _role: inviteLimitCycle: "Intervallo di emissione del codice di invito" inviteExpirationTime: "Scadenza del codice di invito" canManageCustomEmojis: "Gestire le emoji personalizzate" + canManageAvatarDecorations: "Gestisce le decorazioni di immagini del profilo" driveCapacity: "Capienza del Drive" alwaysMarkNsfw: "Impostare sempre come esplicito (NSFW)" pinMax: "Quantità massima di Note in primo piano" @@ -1607,6 +1701,7 @@ _aboutMisskey: donate: "Sostieni Misskey" morePatrons: "Apprezziamo sinceramente il supporto di tante altre persone. Grazie mille! 🥰" patrons: "Sostenitori" + projectMembers: "Partecipanti al progetto" _displayOfSensitiveMedia: respect: "Nascondere i media espliciti" ignore: "Non nascondere i media espliciti" @@ -1690,13 +1785,14 @@ _channel: edit: "Gerisci canale" setBanner: "Scegli intestazione" removeBanner: "Rimuovi intestazione" - featured: "Tendenze" + featured: "Di tendenza" owned: "I miei canali" following: "Seguiti" usersCount: "{n} partecipanti" notesCount: "{n} note" nameAndDescription: "Nome e descrizione" nameOnly: "Solo il nome" + allowRenoteToExternal: "Consenti i Rinota e le citazioni all'esterno del canale" _menuDisplay: sideFull: "Laterale" sideIcon: "Laterale (solo icone)" @@ -1806,16 +1902,6 @@ _time: minute: "min" hour: "ore" day: "giorni" -_timelineTutorial: - title: "Come usare CherryPick" - step1_1: "Questa è la \"Timeline\". tutte le \"Note\" pubblicate su {name} vengono elencate in ordine cronologico." - step1_2: "Le Timeline sono diverse, ad esempio, la \"Home\" elenca le Note dei profili che segui. Quella \"Locale\" elenca quelle di tutti i profili attivi su {name}." - step2_1: "Prova a pubblicare una Nota. Semplicemente premendo il bottone con l'icona di una matita." - step2_2: "Potresti scrivere la tua presentazione, oppure semplicemente \"Ciao da {name}!\"" - step3_1: "Hai pubblicato qualcosa?" - step3_2: "In tal caso, dovrebbe comparire subito nella tua \"Home\"" - step4_1: "Puoi reagire con un emoji alle Note." - step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with.\nPer reagire con una emoji, premi il bottone \"+\" (più) visibile vicino ad ogni Nota e scegli dall'elenco la emoji che rappresenta la tua reazione." _2fa: alreadyRegistered: "La configurazione è stata già completata." registerTOTP: "Registra un'app di autenticazione" @@ -1914,16 +2000,16 @@ _widgets: notifications: "Notifiche" timeline: "Timeline" calendar: "Calendario" - trends: "Tendenze" + trends: "Di tendenza" clock: "Orologio" - rss: "Aggregatore rss" - rssTicker: "Ticker RSS" + rss: "Lettura RSS" + rssTicker: "Nastro RSS" activity: "Attività" photos: "Foto" digitalClock: "Orologio digitale" unixClock: "Orologio UNIX" federation: "Federazione" - instanceCloud: "Istanza Cloud" + instanceCloud: "Nuvola di federazione" postForm: "Finestra di pubblicazione" slideshow: "Diapositive" button: "Pulsante" @@ -1939,7 +2025,7 @@ _widgets: clicker: "Cliccaggio" _cw: hide: "Nascondere" - show: "Apri..." + show: "Attenzione: continua la lettura" chars: "{count} caratteri" files: "{count} file" _poll: @@ -2130,6 +2216,9 @@ _notification: checkNotificationBehavior: "Prova il comportamento della notifica" sendTestNotification: "Spedisci una notifica di prova" notificationWillBeDisplayedLikeThis: "La notifica apparirà così" + reactedBySomeUsers: "{n} reazioni" + renotedBySomeUsers: "{n} Rinota" + followedBySomeUsers: "{n} nuovi follower" _types: all: "Tutto" note: "Nuove Note" @@ -2234,6 +2323,9 @@ _moderationLogTypes: createAd: "Banner creato" deleteAd: "Banner eliminato" updateAd: "Banner aggiornato" + createAvatarDecoration: "Creazione decorazione della foto profilo" + updateAvatarDecoration: "Aggiornamento decorazione foto profilo" + deleteAvatarDecoration: "Eliminazione decorazione della foto profilo" _fileViewer: title: "Dettagli del file" type: "Tipo di file" @@ -2242,3 +2334,44 @@ _fileViewer: uploadedAt: "Caricato il" attachedNotes: "Note a cui è allegato" thisPageCanBeSeenFromTheAuthor: "Questa pagina può essere vista solo da chi ha caricato il file." +_externalResourceInstaller: + title: "Installa da sito esterno" + checkVendorBeforeInstall: "Prima di installare, assicurati che la fonte sia affidabile." + _plugin: + title: "Vuoi davvero installare questo componente aggiuntivo?" + metaTitle: "Informazioni sul componente aggiuntivo" + _theme: + title: "Vuoi davvero installare questa variazione grafica?" + metaTitle: "Informazioni sulla variazione grafica" + _meta: + base: "Combinazione base di colori" + _vendorInfo: + title: "Informazioni sulla fonte" + endpoint: "Punto di riferimento della fonte" + hashVerify: "Codice di verifica della fonte" + _errors: + _invalidParams: + title: "Parametri non validi" + description: "Mancano alcuni parametri per il caricamento, per favore, verifica la URL." + _resourceTypeNotSupported: + title: "Questa risorsa esterna non è supportata" + description: "Il tipo di risorsa ottenuta da questo sito esterno non è supportato. Si prega di contattare la fonte di distribuizone." + _failedToFetch: + title: "Impossibile ottenere i dati" + fetchErrorDescription: "Si è verificato un errore di comunicazione con la fonte. Se riprovare di nuovo non aiuta, contattare la fonte di distribuzione." + parseErrorDescription: "Si è verificato un errore elaborando i dati ottenuti dalla fonte. Per favore contattare il distributore." + _hashUnmatched: + title: "Dati non verificabili, diversi da quelli della fonte" + description: "Si è verificato un errore durante la verifica di integrità dei dati ottenuti. Per sicurezza, l'installazione è stata interrotta. Contattare la fonte di distribuzione." + _pluginParseFailed: + title: "Errore AiScript" + description: "Sebbene i dati ottenuti siano validi, non è stato possibile interpretarli, perché si è verificato un errore durante l'analisi di AiScript. Si prega di contattare gli autori del componente aggiuntivo. Potresti controllare la console di Javascript per ottenere dettagli aggiuntivi." + _pluginInstallFailed: + title: "Impossibile installare il componente aggiuntivo" + description: "Si è verificato un impedimento durante l'installazione del componente aggiuntivo. Per favore riprova e consulta la console di Javascript per ottenere dettagli aggiuntivi." + _themeParseFailed: + title: "Impossibile interpretare la variazione grafica" + description: "Sebbene i dati siano stati ottenuti, non è stato possibile interpretarli, si è verificato un errore durante l'analisi della variazione grafica. Si prega di contattare gli autori. Potresti anche controllare la console di Javascript per ottenere dettagli aggiuntivi." + _themeInstallFailed: + title: "Impossibile installare la variazione grafica" + description: "Si è verificato un impedimento durante l'installazione della variazione grafica. Per favore riprova e consulta la console di Javascript per ottenere dettagli aggiuntivi." diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1786847e2d..0fa801a139 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1,5 +1,13 @@ _lang_: "日本語" +noNyaization: "ニャ化を表示しない" +revertNoNyaization: "ニャ化を含めて表示する" +viewTextSource: "テキストのソースを表示する" +disableNoteEditConfirm: "ノート編集を続行しますか?" +disableNoteEditConfirmWarn: "ノート編集に対応しているソフトウェア(Mastodon、CherryPick、FireFishなど)でのみ、編集された内容と履歴を見ることができます。\nノート編集に対応していないソフトウェアでは、ノートを編集する前の内容が表示されるので、すべての連合サーバーで修正した内容を反映させたい場合は、「削除して編集」でノートを書き直してください。" +disableNoteEditOk: "ノートを編集する" +nsfwOpenBehavior: "センシティブなメディアを開くとき" +previewNoteProfile: "プロフィールを表示" noteEdited: "ノートを編集しました。" removeModalBgColorForBlur: "モーダル背景色を削除" skipThisVersion: "このリリースをスキップする" @@ -99,7 +107,7 @@ copyLinkRenote: "リノートのリンクをコピー" delete: "削除" deleteAndEdit: "削除して編集" deleteAndEditConfirm: "このノートを削除してもう一度編集しますか?このノートへのリアクション、リノート、返信も全て削除されます。" -copyAndEdit: "コピーして編集" +copyAndEdit: "内容をコピーして編集" copyAndEditConfirm: "このノートをコピーして編集しましょうか?ノートに含まれているメディアも一緒にコピーされます。" addToList: "リストに追加" addToAntenna: "アンテナに追加" @@ -171,6 +179,7 @@ pinnedNote: "ピン留めされたノート" pinned: "ピン留め" you: "あなた" clickToShow: "クリックして表示" +doubleClickToShow: "ダブルクリックして表示" sensitive: "センシティブ" add: "追加" reaction: "リアクション" @@ -366,6 +375,7 @@ folderName: "フォルダー名" createFolder: "フォルダーを作成" renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" +folder: "フォルダー" addFile: "ファイルを追加" emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" @@ -1058,6 +1068,7 @@ assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" +manageAvatarDecorations: "アバターデコレーションの管理" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" @@ -1218,6 +1229,10 @@ mutualFollow: "相互フォロー" fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" +showRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めるようにする" +hideRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めないようにする" +confirmShowRepliesAll: "この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めるようにしますか?" +confirmHideRepliesAll: "この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めないようにしますか?" externalServices: "外部サービス" impressum: "運営者情報" impressumUrl: "運営者情報URL" @@ -1225,13 +1240,31 @@ impressumDescription: "ドイツなどの一部の国と地域では表示が義 privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" -showUnreadNotificationCount: "未読の通知の数を表示する" +avatarDecorations: "アイコンデコレーション" +attach: "付ける" +detach: "外す" +angle: "角度" +flip: "反転" +showAvatarDecorations: "アイコンのデコレーションを表示" +releaseToRefresh: "離してリロード" +refreshing: "リロード中" +pullDownToRefresh: "引っ張ってリロード" +disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" +useGroupedNotifications: "通知をグルーピングして表示する" +signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" +cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" +doReaction: "リアクションする" +showUnreadNotificationsCount: "未読の通知の数を表示する" showCatOnly: "キャット付きのみ" additionalPermissionsForFlash: "Playへの追加許可" thisFlashRequiresTheFollowingPermissions: "このPlayは以下の権限を要求しています" doYouWantToAllowThisPlayToAccessYourAccount: "このPlayによるアカウントへのアクセスを許可しますか?" translateProfile: "プロフィールを翻訳する" +_nsfwOpenBehavior: + click: "タップして開く" + doubleClick: "二回タップして開く" + _vibrations: click: "要素をクリックしたとき" note: "タイムラインに新しいノートがあるとき" @@ -1248,12 +1281,6 @@ _showingAnimatedImages: _messaging: direct: "ダイレクトメッセージ" -_tlTutorial: - step1_1: '{icon} ホームタイムラインは、あなたがフォローしているアカウントの投稿を見られます。' - step1_2: '{icon} ローカルタイムラインでは、このサーバーにいるみんなの投稿を見られます。' - step1_3: '{icon} ソーシャルタイムラインでは、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。' - step1_4: '{icon} グローバルタイムラインでは、このサーバーに接続されているすべてのサーバーからの投稿を見られます。' - _announcement: forExistingUsers: "既存ユーザーのみ" forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" @@ -1263,6 +1290,10 @@ _announcement: tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。" readConfirmTitle: "既読にしますか?" readConfirmText: "「{title}」の内容を読み、既読にします。" + shouldNotBeUsedToPresentPermanentInfo: "特に新規ユーザーのUXを損ねる可能性が高いため、ストック情報ではなくフロー情報の掲示にお知らせを使用することを推奨します。" + dialogAnnouncementUxWarn: "ダイアログ形式のお知らせが同時に2つ以上ある場合、UXに悪影響を及ぼす可能性が非常に高いため、使用は慎重に行うことを推奨します。" + silence: "非通知" + silenceDescription: "オンにすると、このお知らせは通知されず、既読にする必要もなくなります。" _group: leader: "グループオーナー" @@ -1296,6 +1327,7 @@ _cherrypick: renameTheButtonInPostFormToNya: "ノート作成画面の「ノート」ボタンを「にゃ!」に変更する" renameTheButtonInPostFormToNyaDescription: "にゃあにゃんにゃんにゃんにゃにゃん?" enableLongPressOpenAccountMenu: "長押しでアカウントメニューを開く" + friendlyShowAvatarDecorationsInNavBtn: "フローティングボタンにアイコンのデコレーションを表示" _bannerDisplay: all: "全て" @@ -1311,7 +1343,7 @@ _requireRefreshBehavior: _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" - letsStartAccountSetup: "アカウントの初期設定を行いましょう。" + letsStartAccountSetup: "さっそくアカウントの初期設定を行いましょう。" letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。" profileSetting: "プロフィール設定" privacySetting: "プライバシー設定" @@ -1324,11 +1356,83 @@ _initialAccountSetting: pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。" initialAccountSettingCompleted: "初期設定が完了しました!" haveFun: "{name}をお楽しみください!" - ifYouNeedLearnMore: "{name}(CherryPick)の使い方などを詳しく知るには{link}をご覧ください。" + youCanContinueTutorial: "このまま{name}(CherryPick)の使い方についてのチュートリアルに進むこともできますが、ここで中断してすぐに使い始めることもできます。" + startTutorial: "チュートリアルを開始" skipAreYouSure: "初期設定をスキップしますか?" skipAreYouSureDescription: "今すぐ初期設定を中断しても、[もっと! - ヘルプ - 初期設定のリプレイ]から再開することができます。" laterAreYouSure: "初期設定をあとでやり直しますか?" +_initialTutorial: + launchTutorial: "チュートリアルを見る" + title: "チュートリアル" + wellDone: "よくできました" + skipAreYouSure: "チュートリアルを終了しますか?" + _landing: + title: "チュートリアルへようこそ" + description: "ここでは、CherryPickの基本的な使い方や機能を確認できます。" + _note: + title: "ノートって何?" + description: "CherryPickでの投稿は「ノート」と呼びます。ノートはタイムラインに時系列で並んでいて、リアルタイムで更新されていきます。" + reply: "返信することができます。返信に対しての返信も可能で、スレッドのように会話を続けることもできます。" + renote: "そのノートを自分のタイムラインに流して共有することができます。テキストを追加して引用することも可能です。" + like: "ハートリアクションを送ることができます。「いいね!」を素早く残したいときに便利です。" + reaction: "リアクションをつけることができます。詳しくは次のページで解説します。" + quote: "引用を付けることができます。何らかの内容に基づき意見を付け加えたいときに便利です。" + menu: "ノートの詳細を表示したり、リンクをコピーしたりなどの様々な操作が行えます。" + _reaction: + title: "リアクションって何?" + description: "ノートには「リアクション」をつけることができます。「いいね」では伝わらないニュアンスも、リアクションで簡単・気軽に表現できます。" + letsTryReacting: "リアクションは、ノートの「+」ボタンをクリックするとつけられます。試しにこのサンプルのノートにリアクションをつけてみてください!" + reactToContinue: "リアクションをつけると先に進めるようになります。" + reactNotification: "あなたのノートが誰かにリアクションされると、リアルタイムで通知を受け取ります。" + reactDone: "「ー」ボタンを押すとリアクションを取り消すことができます。" + _timeline: + title: "タイムラインのしくみ" + description1: "CherryPickには、使い方に応じて複数のタイムラインが用意されています(サーバーによってはいずれかが無効になっていることがあります)。" + home: "あなたがフォローしているアカウントの投稿を見られます。" + local: "このサーバーにいるユーザー全員の投稿を見られます。" + social: "ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + global: "接続している他のすべてのサーバーからの投稿を見られます。" + description2: "それぞれのタイムラインは、画面上部でいつでも切り替えられます。" + description3: "その他にも、リストタイムラインやチャンネルタイムラインなどがあります。詳しくは{link}をご覧ください。" + _postNote: + title: "ノートの投稿設定" + description1: "CherryPickにノートを投稿する際には、様々なオプションの設定が可能です。投稿フォームはこのようになっています。" + _visibility: + description: "ノートを表示できる相手を制限できます。" + public: "すべてのユーザーに公開。" + home: "ホームタイムラインのみに公開。フォロワー・プロフィールを見に来た人・リノートから、他のユーザーも見ることができます。" + followers: "フォロワーにのみ公開。本人以外がリノートすることはできず、またフォロワー以外は閲覧できません。" + direct: "指定したユーザーにのみ公開され、また相手に通知が入ります。ダイレクトメッセージのかわりにお使いいただけます。" + doNotSendConfidencialOnDirect1: "機密情報は送信する際は注意してください。" + doNotSendConfidencialOnDirect2: "送信先のサーバーの管理者は投稿内容を見ることが可能なので、信頼できないサーバーのユーザーにダイレクト投稿を送信する場合は、機密情報の扱いに注意が必要です。" + localOnly: "他のサーバーに投稿を連合しません。上記の公開範囲に関わらず、他のサーバーのユーザーは、この設定がついたノートを直接閲覧することができなくなります。" + _cw: + title: "内容を隠す(CW)" + description: "本文のかわりに「注釈」に書いた内容が表示されます。「もっと見る」を押すと本文が表示されます。" + _exampleNote: + cw: "飯テロ注意" + note: "チョコのかかったドーナツを食べました🍩😋" + useCases: "サーバーのガイドラインにより必要とされるノートに指定したり、ネタバレ投稿やセンシティブな文章を自主規制したりするときに使います。" + _howToMakeAttachmentsSensitive: + title: "添付ファイルをセンシティブにするには?" + description: "サーバーのガイドラインにより必要とされる際や、そのまま見れる状態にしておくべきではない添付ファイルには、「センシティブ」設定を付けます。" + tryThisFile: "試しに、このフォームに添付された画像をセンシティブにしてみてください!" + _exampleNote: + note: "納豆のフタ開けるのミスったわね…" + method: "添付ファイルをセンシティブにする際は、そのファイルをクリックしてメニューを開き、「センシティブとして設定」をクリックします。" + sensitiveSucceeded: "ファイルを添付する際は、サーバーのガイドラインに従ってセンシティブを適切に設定してください。" + doItToContinue: "画像をセンシティブに設定すると先に進めるようになります。" + _done: + title: "チュートリアルは終了です🎉" + description: "ここで紹介した機能はほんの一部にすぎません。CherryPickの使い方をより詳しく知るには、{link}をご覧ください。" + +_timelineDescription: + home: "ホームタイムラインでは、あなたがフォローしているアカウントの投稿を見られます。" + local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。" + social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" + _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" @@ -1368,6 +1472,7 @@ _serverSettings: manifestJsonOverride: "manifest.jsonのオーバーライド" shortName: "略称" shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。" + fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" @@ -1627,6 +1732,9 @@ _achievements: _smashTestNotificationButton: title: "テスト過剰" description: "通知のテストをごく短時間のうちに連続して行った" + _tutorialCompleted: + title: "CherryPick初心者講座 修了証" + description: "チュートリアルを完了した" _role: new: "ロールの作成" @@ -1672,6 +1780,7 @@ _role: inviteLimitCycle: "招待コードの発行間隔" inviteExpirationTime: "招待コードの有効期限" canManageCustomEmojis: "カスタム絵文字の管理" + canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" pinMax: "ノートのピン留めの最大数" @@ -1731,7 +1840,7 @@ _ffVisibility: _signup: almostThere: "ほとんど完了です" emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。" - emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。\nもしメールが来なかったらスパムメールボックスを確認してください。" + emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。\nもしメールが来なかったらスパムメールボックスを確認してください。\nメールに記載されているリンクの有効期限は30分です。" _accountDelete: accountDelete: "アカウントの削除" @@ -1802,13 +1911,14 @@ _registry: _aboutMisskey: about: "CherryPickは、Misskeyをベースに2021年から開発中のカスタマイズクライアントです。" - contributors: "主なコントリビューター" + contributors: "コントリビューター" allContributors: "全てのコントリビューター" source: "ソースコード" translation: "Misskeyを翻訳" donate: "Misskeyに寄付" morePatrons: "他にも多くの方が支援してくれています。ありがとうございます🥰" patrons: "支援者" + projectMembers: "プロジェクトメンバー" _kokonect: serverStatus: "サーバ状態" donate: "ココネクトに寄付" @@ -1926,6 +2036,7 @@ _channel: notesCount: "{n}投稿があります" nameAndDescription: "名前と説明" nameOnly: "名前のみ" + allowRenoteToExternal: "チャンネル外へのリノートと引用リノートを許可する" _menuDisplay: sideFull: "横" @@ -2045,17 +2156,6 @@ _time: hour: "時間" day: "日" -_timelineTutorial: - title: "CherryPickの使い方" - step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。" - step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。" - step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" - step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。" - step3_1: "投稿できましたか?" - step3_2: "あなたのノートがタイムラインに表示されていれば成功です。" - step4_1: "ノートには、「リアクション」を付けることができます。" - step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。" - _2fa: alreadyRegistered: "既に設定は完了しています。" registerTOTP: "認証アプリの設定を開始" @@ -2389,6 +2489,9 @@ _notification: checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" notificationWillBeDisplayedLikeThis: "通知はこのように表示されます" + reactedBySomeUsers: "{n}人がリアクションしました" + renotedBySomeUsers: "{n}人がリノートしました" + followedBySomeUsers: "{n}人にフォローされました" _types: all: "すべて" @@ -2502,6 +2605,9 @@ _moderationLogTypes: createAd: "広告を作成" deleteAd: "広告を削除" updateAd: "広告を更新" + createAvatarDecoration: "アイコンデコレーションを作成" + updateAvatarDecoration: "アイコンデコレーションを更新" + deleteAvatarDecoration: "アイコンデコレーションを削除" _fileViewer: title: "ファイルの詳細" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index ef61640e55..fb26261c6e 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1146,6 +1146,10 @@ mutualFollow: "お互いフォローしてんで" fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "タイムラインに他の人への返信とかも含めんで" hideRepliesToOthersInTimeline: "タイムラインに他の人への返信とかは見ーへんで" +showRepliesToOthersInTimelineAll: "" +hideRepliesToOthersInTimelineAll: "" +confirmShowRepliesAll: "" +confirmHideRepliesAll: "" externalServices: "他のサイトのサービス" impressum: "運営者の情報" impressumUrl: "運営者の情報URL" @@ -1153,6 +1157,12 @@ impressumDescription: "ドイツなどのほんま1部の国と地域ではな privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" +avatarDecorations: "アイコンデコレーション" +attach: "" +detach: "" +angle: "" +flip: "反転" +showAvatarDecorations: "" _announcement: forExistingUsers: "もうおるユーザーのみ" forExistingUsersDescription: "有効にすると、このお知らせ作成時点でおるユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" @@ -1174,7 +1184,6 @@ _initialAccountSetting: pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をあんたのデバイスで受け取れるで。" initialAccountSettingCompleted: "初期設定が終わったで。" haveFun: "{name}、楽しんでな~" - ifYouNeedLearnMore: "{name}(CherryPick)の使い方とかをよー知りたいんやったら{link}をみてな。" skipAreYouSure: "初期設定飛ばすか?" laterAreYouSure: "初期設定あとでやり直すん?" _serverRules: @@ -1188,6 +1197,7 @@ _serverSettings: manifestJsonOverride: "manifest.jsonのオーバーライド" shortName: "略称" shortNameDescription: "サーバーの名前が長い時に、代わりに表示することのできるあだ名。" + fanoutTimelineDescription: "" _accountMigration: moveFrom: "別のアカウントからこのアカウントに引っ越す" moveFromSub: "別のアカウントへエイリアスを作る" @@ -1607,6 +1617,7 @@ _aboutMisskey: donate: "Misskeyに寄付" morePatrons: "他にもぎょうさんの人からサポートしてもろてんねん。ほんまおおきに🥰" patrons: "支援者" + projectMembers: "" _displayOfSensitiveMedia: respect: "きわどいのは見とうない" ignore: "きわどいのも見たい" @@ -1806,16 +1817,6 @@ _time: minute: "分" hour: "時間" day: "日" -_timelineTutorial: - title: "CherryPickってなんや?" - step1_1: "これは「タイムライン」や。{name}に投稿された「ノート」が順番に表示されるで。" - step1_2: "タイムラインには何個か種類があってな、例えば「ホームタイムライン」だったらあんたのフォローしてる人のノート、「ローカルタイムライン」には{name}全部のノートが流れてくるで。" - step2_1: "試しに、何かノートを投稿してみ。画面の鉛筆マークのボタンでフォームが開くで。" - step2_2: "最初のノートは、自己紹介とか「{name}始めてみたんや」とかがええと思うで。" - step3_1: "投稿できた?" - step3_2: "あんたのノートがタイムラインに出てきたら成功や。" - step4_1: "ノートには、「ツッコミ」を付けれるで。" - step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶんやで。" _2fa: alreadyRegistered: "もう設定終わっとるわ。" registerTOTP: "認証アプリの設定はじめる" @@ -2234,6 +2235,9 @@ _moderationLogTypes: createAd: "広告を作んで" deleteAd: "広告ほかす" updateAd: "広告を更新" + createAvatarDecoration: "アイコンデコレーションを作成" + updateAvatarDecoration: "アイコンデコレーションを更新" + deleteAvatarDecoration: "アイコンデコレーションを削除" _fileViewer: title: "ファイルの詳しい情報" type: "ファイルの種類" @@ -2242,3 +2246,39 @@ _fileViewer: uploadedAt: "追加した日" attachedNotes: "ファイルがついてきてるノート" thisPageCanBeSeenFromTheAuthor: "このページはこのファイルをアップした人しか見れへんねん。" +_externalResourceInstaller: + title: "ほかのサイトからインストール" + checkVendorBeforeInstall: "配ってるとこが信頼できるか確認した上でインストールしてな。" + _plugin: + title: "このプラグイン、インストールする?" + metaTitle: "プラグイン情報" + _theme: + title: "このテーマインストールする?" + metaTitle: "テーマ情報" + _meta: + base: "" + _vendorInfo: + title: "" + endpoint: "" + hashVerify: "" + _errors: + _invalidParams: + title: "" + description: "" + _resourceTypeNotSupported: + title: "" + description: "" + _failedToFetch: + title: "" + _pluginParseFailed: + title: "AiScriptエラー起こしてもうたねん" + description: "データは取得できたものの、AiScript解析時にエラーがあったから読み込めへんかってん。すまんが、プラグインを作った人に問い合わせてくれへん?ごめんな。エラーの詳細はJavaScriptコンソール読んでな。" + _pluginInstallFailed: + title: "プラグインのインストール失敗してもた" + description: "プラグインのインストール中に問題発生してもた、もう1度試してな。エラーの詳細はJavaScriptのコンソール見てや。" + _themeParseFailed: + title: "テーマ解析エラー" + description: "データは取得できたものの、テーマファイル解析時にエラーがあったから読み込めへんかってん。すまんが、テーマ作った人に問い合わせてくれへん?ごめんな。エラーの詳細はJavaScriptコンソール読んでな。" + _themeInstallFailed: + title: "テーマインストールに失敗してもた" + description: "テーマのインストール中に問題発生してもた、もう1度試してな。エラーの詳細はJavaScriptのコンソール見てや。" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index f8aa82ef7b..03f5eb368d 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1,5 +1,13 @@ --- _lang_: "한국어" +noNyaization: "고양이체를 표시하지 않기" +revertNoNyaization: "고양이체를 포함하여 표시" +viewTextSource: "텍스트 소스 보기" +disableNoteEditConfirm: "노트 편집을 계속 진행할까요?" +disableNoteEditConfirmWarn: "노트 편집을 대응하는 소프트웨어(Mastodon, CherryPick, FireFish 등)에서만 편집된 내용과 이력을 볼 수 있어요.\n노트 편집이 지원되지 않는 소프트웨어에서는 노트를 편집하기 전의 내용으로 표시되므로, 모든 연합된 서버에서 수정된 내용을 반영하고 싶은 경우, '삭제 후 편집'으로 노트를 재작성해 주세요." +disableNoteEditOk: "노트 편집하기" +nsfwOpenBehavior: "민감한 콘텐츠로 표시된 미디어를 열 때" +previewNoteProfile: "프로필 표시" noteEdited: "노트를 편집했어요!" removeModalBgColorForBlur: "모달 배경색 제거" skipThisVersion: "이 릴리즈 건너뛰기" @@ -99,7 +107,7 @@ copyLinkRenote: "리노트 링크 복사" delete: "삭제" deleteAndEdit: "삭제 후 편집" deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집할까요? 이 노트에 달린 리액션, 리노트, 그리고 답글이 모두 삭제돼요!" -copyAndEdit: "복사 후 편집" +copyAndEdit: "내용 복사 후 편집" copyAndEditConfirm: "이 노트를 복사하고 편집할까요? 노트에 포함된 미디어도 같이 복사돼요!" addToList: "리스트에 추가" addToAntenna: "안테나에 추가" @@ -171,6 +179,7 @@ pinnedNote: "고정해놓은 노트" pinned: "프로필에 고정" you: "나" clickToShow: "클릭하여 보기" +doubleClickToShow: "두 번 탭하여 보기" sensitive: "열람 주의" add: "추가" reaction: "리액션" @@ -363,6 +372,7 @@ folderName: "폴더명" createFolder: "폴더 만들기" renameFolder: "폴더 이름 바꾸기" deleteFolder: "폴더 삭제" +folder: "폴더" addFile: "파일 추가" emptyDrive: "드라이브에 아무것도 없어요!" emptyFolder: "폴더에 아무것도 없어요!" @@ -600,6 +610,7 @@ serverLogs: "서버 로그" deleteAll: "모두 삭제" showFixedPostForm: "타임라인 상단에 글 작성란 표시" showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란 표시" +withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 표시" newNoteRecived: "새 노트가 있어요!" newNoteRecivedCount: "{n}개의 새 노트가 있어요!" sounds: "소리" @@ -1015,7 +1026,7 @@ cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함 cannotUploadBecauseNoFreeSpace: "드라이브에 용량이 부족해서 업로드할 수 없었어요.." cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없어요!" beta: "베타" -enableAutoSensitive: "자동으로 NSFW 탐지" +enableAutoSensitive: "자동으로 민감한 미디어 탐지" enableAutoSensitiveDescription: "사용 가능한 경우, 기계학습을 통해 자동으로 미디어에 NSFW를 설정할 거예요. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있어요." activeEmailValidationDescription: "유저가 입력한 메일 주소가 일회용 메일인지, 실제로 통신할 수 있는 지 엄격하게 검사해요. 해제하면 이메일 형식에 대해서만 검사해요." navbar: "내비게이션 바" @@ -1155,8 +1166,8 @@ update: "업데이트" rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할" rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "역할을 지정하지 않으면 누구나 이 이모지를 리액션으로 사용할 수 있어요." rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "역할은 공개로 설정되어 있어야 해요." -cancelReactionConfirm: "리액션을 취소하시겠습니까?" -changeReactionConfirm: "리액션을 변경하시겠습니까?" +cancelReactionConfirm: "리액션을 취소할까요?" +changeReactionConfirm: "리액션을 변경할까요?" later: "나중에" goToMisskey: "CherryPick으로" additionalEmojiDictionary: "이모지 추가 사전" @@ -1209,13 +1220,39 @@ showRenotes: "리노트 표시" edited: "수정됨" notificationRecieveConfig: "알림 설정" mutualFollow: "맞팔로우" +showRepliesToOthersInTimeline: "타임라인에 다른 사람에게 보내는 답글을 포함" +hideRepliesToOthersInTimeline: "타임라인에 다른 사람에게 보내는 답글을 포함하지 않음" +showRepliesToOthersInTimelineAll: "타임라인에 팔로우 중인 모든 사람의 답글 표시" +hideRepliesToOthersInTimelineAll: "타임라인에 팔로우 중인 모든 사람의 답글 표시하지 않기" +confirmShowRepliesAll: "이 조작은 되돌릴 수 없어요! 정말로 타임라인에 현재 팔로우 중인 모든 사람의 답글을 표시하도록 설정할까요?" +confirmHideRepliesAll: "이 조작은 되돌릴 수 없어요! 정말로 타임라인에 현재 팔로우 중인 모든 사람의 답글을 표시하지 않도록 설정할까요?" +externalServices: "외부 서비스" +impressum: "운영자 정보" +impressumUrl: "운영자 정보 URL" +impressumDescription: "독일 등의 일부 나라와 지역에서는 꼭 표시해야 해요(Impressum)." +avatarDecorations: "아이콘 장식" +attach: "붙이기" +detach: "떼기" +angle: "각도" +flip: "플립" +showAvatarDecorations: "아이콘 장식 표시" +disableStreamingTimeline: "타임라인 실시간 업데이트 비활성화" +useGroupedNotifications: "알림을 묶어서 표시" +signupPendingError: "메일 주소 확인중에 문제가 발생했어요. 링크의 유효기간이 지났을 수도 있어요." +cwNotationRequired: "'내용 숨기기'를 체크했을 경우 주석을 작성해야 해요." fileAttachedOnly: "파일이 포함된 노트만" -showUnreadNotificationCount: "읽지 않은 알림 수 표시" +releaseToRefresh: "놓아서 새로 고침" +refreshing: "새로 고침 중" +pullDownToRefresh: "당겨서 새로 고침" +showUnreadNotificationsCount: "읽지 않은 알림 수 표시" showCatOnly: "고양이만 보기" additionalPermissionsForFlash: "Play에 대한 추가 권한" thisFlashRequiresTheFollowingPermissions: "이 Play는 다음 권한을 요구해요" doYouWantToAllowThisPlayToAccessYourAccount: "이 Play가 계정에 접근하도록 허용할까요?" translateProfile: "프로필 번역하기" +_nsfwOpenBehavior: + click: "탭하여 열기" + doubleClick: "두 번 탭하여 열기" _vibrations: click: "요소를 클릭했을 때" note: "타임라인에 새 노트가 올라왔을 때" @@ -1229,11 +1266,6 @@ _showingAnimatedImages: inactive: "일정 시간이 지나면 멈춤" _messaging: direct: "다이렉트 메시지" -_tlTutorial: - step1_1: '{icon} 홈 타임라인은 내가 팔로우하고 있는 계정의 게시물을 볼 수 있어요.' - step1_2: '{icon} 로컬 타임라인은 이 서버의 모든 유저가 올린 게시물을 볼 수 있어요.' - step1_3: '{icon} 소셜 타임라인은 홈 타임라인과 로컬 타임라인을 합친 것과 같아요.' - step1_4: '{icon} 글로벌 타임라인에서는 이 서버와 연결된 모든 서버의 게시물을 볼 수 있어요.' _announcement: forExistingUsers: "기존 유저에게만 알리기" forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시해요. 비활성화하면 게시 후에 가입한 유저에게도 표시해요." @@ -1274,6 +1306,7 @@ _cherrypick: renameTheButtonInPostFormToNya: "노트 작성 화면의 '노트' 버튼을 '냥!'으로 변경" renameTheButtonInPostFormToNyaDescription: "냐앙냥냥냥냐냥?" enableLongPressOpenAccountMenu: "길게 눌러 계정 메뉴 열기" + friendlyShowAvatarDecorationsInNavBtn: "플로팅 버튼에 아이콘 장식 표시" _bannerDisplay: all: "전부" topBottom: "상단 및 하단" @@ -1299,10 +1332,28 @@ _initialAccountSetting: pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있어요." initialAccountSettingCompleted: "초기 설정을 모두 완료했어요!" haveFun: "{name}와 함께 즐거운 시간 보내세요!" - ifYouNeedLearnMore: "{name}(CherryPick)의 사용 방법에 대해 자세히 알아보려면 {link}를 참고해 주세요." + youCanContinueTutorial: "여기서 {name}(CherryPick)의 사용법을 배워볼 수 있지만, 튜토리얼을 진행하지 않고 바로 시작할 수도 있어요." + startTutorial: "튜토리얼 시작" skipAreYouSure: "초기 설정을 중단하시겠어요?" skipAreYouSureDescription: "지금 초기 설정을 중단해도 [더 보기! - 도움말 - 초기 설정 다시 보기]에서 다시 진행할 수 있어요." laterAreYouSure: "초기 설정을 나중에 진행할까요?" +_initialTutorial: + launchTutorial: "튜토리얼 보기" + title: "튜토리얼" + wellDone: "잘 하셨어요!" + skipAreYouSure: "튜토리얼을 종료할까요?" + _landing: + title: "튜토리얼에 어서오세요!" + description: "여기서는 CherryPick의 기본적인 사용법이나 기능을 확인할 수 있어요." + _note: + title: "노트란 무엇인가요?" + description: "CherryPick에서는 게시물을 '노트'라고 해요. 노트는 타임라인에 시간순으로 정렬되어 실시간으로 갱신돼요." + reply: "답글을 달 수 있어요. 답글에 답글을 다는 것도 할 수 있고, 스레드처럼 대화를 계속하는 것도 가능해요." + renote: "그 노트를 내 타임라인에 가져와서 공유할 수 있어요. 글을 추가해서 인용하는 것도 할 수 있답니다." + like: "하트 리액션을 달 수 있어요. '좋아요!'를 빠르게 남기고 싶을 때 유용해요." + reaction: "리액션을 달 수 있어요. 다음 페이지에서 자세하게 소개해 드릴게요!" + quote: "인용문을 달 수 있어요. 어떤 내용을 바탕으로 의견을 덧붙이고 싶을 때 유용해요." + menu: "노트의 세부 정보를 표시하거나 링크를 복사하는 등 다양한 조작을 할 수 있어요." _serverRules: description: "회원 가입 이전에 간단하게 표시할 서버 규칙이에요. 이용 약관의 요약으로 구성하는 것을 추천해요." _event: @@ -1673,7 +1724,7 @@ _sensitiveMediaDetection: description: "기계학습을 통해 자동으로 민감한 미디어를 탐지하여 모더레이션에 참고할 수 있도록 하지만, 서버의 부하가 약간 증가하게 돼요." sensitivity: "탐지 민감도" sensitivityDescription: "민감도가 낮을수록 안전한 미디어가 잘못 탐지될 확률이 줄어들고, 높을수록 민감한 미디어가 탐지되지 않을 확률이 줄어들어요." - setSensitiveFlagAutomatically: "자동으로 NSFW로 설정하기" + setSensitiveFlagAutomatically: "자동으로 민감한 미디어로 설정하기" setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지돼요." analyzeVideos: "동영상도 같이 확인하기" analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지해요. 서버의 부하가 약간 증가하게 돼요." @@ -1977,16 +2028,6 @@ _time: minute: "분" hour: "시간" day: "일" -_timelineTutorial: - title: "CherryPick의 사용 방법" - step1_1: "이것은 '타임라인'이에요. {name}에 게시된 '노트'가 시간순으로 나타나요." - step1_2: "타임라인은 몇 가지 종류로 나뉘게 되는데, 그 중에 '홈 타임라인'은 내가 팔로우한 사람의 노트가 표시되며, '로컬 타임라인'에는 {name} 의 모든 노트가 표시돼요." - step2_1: "그럼 시험삼아 노트를 작성해 볼까요? 화면에 있는 연필 버튼을 누르면 노트를 작성할 수 있어요!" - step2_2: "첫 노트이니까 자기소개, 혹은 가볍게 \"안녕 {name}\"라고 올려 보는 건 어떨까요?" - step3_1: "노트 작성을 끝내셨나요?" - step3_2: "내가 올린 노트가 타임라인에 표시되어 있다면 성공이에요!" - step4_1: "노트에는 '리액션'을 붙일 수 있어요." - step4_2: "리액션을 붙이려면, 노트의 \"+\" 버튼을 클릭하고 원하는 이모지를 선택하기만 하면 돼요!" _2fa: alreadyRegistered: "이미 설정되어 있어요!" registerTOTP: "인증 앱 설정 시작" @@ -2297,6 +2338,9 @@ _notification: checkNotificationBehavior: "알림 표시 확인하기" sendTestNotification: "테스트 알림 보내기" notificationWillBeDisplayedLikeThis: "알림이 이렇게 표시돼요!" + reactedBySomeUsers: "{n}명이 반응했어요" + renotedBySomeUsers: "{n}명이 리노트했어요" + followedBySomeUsers: "{n}명에게 팔로우됨" _types: all: "전부" note: "새 노트" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 0c600592e2..2957d574b2 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -45,6 +45,7 @@ pin: "Vastmaken aan profielpagina" unpin: "Losmaken van profielpagina" copyContent: "Kopiëren inhoud" copyLink: "Kopiëren link" +copyLinkRenote: "" delete: "Verwijderen" deleteAndEdit: "Verwijderen en bewerken" deleteAndEditConfirm: "Weet je zeker dat je deze notitie wilt verwijderen en dan bewerken? Je verliest alle reacties, herdelingen en antwoorden erop." diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 344dbefcae..2eae35f60c 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -601,9 +601,6 @@ _time: minute: "Minutter" hour: "Timer" day: "Dager" -_timelineTutorial: - title: "Hvordan bruke CherryPick" - step2_2: "Hva med å skrive en selvpresentasjon, eller bare \"Hei {name}!\" hvis du ikke har lyst?" _2fa: renewTOTPCancel: "Avbryt" _weekday: diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index a4fcb60067..31aa449877 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -887,6 +887,7 @@ youFollowing: "Śledzeni" icon: "Awatar" replies: "Odpowiedz" renotes: "Udostępnij" +flip: "Odwróć" _role: priority: "Priorytet" _priority: diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 24987221db..72aaf90450 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1011,6 +1011,7 @@ icon: "Avatar" replies: "Responder" renotes: "Repostar" keepScreenOn: "Manter a tela do dispositivo sempre ligada" +flip: "Inversão" _initialAccountSetting: followUsers: "Siga usuários que lhe interessam para criar a sua linha do tempo." _serverSettings: @@ -1323,8 +1324,6 @@ _sfx: chat: "Chat" _ago: invalid: "Não há nada aqui" -_timelineTutorial: - step1_2: "Existem vários tipos de linhas do tempo, por exemplo, na 'Linha do Tempo Principal', você verá as notas das pessoas que está seguindo, e na 'Linha do Tempo Local', verá todas as notas de {name}." _2fa: securityKeyInfo: "Além da autenticação por impressão digital ou PIN, você também pode configurar a autenticação por chaves de segurança de hardware compatível com FIDO2 para proteger ainda mais a sua conta." removeKeyConfirm: "Deseja excluir {name}?" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 2cb0ad091d..8720c83b31 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1081,6 +1081,7 @@ doYouAgree: "Согласны?" icon: "Аватар" replies: "Ответить" renotes: "Репост" +flip: "Переворот" _initialAccountSetting: accountCreated: "Аккаунт успешно создан!" letsStartAccountSetup: "Давайте настроим вашу учётную запись." @@ -1668,16 +1669,6 @@ _time: minute: "мин" hour: "ч" day: "сут" -_timelineTutorial: - title: "Как пользоваться CherryPick" - step1_1: "Это лицо CherryPick, так называемая лента. Ваш инстанс, {name}, покажет тут все опубликованные на нём заметки в хронологическом порядке." - step1_2: "Здесь есть несколько лент. К примеру «персональная» лента отображает заметки тех, на кого вы подписаны. А «местная» — заметки тех, кого приютил {name}." - step2_1: "Что ж, теперь самое время опубликовать заметку. Если нажать вверху страницы на изображение карандаша, появится форма для текста." - step2_2: "Почему бы не написать немного о себе? Ну, или хотя бы «Привет, {name}»?" - step3_1: "Справились с первой заметкой?" - step3_2: "Отлично, теперь она должна появиться в вашей ленте." - step4_1: "А ещё здесь можно делиться своими реакциями на заметки." - step4_2: "Отмечайте реакции, нажимая на символ «+» под заметкой и выбирая значок по душе." _2fa: alreadyRegistered: "Двухфакторная аутентификация уже настроена." registerTOTP: "Начните настраивать приложение-аутентификатор" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 1b5c04cab8..6a9ff5ad8e 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -935,6 +935,7 @@ youFollowing: "Sledované" icon: "Avatar" replies: "Odpovedať" renotes: "Preposlať" +flip: "Preklopiť" _role: priority: "Priorita" _priority: diff --git a/locales/th-TH.yml b/locales/th-TH.yml index aac08bbf5d..bb70fce82a 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1146,6 +1146,7 @@ impressumUrl: "URL อิมเพรสชั่น" privacyPolicy: "นโยบายความเป็นส่วนตัว" privacyPolicyUrl: "URL นโยบายความเป็นส่วนตัว" tosAndPrivacyPolicy: "เงื่อนไขในการให้บริการและนโยบายความเป็นส่วนตัว" +flip: "ย้อนกลับ" _announcement: forExistingUsers: "ผู้ใช้งานที่มีอยู่เท่านั้น" forExistingUsersDescription: "การประกาศนี้จะแสดงต่อผู้ใช้ที่มีอยู่ ณ จุดที่เผยแพร่นั้นๆถ้าหากเปิดใช้งาน ถ้าหากปิดใช้งานผู้ที่กำลังสมัครใหม่หลังจากโพสต์แล้วนั้นก็จะเห็นเช่นกัน" @@ -1167,7 +1168,6 @@ _initialAccountSetting: pushNotificationDescription: "กำลังเปิดใช้งานการแจ้งเตือนแบบพุชจะช่วยให้คุณได้รับการแจ้งเตือนจาก {name} โดยตรงบนอุปกรณ์ของคุณนะ" initialAccountSettingCompleted: "ตั้งค่าโปรไฟล์เสร็จสมบูรณ์แล้ว!" haveFun: "ขอให้สนุก {name}!" - ifYouNeedLearnMore: "ถ้าหากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีใช้ {ชื่อ} (CherryPick) กรุณาไปที่ {link}" skipAreYouSure: "ต้องการข้ามการตั้งค่าโปรไฟล์จริงๆแบบนั้นหรอ?" laterAreYouSure: "ต้องการตั้งค่าโปรไฟล์ในภายหลังจริงๆอย่างงั้นหรอ?" _serverRules: @@ -1795,16 +1795,6 @@ _time: minute: "นาที" hour: "ชั่วโมง" day: "วัน" -_timelineTutorial: - title: "วิธีใช้งาน CherryPick" - step1_1: "นี่คือ \"ไทม์ไลน์\" \"โน้ต\" ทั้งหมดที่ส่งใน {name} จะแสดงรายการตามลำดับเวลาที่นี่นะ" - step1_2: "อาจจะมีไทม์ไลน์ที่แตกต่างกันเล็กน้อยยกตัวอย่างเช่น \"ไทม์ไลน์หน้าแรก\" จะมีโน้ตของผู้ใช้ที่คุณติดตามและ \"ไทม์ไลน์ท้องถิ่น\" จะมีโน้ตจากผู้ใช้ทั้งหมดของ {name}" - step2_1: "มาลองโพสต์โน้ตต่อไปกัน คุณสามารถทำได้โดยการกดปุ่มที่มีไอคอนดินสอ" - step2_2: "ยังไงไหนลองเขียนแนะนำตัวเองหรือแค่ \"สวัสดี {name}!\" ถ้าคุณไม่รู้สึกเหมือนมัน?" - step3_1: "เสร็จสิ้นการโพสต์โน้ตย่อแรกของคุณแล้วอย่างงั้นหรอ?" - step3_2: "ไชโย! ตอนนี้โน้ตย่อแรกของคุณได้ปรากฏบนไทม์ไลน์ของคุณแล้วนะ" - step4_1: "คุณสามารถเพิ่ม \"การตอบสนอง\" ในโน้ตได้" - step4_2: "หากต้องการแนบการแสดงความรู้สึก ให้กดเครื่องหมาย \"+\" บนโน้ตแล้วเลือกอิโมจิที่คุณต้องการแสดงความรู้สึกที่ตนเองชอบได้เลย" _2fa: alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 47c45f4213..758ae934ca 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -921,6 +921,7 @@ youFollowing: "Підписки" icon: "Аватар" replies: "Відповісти" renotes: "Поширити" +flip: "Перевернути" _achievements: earnedAt: "Відкрито" _types: diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index eb04e2afaf..81be4f9b52 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -845,6 +845,7 @@ sensitiveWords: "Ta'sirchan so'zlar" icon: "Avatar" replies: "Javob berish" renotes: "Qayta qayd etish" +flip: "Teskari" _achievements: _types: _viewInstanceChart: diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 6c7690a56a..1b51b7c20b 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1061,6 +1061,7 @@ loadReplies: "Hiển thị các trả lời" pinnedList: "Các mục đã được ghim" keepScreenOn: "Giữ màn hình luôn bật" verifiedLink: "Chúng tôi đã xác nhận bạn là chủ sở hữu của đường dẫn này" +flip: "Lật" _announcement: forExistingUsers: "Chỉ những người dùng đã tồn tại" forExistingUsersDescription: "Nếu được bật, thông báo này sẽ chỉ hiển thị với những người dùng đã tồn tại vào lúc thông báo được tạo. Nếu tắt đi, những tài khoản mới đăng ký sau khi thông báo được đăng lên cũng sẽ thấy nó." @@ -1080,7 +1081,6 @@ _initialAccountSetting: pushNotificationDescription: "Bật thông báo đẩy sẽ cho phép bạn nhận thông báo từ {name} trực tiếp từ thiết bị của bạn." initialAccountSettingCompleted: "Thiết lập tài khoản thành công!" haveFun: "Hãy tận hưởng {name} nhé!" - ifYouNeedLearnMore: "Nếu bạn muốn tìm hiểu thêm về cách sử dụng {name} (Misskey), hãy vào {link}." skipAreYouSure: "Bạn thực sự muốn bỏ qua mục thiết lập tài khoản?" laterAreYouSure: "Bạn thực sự muốn thiết lập tài khoản vào lúc khác?" _serverSettings: @@ -1584,9 +1584,6 @@ _time: minute: "phút" hour: "giờ" day: "ngày" -_timelineTutorial: - step4_1: "Bạn có thể thêm \"Reaction\" vào ghi chú" - step4_2: "Khi thêm biểu cảm hãy nhấn dấu \"+\"" _2fa: alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước." registerTOTP: "Đăng ký ứng dụng xác thực" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index a79d694397..eb117273a7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1145,6 +1145,7 @@ mutualFollow: "互相关注" fileAttachedOnly: "仅限媒体" showRepliesToOthersInTimeline: "在时间线上显示给其他人的回复" hideRepliesToOthersInTimeline: "在时间线上隐藏给其他人的回复" +flip: "翻转" _announcement: forExistingUsers: "仅限现有用户" forExistingUsersDescription: "若启用,该公告将仅对创建此公告时存在的用户可见。 如果禁用,则在创建此公告后注册的用户也可以看到该公告。" @@ -1166,7 +1167,6 @@ _initialAccountSetting: pushNotificationDescription: "启用推送通知的话,就可以在设备上接收来自 {name} 的通知了。" initialAccountSettingCompleted: "初始设定已经完成了!" haveFun: "希望 {name} 在这里玩得开心!" - ifYouNeedLearnMore: "关于 {name}(CherryPick) 的使用方法,详见 {link}。" skipAreYouSure: "要跳过初始设置吗?" laterAreYouSure: "要稍后再进行初始设定吗?" _serverRules: @@ -1794,16 +1794,6 @@ _time: minute: "分" hour: "小时" day: "日" -_timelineTutorial: - title: "CherryPick 的使用方法" - step1_1: "这个画面是「时间线」。{name}的投稿会按照帖子的发布时间顺序来显示。" - step1_2: "时间线有许多种类,比如在「首页时间线」中展现的是你关注的人的贴文;而在「本地时间线」中展现的是{name}里全部用户的贴文。" - step2_1: "那么接下来,试着写一些什么东西来发布吧!你可以通过点击屏幕上的铅笔图标来打开投稿页面。" - step2_2: "第一次发布的帖子内容,建议包含自我介绍,以及「开始使用{name}了」。" - step3_1: "将想说的话发出去了吗?" - step3_2: "太棒了!现在你可以在你的时间线中看到刚刚发布的帖子了。" - step4_1: "试着对帖子使用「回应」吧!" - step4_2: "在他人的帖子上按下「+」图标,即可选择想要的表情来进行「回应」。" _2fa: alreadyRegistered: "此设备已被注册" registerTOTP: "开始设置认证应用" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index e77d3358a3..f5d9c9a03a 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -161,7 +161,7 @@ youCanCleanRemoteFilesCache: "按檔案管理的🗑️按鈕,可將快取全 cacheRemoteSensitiveFiles: "快取遠端的敏感檔案" cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取遠端的敏感檔案,而是直接連結。" flagAsBot: "此使用者是機器人" -flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Misskey內部系統將本帳戶識別為機器人" +flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整 CherryPick 內部系統將本帳戶識別為機器人。" flagAsCat: "此帳戶是一隻貓,喵~~~!!!" flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" @@ -993,6 +993,7 @@ assign: "指派" unassign: "取消指派" color: "顏色" manageCustomEmojis: "管理自訂表情符號" +manageAvatarDecorations: "管理頭像裝飾" youCannotCreateAnymore: "您無法再建立更多了。" cannotPerformTemporary: "暫時無法進行" cannotPerformTemporaryDescription: "由於超過操作次數限制,因此暫時無法進行。請稍後再嘗試。" @@ -1044,7 +1045,7 @@ retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端使用者的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" showClipButtonInNoteFooter: "新增摘錄至貼文" -reactionsDisplaySize: "表情回應的顯示尺寸" +reactionsDisplaySize: "反應的顯示尺寸" noteIdOrUrl: "貼文ID或URL" video: "影片" videos: "影片" @@ -1139,13 +1140,17 @@ unnotifyNotes: "關閉貼文通知" authentication: "驗證" authenticationRequiredToContinue: "請於繼續前完成驗證" dateAndTime: "日期與時間" -showRenotes: "顯示轉發貼文" +showRenotes: "顯示其他人的轉發貼文" edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" fileAttachedOnly: "顯示包含附件的貼文" showRepliesToOthersInTimeline: "顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" +showRepliesToOthersInTimelineAll: "在時間軸包含追隨中所有人的回覆" +hideRepliesToOthersInTimelineAll: "在時間軸不包含追隨中所有人的回覆" +confirmShowRepliesAll: "進行此操作後無法復原。您真的希望時間軸「包含」您目前追隨的所有人的回覆嗎?" +confirmHideRepliesAll: "進行此操作後無法復原。您真的希望時間軸「不包含」您目前追隨的所有人的回覆嗎?" externalServices: "外部服務" impressum: "營運者資訊" impressumUrl: "營運者資訊網址" @@ -1153,6 +1158,20 @@ impressumDescription: "在德國與部份地區必須要明確顯示營運者資 privacyPolicy: "隱私政策" privacyPolicyUrl: "隱私政策網址" tosAndPrivacyPolicy: "服務條款和隱私政策" +avatarDecorations: "頭像裝飾" +attach: "裝上" +detach: "取下" +angle: "角度" +flip: "翻轉" +showAvatarDecorations: "顯示頭像裝飾" +releaseToRefresh: "放開以更新內容" +refreshing: "載入更新中" +pullDownToRefresh: "往下拉來更新內容" +disableStreamingTimeline: "停用時間軸的即時更新" +useGroupedNotifications: "分組顯示通知訊息" +signupPendingError: "驗證您的電子郵件地址時出現問題。連結可能已過期。" +cwNotationRequired: "如果開啟「隱藏內容」,則需要註解說明。" +doReaction: "做出反應" _announcement: forExistingUsers: "僅限既有的使用者" forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。" @@ -1162,6 +1181,8 @@ _announcement: tooManyActiveAnnouncementDescription: "有過多公告可能會影響使用者體驗。請考慮歸檔已結束的公告。" readConfirmTitle: "標記為已讀嗎?" readConfirmText: "閱讀「{title}」的內容並標記為已讀。" + shouldNotBeUsedToPresentPermanentInfo: "由於可能會破壞使用者體驗,尤其是對於新使用者而言,我們建議使用公告來發布有時效性的資訊而不是固定不變的資訊。" + dialogAnnouncementUxWarn: "如果同時有 2 個以上對話方塊形式的公告存在,對於使用者體驗很可能會有不良的影響,因此建議謹慎使用。" _initialAccountSetting: accountCreated: "帳戶已建立完成!" letsStartAccountSetup: "來進行帳戶的初始設定吧。" @@ -1174,9 +1195,77 @@ _initialAccountSetting: pushNotificationDescription: "啟用推送通知,就可以在設備上接收{name}的通知。" initialAccountSettingCompleted: "初始設定完成了!" haveFun: "盡情享受{name}吧!" - ifYouNeedLearnMore: "請瀏覽{link}以更瞭解{name}(CherryPick)的使用方法。" + youCanContinueTutorial: "您可以繼續學習如何使用{name}(CherryPick),也可以就此打住,立即開始使用。" + startTutorial: "開始教學課程" skipAreYouSure: "要略過初始設定嗎?" laterAreYouSure: "稍後再重新進行初始設定嗎?" +_initialTutorial: + launchTutorial: "觀看教學課程" + title: "新手教學" + wellDone: "做得好" + skipAreYouSure: "結束教學模式?" + _landing: + title: "歡迎使用本教學課程" + description: "在這裡您可以查看CherryPick的基本使用方法和功能。" + _note: + title: "什麼是貼文?" + description: "在CherryPick上發布的內容稱為「貼文」。貼文在時間軸上按時間順序排列,並即時更新。" + reply: "您可以回覆貼文,並像討論串一樣繼續對話。" + renote: "您可以將此貼文分享到自己的時間軸。您也可以在引用時添加文字。" + reaction: "您可以添加反應。詳細資訊將在下一頁進行說明。" + menu: "可執行各種操作,如查看貼文詳細資訊和複製連結。" + _reaction: + title: "什麼是反應?" + description: "您可以在貼文中添加「反應」。您可以使用反應輕鬆隨意地表達「最愛/大心」所無法傳達的細微差別。" + letsTryReacting: "可以透過點擊貼文上的「+」按鈕來添加反應。請嘗試在此範例貼文添加反應!" + reactToContinue: "添加反應以繼續教學課程。" + reactNotification: "當有人對您的貼文做出反應時會即時接收到通知。" + reactDone: "按下「-」按鈕可以取消反應。" + _timeline: + title: "時間軸如何運作" + description1: "CherryPick根據使用方式提供了多個時間軸(伺服器可能會將部份時間軸停用)。" + home: "您可以查看您追隨的使用者的貼文。" + local: "您可以看到此伺服器上所有使用者的貼文。" + social: "來自首頁時間軸和本地時間軸的貼文都會顯示。" + global: "可以看到其他已連接伺服器的貼文。" + description2: "您可以隨時在螢幕上方切換對應的時間軸。" + description3: "除此之外還有清單時間軸、頻道時間軸等。請參閱{link}以了解更多詳情。" + _postNote: + title: "貼文的發布設定" + description1: "在CherryPick上發布貼文時,可以設定各種選項。發布表單如下所示。" + _visibility: + description: "可以限制誰可以看到您的貼文。" + public: "所有人都可以看見。" + home: "僅在首頁時間軸上發布。其他使用者只在下列情況可看見該貼文:追隨者、觀看使用者的個人資料頁面,以及貼文被轉發時。" + followers: "僅追隨者可見。只有發文者本人可轉發,未追隨發文者的使用者無法看見。" + direct: "僅指定的使用者可見,對方也會收到通知。可代替直接訊息使用。" + doNotSendConfidencialOnDirect1: "發送機密訊息時請務必注意。" + doNotSendConfidencialOnDirect2: "目標伺服器的管理員可以看到發布的內容,因此如果您向不受信任的伺服器上的使用者發送直接訊息,必須小心處理機密訊息。" + localOnly: "不將貼文發布到聯邦上的其他伺服器。不論上述發布範圍,使用此設定後,其他伺服器上的使用者將無法直接查看此貼文。" + _cw: + title: "隱藏內容(CW)" + description: "將顯示「註釋」中寫入的內容而不是本文。按一下「顯示內容」以顯示本文。" + _exampleNote: + cw: "美食恐怖主義注意" + note: "我吃了一個巧克力甜甜圈🍩😋" + useCases: "伺服器的服務條款可能會規範特定的貼文需要使用隱藏內容,除此之外也會用在隱藏劇情洩漏與敏感內容的貼文。" + _howToMakeAttachmentsSensitive: + title: "如何標記上傳附件為敏感內容?" + description: "如果伺服器服務條款有規範,又或者不希望上傳附件直接被看見,可以設置為「敏感內容」" + tryThisFile: "試試看!把附加在發文表單的圖像檔案標記為敏感內容。" + _exampleNote: + note: "打開納豆的包裝失敗了…" + method: "若要使上傳附件標記為敏感內容,請按一下該檔案以開啟選單,然後點擊「標記為敏感內容」。" + sensitiveSucceeded: "上傳附件時,請務必根據伺服器的服務條款適當設定敏感內容。" + doItToContinue: "把圖像標記為敏感內容以繼續教學課程。" + _done: + title: "教學課程已結束" + description: "這裡介紹的功能只是其中的一小部分。要了解更多有關如何使用CherryPick的資訊,請瀏覽{link}。" +_timelineDescription: + home: "在首頁時間線上,可以看到您追隨的使用者的貼文。" + local: "在本地時間軸上,可以看到此伺服器所有使用者的貼文。" + social: "在社交時間軸上,可以看到首頁與本地時間軸的貼文。" + global: "在公開時間軸上,可以看到其他已連接伺服器的貼文。\n" _serverRules: description: "設定在註冊頁面顯示的伺服器簡要規則。建議是服務條款的摘要。" _serverSettings: @@ -1188,6 +1277,7 @@ _serverSettings: manifestJsonOverride: "覆寫 manifest.json" shortName: "簡稱" shortNameDescription: "如果伺服器的正式名稱很長,可用簡稱或通稱代替。" + fanoutTimelineDescription: "如果啟用的話,檢索各個時間軸的性能會顯著提昇,資料庫的負荷也會減少。不過,Redis 的記憶體使用量會增加。如果伺服器的記憶體容量比較少或者運行不穩定,可以停用。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1445,6 +1535,9 @@ _achievements: _smashTestNotificationButton: title: "過度測試" description: "極短時間內連續測試通知" + _tutorialCompleted: + title: "CherryPick新手講座 結業證書" + description: "已完成教學課程" _role: new: "建立角色" edit: "編輯角色" @@ -1489,6 +1582,7 @@ _role: inviteLimitCycle: "邀請碼的發放間隔" inviteExpirationTime: "邀請碼的有效日期" canManageCustomEmojis: "管理自訂表情符號" + canManageAvatarDecorations: "管理頭像裝飾" driveCapacity: "雲端硬碟容量" alwaysMarkNsfw: "總是將檔案標記為NSFW" pinMax: "置頂貼文的最大數量" @@ -1608,6 +1702,7 @@ _aboutMisskey: donate: "贊助 Misskey" morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰" patrons: "贊助者" + projectMembers: "專案成員" _displayOfSensitiveMedia: respect: "隱藏敏感檔案" ignore: "顯示敏感檔案" @@ -1698,6 +1793,7 @@ _channel: notesCount: "有 {n} 篇貼文" nameAndDescription: "名稱與說明" nameOnly: "僅名稱" + allowRenoteToExternal: "允許在頻道外轉發和引用" _menuDisplay: sideFull: "橫向" sideIcon: "橫向(圖示)" @@ -1807,16 +1903,6 @@ _time: minute: "分鐘" hour: "小時" day: "日" -_timelineTutorial: - title: "CherryPick 的使用方法" - step1_1: "這個畫面是「時間軸」。發佈到{name}的「貼文」會按照時間順序顯示。" - step1_2: "時間軸有多種類型,例如「首頁時間軸」是您追蹤帳戶的貼文、「本地時間軸」是{name}內所有帳戶的貼文。" - step2_1: "不如現在就嘗試發文吧!按鉛筆圖示的按鈕開啟發文頁面。" - step2_2: "您可以在第一篇貼文裡寫自我介紹,或是「我來到 {name} 了」之類的話。" - step3_1: "貼文發出去了嗎?" - step3_2: "如果您的貼文出現在時間軸上,就代表發文成功。" - step4_1: "可以對貼文標記「反應」。" - step4_2: "點擊貼文的「+」圖示,即可選擇表情符號來反應。" _2fa: alreadyRegistered: "此裝置已被註冊過了" registerTOTP: "開始設定驗證應用程式" @@ -1940,7 +2026,7 @@ _widgets: clicker: "點擊器" _cw: hide: "隱藏" - show: "瀏覽更多" + show: "顯示內容" chars: "{count} 個字元" files: "{count} 個檔案" _poll: @@ -2131,6 +2217,9 @@ _notification: checkNotificationBehavior: "確認通知的顯示行為" sendTestNotification: "發送測試通知" notificationWillBeDisplayedLikeThis: "通知會以這樣的方式顯示" + reactedBySomeUsers: "{n}人做出了反應" + renotedBySomeUsers: "{n}人做了轉發" + followedBySomeUsers: "被{n}人追隨了" _types: all: "全部 " note: "使用者的最新貼文" @@ -2235,6 +2324,9 @@ _moderationLogTypes: createAd: "建立廣告" deleteAd: "刪除廣告" updateAd: "更新廣告" + createAvatarDecoration: "建立頭像裝飾" + updateAvatarDecoration: "更新頭像裝飾" + deleteAvatarDecoration: "刪除頭像裝飾" _fileViewer: title: "檔案詳細資訊" type: "檔案類型 " @@ -2243,3 +2335,44 @@ _fileViewer: uploadedAt: "加入日期" attachedNotes: "含有附件的貼文" thisPageCanBeSeenFromTheAuthor: "本頁面僅限上傳了這個檔案的使用者可以檢視。" +_externalResourceInstaller: + title: "從外部網站安裝" + checkVendorBeforeInstall: "安裝前請確認提供者是可信賴的。" + _plugin: + title: "要安裝此外掛嘛?" + metaTitle: "外掛資訊" + _theme: + title: "要安裝此外觀主題嘛?" + metaTitle: "外觀主題資訊" + _meta: + base: "基本配色方案" + _vendorInfo: + title: "提供者資訊" + endpoint: "引用端點" + hashVerify: "確認檔案的完整性" + _errors: + _invalidParams: + title: "缺少參數" + description: "缺少從外部網站取得資料的必要資訊。請檢查 URL 是否正確。" + _resourceTypeNotSupported: + title: "不支援此外部資源。" + description: "不支援從此外部網站取得的資源類型。請聯絡網站管理員。" + _failedToFetch: + title: "無法取得資料" + fetchErrorDescription: "與外部站點的通訊失敗。如果重試後問題仍然存在,請聯絡網站管理員。" + parseErrorDescription: "無法讀取從外部站點取得的資料。請聯絡網站管理員。" + _hashUnmatched: + title: "無法取得正確資料" + description: "所提供資料的完整性驗證失敗。出於安全原因,安裝無法繼續。請聯絡網站管理員。" + _pluginParseFailed: + title: "AiScript 錯誤" + description: "已取得資料但解析 AiScript 時發生錯誤,導致無法載入。請聯絡外掛作者。請檢查 Javascript 控制台以取得錯誤詳細資訊。" + _pluginInstallFailed: + title: "外掛安裝失敗" + description: "安裝插件時出現問題。請再試一次。請參閱 Javascript 控制台以取得錯誤詳細資訊。" + _themeParseFailed: + title: "外觀主題解析錯誤" + description: "已取得資料但解析外觀主題時發生錯誤,導致無法載入。請聯絡主題作者。請檢查 Javascript 控制台以取得錯誤詳細資訊。" + _themeInstallFailed: + title: "無法安裝外觀主題" + description: "安裝外觀主題時出現問題。請再試一次。請參閱 Javascript 控制台以取得錯誤詳細資訊。" diff --git a/package.json b/package.json index 32d00b6dad..ee32819d4b 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "cherrypick", - "version": "4.4.1", - "basedMisskeyVersion": "2023.10.2", + "version": "4.5.0", + "basedMisskeyVersion": "2023.11.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/kokonect-link/cherrypick.git" }, - "packageManager": "pnpm@8.9.2", + "packageManager": "pnpm@8.10.0", "workspaces": [ "packages/frontend", "packages/backend", @@ -24,6 +24,7 @@ "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", "migrate": "cd packages/backend && pnpm migrate", + "revert": "cd packages/backend && pnpm revert", "check:connect": "cd packages/backend && pnpm check:connect", "migrateandstart": "pnpm migrate && pnpm start", "migrateandstart:docker": "pnpm migrate && exec pnpm start:docker", @@ -39,7 +40,8 @@ "test-and-coverage": "pnpm -r test-and-coverage", "clean": "node ./scripts/clean.js", "clean-all": "node ./scripts/clean-all.js", - "cleanall": "pnpm clean-all" + "cleanall": "pnpm clean-all", + "schema:sync": "cd packages/backend && pnpm typeorm schema:sync -d ormconfig.js" }, "resolutions": { "chokidar": "3.5.3", @@ -50,15 +52,15 @@ "cssnano": "6.0.1", "js-yaml": "4.1.0", "postcss": "8.4.31", - "terser": "5.22.0", + "terser": "5.24.0", "typescript": "5.2.2" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "6.8.0", - "@typescript-eslint/parser": "6.8.0", + "@typescript-eslint/eslint-plugin": "6.9.1", + "@typescript-eslint/parser": "6.9.1", "cross-env": "7.0.3", - "cypress": "13.3.2", - "eslint": "8.51.0", + "cypress": "13.4.0", + "eslint": "8.52.0", "start-server-and-test": "2.0.1" }, "optionalDependencies": { diff --git a/packages/backend/migration/1696604572677-poll-vote-poll.js b/packages/backend/migration/1696604572677-poll-vote-poll.js new file mode 100644 index 0000000000..da52904565 --- /dev/null +++ b/packages/backend/migration/1696604572677-poll-vote-poll.js @@ -0,0 +1,12 @@ +export class PollVotePoll1696604572677 { + name = 'PollVotePoll1696604572677'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "poll_vote" ADD CONSTRAINT "FK_poll_vote_poll" FOREIGN KEY ("noteId") REFERENCES "poll"("noteId") ON DELETE CASCADE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "poll_vote" DROP CONSTRAINT "FK_poll_vote_poll"`); + } + +} diff --git a/packages/backend/migration/1696807733453-userListUserId.js b/packages/backend/migration/1696807733453-userListUserId.js index 62ce987a34..253352ccb5 100644 --- a/packages/backend/migration/1696807733453-userListUserId.js +++ b/packages/backend/migration/1696807733453-userListUserId.js @@ -8,11 +8,7 @@ export class UserListUserId1696807733453 { async up(queryRunner) { await queryRunner.query(`ALTER TABLE "user_list_membership" ADD "userListUserId" character varying(32) NOT NULL DEFAULT ''`); - const memberships = await queryRunner.query(`SELECT "id", "userListId" FROM "user_list_membership"`); - for(let i = 0; i < memberships.length; i++) { - const userList = await queryRunner.query(`SELECT "userId" FROM "user_list" WHERE "id" = $1`, [memberships[i].userListId]); - await queryRunner.query(`UPDATE "user_list_membership" SET "userListUserId" = $1 WHERE "id" = $2`, [userList[0].userId, memberships[i].id]); - } + await queryRunner.query(`UPDATE "user_list_membership" SET "userListUserId" = "user_list"."userId" FROM "user_list" WHERE "user_list_membership"."userListId" = "user_list"."id"`); } async down(queryRunner) { diff --git a/packages/backend/migration/1697847397844-avatar-decoration.js b/packages/backend/migration/1697847397844-avatar-decoration.js new file mode 100644 index 0000000000..c8427feaa5 --- /dev/null +++ b/packages/backend/migration/1697847397844-avatar-decoration.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AvatarDecoration1697847397844 { + name = 'AvatarDecoration1697847397844' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "avatar_decoration" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "url" character varying(1024) NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(2048) NOT NULL, "roleIdsThatCanBeUsedThisDecoration" character varying(128) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_b6de9296f6097078e1dc53f7603" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`DROP TABLE "avatar_decoration"`); + } +} diff --git a/packages/backend/migration/1697941908548-avatar-decoration2.js b/packages/backend/migration/1697941908548-avatar-decoration2.js new file mode 100644 index 0000000000..7e2d16b356 --- /dev/null +++ b/packages/backend/migration/1697941908548-avatar-decoration2.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AvatarDecoration21697941908548 { + name = 'AvatarDecoration21697941908548' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" jsonb NOT NULL DEFAULT '[]'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`); + } +} diff --git a/packages/backend/migration/1698041201306-enable-ftt.js b/packages/backend/migration/1698041201306-enable-ftt.js new file mode 100644 index 0000000000..4c0f528885 --- /dev/null +++ b/packages/backend/migration/1698041201306-enable-ftt.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class EnableFtt1698041201306 { + name = 'EnableFtt1698041201306' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableFanoutTimeline" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFanoutTimeline"`); + } +} diff --git a/packages/backend/migration/1698840138000-add-allow-renote-to-external.js b/packages/backend/migration/1698840138000-add-allow-renote-to-external.js new file mode 100644 index 0000000000..8f017c1068 --- /dev/null +++ b/packages/backend/migration/1698840138000-add-allow-renote-to-external.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAllowRenoteToExternal1698840138000 { + name = 'AddAllowRenoteToExternal1698840138000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "allowRenoteToExternal" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "allowRenoteToExternal"`); + } +} diff --git a/packages/backend/migration/1699141698112-announcement-silence.js b/packages/backend/migration/1699141698112-announcement-silence.js new file mode 100644 index 0000000000..f3e56e4547 --- /dev/null +++ b/packages/backend/migration/1699141698112-announcement-silence.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AnnouncementSilence1699141698112 { + name = 'AnnouncementSilence1699141698112' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "silence" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_7b8d9225168e962f94ea517e00" ON "announcement" ("silence") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_7b8d9225168e962f94ea517e00"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "silence"`); + } +} diff --git a/packages/backend/migration/1699432324194-remoteAvaterDecoration.js b/packages/backend/migration/1699432324194-remoteAvaterDecoration.js new file mode 100644 index 0000000000..5b2762b476 --- /dev/null +++ b/packages/backend/migration/1699432324194-remoteAvaterDecoration.js @@ -0,0 +1,13 @@ +export class RemoteAvaterDecoration1699432324194 { + name = 'RemoteAvaterDecoration1699432324194' + + async up(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "remoteId" varchar(32)`); + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" varchar(128)`); + } + + async down(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "remoteId"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 16f1161a19..e0b5ac4adb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,6 +10,7 @@ "start": "node ./built/index.js", "start:test": "NODE_ENV=test node ./built/index.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js", + "revert": "pnpm typeorm migration:revert -d ormconfig.js", "check:connect": "node ./check_connect.js", "build": "swc src -d built -D", "watch:swc": "swc src -d built -D -w", @@ -22,7 +23,8 @@ "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "test": "pnpm jest", - "test-and-coverage": "pnpm jest-and-coverage" + "test-and-coverage": "pnpm jest-and-coverage", + "schema:sync": "pnpm typeorm schema:sync -d ormconfig.js" }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", @@ -58,29 +60,29 @@ "dependencies": { "@aws-sdk/client-s3": "3.412.0", "@aws-sdk/lib-storage": "3.412.0", - "@smithy/node-http-handler": "2.1.5", "@bull-board/api": "5.9.1", "@bull-board/fastify": "5.9.1", "@bull-board/ui": "5.9.1", "@discordapp/twemoji": "14.1.2", "@fastify/accepts": "4.2.0", "@fastify/cookie": "9.1.0", - "@fastify/cors": "8.4.0", + "@fastify/cors": "8.4.1", "@fastify/express": "2.3.0", "@fastify/http-proxy": "9.2.1", "@fastify/multipart": "8.0.0", - "@fastify/static": "6.11.2", + "@fastify/static": "6.12.0", "@fastify/view": "8.2.0", "@google-cloud/logging": "^10.5.0", "@google-cloud/translate": "^7.2.1", - "@nestjs/common": "10.2.7", - "@nestjs/core": "10.2.7", - "@nestjs/testing": "10.2.7", + "@nestjs/common": "10.2.8", + "@nestjs/core": "10.2.8", + "@nestjs/testing": "10.2.8", "@peertube/http-signature": "1.7.0", - "@simplewebauthn/server": "8.3.2", - "@sinonjs/fake-timers": "11.2.1", + "@simplewebauthn/server": "8.3.5", + "@sinonjs/fake-timers": "11.2.2", + "@smithy/node-http-handler": "2.1.5", "@swc/cli": "0.1.62", - "@swc/core": "1.3.93", + "@swc/core": "1.3.95", "@vitalets/google-translate-api": "9.2.0", "accepts": "1.3.8", "ajv": "8.12.0", @@ -89,7 +91,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.2", - "bullmq": "4.12.5", + "bullmq": "4.12.8", "cacheable-lookup": "7.0.0", "cbor": "9.0.1", "chalk": "5.3.0", @@ -103,8 +105,9 @@ "date-fns": "2.30.0", "deep-email-validator": "0.1.21", "fastify": "4.24.3", + "fastify-raw-body": "^4.2.2", "feed": "4.2.2", - "file-type": "18.5.0", + "file-type": "18.6.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "13.0.0", @@ -127,7 +130,7 @@ "nanoid": "5.0.2", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.6", + "nodemailer": "6.9.7", "nsfwjs": "2.4.2", "oauth": "0.10.0", "oauth2orize": "1.12.0", @@ -140,18 +143,19 @@ "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", - "punycode": "2.3.0", + "punycode": "2.3.1", "pureimage": "0.3.17", "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.20.3", + "re2": "1.20.5", "redis-lock": "0.1.4", "reflect-metadata": "0.1.13", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.1", "sanitize-html": "2.11.0", + "secure-json-parse": "^2.4.0", "sharp": "0.32.6", "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", "slacc": "0.0.10", @@ -159,7 +163,7 @@ "stringz": "2.1.0", "strip-ansi": "^7.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.21.12", + "systeminformation": "5.21.15", "tinycolor2": "1.6.0", "tmp": "0.2.1", "tsc-alias": "1.8.8", @@ -175,10 +179,10 @@ }, "devDependencies": { "@jest/globals": "29.7.0", - "@simplewebauthn/typescript-types": "8.0.0", + "@simplewebauthn/typescript-types": "8.3.4", "@swc/jest": "0.2.29", "@types/accepts": "1.3.6", - "@types/archiver": "5.3.4", + "@types/archiver": "6.0.0", "@types/bcryptjs": "2.4.5", "@types/body-parser": "1.19.4", "@types/cbor": "6.0.0", @@ -186,14 +190,14 @@ "@types/content-disposition": "0.5.7", "@types/fluent-ffmpeg": "2.1.23", "@types/http-link-header": "1.0.4", - "@types/jest": "29.5.6", + "@types/jest": "29.5.7", "@types/js-yaml": "4.0.8", "@types/jsdom": "21.1.4", "@types/jsonld": "1.5.11", "@types/jsrsasign": "10.5.11", "@types/mime-types": "2.1.3", "@types/ms": "0.7.33", - "@types/node": "20.8.7", + "@types/node": "20.8.10", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.13", "@types/oauth": "0.9.3", @@ -216,12 +220,12 @@ "@types/vary": "1.1.2", "@types/web-push": "3.6.2", "@types/ws": "8.5.8", - "@typescript-eslint/eslint-plugin": "6.8.0", - "@typescript-eslint/parser": "6.8.0", + "@typescript-eslint/eslint-plugin": "6.9.1", + "@typescript-eslint/parser": "6.9.1", "aws-sdk-client-mock": "3.0.0", "cross-env": "7.0.3", - "eslint": "8.51.0", - "eslint-plugin-import": "2.28.1", + "eslint": "8.52.0", + "eslint-plugin-import": "2.29.0", "execa": "8.0.1", "jest": "29.7.0", "jest-mock": "29.7.0", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 1856600943..f491239e0c 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -278,7 +278,7 @@ export function loadConfig(): Config { clientEntry: clientManifest['src/_boot_.ts'], clientManifestExists: clientManifestExists, perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, - perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 300, + perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), pidFile: config.pidFile, }; diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 9587b95ca3..aa1546a90d 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -86,6 +86,7 @@ export const ACHIEVEMENT_TYPES = [ 'cookieClicked', 'brainDiver', 'smashTestNotificationButton', + 'tutorialCompleted', ] as const; @Injectable() diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 9fcb1b22bb..df21be21b7 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -47,6 +47,7 @@ export class AnnouncementService { const q = this.announcementsRepository.createQueryBuilder('announcement') .where('announcement.isActive = true') + .andWhere('announcement.silence = false') .andWhere(new Brackets(qb => { qb.orWhere('announcement.userId = :userId', { userId: user.id }); qb.orWhere('announcement.userId IS NULL'); @@ -73,6 +74,7 @@ export class AnnouncementService { icon: values.icon, display: values.display, forExistingUsers: values.forExistingUsers, + silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, userId: values.userId, }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); @@ -126,6 +128,7 @@ export class AnnouncementService { display: values.display, icon: values.icon, forExistingUsers: values.forExistingUsers, + silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, isActive: values.isActive, }); @@ -212,6 +215,7 @@ export class AnnouncementService { icon: announcement.icon, display: announcement.display, needConfirmationToRead: announcement.needConfirmationToRead, + silence: announcement.silence, forYou: announcement.userId === me?.id, isRead: reads.some(read => read.announcementId === announcement.id), })); diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts new file mode 100644 index 0000000000..d50bcf9480 --- /dev/null +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { IsNull } from 'typeorm'; +import type { AvatarDecorationsRepository, InstancesRepository, UsersRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { MemorySingleCache } from '@/misc/cache.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; +import type { Config } from '@/config.js'; + +@Injectable() +export class AvatarDecorationService implements OnApplicationShutdown { + public cache: MemorySingleCache; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.avatarDecorationsRepository) + private avatarDecorationsRepository: AvatarDecorationsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private idService: IdService, + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + private httpRequestService: HttpRequestService, + ) { + this.cache = new MemorySingleCache(1000 * 60 * 30); + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'avatarDecorationCreated': + case 'avatarDecorationUpdated': + case 'avatarDecorationDeleted': { + this.cache.delete(); + break; + } + default: + break; + } + } + } + + @bindThis + public async create(options: Partial, moderator?: MiUser): Promise { + const created = await this.avatarDecorationsRepository.insert({ + id: this.idService.gen(), + ...options, + }).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0])); + + this.globalEventService.publishInternalEvent('avatarDecorationCreated', created); + + if (moderator) { + this.moderationLogService.log(moderator, 'createAvatarDecoration', { + avatarDecorationId: created.id, + avatarDecoration: created, + }); + } + + return created; + } + + @bindThis + public async update(id: MiAvatarDecoration['id'], params: Partial, moderator?: MiUser): Promise { + const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); + + const date = new Date(); + await this.avatarDecorationsRepository.update(avatarDecoration.id, { + updatedAt: date, + ...params, + }); + + const updated = await this.avatarDecorationsRepository.findOneByOrFail({ id: avatarDecoration.id }); + this.globalEventService.publishInternalEvent('avatarDecorationUpdated', updated); + + if (moderator) { + this.moderationLogService.log(moderator, 'updateAvatarDecoration', { + avatarDecorationId: avatarDecoration.id, + before: avatarDecoration, + after: updated, + }); + } + } + + @bindThis + private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { + return appendQuery( + `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, + query({ + url, + ...(mode ? { [mode]: '1' } : {}), + }), + ); + } + + @bindThis + public async remoteUserUpdate(user: MiUser) { + const userHost = user.host ?? ''; + const instance = await this.instancesRepository.findOneBy({ host: userHost }); + const userHostUrl = `https://${user.host}`; + const showUserApiUrl = `${userHostUrl}/api/users/show`; + + if (instance?.softwareName !== 'misskey' && instance?.softwareName !== 'cherrypick') { + return; + } + + const res = await this.httpRequestService.send(showUserApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'username': user.username }), + }); + + const userData: any = await res.json(); + const avatarDecorations = userData.avatarDecorations?.[0]; + + if (!avatarDecorations) { + const updates = {} as Partial; + updates.avatarDecorations = []; + await this.usersRepository.update({ id: user.id }, updates); + return; + } + + const avatarDecorationId = avatarDecorations.id; + const instanceHost = instance.host; + const decorationApiUrl = `https://${instanceHost}/api/get-avatar-decorations`; + const allRes = await this.httpRequestService.send(decorationApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const allDecorations: any = await allRes.json(); + let name; + let description; + for (const decoration of allDecorations) { + if (decoration.id == avatarDecorationId) { + name = decoration.name; + description = decoration.description; + break; + } + } + const existingDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId, + }); + const decorationData = { + name: name, + description: description, + url: this.getProxiedUrl(avatarDecorations.url, 'static'), + remoteId: avatarDecorationId, + host: userHost, + }; + if (existingDecoration == null) { + await this.create(decorationData); + } else { + await this.update(existingDecoration.id, decorationData); + } + const findDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId, + }); + const updates = {} as Partial; + updates.avatarDecorations = [{ + id: findDecoration?.id ?? '', + angle: avatarDecorations.angle ?? 0, + flipH: avatarDecorations.flipH ?? false, + }]; + await this.usersRepository.update({ id: user.id }, updates); + } + + @bindThis + public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise { + const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); + + await this.avatarDecorationsRepository.delete({ id: avatarDecoration.id }); + this.globalEventService.publishInternalEvent('avatarDecorationDeleted', avatarDecoration); + + if (moderator) { + this.moderationLogService.log(moderator, 'deleteAvatarDecoration', { + avatarDecorationId: avatarDecoration.id, + avatarDecoration: avatarDecoration, + }); + } + } + + @bindThis + public async getAll(noCache = false, withRemote = false): Promise { + if (noCache) { + this.cache.delete(); + } + if (!withRemote) { + return this.cache.fetch(() => this.avatarDecorationsRepository.find({ where: { host: IsNull() } })); + } else { + return this.cache.fetch(() => this.avatarDecorationsRepository.find()); + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index f30f636530..82fa1ddcc2 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -28,7 +28,6 @@ export class CacheService implements OnApplicationShutdown { public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache>; public userFollowingsCache: RedisKVCache | undefined>>; - public userFollowingChannelsCache: RedisKVCache>; constructor( @Inject(DI.redis) @@ -55,9 +54,6 @@ export class CacheService implements OnApplicationShutdown { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private userEntityService: UserEntityService, ) { //this.onMessage = this.onMessage.bind(this); @@ -152,13 +148,7 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => JSON.parse(value), }); - this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { - lifetime: 1000 * 60 * 30, // 30m - memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), - }); + // NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている this.flashAccessTokensCache = new RedisKVCache(this.redisClient, 'flashAccessTokens', { lifetime: 1000 * 60 * 30, // 30m @@ -230,7 +220,6 @@ export class CacheService implements OnApplicationShutdown { this.userBlockedCache.dispose(); this.renoteMutingsCache.dispose(); this.userFollowingsCache.dispose(); - this.userFollowingChannelsCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts new file mode 100644 index 0000000000..75843b9773 --- /dev/null +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { ChannelFollowingsRepository } from '@/models/_.js'; +import { MiChannel } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import type { MiLocalUser } from '@/models/User.js'; +import { RedisKVCache } from '@/misc/cache.js'; + +@Injectable() +export class ChannelFollowingService implements OnModuleInit { + public userFollowingChannelsCache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.channelFollowingsRepository.find({ + where: { followerId: key }, + select: ['followeeId'], + }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); + } + + onModuleInit() { + } + + @bindThis + public async follow( + requestUser: MiLocalUser, + targetChannel: MiChannel, + ): Promise { + await this.channelFollowingsRepository.insert({ + id: this.idService.gen(), + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + + this.globalEventService.publishInternalEvent('followChannel', { + userId: requestUser.id, + channelId: targetChannel.id, + }); + } + + @bindThis + public async unfollow( + requestUser: MiLocalUser, + targetChannel: MiChannel, + ): Promise { + await this.channelFollowingsRepository.delete({ + followerId: requestUser.id, + followeeId: targetChannel.id, + }); + + this.globalEventService.publishInternalEvent('unfollowChannel', { + userId: requestUser.id, + channelId: targetChannel.id, + }); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'followChannel': { + this.userFollowingChannelsCache.refresh(body.userId); + break; + } + case 'unfollowChannel': { + this.userFollowingChannelsCache.delete(body.userId); + break; + } + } + } + } + + @bindThis + public dispose(): void { + this.userFollowingChannelsCache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 9b2b2b7403..ea32ee2131 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -11,6 +11,7 @@ import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; +import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; @@ -32,6 +33,7 @@ import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; +import { NoteUpdateService } from './NoteUpdateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; @@ -63,6 +65,8 @@ import { SearchService } from './SearchService.js'; import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FunoutTimelineService } from './FunoutTimelineService.js'; +import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { RegistryApiService } from './RegistryApiService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; import NotesChart from './chart/charts/notes.js'; @@ -145,6 +149,7 @@ const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExis const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; +const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; @@ -166,6 +171,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; @@ -197,6 +203,8 @@ const $SearchService: Provider = { provide: 'SearchService', useExisting: Search const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FunoutTimelineService: Provider = { provide: 'FunoutTimelineService', useExisting: FunoutTimelineService }; +const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -283,6 +291,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv AntennaService, AppLockService, AchievementService, + AvatarDecorationService, CaptchaService, CreateSystemUserService, CustomEmojiService, @@ -304,6 +313,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -335,6 +345,8 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv ClipService, FeaturedService, FunoutTimelineService, + ChannelFollowingService, + RegistryApiService, ChartLoggerService, FederationChart, NotesChart, @@ -414,6 +426,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $AntennaService, $AppLockService, $AchievementService, + $AvatarDecorationService, $CaptchaService, $CreateSystemUserService, $CustomEmojiService, @@ -435,6 +448,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -466,6 +480,8 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $ClipService, $FeaturedService, $FunoutTimelineService, + $ChannelFollowingService, + $RegistryApiService, $ChartLoggerService, $FederationChart, $NotesChart, @@ -546,6 +562,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv AntennaService, AppLockService, AchievementService, + AvatarDecorationService, CaptchaService, CreateSystemUserService, CustomEmojiService, @@ -567,6 +584,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -598,6 +616,8 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv ClipService, FeaturedService, FunoutTimelineService, + ChannelFollowingService, + RegistryApiService, FederationChart, NotesChart, UsersChart, @@ -676,6 +696,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $AntennaService, $AppLockService, $AchievementService, + $AvatarDecorationService, $CaptchaService, $CreateSystemUserService, $CustomEmojiService, @@ -697,6 +718,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -728,6 +750,8 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $ClipService, $FeaturedService, $FunoutTimelineService, + $ChannelFollowingService, + $RegistryApiService, $FederationChart, $NotesChart, $UsersChart, diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index 5b9a412ddd..51715212bf 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -52,7 +52,7 @@ export class FeaturedService { `${name}:${currentWindow}`, 0, threshold, 'REV', 'WITHSCORES'); redisPipeline.zrange( `${name}:${previousWindow}`, 0, threshold, 'REV', 'WITHSCORES'); - const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => r[1] as string[]) : [[], []]); + const [currentRankingResult, previousRankingResult] = await redisPipeline.exec().then(result => result ? result.map(r => (r[1] ?? []) as string[]) : [[], []]); const ranking = new Map(); for (let i = 0; i < currentRankingResult.length; i += 2) { diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index e00e2962a0..01a542c2cb 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { MiSignin } from '@/models/Signin.js'; import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -82,7 +82,13 @@ export interface MainEventTypes { unreadAntenna: MiAntenna; readAllAnnouncements: undefined; myTokenRegenerated: undefined; - signin: MiSignin; + signin: { + id: MiSignin['id']; + createdAt: string; + ip: string; + headers: Record; + success: boolean; + }; registryUpdated: { scope?: string[]; key: string; @@ -115,7 +121,7 @@ export interface NoteEventTypes { }; updated: { cw: string | null; - text: string; + text: string | null; }; reacted: { reaction: string; @@ -219,6 +225,9 @@ export interface InternalEventTypes { antennaCreated: MiAntenna; antennaDeleted: MiAntenna; antennaUpdated: MiAntenna; + avatarDecorationCreated: MiAvatarDecoration; + avatarDecorationDeleted: MiAvatarDecoration; + avatarDecorationUpdated: MiAvatarDecoration; metaUpdated: MiMeta; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 2149913be9..82430b7687 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -57,6 +57,7 @@ import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -100,17 +101,14 @@ class NotificationManager { } @bindThis - public async deliver() { + public async notify() { for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await this.mutingsRepository.findBy({ - muterId: x.target, - }); - - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); - - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + if (x.reason === 'renote') { + this.notificationService.createNotification(x.target, 'renote', { + noteId: this.note.id, + targetNoteId: this.note.renoteId!, + }, this.notifier.id); + } else { this.notificationService.createNotification(x.target, x.reason, { noteId: this.note.id, }, this.notifier.id); @@ -128,6 +126,7 @@ type MinimumUser = { type Option = { createdAt?: Date | null; + updatedAt?: Date | null; name?: string | null; text?: string | null; reply?: MiNote | null; @@ -219,6 +218,7 @@ export class NoteCreateService implements OnApplicationShutdown { private activeUsersChart: ActiveUsersChart, private instanceChart: InstanceChart, private utilityService: UtilityService, + private userBlockingService: UserBlockingService, ) { } @bindThis @@ -296,6 +296,18 @@ export class NoteCreateService implements OnApplicationShutdown { } } + // Check blocking + if (data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)) { + if (data.renote.userHost === null) { + if (data.renote.userId !== user.id) { + const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); + if (blocked) { + throw new Error('blocked'); + } + } + } + } + // 返信対象がpublicではないならhomeにする if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { data.visibility = 'home'; @@ -651,7 +663,7 @@ export class NoteCreateService implements OnApplicationShutdown { } } - nm.deliver(); + nm.notify(); //#region AP deliver if (this.userEntityService.isLocalUser(user)) { @@ -848,6 +860,7 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { const meta = await this.metaService.fetch(); + if (!meta.enableFanoutTimeline) return; const r = this.redisForTimelines.pipeline(); @@ -891,7 +904,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (note.visibility === 'followers') { // TODO: 重そうだから何とかしたい Set 使う? - userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId)); + userListMemberships = userListMemberships.filter(x => x.userListUserId === user.id || followings.some(f => f.followerId === x.userListUserId)); } // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index abe8e9ad00..04250894e2 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,6 +24,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; @Injectable() export class NoteDeleteService { @@ -77,8 +78,8 @@ export class NoteDeleteService { if (this.userEntityService.isLocalUser(user) && !note.localOnly) { let renote: MiNote | null = null; - // if deletd note is renote - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + // if deleted note is renote + if (isPureRenote(note)) { renote = await this.notesRepository.findOneBy({ id: note.renoteId, }); diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts new file mode 100644 index 0000000000..418e5bb292 --- /dev/null +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -0,0 +1,297 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from 'node:timers/promises'; +import util from 'util'; +import { In, DataSource } from 'typeorm'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as mfm from 'cherrypick-mfm-js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { RelayService } from '@/core/RelayService.js'; +import { DI } from '@/di-symbols.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { bindThis } from '@/decorators.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { SearchService } from '@/core/SearchService.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { MiDriveFile } from '@/models/_.js'; +import { MiPoll, IPoll } from '@/models/Poll.js'; +import { concat } from '@/misc/prelude/array.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + updatedAt?: Date | null; + files?: MiDriveFile[] | null; + name?: string | null; + text?: string | null; + cw?: string | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + poll?: IPoll | null; +}; + +@Injectable() +export class NoteUpdateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + ) { } + + @bindThis + public async update(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, data: Option, note: MiNote, silent = false): Promise { + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + + // Parse MFM if needed + if (!tags || !emojis) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + const updatedNote = await this.updateNote(user, note, data, tags, emojis); + + if (updatedNote) { + setImmediate('post updated', { signal: this.#shutdownController.signal }).then( + () => this.postNoteUpdated(updatedNote, user, silent), + () => { /* aborted, ignore this */ }, + ); + } + + return updatedNote; + } + + @bindThis + private async updateNote(user: { + id: MiUser['id']; host: MiUser['host']; + }, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise { + const updatedAtHistory = note.updatedAtHistory ? note.updatedAtHistory : []; + + const values = new MiNote({ + updatedAt: data.updatedAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + updatedAtHistory: [...updatedAtHistory, new Date()], + noteEditHistory: [...note.noteEditHistory, (note.cw ? note.cw + '\n' : '') + note.text!], + }); + + // 投稿を更新 + try { + if (note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id }); + if (old_poll!.choices.toString() !== data.poll!.choices.toString() || old_poll!.multiple !== data.poll!.multiple) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + await transactionalEntityManager.insert(MiPoll, poll); + } + } + }); + } else if (!note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(MiPoll, poll); + } + }); + } else if (note.hasPoll && !values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (!values.hasPoll) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + } + }); + } else { + await this.notesRepository.update({ id: note.id }, values); + } + + return await this.notesRepository.findOneBy({ id: note.id }); + } catch (e) { + console.error(e); + + throw e; + } + } + + @bindThis + private async postNoteUpdated(note: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, silent: boolean) { + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + this.globalEventService.publishNoteStream(note.id, 'updated', { cw: note.cw, text: note.text }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + await (async () => { + // @ts-ignore + const noteActivity = await this.renderNoteActivity(note, user); + + await this.deliverToConcerned(user, note, noteActivity); + })(); + } + //#endregion + } + + // Register to search database + this.reIndex(note); + } + + @bindThis + private async renderNoteActivity(note: MiNote, user: MiUser) { + const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user); + + return this.apRendererService.addContext(content); + } + + @bindThis + private async getMentionedRemoteUsers(note: MiNote) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as MiRemoteUser[]; + } + + @bindThis + private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { + console.log('deliverToConcerned', util.inspect(content, { depth: null })); + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + await this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } + + @bindThis + private reIndex(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.unindexNote(note); + this.searchService.indexNote(note); + } + + @bindThis + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 4546e3c0e9..41a9ce2d6e 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -19,6 +19,7 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { UserListService } from '@/core/UserListService.js'; +import type { FilterUnionByProperty } from '@/types.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { @@ -73,10 +74,10 @@ export class NotificationService implements OnApplicationShutdown { } @bindThis - public async createNotification( + public async createNotification( notifieeId: MiUser['id'], - type: MiNotification['type'], - data: Omit, 'notifierId'>, + type: T, + data: Omit, 'type' | 'id' | 'createdAt' | 'notifierId'>, notifierId?: MiUser['id'] | null, ): Promise { const profile = await this.cacheService.userProfileCache.fetch(notifieeId); @@ -128,9 +129,11 @@ export class NotificationService implements OnApplicationShutdown { id: this.idService.gen(), createdAt: new Date(), type: type, - notifierId: notifierId, + ...(notifierId ? { + notifierId, + } : {}), ...data, - } as MiNotification; + } as any as FilterUnionByProperty; const redisIdPromise = this.redisClient.xadd( `notificationTimeline:${notifieeId}`, diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 3b599a96da..deefb9adfe 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -40,7 +40,7 @@ export class QueryService { ) { } - public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder { + public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder { if (sinceId && untilId) { q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); diff --git a/packages/backend/src/core/RegistryApiService.ts b/packages/backend/src/core/RegistryApiService.ts new file mode 100644 index 0000000000..98fafb4e24 --- /dev/null +++ b/packages/backend/src/core/RegistryApiService.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiRegistryItem, RegistryItemsRepository } from '@/models/_.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { MiUser } from '@/models/User.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class RegistryApiService { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + } + + @bindThis + public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) { + // TODO: 作成できるキーの数を制限する + + const query = this.registryItemsRepository.createQueryBuilder('item'); + if (domain) { + query.where('item.domain = :domain', { domain: domain }); + } else { + query.where('item.domain IS NULL'); + } + query.andWhere('item.userId = :userId', { userId: userId }); + query.andWhere('item.key = :key', { key: key }); + query.andWhere('item.scope = :scope', { scope: scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await this.registryItemsRepository.update(existingItem.id, { + updatedAt: new Date(), + value: value, + }); + } else { + await this.registryItemsRepository.insert({ + id: this.idService.gen(), + updatedAt: new Date(), + userId: userId, + domain: domain, + scope: scope, + key: key, + value: value, + }); + } + + if (domain == null) { + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + this.globalEventService.publishMainStream(userId, 'registryUpdated', { + scope: scope, + key: key, + value: value, + }); + } + } + + @bindThis + public async getItem(userId: MiUser['id'], domain: string | null, scope: string[], key: string): Promise { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }) + .andWhere('item.userId = :userId', { userId: userId }) + .andWhere('item.key = :key', { key: key }) + .andWhere('item.scope = :scope', { scope: scope }); + + const item = await query.getOne(); + + return item; + } + + @bindThis + public async getAllItemsOfScope(userId: MiUser['id'], domain: string | null, scope: string[]): Promise { + const query = this.registryItemsRepository.createQueryBuilder('item'); + query.where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }); + query.andWhere('item.userId = :userId', { userId: userId }); + query.andWhere('item.scope = :scope', { scope: scope }); + + const items = await query.getMany(); + + return items; + } + + @bindThis + public async getAllKeysOfScope(userId: MiUser['id'], domain: string | null, scope: string[]): Promise { + const query = this.registryItemsRepository.createQueryBuilder('item'); + query.select('item.key'); + query.where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }); + query.andWhere('item.userId = :userId', { userId: userId }); + query.andWhere('item.scope = :scope', { scope: scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); + } + + @bindThis + public async getAllScopeAndDomains(userId: MiUser['id']): Promise<{ domain: string | null; scopes: string[][] }[]> { + const query = this.registryItemsRepository.createQueryBuilder('item') + .select(['item.scope', 'item.domain']) + .where('item.userId = :userId', { userId: userId }); + + const items = await query.getMany(); + + const res = [] as { domain: string | null; scopes: string[][] }[]; + + for (const item of items) { + const target = res.find(x => x.domain === item.domain); + if (target) { + if (target.scopes.some(scope => scope.join('.') === item.scope.join('.'))) continue; + target.scopes.push(item.scope); + } else { + res.push({ + domain: item.domain, + scopes: [item.scope], + }); + } + } + + return res; + } + + @bindThis + public async remove(userId: MiUser['id'], domain: string | null, scope: string[], key: string) { + const query = this.registryItemsRepository.createQueryBuilder().delete(); + if (domain) { + query.where('domain = :domain', { domain: domain }); + } else { + query.where('domain IS NULL'); + } + query.andWhere('userId = :userId', { userId: userId }); + query.andWhere('key = :key', { key: key }); + query.andWhere('scope = :scope', { scope: scope }); + + await query.execute(); + } +} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 6424a9f17b..289e04aa05 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -34,6 +34,7 @@ export type RolePolicies = { inviteLimitCycle: number; inviteExpirationTime: number; canManageCustomEmojis: boolean; + canManageAvatarDecorations: boolean; canSearchNotes: boolean; canUseTranslator: boolean; canHideAds: boolean; @@ -61,6 +62,7 @@ export const DEFAULT_POLICIES: RolePolicies = { inviteLimitCycle: 60 * 24 * 7, inviteExpirationTime: 0, canManageCustomEmojis: false, + canManageAvatarDecorations: false, canSearchNotes: false, canUseTranslator: true, canHideAds: false, @@ -231,6 +233,12 @@ export class RoleService implements OnApplicationShutdown { } } + @bindThis + public async getRoles() { + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); + return roles; + } + @bindThis public async getUserAssigns(userId: MiUser['id']) { const now = Date.now(); @@ -306,6 +314,7 @@ export class RoleService implements OnApplicationShutdown { inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)), inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index cd0620528c..92204517d8 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -509,7 +509,6 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { - followRequestId: followRequest.id, }, follower.id); } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 598c3b6f57..de03f1fd1e 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -14,6 +14,7 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; @@ -78,6 +79,7 @@ export class ApInboxService { private notePiningService: NotePiningService, private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private noteDeleteService: NoteDeleteService, private appLockService: AppLockService, private apResolverService: ApResolverService, @@ -774,11 +776,13 @@ export class ApInboxService { @bindThis private async update(actor: MiRemoteUser, activity: IUpdate): Promise { + const uri = getApId(activity); + if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } - this.logger.debug('Update'); + this.logger.debug(`Update: ${uri}`); const resolver = this.apResolverService.createResolver(); @@ -793,11 +797,48 @@ export class ApInboxService { } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); return 'ok: Question updated'; + } else if (getApType(object) === 'Note') { + await this.updateNote(resolver, actor, object, false, activity); + return 'ok: Note updated'; } else { return `skip: Unknown type: ${getApType(object)}`; } } + @bindThis + private async updateNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: IUpdate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return 'skip: actor.uri !== note.attributedTo'; + } + + if (typeof note.id === 'string') { + if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { + return 'skip: host in actor.uri !== note.id'; + } + } + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + const target = await this.notesRepository.findOneBy({ uri: uri }); + if (!target) return `skip: target note not located: ${uri}`; + await this.apNoteService.updateNote(note, target, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + @bindThis private async move(actor: MiRemoteUser, activity: IMove): Promise { // fetch the new and old accounts diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index b149605070..f9e5f7dda5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -112,6 +112,7 @@ export class ApRendererService { actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, object, @@ -460,6 +461,7 @@ export class ApRendererService { _misskey_quote: quote, quoteUrl: quote, published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, inReplyTo, @@ -517,6 +519,7 @@ export class ApRendererService { preferredUsername: user.username, name: user.name, summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, + _misskey_summary: profile.description, icon: avatar ? this.renderImage(avatar) : null, image: banner ? this.renderImage(banner) : null, tag, @@ -675,6 +678,7 @@ export class ApRendererService { '_misskey_quote': 'misskey:_misskey_quote', '_misskey_reaction': 'misskey:_misskey_reaction', '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_summary': 'misskey:_misskey_summary', '_misskey_talk': 'misskey:_misskey_talk', 'isCat': 'misskey:isCat', // vcard diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 01308dfb0c..36bb9bb33c 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -7,7 +7,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/_.js'; +import type { EmojisRepository, MessagingMessagesRepository, NotesRepository, PollsRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -25,7 +25,8 @@ import { UtilityService } from '@/core/UtilityService.js'; import { MessagingService } from '@/core/MessagingService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; -import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; +import { getApId, getApType, getOneApHrefNullable, getOneApId, isEmoji, validPost } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; import { ApDbResolverService } from '../ApDbResolverService.js'; @@ -57,6 +58,9 @@ export class ApNoteService { @Inject(DI.messagingMessagesRepository) private messagingMessagesRepository: MessagingMessagesRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private apMfmService: ApMfmService, private apResolverService: ApResolverService, @@ -76,6 +80,7 @@ export class ApNoteService { private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -302,6 +307,7 @@ export class ApNoteService { try { return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, + updatedAt: note.updated ? new Date(note.updated) : null, files, reply, renote: quote, @@ -333,6 +339,85 @@ export class ApNoteService { } } + @bindThis + public async updateNote(value: string | IObject, target: MiNote, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); + const entryUri = getApId(value); + + const err = this.validateNote(object, entryUri); + if (err) { + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, + }); + throw new Error('invalid note'); + } + + const note = object as IPost; + + // 投稿者をフェッチ + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const limit = promiseLimit(2); + const files = (await Promise.all(toArray(note.attachment).map(attach => ( + limit(() => this.apImageService.resolveImage(actor, { + ...attach, + sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする + })) + )))); + + const cw = note.summary === '' ? null : note.summary; + + // テキストのパース + let text: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { + text = note.source.content; + } else if (typeof note._misskey_content !== 'undefined') { + text = note._misskey_content; + } else if (typeof note.content === 'string') { + text = this.apMfmService.htmlToMfm(note.content, note.tag); + } + + const apHashtags = extractApHashtags(note.tag); + + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return []; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + try { + return await this.noteUpdateService.update(actor, { + updatedAt: note.updated ? new Date(note.updated) : null, + files, + name: note.name, + cw, + text, + apHashtags, + apEmojis, + poll, + }, target, silent); + } catch (err: any) { + this.logger.warn(`note update failed: ${err}`); + return err; + } + } + /** * Noteを解決します。 * diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 07a66ed19d..a80110c57b 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -38,6 +38,7 @@ import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -100,6 +101,8 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + + private avatarDecorationService: AvatarDecorationService, ) { } @@ -284,7 +287,7 @@ export class ApPersonService implements OnModuleInit { const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); - const isBot = getApType(object) === 'Service'; + const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); @@ -403,9 +406,17 @@ export class ApPersonService implements OnModuleInit { emojis, })) as MiRemoteUser; + let _description: string | null = null; + + if (person._misskey_summary) { + _description = truncate(person._misskey_summary, summaryLength); + } else if (person.summary) { + _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); + } + await transactionalEntityManager.save(new MiUserProfile({ userId: user.id, - description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + description: _description, url, fields, birthday: bday?.[0] ?? null, @@ -454,6 +465,8 @@ export class ApPersonService implements OnModuleInit { // ハッシュタグ更新 this.hashtagService.updateUsertags(user, tags); + this.avatarDecorationService.remoteUserUpdate(user); + //#region アバターとヘッダー画像をフェッチ try { const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image); @@ -601,7 +614,7 @@ export class ApPersonService implements OnModuleInit { emojis: emojiNames, name: truncate(person.name, nameLength), tags, - isBot: getApType(object) === 'Service', + isBot: getApType(object) === 'Service' || getApType(object) === 'Application', isCat: (person as any).isCat === true, isLocked: person.manuallyApprovesFollowers, movedToUri: person.movedTo ?? null, @@ -631,6 +644,8 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user + const user = await this.usersRepository.findOneByOrFail({ id: exist.id }); + await this.avatarDecorationService.remoteUserUpdate(user); await this.usersRepository.update(exist.id, updates); if (person.publicKey) { @@ -640,10 +655,18 @@ export class ApPersonService implements OnModuleInit { }); } + let _description: string | null = null; + + if (person._misskey_summary) { + _description = truncate(person._misskey_summary, summaryLength); + } else if (person.summary) { + _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); + } + await this.userProfilesRepository.update({ userId: exist.id }, { url, fields, - description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + description: _description, birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, }); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index eda7bfc142..fe8c2aa36a 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -12,7 +12,9 @@ export interface IObject { id?: string; name?: string | null; summary?: string; + _misskey_summary?: string; published?: string; + updated?: string; cc?: ApObject; to?: ApObject; attributedTo?: ApObject; diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 9a89f78fa9..4ffd38dc69 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/_.js'; +import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, 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'; @@ -31,9 +31,6 @@ export class ChannelEntityService { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -54,13 +51,6 @@ export class ChannelEntityService { const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; - const hasUnreadNote = meId ? await this.noteUnreadsRepository.exist({ - where: { - noteChannelId: channel.id, - userId: meId, - }, - }) : undefined; - const isFollowing = meId ? await this.channelFollowingsRepository.exist({ where: { followerId: meId, @@ -95,11 +85,12 @@ export class ChannelEntityService { usersCount: channel.usersCount, notesCount: channel.notesCount, isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, ...(me ? { isFollowing, isFavorited, - hasUnreadNote, + hasUnreadNote: false, // 後方互換性のため } : {}), ...(detailed ? { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index bc2cb086d9..3bb5936818 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -47,6 +47,7 @@ export class InstanceEntityService { faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, + latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, }; } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 52c5988cb7..fba1b098ed 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -369,6 +369,7 @@ export class NoteEntityService implements OnModuleInit { name: channel.name, color: channel.color, isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, } : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 2b3246e52b..fc45d67b5d 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -7,14 +7,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, FollowRequestsRepository, NotesRepository, MiUser, UsersRepository, UserGroupInvitationsRepository } from '@/models/_.js'; +import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository, UserGroupInvitationsRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiNotification } from '@/models/Notification.js'; +import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; -import { notificationTypes } from '@/types.js'; +import { FilterUnionByProperty, notificationTypes } from '@/types.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; @@ -22,6 +22,7 @@ import type { NoteEntityService } from './NoteEntityService.js'; import type { UserGroupInvitationEntityService } from './UserGroupInvitationEntityService.js'; const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); +const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']); @Injectable() export class NotificationEntityService implements OnModuleInit { @@ -42,9 +43,6 @@ export class NotificationEntityService implements OnModuleInit { @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, - @Inject(DI.accessTokensRepository) - private accessTokensRepository: AccessTokensRepository, - @Inject(DI.userGroupInvitationsRepository) private userGroupInvitationsRepository: UserGroupInvitationsRepository, @@ -76,18 +74,17 @@ export class NotificationEntityService implements OnModuleInit { }, ): Promise> { const notification = src; - const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; - const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( + const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( hint?.packedNotes != null ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId!, { id: meId }, { + : this.noteEntityService.pack(notification.noteId, { id: meId }, { detail: true, }) ) : undefined; - const userIfNeed = notification.notifierId != null ? ( + const userIfNeed = 'notifierId' in notification ? ( hint?.packedUsers != null ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + : this.userEntityService.pack(notification.notifierId, { id: meId }, { detail: false, }) ) : undefined; @@ -96,7 +93,7 @@ export class NotificationEntityService implements OnModuleInit { id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - userId: notification.notifierId, + userId: 'notifierId' in notification ? notification.notifierId : undefined, ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { @@ -116,8 +113,8 @@ export class NotificationEntityService implements OnModuleInit { } : {}), ...(notification.type === 'app' ? { body: notification.customBody, - header: notification.customHeader ?? token?.name, - icon: notification.customIcon ?? token?.iconUrl, + header: notification.customHeader, + icon: notification.customIcon, } : {}), }); } @@ -131,7 +128,7 @@ export class NotificationEntityService implements OnModuleInit { let validNotifications = notifications; - const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], @@ -141,9 +138,9 @@ export class NotificationEntityService implements OnModuleInit { }); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); + validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); - const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); + const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull); const users = userIds.length > 0 ? await this.usersRepository.find({ where: { id: In(userIds) }, }) : []; @@ -153,10 +150,10 @@ export class NotificationEntityService implements OnModuleInit { const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest'); + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); if (followRequestNotifications.length > 0) { const reqs = await this.followRequestsRepository.find({ - where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) }, + where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, }); validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); } @@ -174,4 +171,152 @@ export class NotificationEntityService implements OnModuleInit { packedUsers, }))); } + + @bindThis + public async packGrouped( + src: MiGroupedNotification, + meId: MiUser['id'], + // eslint-disable-next-line @typescript-eslint/ban-types + options: { + + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; + }, + ): Promise> { + const notification = src; + const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId, { id: meId }, { + detail: true, + }) + ) : undefined; + const userIfNeed = 'notifierId' in notification ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId, { id: meId }, { + detail: false, + }) + ) : undefined; + + if (notification.type === 'reaction:grouped') { + const reactions = await Promise.all(notification.reactions.map(async reaction => { + const user = hint?.packedUsers != null + ? hint.packedUsers.get(reaction.userId)! + : await this.userEntityService.pack(reaction.userId, { id: meId }, { + detail: false, + }); + return { + user, + reaction: reaction.reaction, + }; + })); + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + note: noteIfNeed, + reactions, + }); + } else if (notification.type === 'renote:grouped') { + const users = await Promise.all(notification.userIds.map(userId => { + const user = hint?.packedUsers != null + ? hint.packedUsers.get(userId) + : this.userEntityService.pack(userId!, { id: meId }, { + detail: false, + }); + return user; + })); + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + note: noteIfNeed, + users, + }); + } + + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + userId: 'notifierId' in notification ? notification.notifierId : undefined, + ...(userIfNeed != null ? { user: userIfNeed } : {}), + ...(noteIfNeed != null ? { note: noteIfNeed } : {}), + ...(notification.type === 'reaction' ? { + reaction: notification.reaction, + } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), + } : {}), + ...(notification.type === 'achievementEarned' ? { + achievement: notification.achievement, + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader, + icon: notification.customIcon, + } : {}), + }); + } + + @bindThis + public async packGroupedMany( + notifications: MiGroupedNotification[], + meId: MiUser['id'], + ) { + if (notifications.length === 0) return []; + + let validNotifications = notifications; + + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); + const notes = noteIds.length > 0 ? await this.notesRepository.find({ + where: { id: In(noteIds) }, + relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], + }) : []; + const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { + detail: true, + }); + const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); + + validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); + + const userIds = []; + for (const notification of validNotifications) { + if ('notifierId' in notification) userIds.push(notification.notifierId); + if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId)); + if (notification.type === 'renote:grouped') userIds.push(...notification.userIds); + } + const users = userIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(userIds) }, + }) : []; + const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { + detail: false, + }); + const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + + // 既に解決されたフォローリクエストの通知を除外 + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); + if (followRequestNotifications.length > 0) { + const reqs = await this.followRequestsRepository.find({ + where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); + } + + const groupInvitedNotifications = validNotifications.filter(x => x.type === 'groupInvited'); + if (groupInvitedNotifications.length > 0) { + const existingInvitationIds = await this.userGroupInvitationsRepository.find({ + where: { id: In(groupInvitedNotifications.map(x => x.userGroupInvitationId!)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'groupInvited') || existingInvitationIds.some(r => r.id === x.userGroupInvitationId)); + } + + return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, { + packedNotes, + packedUsers, + }))); + } } diff --git a/packages/backend/src/core/entities/SigninEntityService.ts b/packages/backend/src/core/entities/SigninEntityService.ts index 50ddd695af..a9f6260777 100644 --- a/packages/backend/src/core/entities/SigninEntityService.ts +++ b/packages/backend/src/core/entities/SigninEntityService.ts @@ -7,10 +7,12 @@ import { Injectable } from '@nestjs/common'; import type { } from '@/models/Blocking.js'; import type { MiSignin } from '@/models/Signin.js'; import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; @Injectable() export class SigninEntityService { constructor( + private idService: IdService, ) { } @@ -18,7 +20,13 @@ export class SigninEntityService { public async pack( src: MiSignin, ) { - return src; + return { + id: src.id, + createdAt: this.idService.parse(src.id).date.toISOString(), + ip: src.ip, + headers: src.headers, + success: src.success, + }; } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 5e34a3abd8..72e4caf7ab 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -22,9 +22,10 @@ import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { IdService } from '@/core/IdService.js'; +import type { AnnouncementService } from '@/core/AnnouncementService.js'; +import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import type { OnModuleInit } from '@nestjs/common'; -import type { AnnouncementService } from '../AnnouncementService.js'; -import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; @@ -63,6 +64,7 @@ export class UserEntityService implements OnModuleInit { private roleService: RoleService; private federatedInstanceService: FederatedInstanceService; private idService: IdService; + private avatarDecorationService: AvatarDecorationService; constructor( private moduleRef: ModuleRef, @@ -133,6 +135,7 @@ export class UserEntityService implements OnModuleInit { this.roleService = this.moduleRef.get('RoleService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); + this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); } //#region Validators @@ -384,8 +387,6 @@ export class UserEntityService implements OnModuleInit { const notificationsInfo = isMe && opts.detail ? await this.getNotificationsInfo(user.id) : null; - const falsy = opts.detail ? false : undefined; - const packed = { id: user.id, name: user.name, @@ -393,6 +394,12 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll(false, true).then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ + id: ud.id, + angle: ud.angle || undefined, + flipH: ud.flipH || undefined, + url: decorations.find(d => d.id === ud.id)!.url, + }))) : [], isBot: user.isBot, isCat: user.isCat, instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { @@ -499,9 +506,9 @@ export class UserEntityService implements OnModuleInit { hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadChannel: false, // 後方互換性のため hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), - hasUnreadNotification: notificationsInfo?.hasUnread, + hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), - unreadNotificationCount: notificationsInfo?.unreadCount, + unreadNotificationsCount: notificationsInfo?.unreadCount, mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, mutingNotificationTypes: [], // 後方互換性のため diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 6fab5440c1..4d1da11d03 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -20,6 +20,7 @@ export const DI = { announcementsRepository: Symbol('announcementsRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), + avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/misc/is-pure-renote.ts b/packages/backend/src/misc/is-pure-renote.ts new file mode 100644 index 0000000000..994d981522 --- /dev/null +++ b/packages/backend/src/misc/is-pure-renote.ts @@ -0,0 +1,10 @@ +import type { MiNote } from '@/models/Note.js'; + +export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable } { + if (!note.renoteId) return false; + + if (note.text) return false; // it's quoted with text + if (note.fileIds.length !== 0) return false; // it's quoted with files + if (note.hasPoll) return false; // it's quoted with poll + return true; +} diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index 9158f10b9d..ddca1e4b69 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -66,6 +66,12 @@ export class MiAnnouncement { }) public forExistingUsers: boolean; + @Index() + @Column('boolean', { + default: false, + }) + public silence: boolean; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts new file mode 100644 index 0000000000..5394cc3dea --- /dev/null +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { id } from './util/id.js'; + +@Entity('avatar_decoration') +export class MiAvatarDecoration { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + }) + public updatedAt: Date | null; + + @Column('varchar', { + length: 1024, + }) + public url: string; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 2048, + }) + public description: string; + + // TODO: 定期ジョブで存在しなくなったロールIDを除去するようにする + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public roleIdsThatCanBeUsedThisDecoration: string[]; + + @Column('varchar', { + length: 32, + }) + public remoteId: string; + + @Column('varchar', { + length: 128, nullable: true, + }) + public host: string | null; +} diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index 7c36a937bf..ea9e993c9a 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -93,4 +93,9 @@ export class MiChannel { default: false, }) public isSensitive: boolean; + + @Column('boolean', { + default: true, + }) + public allowRenoteToExternal: boolean; } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index bd910837ad..e6707a5b1d 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -607,6 +607,11 @@ export class MiMeta { }) public preservedUsernames: string[]; + @Column('boolean', { + default: true, + }) + public enableFanoutTimeline: boolean; + @Column('integer', { default: 300, }) diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index b47ff1c22f..9b2966b17b 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -11,34 +11,79 @@ import { MiUserGroupInvitation } from './UserGroupInvitation.js'; import { MiAccessToken } from './AccessToken.js'; export type MiNotification = { + type: 'note'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'follow'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'mention'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'reply'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'renote'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; + targetNoteId: MiNote['id']; +} | { + type: 'quote'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'reaction'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; + reaction: string; +} | { + type: 'pollEnded'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'receiveFollowRequest'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'followRequestAccepted'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'groupInvited'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + userGroupInvitationId: MiUserGroupInvitation['id']; +} | { + type: 'achievementEarned'; + id: string; + createdAt: string; + achievement: string; +} | { + type: 'app'; id: string; - - // RedisのためDateではなくstring createdAt: string; - - /** - * 通知の送信者(initiator) - */ - notifierId: MiUser['id'] | null; - - /** - * 通知の種類。 - */ - type: typeof notificationTypes[number]; - - noteId: MiNote['id'] | null; - - followRequestId: MiFollowRequest['id'] | null; - - reaction: string | null; - - userGroupInvitationId: MiUserGroupInvitation['id'] | null; - - userGroupInvitation: MiUserGroupInvitation | null; - - choice: number | null; - - achievement: string | null; /** * アプリ通知のbody @@ -61,4 +106,25 @@ export type MiNotification = { * アプリ通知のアプリ(のトークン) */ appAccessTokenId: MiAccessToken['id'] | null; -} +} | { + type: 'test'; + id: string; + createdAt: string; +}; + +export type MiGroupedNotification = MiNotification | { + type: 'reaction:grouped'; + id: string; + createdAt: string; + noteId: MiNote['id']; + reactions: { + userId: string; + reaction: string; + }[]; +} | { + type: 'renote:grouped'; + id: string; + createdAt: string; + noteId: MiNote['id']; + userIds: string[]; +}; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 8bff6e9069..94b19959dd 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseReportResolver, MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiEvent, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMessagingMessage, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserGroup, MiUserGroupJoining, MiUserGroupInvitation, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; +import { MiAbuseReportResolver, MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiEvent, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMessagingMessage, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserGroup, MiUserGroupJoining, MiUserGroupInvitation, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './_.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -39,6 +39,12 @@ const $appsRepository: Provider = { inject: [DI.db], }; +const $avatarDecorationsRepository: Provider = { + provide: DI.avatarDecorationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), @@ -438,6 +444,7 @@ const $abuseReportResolversRepository: Provider = { $announcementsRepository, $announcementReadsRepository, $appsRepository, + $avatarDecorationsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -510,6 +517,7 @@ const $abuseReportResolversRepository: Provider = { $announcementsRepository, $announcementReadsRepository, $appsRepository, + $avatarDecorationsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 3bc6baa641..031b24ade7 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -138,6 +138,15 @@ export class MiUser { }) public bannerBlurhash: string | null; + @Column('jsonb', { + default: [], + }) + public avatarDecorations: { + id: string; + angle: number; + flipH: boolean; + }[]; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 39e8310a5f..dc72c8b598 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -11,6 +11,7 @@ import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; @@ -84,6 +85,7 @@ export { MiAnnouncementRead, MiAntenna, MiApp, + MiAvatarDecoration, MiAuthSession, MiBlocking, MiChannelFollowing, @@ -156,6 +158,7 @@ export type AnnouncementsRepository = Repository; export type AnnouncementReadsRepository = Repository; export type AntennasRepository = Repository; export type AppsRepository = Repository; +export type AvatarDecorationsRepository = Repository; export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index e492521f08..2c242e82ca 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -76,5 +76,9 @@ export const packedChannelSchema = { type: 'boolean', optional: false, nullable: false, }, + allowRenoteToExternal: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index 11a9c697f8..6397a092d3 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -103,5 +103,10 @@ export const packedFederationInstanceSchema = { optional: false, nullable: true, format: 'date-time', }, + latestRequestReceivedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index dce9668169..d00ec6b155 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -12,7 +12,6 @@ export const packedNotificationSchema = { type: 'string', optional: false, nullable: false, format: 'id', - example: 'xxxxxxxxxx', }, createdAt: { type: 'string', @@ -22,7 +21,7 @@ export const packedNotificationSchema = { type: { type: 'string', optional: false, nullable: false, - enum: [...notificationTypes], + enum: [...notificationTypes, 'reaction:grouped', 'renote:grouped'], }, user: { type: 'object', @@ -63,5 +62,33 @@ export const packedNotificationSchema = { type: 'string', optional: true, nullable: true, }, + reactions: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'object', + properties: { + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + required: ['user', 'reaction'], + }, + }, + }, + users: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index a5a0cfc301..b21426145e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -37,6 +37,34 @@ export const packedUserLiteSchema = { type: 'string', nullable: true, optional: false, }, + avatarDecorations: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + id: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + url: { + type: 'string', + format: 'url', + nullable: false, optional: false, + }, + angle: { + type: 'number', + nullable: false, optional: true, + }, + flipH: { + type: 'boolean', + nullable: false, optional: true, + }, + }, + }, + }, isAdmin: { type: 'boolean', nullable: false, optional: true, @@ -375,7 +403,7 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, - unreadNotificationCount: { + unreadNotificationsCount: { type: 'number', nullable: false, optional: false, }, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 1b4bedb7da..3d62224485 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -19,6 +19,7 @@ import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; @@ -136,6 +137,7 @@ export const entities = [ MiMeta, MiInstance, MiApp, + MiAvatarDecoration, MiAuthSession, MiAccessToken, MiUser, diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 5c91d3ab85..dfa90515f7 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import * as crypto from 'node:crypto'; import { IncomingMessage } from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; import fastifyAccepts from '@fastify/accepts'; @@ -10,6 +11,7 @@ import httpSignature from '@peertube/http-signature'; import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import accepts from 'accepts'; import vary from 'vary'; +import secureJson from 'secure-json-parse'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; import * as url from '@/misc/prelude/url.js'; @@ -26,7 +28,8 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; -import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; @@ -88,7 +91,7 @@ export class ActivityPubServerService { */ @bindThis private async packActivity(note: MiNote): Promise { - if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + if (isPureRenote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); } @@ -107,7 +110,58 @@ export class ActivityPubServerService { return; } - // TODO: request.bodyのバリデーション? + if (signature.params.headers.indexOf('host') === -1 + || request.headers.host !== this.config.host) { + // Host not specified or not match. + reply.code(401); + return; + } + + if (signature.params.headers.indexOf('digest') === -1) { + // Digest not found. + reply.code(401); + } else { + const digest = request.headers.digest; + + if (typeof digest !== 'string') { + // Huh? + reply.code(401); + return; + } + + const re = /^([a-zA-Z0-9\-]+)=(.+)$/; + const match = digest.match(re); + + if (match == null) { + // Invalid digest + reply.code(401); + return; + } + + const algo = match[1]; + const digestValue = match[2]; + + if (algo !== 'SHA-256') { + // Unsupported digest algorithm + reply.code(401); + return; + } + + if (request.rawBody == null) { + // Bad request + reply.code(400); + return; + } + + const hash = crypto.createHash('sha256').update(request.rawBody).digest('base64'); + + if (hash !== digestValue) { + // Invalid digest + reply.code(401); + return; + } + } + this.queueService.inbox(request.body as IActivity, signature); reply.code(202); @@ -459,9 +513,28 @@ export class ActivityPubServerService { }, }); + const almostDefaultJsonParser: FastifyBodyParser = function (request, rawBody, done) { + if (rawBody.length === 0) { + const err = new Error('Body cannot be empty!') as any; + err.statusCode = 400; + return done(err); + } + + try { + const json = secureJson.parse(rawBody.toString('utf8'), null, { + protoAction: 'ignore', + constructorAction: 'ignore', + }); + done(null, json); + } catch (err: any) { + err.statusCode = 400; + return done(err); + } + }; + fastify.register(fastifyAccepts); - fastify.addContentTypeParser('application/activity+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); - fastify.addContentTypeParser('application/ld+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); + fastify.addContentTypeParser('application/activity+json', { parseAs: 'buffer' }, almostDefaultJsonParser); + fastify.addContentTypeParser('application/ld+json', { parseAs: 'buffer' }, almostDefaultJsonParser); fastify.addHook('onRequest', (request, reply, done) => { reply.header('Access-Control-Allow-Headers', 'Accept'); @@ -473,8 +546,8 @@ export class ActivityPubServerService { //#region Routing // inbox (limit: 64kb) - fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); - fastify.post('/users/:user/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); + fastify.post('/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); + fastify.post('/users/:user/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 4b4b91b402..5e66c98929 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import Fastify, { FastifyInstance } from 'fastify'; import fastifyStatic from '@fastify/static'; +import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; @@ -86,6 +87,13 @@ export class ServerService implements OnApplicationShutdown { }); } + // Register raw-body parser for ActivityPub HTTP signature validation. + await fastify.register(fastifyRawBody, { + global: false, + encoding: null, + runFirst: true, + }); + // Register non-serving static server so that the child services can use reply.sendFile. // `root` here is just a placeholder and each call must use its own `rootPath`. fastify.register(fastifyStatic, { diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 742b12a42c..c65081ee81 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -8,6 +8,10 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; import { ServerStatsService } from '@/daemons/ServerStatsService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseReportResolver_create from './endpoints/admin/abuse-report-resolver/create.js'; +import * as ep___admin_abuseReportResolver_update from './endpoints/admin/abuse-report-resolver/update.js'; +import * as ep___admin_abuseReportResolver_delete from './endpoints/admin/abuse-report-resolver/delete.js'; +import * as ep___admin_abuseReportResolver_list from './endpoints/admin/abuse-report-resolver/list.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; @@ -19,10 +23,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_abuseReportResolver_create from './endpoints/admin/abuse-report-resolver/create.js'; -import * as ep___admin_abuseReportResolver_update from './endpoints/admin/abuse-report-resolver/update.js'; -import * as ep___admin_abuseReportResolver_delete from './endpoints/admin/abuse-report-resolver/delete.js'; -import * as ep___admin_abuseReportResolver_list from './endpoints/admin/abuse-report-resolver/list.js'; +import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; +import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; +import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; +import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; @@ -168,6 +172,7 @@ import * as ep___federation_stats from './endpoints/federation/stats.js'; import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_delete from './endpoints/following/delete.js'; import * as ep___following_update from './endpoints/following/update.js'; +import * as ep___following_update_all from './endpoints/following/update-all.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; @@ -183,6 +188,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -218,6 +224,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; @@ -231,7 +238,7 @@ import * as ep___i_registry_get from './endpoints/i/registry/get.js'; import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopes from './endpoints/i/registry/scopes.js'; +import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; @@ -388,6 +395,10 @@ import { ApiLoggerService } from './ApiLoggerService.js'; import type { Provider } from '@nestjs/common'; const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; +const $admin_abuseReportResolver_create: Provider = { provide: 'ep:admin/abuse-report-resolver/create', useClass: ep___admin_abuseReportResolver_create.default }; +const $admin_abuseReportResolver_update: Provider = { provide: 'ep:admin/abuse-report-resolver/update', useClass: ep___admin_abuseReportResolver_update.default }; +const $admin_abuseReportResolver_list: Provider = { provide: 'ep:admin/abuse-report-resolver/list', useClass: ep___admin_abuseReportResolver_list.default }; +const $admin_abuseReportResolver_delete: Provider = { provide: 'ep:admin/abuse-report-resolver/delete', useClass: ep___admin_abuseReportResolver_delete.default }; const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; @@ -399,10 +410,10 @@ const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; -const $admin_abuseReportResolver_create: Provider = { provide: 'ep:admin/abuse-report-resolver/create', useClass: ep___admin_abuseReportResolver_create.default }; -const $admin_abuseReportResolver_update: Provider = { provide: 'ep:admin/abuse-report-resolver/update', useClass: ep___admin_abuseReportResolver_update.default }; -const $admin_abuseReportResolver_list: Provider = { provide: 'ep:admin/abuse-report-resolver/list', useClass: ep___admin_abuseReportResolver_list.default }; -const $admin_abuseReportResolver_delete: Provider = { provide: 'ep:admin/abuse-report-resolver/delete', useClass: ep___admin_abuseReportResolver_delete.default }; +const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default }; +const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; +const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; +const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; @@ -548,6 +559,7 @@ const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass: const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default }; const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default }; const $following_update: Provider = { provide: 'ep:following/update', useClass: ep___following_update.default }; +const $following_update_all: Provider = { provide: 'ep:following/update-all', useClass: ep___following_update_all.default }; const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default }; const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; @@ -563,6 +575,7 @@ const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useCla const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; +const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default }; const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; @@ -598,6 +611,7 @@ const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep_ const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; +const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; @@ -611,7 +625,7 @@ const $i_registry_get: Provider = { provide: 'ep:i/registry/get', useClass: ep__ const $i_registry_keysWithType: Provider = { provide: 'ep:i/registry/keys-with-type', useClass: ep___i_registry_keysWithType.default }; const $i_registry_keys: Provider = { provide: 'ep:i/registry/keys', useClass: ep___i_registry_keys.default }; const $i_registry_remove: Provider = { provide: 'ep:i/registry/remove', useClass: ep___i_registry_remove.default }; -const $i_registry_scopes: Provider = { provide: 'ep:i/registry/scopes', useClass: ep___i_registry_scopes.default }; +const $i_registry_scopesWithDomain: Provider = { provide: 'ep:i/registry/scopes-with-domain', useClass: ep___i_registry_scopesWithDomain.default }; const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; @@ -773,6 +787,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention ApiLoggerService, ServerStatsService, $admin_meta, + $admin_abuseReportResolver_create, + $admin_abuseReportResolver_delete, + $admin_abuseReportResolver_list, + $admin_abuseReportResolver_update, $admin_abuseUserReports, $admin_accounts_create, $admin_accounts_delete, @@ -784,10 +802,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_announcements_delete, $admin_announcements_list, $admin_announcements_update, - $admin_abuseReportResolver_create, - $admin_abuseReportResolver_delete, - $admin_abuseReportResolver_list, - $admin_abuseReportResolver_update, + $admin_avatarDecorations_create, + $admin_avatarDecorations_delete, + $admin_avatarDecorations_list, + $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, @@ -933,6 +951,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $following_create, $following_delete, $following_update, + $following_update_all, $following_invalidate, $following_requests_accept, $following_requests_cancel, @@ -948,6 +967,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $gallery_posts_unlike, $gallery_posts_update, $getOnlineUsersCount, + $getAvatarDecorations, $hashtags_list, $hashtags_search, $hashtags_show, @@ -983,6 +1003,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importUserLists, $i_importAntennas, $i_notifications, + $i_notificationsGrouped, $i_pageLikes, $i_pages, $i_pin, @@ -996,7 +1017,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_registry_keysWithType, $i_registry_keys, $i_registry_remove, - $i_registry_scopes, + $i_registry_scopesWithDomain, $i_registry_set, $i_revokeToken, $i_signinHistory, @@ -1151,6 +1172,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention ], exports: [ $admin_meta, + $admin_abuseReportResolver_create, + $admin_abuseReportResolver_delete, + $admin_abuseReportResolver_list, + $admin_abuseReportResolver_update, $admin_abuseUserReports, $admin_accounts_create, $admin_accounts_delete, @@ -1162,10 +1187,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_announcements_delete, $admin_announcements_list, $admin_announcements_update, - $admin_abuseReportResolver_create, - $admin_abuseReportResolver_delete, - $admin_abuseReportResolver_list, - $admin_abuseReportResolver_update, + $admin_avatarDecorations_create, + $admin_avatarDecorations_delete, + $admin_avatarDecorations_list, + $admin_avatarDecorations_update, $admin_deleteAllFilesOfAUser, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, @@ -1311,6 +1336,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $following_create, $following_delete, $following_update, + $following_update_all, $following_invalidate, $following_requests_accept, $following_requests_cancel, @@ -1326,6 +1352,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $gallery_posts_unlike, $gallery_posts_update, $getOnlineUsersCount, + $getAvatarDecorations, $hashtags_list, $hashtags_search, $hashtags_show, @@ -1361,6 +1388,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importUserLists, $i_importAntennas, $i_notifications, + $i_notificationsGrouped, $i_pageLikes, $i_pages, $i_pin, @@ -1374,7 +1402,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_registry_keysWithType, $i_registry_keys, $i_registry_remove, - $i_registry_scopes, + $i_registry_scopesWithDomain, $i_registry_set, $i_revokeToken, $i_signinHistory, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index f9a86427a0..f1296da57e 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -136,7 +136,20 @@ export class SignupApiService { return; } - if (ticket.usedAt) { + // メアド認証が有効の場合 + if (instance.emailRequiredForSignup) { + // メアド認証済みならエラー + if (ticket.usedBy) { + reply.code(400); + return; + } + + // 認証しておらず、メール送信から30分以内ならエラー + if (ticket.usedAt && ticket.usedAt.getTime() + (1000 * 60 * 30) > Date.now()) { + reply.code(400); + return; + } + } else if (ticket.usedAt) { reply.code(400); return; } @@ -224,6 +237,10 @@ export class SignupApiService { try { const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); + if (this.idService.parse(pendingUser.id).date.getTime() + (1000 * 60 * 30) < Date.now()) { + throw new FastifyReplyError(400, 'EXPIRED'); + } + const { account, secret } = await this.signupService.signup({ username: pendingUser.username, passwordHash: pendingUser.password, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 4009df0d66..bec3928493 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -15,6 +15,7 @@ import { bindThis } from '@/decorators.js'; 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 { 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 channelsService: ChannelsService, private notificationService: NotificationService, private usersService: UserService, + private channelFollowingService: ChannelFollowingService, ) { } @@ -93,6 +95,7 @@ export class StreamingApiServerService { this.noteReadService, this.notificationService, this.cacheService, + this.channelFollowingService, user, app, ); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 1eb2670b68..e702883cb9 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -7,6 +7,10 @@ import type { Schema } from '@/misc/json-schema.js'; import { RolePolicies } from '@/core/RoleService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseReportResolver_create from './endpoints/admin/abuse-report-resolver/create.js'; +import * as ep___admin_abuseReportResolver_update from './endpoints/admin/abuse-report-resolver/update.js'; +import * as ep___admin_abuseReportResolver_delete from './endpoints/admin/abuse-report-resolver/delete.js'; +import * as ep___admin_abuseReportResolver_list from './endpoints/admin/abuse-report-resolver/list.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; @@ -18,10 +22,10 @@ import * as ep___admin_announcements_create from './endpoints/admin/announcement import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_abuseReportResolver_create from './endpoints/admin/abuse-report-resolver/create.js'; -import * as ep___admin_abuseReportResolver_update from './endpoints/admin/abuse-report-resolver/update.js'; -import * as ep___admin_abuseReportResolver_delete from './endpoints/admin/abuse-report-resolver/delete.js'; -import * as ep___admin_abuseReportResolver_list from './endpoints/admin/abuse-report-resolver/list.js'; +import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; +import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; +import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; +import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; @@ -167,6 +171,7 @@ import * as ep___federation_stats from './endpoints/federation/stats.js'; import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_delete from './endpoints/following/delete.js'; import * as ep___following_update from './endpoints/following/update.js'; +import * as ep___following_update_all from './endpoints/following/update-all.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js'; import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; @@ -182,6 +187,7 @@ import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; +import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; import * as ep___hashtags_list from './endpoints/hashtags/list.js'; import * as ep___hashtags_search from './endpoints/hashtags/search.js'; import * as ep___hashtags_show from './endpoints/hashtags/show.js'; @@ -217,6 +223,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; @@ -230,7 +237,7 @@ import * as ep___i_registry_get from './endpoints/i/registry/get.js'; import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopes from './endpoints/i/registry/scopes.js'; +import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; @@ -385,6 +392,10 @@ import * as ep___retention from './endpoints/retention.js'; const eps = [ ['admin/meta', ep___admin_meta], + ['admin/abuse-report-resolver/create', ep___admin_abuseReportResolver_create], + ['admin/abuse-report-resolver/list', ep___admin_abuseReportResolver_list], + ['admin/abuse-report-resolver/delete', ep___admin_abuseReportResolver_delete], + ['admin/abuse-report-resolver/update', ep___admin_abuseReportResolver_update], ['admin/abuse-user-reports', ep___admin_abuseUserReports], ['admin/accounts/create', ep___admin_accounts_create], ['admin/accounts/delete', ep___admin_accounts_delete], @@ -396,10 +407,10 @@ const eps = [ ['admin/announcements/delete', ep___admin_announcements_delete], ['admin/announcements/list', ep___admin_announcements_list], ['admin/announcements/update', ep___admin_announcements_update], - ['admin/abuse-report-resolver/create', ep___admin_abuseReportResolver_create], - ['admin/abuse-report-resolver/list', ep___admin_abuseReportResolver_list], - ['admin/abuse-report-resolver/delete', ep___admin_abuseReportResolver_delete], - ['admin/abuse-report-resolver/update', ep___admin_abuseReportResolver_update], + ['admin/avatar-decorations/create', ep___admin_avatarDecorations_create], + ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], + ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], + ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], ['admin/drive/cleanup', ep___admin_drive_cleanup], @@ -545,6 +556,7 @@ const eps = [ ['following/create', ep___following_create], ['following/delete', ep___following_delete], ['following/update', ep___following_update], + ['following/update-all', ep___following_update_all], ['following/invalidate', ep___following_invalidate], ['following/requests/accept', ep___following_requests_accept], ['following/requests/cancel', ep___following_requests_cancel], @@ -560,6 +572,7 @@ const eps = [ ['gallery/posts/unlike', ep___gallery_posts_unlike], ['gallery/posts/update', ep___gallery_posts_update], ['get-online-users-count', ep___getOnlineUsersCount], + ['get-avatar-decorations', ep___getAvatarDecorations], ['hashtags/list', ep___hashtags_list], ['hashtags/search', ep___hashtags_search], ['hashtags/show', ep___hashtags_show], @@ -595,6 +608,7 @@ const eps = [ ['i/import-user-lists', ep___i_importUserLists], ['i/import-antennas', ep___i_importAntennas], ['i/notifications', ep___i_notifications], + ['i/notifications-grouped', ep___i_notificationsGrouped], ['i/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], ['i/pin', ep___i_pin], @@ -608,7 +622,7 @@ const eps = [ ['i/registry/keys-with-type', ep___i_registry_keysWithType], ['i/registry/keys', ep___i_registry_keys], ['i/registry/remove', ep___i_registry_remove], - ['i/registry/scopes', ep___i_registry_scopes], + ['i/registry/scopes-with-domain', ep___i_registry_scopesWithDomain], ['i/registry/set', ep___i_registry_set], ['i/revoke-token', ep___i_revokeToken], ['i/signin-history', ep___i_signinHistory], diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 7ee6cde998..010c6567f4 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -58,6 +58,7 @@ export const paramDef = { icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, forExistingUsers: { type: 'boolean', default: false }, + silence: { type: 'boolean', default: false }, needConfirmationToRead: { type: 'boolean', default: false }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, @@ -78,6 +79,7 @@ export default class extends Endpoint { // eslint- icon: ps.icon, display: ps.display, forExistingUsers: ps.forExistingUsers, + silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, userId: ps.userId, }, me); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 328ea5e722..ab24533b2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -86,6 +86,7 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + query.andWhere('announcement.isActive = true'); if (ps.userId) { query.andWhere('announcement.userId = :userId', { userId: ps.userId }); } else { @@ -113,6 +114,7 @@ export default class extends Endpoint { // eslint- display: announcement.display, isActive: announcement.isActive, forExistingUsers: announcement.forExistingUsers, + silence: announcement.silence, needConfirmationToRead: announcement.needConfirmationToRead, userId: announcement.userId, reads: reads.get(announcement)!, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index f1fe983e41..b682279dd1 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -35,6 +35,7 @@ export const paramDef = { icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, forExistingUsers: { type: 'boolean' }, + silence: { type: 'boolean' }, needConfirmationToRead: { type: 'boolean' }, isActive: { type: 'boolean' }, }, @@ -63,6 +64,7 @@ export default class extends Endpoint { // eslint- display: ps.display, icon: ps.icon, forExistingUsers: ps.forExistingUsers, + silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, isActive: ps.isActive, }, me); diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts new file mode 100644 index 0000000000..e1f7bc060b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageAvatarDecorations', +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + description: { type: 'string' }, + url: { type: 'string', minLength: 1 }, + roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['name', 'description', 'url'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.create({ + name: ps.name, + description: ps.description, + url: ps.url, + roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts new file mode 100644 index 0000000000..ba51fd9d3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageAvatarDecorations', + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.delete(ps.id, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts new file mode 100644 index 0000000000..620501ecc3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; +import type { MiAnnouncement } from '@/models/Announcement.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageAvatarDecorations', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const avatarDecorations = await this.avatarDecorationService.getAll(true); + + return avatarDecorations.map(avatarDecoration => ({ + id: avatarDecoration.id, + createdAt: this.idService.parse(avatarDecoration.id).date.toISOString(), + updatedAt: avatarDecoration.updatedAt?.toISOString() ?? null, + name: avatarDecoration.name, + description: avatarDecoration.description, + url: avatarDecoration.url, + roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts new file mode 100644 index 0000000000..17e13588b5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageAvatarDecorations', + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1 }, + description: { type: 'string' }, + url: { type: 'string', minLength: 1 }, + roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.avatarDecorationService.update(ps.id, { + name: ps.name, + description: ps.description, + url: ps.url, + roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, + }, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index a4cc35cc82..0fe091de83 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -351,6 +351,10 @@ export const meta = { type: 'object', optional: false, nullable: false, }, + enableFanoutTimeline: { + type: 'boolean', + optional: false, nullable: false, + }, perLocalUserUserTimelineCacheMax: { type: 'number', optional: false, nullable: false, @@ -516,6 +520,7 @@ export default class extends Endpoint { // eslint- emailToReceiveAbuseReport: instance.emailToReceiveAbuseReport, policies: { ...DEFAULT_POLICIES, ...instance.policies }, manifestJsonOverride: instance.manifestJsonOverride, + enableFanoutTimeline: instance.enableFanoutTimeline, perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax, perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 6c94ad8ba3..29c389d01a 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -143,6 +143,7 @@ export const paramDef = { serverRules: { type: 'array', items: { type: 'string' } }, preservedUsernames: { type: 'array', items: { type: 'string' } }, manifestJsonOverride: { type: 'string' }, + enableFanoutTimeline: { type: 'boolean' }, perLocalUserUserTimelineCacheMax: { type: 'integer' }, perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, @@ -595,6 +596,10 @@ export default class extends Endpoint { // eslint- set.manifestJsonOverride = ps.manifestJsonOverride; } + if (ps.enableFanoutTimeline !== undefined) { + set.enableFanoutTimeline = ps.enableFanoutTimeline; + } + if (ps.perLocalUserUserTimelineCacheMax !== undefined) { set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax; } diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index 939e1e4532..520577c831 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -50,6 +50,7 @@ export const paramDef = { bannerId: { type: 'string', format: 'misskey:id', nullable: true }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, + allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['name'], } as const; @@ -87,6 +88,7 @@ export default class extends Endpoint { // eslint- bannerId: banner ? banner.id : null, isSensitive: ps.isSensitive ?? false, ...(ps.color !== undefined ? { color: ps.color } : {}), + allowRenoteToExternal: ps.allowRenoteToExternal ?? true, } as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 2d7727d811..3a38d1aa7b 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -5,9 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFollowingsRepository, ChannelsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; +import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -41,11 +41,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - private idService: IdService, + private channelFollowingService: ChannelFollowingService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -56,11 +52,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingsRepository.insert({ - id: this.idService.gen(), - followerId: me.id, - followeeId: channel.id, - }); + await this.channelFollowingService.follow(me, channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index 16de089eef..b93219ff38 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -5,8 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelFollowingsRepository, ChannelsRepository } from '@/models/_.js'; +import type { ChannelsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -40,9 +41,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, + private channelFollowingService: ChannelFollowingService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -53,10 +52,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - await this.channelFollowingsRepository.delete({ - followerId: me.id, - followeeId: channel.id, - }); + await this.channelFollowingService.unfollow(me, channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index 7fd090388f..6dcade4b17 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -61,6 +61,7 @@ export const paramDef = { }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, + allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['channelId'], } as const; @@ -115,6 +116,7 @@ export default class extends Endpoint { // eslint- ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(banner ? { bannerId: banner.id } : {}), ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), + ...(typeof ps.allowRenoteToExternal === 'boolean' ? { allowRenoteToExternal: ps.allowRenoteToExternal } : {}), }); return await this.channelEntityService.pack(channel.id, me); diff --git a/packages/backend/src/server/api/endpoints/following/update-all.ts b/packages/backend/src/server/api/endpoints/following/update-all.ts new file mode 100644 index 0000000000..5859c4c29e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/update-all.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { FollowingsRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['following', 'users'], + + limit: { + duration: ms('1hour'), + max: 10, + }, + + requireCredential: true, + + kind: 'write:following', +} as const; + +export const paramDef = { + type: 'object', + properties: { + notify: { type: 'string', enum: ['normal', 'none'] }, + withReplies: { type: 'boolean' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + await this.followingsRepository.update({ + followerId: me.id, + }, { + notify: ps.notify != null ? (ps.notify === 'none' ? null : ps.notify) : undefined, + withReplies: ps.withReplies != null ? ps.withReplies : undefined, + }); + + return; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts new file mode 100644 index 0000000000..be7dec03d4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-avatar-decorations.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private avatarDecorationService: AvatarDecorationService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const decorations = await this.avatarDecorationService.getAll(true); + const allRoles = await this.roleService.getRoles(); + + return decorations.map(decoration => ({ + id: decoration.id, + name: decoration.name, + description: decoration.description, + url: decoration.url, + roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(role => role.id === roleId)), + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts new file mode 100644 index 0000000000..23aec8cb6e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Brackets, In } from 'typeorm'; +import * as Redis from 'ioredis'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/_.js'; +import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; + +export const meta = { + tags: ['account', 'notifications'], + + requireCredential: true, + + limit: { + duration: 30000, + max: 30, + }, + + kind: 'read:notifications', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Notification', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + markAsRead: { type: 'boolean', default: true }, + // 後方互換のため、廃止された通知タイプも受け付ける + includeTypes: { type: 'array', items: { + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + } }, + excludeTypes: { type: 'array', items: { + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + } }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private idService: IdService, + private notificationEntityService: NotificationEntityService, + private notificationService: NotificationService, + private noteReadService: NoteReadService, + ) { + super(meta, paramDef, async (ps, me) => { + const EXTRA_LIMIT = 100; + + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + + const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + + const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', + 'COUNT', limit); + + if (notificationsRes.length === 0) { + return []; + } + + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length === 0) { + return []; + } + + // Mark all as read + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); + } + + // grouping + let groupedNotifications = [notifications[0]] as MiGroupedNotification[]; + for (let i = 1; i < notifications.length; i++) { + const notification = notifications[i]; + const prev = notifications[i - 1]; + let prevGroupedNotification = groupedNotifications.at(-1)!; + + if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) { + if (prevGroupedNotification.type !== 'reaction:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'reaction:grouped', + id: '', + createdAt: prev.createdAt, + noteId: prev.noteId!, + reactions: [{ + userId: prev.notifierId!, + reaction: prev.reaction!, + }], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + (prevGroupedNotification as FilterUnionByProperty).reactions.push({ + userId: notification.notifierId!, + reaction: notification.reaction!, + }); + prevGroupedNotification.id = notification.id; + continue; + } + if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) { + if (prevGroupedNotification.type !== 'renote:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'renote:grouped', + id: '', + createdAt: notification.createdAt, + noteId: prev.noteId!, + userIds: [prev.notifierId!], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + (prevGroupedNotification as FilterUnionByProperty).userIds.push(notification.notifierId!); + prevGroupedNotification.id = notification.id; + continue; + } + + groupedNotifications.push(notification); + } + + groupedNotifications = groupedNotifications.slice(0, ps.limit); + + const noteIds = groupedNotifications + .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); + + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); + this.noteReadService.read(me.id, notes); + } + + return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 68081fb7c6..9b5558b6d3 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -7,7 +7,7 @@ import { Brackets, In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; @@ -113,8 +113,8 @@ export default class extends Endpoint { // eslint- } const noteIds = notifications - .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId!); + .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId); if (noteIds.length > 0) { const notes = await this.notesRepository.findBy({ id: In(noteIds) }); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts index 77c9e691e3..ff56b219b8 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -5,13 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -20,23 +17,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: [], + required: ['scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const items = await query.getMany(); + super(meta, paramDef, async (ps, me, accessToken) => { + const items = await this.registryApiService.getAllItemsOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); const res = {} as Record; diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts index bc9bd423ae..af6fe49787 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -5,15 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, - secure: true, - errors: { noSuchKey: { message: 'No such key.', @@ -30,24 +27,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key'], + required: ['key', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); + super(meta, paramDef, async (ps, me, accessToken) => { + const item = await this.registryApiService.getItem(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); if (item == null) { throw new ApiError(meta.errors.noSuchKey); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts index b34d93409a..819a63891b 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -5,15 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, - secure: true, - errors: { noSuchKey: { message: 'No such key.', @@ -30,24 +27,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key'], + required: ['key', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); + super(meta, paramDef, async (ps, me, accessToken) => { + const item = await this.registryApiService.getItem(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); if (item == null) { throw new ApiError(meta.errors.noSuchKey); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts index 7ab9ef8295..f2a96464cd 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -5,13 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -20,36 +17,31 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: [], + required: ['scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const items = await query.getMany(); + super(meta, paramDef, async (ps, me, accessToken) => { + const items = await this.registryApiService.getAllItemsOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); const res = {} as Record; for (const item of items) { const type = typeof item.value; res[item.key] = - item.value === null ? 'null' : - Array.isArray(item.value) ? 'array' : - type === 'number' ? 'number' : - type === 'string' ? 'string' : - type === 'boolean' ? 'boolean' : - type === 'object' ? 'object' : - null as never; + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; } return res; diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts index 58aba50d56..ac008466f9 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -5,13 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -20,26 +17,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: [], + required: ['scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .select('item.key') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const items = await query.getMany(); - - return items.map(x => x.key); + super(meta, paramDef, async (ps, me, accessToken) => { + return await this.registryApiService.getAllKeysOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts index cd43107097..2ed6f21d41 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/remove.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -7,13 +7,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { RegistryItemsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, - secure: true, - errors: { noSuchKey: { message: 'No such key.', @@ -30,30 +29,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key'], + required: ['key', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); - - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); - } - - await this.registryItemsRepository.remove(item); + super(meta, paramDef, async (ps, me, accessToken) => { + await this.registryApiService.remove(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts new file mode 100644 index 0000000000..a5088cab76 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; + +export const meta = { + requireCredential: true, + secure: true, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private registryApiService: RegistryApiService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.registryApiService.getAllScopeAndDomains(me.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts deleted file mode 100644 index 00b7154c0d..0000000000 --- a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - requireCredential: true, - - secure: true, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .select('item.scope') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }); - - const items = await query.getMany(); - - const res = [] as string[][]; - - for (const item of items) { - if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; - res.push(item.scope); - } - - return res; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts index 683dbf811e..ca7f45afe6 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/set.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -5,15 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -24,51 +19,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key', 'value'], + required: ['key', 'value', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const existingItem = await query.getOne(); - - if (existingItem) { - await this.registryItemsRepository.update(existingItem.id, { - updatedAt: new Date(), - value: ps.value, - }); - } else { - await this.registryItemsRepository.insert({ - id: this.idService.gen(), - updatedAt: new Date(), - userId: me.id, - domain: null, - scope: ps.scope, - key: ps.key, - value: ps.value, - }); - } - - // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする - this.globalEventService.publishMainStream(me.id, 'registryUpdated', { - scope: ps.scope, - key: ps.key, - value: ps.value, - }); + super(meta, paramDef, async (ps, me, accessToken) => { + await this.registryApiService.set(me.id, accessToken ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key, ps.value); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 86f56b929f..a5eb673df8 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -18,8 +18,12 @@ export const paramDef = { type: 'object', properties: { tokenId: { type: 'string', format: 'misskey:id' }, + token: { type: 'string' }, }, - required: ['tokenId'], + anyOf: [ + { required: ['tokenId'] }, + { required: ['token'] }, + ], } as const; @Injectable() @@ -29,13 +33,24 @@ export default class extends Endpoint { // eslint- private accessTokensRepository: AccessTokensRepository, ) { super(meta, paramDef, async (ps, me) => { - const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); + if (ps.tokenId) { + const tokenExist = await this.accessTokensRepository.exist({ where: { id: ps.tokenId } }); - if (tokenExist) { - await this.accessTokensRepository.delete({ - id: ps.tokenId, - userId: me.id, - }); + if (tokenExist) { + await this.accessTokensRepository.delete({ + id: ps.tokenId, + userId: me.id, + }); + } + } else if (ps.token) { + const tokenExist = await this.accessTokensRepository.exist({ where: { token: ps.token } }); + + if (tokenExist) { + await this.accessTokensRepository.delete({ + token: ps.token, + userId: me.id, + }); + } } }); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index dd14b17972..9fdb72ca67 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -32,6 +32,7 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { Config } from '@/config.js'; import { safeForSql } from '@/misc/safe-for-sql.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; @@ -44,7 +45,7 @@ export const meta = { limit: { duration: ms('1hour'), - max: 10, + max: 20, }, errors: { @@ -131,6 +132,15 @@ export const paramDef = { birthday: { ...birthdaySchema, nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, avatarId: { type: 'string', format: 'misskey:id', nullable: true }, + avatarDecorations: { type: 'array', maxItems: 1, items: { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + angle: { type: 'number', nullable: true, maximum: 0.5, minimum: -0.5 }, + flipH: { type: 'boolean', nullable: true }, + }, + required: ['id'], + } }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, fields: { type: 'array', @@ -207,6 +217,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private cacheService: CacheService, private httpRequestService: HttpRequestService, + private avatarDecorationService: AvatarDecorationService, ) { super(meta, paramDef, async (ps, _user, token, flashToken) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -296,6 +307,21 @@ export default class extends Endpoint { // eslint- updates.bannerBlurhash = null; } + if (ps.avatarDecorations) { + const decorations = await this.avatarDecorationService.getAll(true); + const myRoles = await this.roleService.getUserRoles(user.id); + const allRoles = await this.roleService.getRoles(); + const decorationIds = decorations + .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) + .map(d => d.id); + + updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ + id: d.id, + angle: d.angle ?? 0, + flipH: d.flipH ?? false, + })); + } + if (ps.pinnedPageId) { const page = await this.pagesRepository.findOneBy({ id: ps.pinnedPageId }); @@ -421,9 +447,13 @@ export default class extends Endpoint { // eslint- const myLink = `${this.config.url}/@${user.username}`; - const includesMyLink = Array.from(doc.getElementsByTagName('a')).some(a => a.href === myLink); + const aEls = Array.from(doc.getElementsByTagName('a')); + const linkEls = Array.from(doc.getElementsByTagName('link')); + + const includesMyLink = aEls.some(a => a.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); - if (includesMyLink) { + if (includesMyLink || includesRelMeLinks) { await this.userProfilesRepository.createQueryBuilder('profile').update() .where('userId = :userId', { userId: user.id }) .set({ diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts index 6830443096..7efa791b94 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -64,7 +64,7 @@ describe('api:notes/create', () => { test('0 characters cw', () => { expect(v({ text: 'Body', cw: '' })) - .toBe(VALID); + .toBe(INVALID); }); test('reject only cw', () => { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 697e7e6e74..cf934054fe 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -16,6 +16,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -98,6 +99,12 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, }, } as const; @@ -108,7 +115,7 @@ export const paramDef = { visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, - cw: { type: 'string', nullable: true, maxLength: 100 }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, disableRightClick: { type: 'boolean', default: false }, @@ -232,7 +239,7 @@ export default class extends Endpoint { // eslint- if (renote == null) { throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (renote.renoteId && !renote.text && !renote.fileIds && !renote.hasPoll) { + } else if (isPureRenote(renote)) { throw new ApiError(meta.errors.cannotReRenote); } @@ -256,6 +263,19 @@ export default class extends Endpoint { // eslint- // specified / direct noteはreject throw new ApiError(meta.errors.cannotRenoteDueToVisibility); } + + if (renote.channelId && renote.channelId !== ps.channelId) { + // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック + // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する + const renoteChannel = await this.channelsRepository.findOneById(renote.channelId); + if (renoteChannel == null) { + // リノートしたいノートが書き込まれているチャンネルが無い + throw new ApiError(meta.errors.noSuchChannel); + } else if (!renoteChannel.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + } + } } let reply: MiNote | null = null; @@ -265,7 +285,7 @@ export default class extends Endpoint { // eslint- if (reply == null) { throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (reply.renoteId && !reply.text && !reply.fileIds && !reply.hasPoll) { + } else if (isPureRenote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); } diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 97bd410f90..dece70915c 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -89,6 +89,16 @@ export default class extends Endpoint { // eslint- query.andWhere('note.fileIds != \'{}\''); } + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.where('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.where('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + if (ps.withCats) { query.andWhere('(select "isCat" from "user" where id = note."userId")'); } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 3121600ed5..94d98a6912 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js'; +import type { NotesRepository, FollowingsRepository, MiNote, ChannelFollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -17,6 +17,8 @@ import { CacheService } from '@/core/CacheService.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -68,6 +70,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, @@ -76,6 +81,7 @@ export default class extends Endpoint { // eslint- private funoutTimelineService: FunoutTimelineService, private queryService: QueryService, private userFollowingService: UserFollowingService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -86,167 +92,229 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.stlDisabled); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let noteIds: string[]; - let shouldFallbackToDb = false; - - if (ps.withFiles) { - const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimelineWithFiles:${me.id}`, - 'localTimelineWithFiles', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - } else if (ps.withReplies) { - const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); - } else { - const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ - `homeTimeline:${me.id}`, - 'localTimeline', - ], untilId, sinceId); - noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); - shouldFallbackToDb = htlNoteIds.length === 0; - } - - noteIds.sort((a, b) => a > b ? -1 : 1); - noteIds = noteIds.slice(0, ps.limit); + const serverSettings = await this.metaService.fetch(); - shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); + if (serverSettings.enableFanoutTimeline) { + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); - if (!shouldFallbackToDb) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + let noteIds: string[]; + let shouldFallbackToDb = false; - if (ps.withCats) { - query.andWhere('(select "isCat" from "user" where id = note."userId")'); + if (ps.withFiles) { + const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ + `homeTimelineWithFiles:${me.id}`, + 'localTimelineWithFiles', + ], untilId, sinceId); + noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); + } else if (ps.withReplies) { + const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([ + `homeTimeline:${me.id}`, + 'localTimeline', + 'localTimelineWithReplies', + ], untilId, sinceId); + noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds])); + } else { + const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([ + `homeTimeline:${me.id}`, + 'localTimeline', + ], untilId, sinceId); + noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds])); + shouldFallbackToDb = htlNoteIds.length === 0; } - let timeline = await query.getMany(); - - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } + noteIds.sort((a, b) => a > b ? -1 : 1); + noteIds = noteIds.slice(0, ps.limit); - return true; - }); + shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); - // TODO: フィルタした結果件数が足りなかった場合の対応 + let redisTimeline: MiNote[] = []; - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + if (!shouldFallbackToDb) { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - process.nextTick(() => { - this.activeUsersChart.read(me); - }); + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } - return await this.noteEntityService.packMany(timeline, me); - } else { // fallback to db - const followees = await this.userFollowingService.getFollowees(me.id); + redisTimeline = await query.getMany(); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere(new Brackets(qb => { - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } else { - qb.where('note.userId = :meId', { meId: me.id }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + redisTimeline = redisTimeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } } - })) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + return true; + }); - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); } - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); + if (redisTimeline.length > 0) { + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + + return await this.noteEntityService.packMany(redisTimeline, me); + } else { // fallback to db + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withCats: ps.withCats, + }, me); } - //#endregion - - const timeline = await query.limit(ps.limit).getMany(); + } else { + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withCats: ps.withCats, + }, me); + } + }); + } - process.nextTick(() => { - this.activeUsersChart.read(me); - }); + private async getFromDb(ps: { + untilId: string | null, + sinceId: string | null, + limit: number, + includeMyRenotes: boolean, + includeRenotedMyNotes: boolean, + includeLocalRenotes: boolean, + withFiles: boolean, + withReplies: boolean, + withCats: boolean, + }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); - return await this.noteEntityService.packMany(timeline, me); - } + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { + if (followees.length > 0) { + const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + } else { + qb.where('note.userId = :meId', { meId: me.id }); + qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + } + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followingChannels.length > 0) { + const followingChannelIds = followingChannels.map(x => x.followeeId); + + query.andWhere(new Brackets(qb => { + qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + qb.orWhere('note.channelId IS NULL'); + })); + } else { + query.andWhere('note.channelId IS NULL'); + } + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); }); + + return await this.noteEntityService.packMany(timeline, me); } } diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index dd336aa4f1..0d9bad4b11 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -70,6 +72,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private funoutTimelineService: FunoutTimelineService, private queryService: QueryService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -80,116 +83,153 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.ltlDisabled); } - const [ - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set(), new Set()]; + const serverSettings = await this.metaService.fetch(); - let noteIds: string[]; + if (serverSettings.enableFanoutTimeline) { + const [ + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = me ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]) : [new Set(), new Set(), new Set()]; - if (ps.withFiles) { - noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); - } else { - const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ - 'localTimeline', - 'localTimelineWithReplies', - ], untilId, sinceId); - noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); - noteIds.sort((a, b) => a > b ? -1 : 1); - } + let noteIds: string[]; - noteIds = noteIds.slice(0, ps.limit); + if (ps.withFiles) { + noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId); + } else { + const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([ + 'localTimeline', + 'localTimelineWithReplies', + ], untilId, sinceId); + noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds])); + noteIds.sort((a, b) => a > b ? -1 : 1); + } - if (noteIds.length > 0) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + noteIds = noteIds.slice(0, ps.limit); - if (ps.withCats) { - query.andWhere('(select "isCat" from "user" where id = note."userId")'); - } + let redisTimeline: MiNote[] = []; - let timeline = await query.getMany(); + if (noteIds.length > 0) { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - timeline = timeline.filter(note => { - if (me && (note.userId === me.id)) { - return true; - } - if (!ps.withReplies && note.replyId && (me == null || note.replyUserId !== me.id)) return false; - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); } - return true; - }); + redisTimeline = await query.getMany(); - // TODO: フィルタした結果件数が足りなかった場合の対応 + redisTimeline = redisTimeline.filter(note => { + if (me && (note.userId === me.id)) { + return true; + } + if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false; + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + return true; + }); - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); - - return await this.noteEntityService.packMany(timeline, me); - } else { // fallback to db - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); + if (redisTimeline.length > 0) { + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); + } + }); + + return await this.noteEntityService.packMany(redisTimeline, me); + } else { // fallback to db + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withCats: ps.withCats, + }, me); } + } else { + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + withFiles: ps.withFiles, + withReplies: ps.withReplies, + withCats: ps.withCats, + }, me); + } + }); + } - if (!ps.withReplies) { - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); + private async getFromDb(ps: { + sinceId: string | null, + untilId: string | null, + limit: number, + withFiles: boolean, + withReplies: boolean, + withCats: boolean, + }, me: MiLocalUser | null) { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId) + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (!ps.withReplies) { + query.andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); })); - } + })); + } - const timeline = await query.limit(ps.limit).getMany(); + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } - process.nextTick(() => { - if (me) { - this.activeUsersChart.read(me); - } - }); + const timeline = await query.limit(ps.limit).getMany(); - return await this.noteEntityService.packMany(timeline, me); + process.nextTick(() => { + if (me) { + this.activeUsersChart.read(me); } }); + + return await this.noteEntityService.packMany(timeline, me); } } diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 97bb113ea4..b70845cf38 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiNote, NotesRepository, ChannelFollowingsRepository } 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 { isUserRelated } from '@/misc/is-user-related.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['notes'], @@ -57,6 +59,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private idService: IdService, @@ -64,148 +69,219 @@ export default class extends Endpoint { // eslint- private funoutTimelineService: FunoutTimelineService, private userFollowingService: UserFollowingService, private queryService: QueryService, + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const [ - followings, - userIdsWhoMeMuting, - userIdsWhoMeMutingRenotes, - userIdsWhoBlockingMe, - ] = await Promise.all([ - this.cacheService.userFollowingsCache.fetch(me.id), - this.cacheService.userMutingsCache.fetch(me.id), - this.cacheService.renoteMutingsCache.fetch(me.id), - this.cacheService.userBlockedCache.fetch(me.id), - ]); - - let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); - noteIds = noteIds.slice(0, ps.limit); - - if (noteIds.length > 0) { - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - if (ps.withCats) { - query.andWhere('(select "isCat" from "user" where id = note."userId")'); - } + const serverSettings = await this.metaService.fetch(); - let timeline = await query.getMany(); + if (serverSettings.enableFanoutTimeline) { + const [ + followings, + userIdsWhoMeMuting, + userIdsWhoMeMutingRenotes, + userIdsWhoBlockingMe, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + this.cacheService.userMutingsCache.fetch(me.id), + this.cacheService.renoteMutingsCache.fetch(me.id), + this.cacheService.userBlockedCache.fetch(me.id), + ]); - timeline = timeline.filter(note => { - if (note.userId === me.id) { - return true; - } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } - } - if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId)) return false; - } + let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId); + noteIds = noteIds.slice(0, ps.limit); - return true; - }); + let redisTimeline: MiNote[] = []; - // TODO: フィルタした結果件数が足りなかった場合の対応 + if (noteIds.length > 0) { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } - process.nextTick(() => { - this.activeUsersChart.read(me); - }); + redisTimeline = await query.getMany(); - return await this.noteEntityService.packMany(timeline, me); - } else { // fallback to db - const followees = await this.userFollowingService.getFollowees(me.id); + redisTimeline = redisTimeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId)) return false; + } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId IS NULL') - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + return true; + }); - if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } + + if (redisTimeline.length > 0) { + process.nextTick(() => { + this.activeUsersChart.read(me); + }); - query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else { - query.andWhere('note.userId = :meId', { meId: me.id }); + return await this.noteEntityService.packMany(redisTimeline, me); + } else { // fallback to db + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + withCats: ps.withCats, + }, me); } + } else { + return await this.getFromDb({ + untilId, + sinceId, + limit: ps.limit, + includeMyRenotes: ps.includeMyRenotes, + includeRenotedMyNotes: ps.includeRenotedMyNotes, + includeLocalRenotes: ps.includeLocalRenotes, + withFiles: ps.withFiles, + withRenotes: ps.withRenotes, + withCats: ps.withCats, + }, me); + } + }); + } + + private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; withCats: boolean; }, me: MiLocalUser) { + const followees = await this.userFollowingService.getFollowees(me.id); + const followingChannels = await this.channelFollowingsRepository.find({ + where: { + followerId: me.id, + }, + }); - query.andWhere(new Brackets(qb => { - qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { - qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (followees.length > 0 && followingChannels.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.channelId IS NULL'); + })) + .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); + })); + } 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 + .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 + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); })); + })); - this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - if (ps.withFiles) { - query.andWhere('note.fileIds != \'{}\''); - } - //#endregion + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } - const timeline = await query.limit(ps.limit).getMany(); + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } - process.nextTick(() => { - this.activeUsersChart.read(me); - }); + if (ps.withRenotes === false) { + query.andWhere('note.renoteId IS NULL'); + } - return await this.noteEntityService.packMany(timeline, me); - } + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } + //#endregion + + const timeline = await query.limit(ps.limit).getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); }); + + return await this.noteEntityService.packMany(timeline, me); } } diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts index a4fdebe383..55d11c98fb 100644 --- a/packages/backend/src/server/api/endpoints/notes/update.ts +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -5,13 +5,13 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -34,6 +34,16 @@ export const meta = { code: 'NO_SUCH_NOTE', id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474', }, + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, }, } as const; @@ -47,7 +57,39 @@ export const paramDef = { maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false, }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, cw: { type: 'string', nullable: true, maxLength: 100 }, + disableRightClick: { type: 'boolean', default: false }, }, required: ['noteId', 'text', 'cw'], } as const; @@ -55,14 +97,12 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, private getterService: GetterService, - private globalEventService: GlobalEventService, + private noteEntityService: NoteEntityService, + private noteUpdateService: NoteUpdateService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.getterService.getNote(ps.noteId).catch(err => { @@ -74,19 +114,49 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchNote); } - const updatedAtHistory = note.updatedAtHistory ? note.updatedAtHistory : []; - await this.notesRepository.update({ id: note.id }, { - updatedAt: new Date(), - cw: ps.cw, - text: ps.text, - updatedAtHistory: [...updatedAtHistory, new Date()], - noteEditHistory: [...note.noteEditHistory, (note.cw ? note.cw + '\n' : '') + note.text!], - }); + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); - this.globalEventService.publishNoteStream(note.id, 'updated', { - cw: ps.cw, + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + const data = { text: ps.text, - }); + files: files, + cw: ps.cw, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + }; + + const updatedNote = await this.noteUpdateService.update(me, data, note, false); + + return { + updatedNote: await this.noteEntityService.pack(updatedNote!, me), + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 082871929e..696f2d82d4 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -4,7 +4,8 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, UserListsRepository } from '@/models/_.js'; +import { Brackets } from 'typeorm'; +import type { MiNote, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -13,6 +14,7 @@ import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { QueryService } from '@/core/QueryService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -71,11 +73,16 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private cacheService: CacheService, private idService: IdService, private funoutTimelineService: FunoutTimelineService, + private queryService: QueryService, + ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -103,48 +110,133 @@ export default class extends Endpoint { // eslint- let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`, untilId, sinceId); noteIds = noteIds.slice(0, ps.limit); - if (noteIds.length === 0) { - return []; - } + let redisTimeline: MiNote[] = []; - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); - - if (ps.withCats) { - query.andWhere('(select "isCat" from "user" where id = note."userId")'); - } + if (noteIds.length > 0) { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + redisTimeline = await query.getMany(); - let timeline = await query.getMany(); + redisTimeline = redisTimeline.filter(note => { + if (note.userId === me.id) { + return true; + } + if (isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (isUserRelated(note, userIdsWhoMeMuting)) return false; + if (note.renoteId) { + if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { + if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; + if (ps.withRenotes === false) return false; + } + } - timeline = timeline.filter(note => { - if (note.userId === me.id) { return true; + }); + + redisTimeline.sort((a, b) => a.id > b.id ? -1 : 1); + } + + if (redisTimeline.length > 0) { + this.activeUsersChart.read(me); + return await this.noteEntityService.packMany(redisTimeline, me); + } else { // fallback to db + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) + .andWhere('note.channelId IS NULL') // チャンネルノートではない + .andWhere(new Brackets(qb => { + qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { + qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })) + .orWhere(new Brackets(qb => { + qb // 返信だけど自分宛ての返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { + qb // 返信だけどwithRepliesがtrueの場合 + .where('note.replyId IS NOT NULL') + .andWhere('userListMemberships.withReplies = true'); + })); + })); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); } - if (isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (isUserRelated(note, userIdsWhoMeMuting)) return false; - if (note.renoteId) { - if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) { - if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false; - if (ps.withRenotes === false) return false; - } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); } - return true; - }); + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } - // TODO: フィルタした結果件数が足りなかった場合の対応 + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } - timeline.sort((a, b) => a.id > b.id ? -1 : 1); + if (ps.withCats) { + query.andWhere('(select "isCat" from "user" where id = note."userId")'); + } + //#endregion - this.activeUsersChart.read(me); + const timeline = await query.limit(ps.limit).getMany(); - return await this.noteEntityService.packMany(timeline, me); + this.activeUsersChart.read(me); + + return await this.noteEntityService.packMany(timeline, me); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 970d0fc649..789aa53829 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -42,8 +42,8 @@ export default class extends Endpoint { // eslint- this.notificationService.createNotification(user.id, 'app', { appAccessTokenId: token ? token.id : null, customBody: ps.body, - customHeader: ps.header, - customIcon: ps.icon, + customHeader: ps.header ?? token?.name ?? null, + customIcon: ps.icon ?? token?.iconUrl ?? null, }); }); } diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 500d902d12..38f884df2a 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -26,7 +26,12 @@ export function convertSchemaToOpenApiSchema(schema: Schema) { if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema); if (schema.ref) { - res.$ref = `#/components/schemas/${schema.ref}`; + const $ref = `#/components/schemas/${schema.ref}`; + if (schema.nullable || schema.optional) { + res.allOf = [{ $ref }]; + } else { + res.$ref = $ref; + } } return res; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index a50adaa438..7bd963d550 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -14,6 +14,7 @@ import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { MiUserGroup } from '@/models/UserGroup.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; @@ -43,6 +44,7 @@ export default class Connection { private noteReadService: NoteReadService, private notificationService: NotificationService, private cacheService: CacheService, + private channelFollowingService: ChannelFollowingService, user: MiUser | null | undefined, token: MiAccessToken | null | undefined, @@ -57,7 +59,7 @@ export default class Connection { const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ this.cacheService.userProfileCache.fetch(this.user.id), this.cacheService.userFollowingsCache.fetch(this.user.id), - this.cacheService.userFollowingChannelsCache.fetch(this.user.id), + this.channelFollowingService.userFollowingChannelsCache.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), diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 6048b4d66c..d570dd3673 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -67,6 +67,8 @@ export default abstract class Channel { } public abstract init(params: any): void; + public dispose?(): void; + public onMessage?(type: string, body: any): void; } diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index f49f35b3aa..2bb888bbce 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -56,14 +56,18 @@ class HomeTimelineChannel extends Channel { if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } else if (note.visibility === 'specified') { - if (!note.visibleUserIds!.includes(this.user!.id)) return; + if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; } - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { + if (note.reply) { const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if (this.following[note.userId]?.withReplies) { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + } else { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } } if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index b02720f6d6..0180c51d38 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -67,17 +67,21 @@ class HybridTimelineChannel extends Channel { if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } else if (note.visibility === 'specified') { - if (!note.visibleUserIds!.includes(this.user!.id)) return; + if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; } // Ignore notes from instances the user has muted if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances))) return; - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies && !this.withReplies) { + if (note.reply) { const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + } else { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } } if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 5869d7d9b8..109e41fddb 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -90,11 +90,15 @@ class UserListChannel extends Channel { if (!note.visibleUserIds!.includes(this.user!.id)) return; } - // 関係ない返信は除外 - if (note.reply && !this.membershipsMap[note.userId]?.withReplies) { + if (note.reply) { const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if (this.membershipsMap[note.userId]?.withReplies) { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + } else { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 6bbf2c48fc..11e8ce93d8 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -254,8 +254,9 @@ export class ClientServerService { decorateReply: false, }); } else { + const port = (process.env.VITE_PORT ?? '5173'); fastify.register(fastifyProxy, { - upstream: 'http://localhost:5173', // TODO: port configuration + upstream: 'http://localhost:' + port, prefix: '/vite', rewritePrefix: '/vite', }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d1869ba627..fe8645ab38 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -61,6 +61,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createAvatarDecoration', + 'updateAvatarDecoration', + 'deleteAvatarDecoration', ] as const; export type ModerationLogPayloads = { @@ -222,6 +225,19 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; + updateAvatarDecoration: { + avatarDecorationId: string; + before: any; + after: any; + }; + deleteAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; }; export type Serialized = { @@ -234,3 +250,9 @@ export type Serialized = { ? Serialized : T[K]; }; + +export type FilterUnionByProperty< + Union, + Property extends string | number | symbol, + Condition +> = Union extends Record ? Union : never; diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index f93e2df399..25ec521d2c 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -720,7 +720,7 @@ describe('クリップ', () => { test('を追加できる。', async () => { await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); const res = await show({ clipId: aliceClip.id }); - assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString()); + assert.strictEqual(res.lastClippedAt, res.lastClippedAt ? new Date(res.lastClippedAt).toISOString() : null); assert.deepStrictEqual((await notes({ clipId: aliceClip.id })).map(x => x.id), [aliceNote.id]); // 他人の非公開ノートも突っ込める diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 06f5de1270..ece3c92b72 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -159,6 +159,10 @@ describe('Streaming', () => { }); */ + test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => { + // TODO + }); + test('フォローしていないユーザーの投稿は流れない', async () => { const fired = await waitFire( kyoko, 'homeTimeline', // kyoko:home diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index fd1f38c06c..cdc19f770c 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -526,6 +526,20 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); + test.concurrent('他人のその人自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await waitForPushToTl(); + + const res = await api('/notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + }); + test.concurrent('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -947,6 +961,22 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); }); + test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { + const [alice] = await Promise.all([signup(), signup()]); + + const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('/users/lists/push', { listId: list.id, userId: alice.id }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('/notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + }); + test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 7244ac3832..89bacc7bd3 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -68,6 +68,7 @@ describe('ユーザー', () => { host: user.host, avatarUrl: user.avatarUrl, avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations, isBot: user.isBot, isCat: user.isCat, instance: user.instance, @@ -164,7 +165,7 @@ describe('ユーザー', () => { hasUnreadAntenna: user.hasUnreadAntenna, hasUnreadChannel: user.hasUnreadChannel, hasUnreadNotification: user.hasUnreadNotification, - unreadNotificationCount: user.unreadNotificationCount, + unreadNotificationsCount: user.unreadNotificationsCount, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, @@ -351,6 +352,7 @@ describe('ユーザー', () => { assert.strictEqual(response.host, null); assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); assert.strictEqual(response.avatarBlurhash, null); + assert.deepStrictEqual(response.avatarDecorations, []); assert.strictEqual(response.isBot, false); assert.strictEqual(response.isCat, false); assert.strictEqual(response.instance, undefined); @@ -415,7 +417,7 @@ describe('ユーザー', () => { assert.strictEqual(response.hasUnreadAntenna, false); assert.strictEqual(response.hasUnreadChannel, false); assert.strictEqual(response.hasUnreadNotification, false); - assert.strictEqual(response.unreadNotificationCount, 0); + assert.strictEqual(response.unreadNotificationsCount, 0); assert.strictEqual(response.hasPendingReceivedFollowRequest, false); assert.deepStrictEqual(response.unreadAnnouncements, []); assert.deepStrictEqual(response.mutedWords, []); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index e57a2df3ce..65de5c333f 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -93,6 +93,7 @@ describe('ActivityPub', () => { const metaInitial = { cacheRemoteFiles: true, cacheRemoteSensitiveFiles: true, + enableFanoutTimeline: true, perUserHomeTimelineCacheMax: 100, perLocalUserUserTimelineCacheMax: 100, perRemoteUserUserTimelineCacheMax: 100, diff --git a/packages/cherrypick-js/CONTRIBUTING.md b/packages/cherrypick-js/CONTRIBUTING.md index aa759345b0..be3232de01 100644 --- a/packages/cherrypick-js/CONTRIBUTING.md +++ b/packages/cherrypick-js/CONTRIBUTING.md @@ -15,7 +15,7 @@ Issueを作成する前に、以下をご確認ください: - 重複を防ぐため、既に同様の内容のIssueが作成されていないか検索してから新しいIssueを作ってください。 - Issueを質問に使わないでください。 - Issueは、要望、提案、問題の報告にのみ使用してください。 - - 質問は、[Misskey Forum](https://forum.misskey.io/)や[Discord](https://discord.gg/Wp8gVStHW3)でお願いします。 + - 質問は、[GitHub Discussions](https://github.com/kokonect-link/cherrypick/discussions)や[Discord](https://discord.gg/V8qghB28Aj)でお願いします。 ## PRの作成 PRを作成する前に、以下をご確認ください: @@ -23,7 +23,7 @@ PRを作成する前に、以下をご確認ください: - fix / refactor / feat / enhance / perf / chore 等 - また、PRの粒度が適切であることを確認してください。ひとつのPRに複数の種類の変更や関心を含めることは避けてください。 - このPRによって解決されるIssueがある場合は、そのIssueへの参照を本文内に含めてください。 -- [`CHANGELOG.md`](/CHANGELOG.md)に変更点を追記してください。リファクタリングなど、利用者に影響を与えない変更についてはこの限りではありません。 +- [`CHANGELOG_CHERRYPICK.md`](/CHANGELOG_CHERRYPICK.md)に変更点を追記してください。リファクタリングなど、利用者に影響を与えない変更についてはこの限りではありません。 - この変更により新たに作成、もしくは更新すべきドキュメントがないか確認してください。 - 機能追加やバグ修正をした場合は、可能であればテストケースを追加してください。 - テスト、Lintが通っていることを予め確認してください。 diff --git a/packages/cherrypick-js/docs/CONTRIBUTING.en.md b/packages/cherrypick-js/docs/CONTRIBUTING.en.md index 1db282e356..2279d27567 100644 --- a/packages/cherrypick-js/docs/CONTRIBUTING.en.md +++ b/packages/cherrypick-js/docs/CONTRIBUTING.en.md @@ -11,7 +11,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues as a question. - Issues should only be used to feature requests, suggestions, and report problems. - - Please ask questions in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions in [GitHub Discussions](https://github.com/kokonect-link/cherrypick/discussions) or [Discord](https://discord.gg/V8qghB28Aj). ## Creating a PR Thank you for your PR! Before creating a PR, please check the following: @@ -19,7 +19,7 @@ Thank you for your PR! Before creating a PR, please check the following: - fix / refactor / feat / enhance / perf / chore etc. - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR. - If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text. -- Please add the summary of the changes to [`CHANGELOG.md`](/CHANGELOG.md). However, this is not necessary for changes that do not affect the users, such as refactoring. +- Please add the summary of the changes to [`CHANGELOG_CHERRYPICK.md`](/CHANGELOG_CHERRYPICK.md). However, this is not necessary for changes that do not affect the users, such as refactoring. - Check if there are any documents that need to be created or updated due to this change. - If you have added a feature or fixed a bug, please add a test case if possible. - Please make sure that tests and Lint are passed in advance. diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 981489e2d9..45f74faa28 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -134,6 +134,20 @@ type Blocking = { // @public (undocumented) type Channel = { id: ID; + lastNotedAt: Date | null; + userId: User['id'] | null; + user: User | null; + name: string; + description: string | null; + bannerId: DriveFile['id'] | null; + banner: DriveFile | null; + pinnedNoteIds: string[]; + color: string; + isArchived: boolean; + notesCount: number; + usersCount: number; + isSensitive: boolean; + allowRenoteToExternal: boolean; }; // Warning: (ae-forgotten-export) The symbol "AnyOf" needs to be exported by the entry point index.d.ts @@ -1510,10 +1524,6 @@ export type Endpoints = { }; res: null; }; - 'i/registry/scopes': { - req: NoParams; - res: string[][]; - }; 'i/registry/set': { req: { key: string; @@ -2553,7 +2563,7 @@ type MeDetailed = UserDetailed & { hasUnreadMessagingMessage: boolean; hasUnreadNotification: boolean; hasUnreadSpecifiedNotes: boolean; - unreadNotificationCount: number; + unreadNotificationsCount: number; hideOnlineStatus: boolean; injectFeaturedNote: boolean; integrations: Record; @@ -2716,10 +2726,22 @@ type ModerationLog = { } | { type: 'deleteAd'; info: ModerationLogPayloads['deleteAd']; +} | { + type: 'createAvatarDecoration'; + info: ModerationLogPayloads['createAvatarDecoration']; +} | { + type: 'updateAvatarDecoration'; + info: ModerationLogPayloads['updateAvatarDecoration']; +} | { + type: 'deleteAvatarDecoration'; + info: ModerationLogPayloads['deleteAvatarDecoration']; +} | { + type: 'resolveAbuseReport'; + info: ModerationLogPayloads['resolveAbuseReport']; }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration"]; // @public (undocumented) export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"]; @@ -2749,6 +2771,8 @@ type Note = { fileIds: DriveFile['id'][]; visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUserIds?: User['id'][]; + channel?: Channel; + channelId?: Channel['id']; localOnly?: boolean; myReaction?: string; reactions: Record; @@ -3056,6 +3080,12 @@ type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + avatarDecorations: { + id: ID; + url: string; + angle?: number; + flipH?: boolean; + }[]; emojis: { name: string; url: string; @@ -3079,9 +3109,9 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts -// src/api.types.ts:662:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:110:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:616:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/api.types.ts:661:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts +// src/entities.ts:116:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:637:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/cherrypick-js/package.json b/packages/cherrypick-js/package.json index c2148ecea5..4c8a103658 100644 --- a/packages/cherrypick-js/package.json +++ b/packages/cherrypick-js/package.json @@ -20,13 +20,13 @@ "url": "git+https://github.com/misskey-dev/misskey.js.git" }, "devDependencies": { - "@microsoft/api-extractor": "7.38.0", + "@microsoft/api-extractor": "7.38.2", "@swc/jest": "0.2.29", - "@types/jest": "29.5.6", - "@types/node": "20.8.7", - "@typescript-eslint/eslint-plugin": "6.8.0", - "@typescript-eslint/parser": "6.8.0", - "eslint": "8.51.0", + "@types/jest": "29.5.7", + "@types/node": "20.8.10", + "@typescript-eslint/eslint-plugin": "6.9.1", + "@typescript-eslint/parser": "6.9.1", + "eslint": "8.52.0", "jest": "29.7.0", "jest-fetch-mock": "3.0.3", "jest-websocket-mock": "2.5.0", @@ -39,7 +39,7 @@ ], "dependencies": { "@swc/cli": "0.1.62", - "@swc/core": "1.3.93", + "@swc/core": "1.3.95", "eventemitter3": "5.0.1", "reconnecting-websocket": "4.4.0" } diff --git a/packages/cherrypick-js/src/api.types.ts b/packages/cherrypick-js/src/api.types.ts index d652e69283..e0180c105b 100644 --- a/packages/cherrypick-js/src/api.types.ts +++ b/packages/cherrypick-js/src/api.types.ts @@ -406,7 +406,6 @@ export type Endpoints = { 'i/registry/keys-with-type': { req: { scope?: string[]; }; res: Record; }; 'i/registry/keys': { req: { scope?: string[]; }; res: string[]; }; 'i/registry/remove': { req: { key: string; scope?: string[]; }; res: null; }; - 'i/registry/scopes': { req: NoParams; res: string[][]; }; 'i/registry/set': { req: { key: string; value: any; scope?: string[]; }; res: null; }; 'i/revoke-token': { req: TODO; res: TODO; }; 'i/signin-history': { req: { limit?: number; sinceId?: Signin['id']; untilId?: Signin['id']; }; res: Signin[]; }; diff --git a/packages/cherrypick-js/src/consts.ts b/packages/cherrypick-js/src/consts.ts index c4ddead823..48a36a31d6 100644 --- a/packages/cherrypick-js/src/consts.ts +++ b/packages/cherrypick-js/src/consts.ts @@ -78,6 +78,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createAvatarDecoration', + 'updateAvatarDecoration', + 'deleteAvatarDecoration', ] as const; export type ModerationLogPayloads = { @@ -239,4 +242,17 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; + updateAvatarDecoration: { + avatarDecorationId: string; + before: any; + after: any; + }; + deleteAvatarDecoration: { + avatarDecorationId: string; + avatarDecoration: any; + }; }; diff --git a/packages/cherrypick-js/src/entities.ts b/packages/cherrypick-js/src/entities.ts index ed3040309a..d304f3f4a6 100644 --- a/packages/cherrypick-js/src/entities.ts +++ b/packages/cherrypick-js/src/entities.ts @@ -16,6 +16,12 @@ export type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + avatarDecorations: { + id: ID; + url: string; + angle?: number; + flipH?: boolean; + }[]; emojis: { name: string; url: string; @@ -100,7 +106,7 @@ export type MeDetailed = UserDetailed & { hasUnreadMessagingMessage: boolean; hasUnreadNotification: boolean; hasUnreadSpecifiedNotes: boolean; - unreadNotificationCount: number; + unreadNotificationsCount: number; hideOnlineStatus: boolean; injectFeaturedNote: boolean; integrations: Record; @@ -201,6 +207,8 @@ export type Note = { fileIds: DriveFile['id'][]; visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUserIds?: User['id'][]; + channel?: Channel; + channelId?: Channel['id']; localOnly?: boolean; myReaction?: string; reactions: Record; @@ -518,7 +526,20 @@ export type FollowRequest = { export type Channel = { id: ID; - // TODO + lastNotedAt: Date | null; + userId: User['id'] | null; + user: User | null; + name: string; + description: string | null; + bannerId: DriveFile['id'] | null; + banner: DriveFile | null; + pinnedNoteIds: string[]; + color: string; + isArchived: boolean; + notesCount: number; + usersCount: number; + isSensitive: boolean; + allowRenoteToExternal: boolean; }; export type Following = { @@ -704,4 +725,16 @@ export type ModerationLog = { } | { type: 'deleteAd'; info: ModerationLogPayloads['deleteAd']; +} | { + type: 'createAvatarDecoration'; + info: ModerationLogPayloads['createAvatarDecoration']; +} | { + type: 'updateAvatarDecoration'; + info: ModerationLogPayloads['updateAvatarDecoration']; +} | { + type: 'deleteAvatarDecoration'; + info: ModerationLogPayloads['deleteAvatarDecoration']; +} | { + type: 'resolveAbuseReport'; + info: ModerationLogPayloads['resolveAbuseReport']; }); diff --git a/packages/cherrypick-js/src/streaming.ts b/packages/cherrypick-js/src/streaming.ts index c641706a4b..bb53bf3d6c 100644 --- a/packages/cherrypick-js/src/streaming.ts +++ b/packages/cherrypick-js/src/streaming.ts @@ -172,8 +172,11 @@ export default class Stream extends EventEmitter { * ! ストリーム上のやり取りはすべてJSONで行われます ! */ public send(typeOrPayload: string): void + public send(typeOrPayload: string, payload: any): void + public send(typeOrPayload: Record | any[]): void + public send(typeOrPayload: string | Record | any[], payload?: any): void { if (typeof typeOrPayload === 'string') { this.stream.send(JSON.stringify({ diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 79d56b91e4..6c082d9d52 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -74,6 +74,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi onlineStatus: 'unknown', avatarUrl: 'https://github.com/kokonect-link/cherrypick/blob/master/packages/frontend/assets/about-icon.png?raw=true', avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + avatarDecorations: [], emojis: [], bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', bannerColor: '#000000', diff --git a/packages/frontend/assets/tutorial/ai.webp b/packages/frontend/assets/tutorial/ai.webp new file mode 100644 index 0000000000..d9d4564942 Binary files /dev/null and b/packages/frontend/assets/tutorial/ai.webp differ diff --git a/packages/frontend/assets/tutorial/natto_failed.webp b/packages/frontend/assets/tutorial/natto_failed.webp new file mode 100644 index 0000000000..87db5f7732 Binary files /dev/null and b/packages/frontend/assets/tutorial/natto_failed.webp differ diff --git a/packages/frontend/assets/tutorial/timeline_tab.png b/packages/frontend/assets/tutorial/timeline_tab.png new file mode 100644 index 0000000000..fdf7e9f36e Binary files /dev/null and b/packages/frontend/assets/tutorial/timeline_tab.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 155023a5e0..5e3f770cfc 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -10,7 +10,7 @@ "build-storybook": "pnpm build-storybook-pre && storybook build", "chromatic": "chromatic", "test": "vitest --run", - "test-and-coverage": "vitest --run --coverage", + "test-and-coverage": "vitest --run --coverage --globals", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" @@ -21,16 +21,17 @@ "@github/webauthn-json": "2.1.1", "@rollup/plugin-alias": "5.0.1", "@rollup/plugin-json": "6.0.1", - "@rollup/plugin-replace": "5.0.4", + "@rollup/plugin-replace": "5.0.5", "@rollup/pluginutils": "5.0.5", "@syuilo/aiscript": "0.16.0", "@tabler/icons-webfont": "2.37.0", "@vitejs/plugin-vue": "4.4.0", "@vue-macros/reactivity-transform": "0.3.23", - "@vue/compiler-sfc": "3.3.5", + "@vue/compiler-sfc": "3.3.7", "astring": "1.8.6", "autosize": "6.0.1", - "broadcast-channel": "5.5.0", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.5", + "broadcast-channel": "6.0.0", "browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "buraha": "0.0.1", "canvas-confetti": "1.6.1", @@ -41,7 +42,7 @@ "chartjs-plugin-zoom": "2.0.1", "cherrypick-js": "workspace:*", "cherrypick-mfm-js": "github:kokonect-link/mfm.js", - "chromatic": "7.4.0", + "chromatic": "7.6.0", "compare-versions": "6.1.0", "cropperjs": "2.0.0-beta.4", "date-fns": "2.30.0", @@ -58,15 +59,16 @@ "pretendard": "^1.3.8", "pretendard-jp": "^1.3.8", "prismjs": "1.29.0", - "punycode": "2.3.0", + "punycode": "2.3.1", "querystring": "0.2.1", - "rollup": "4.1.4", + "rollup": "4.2.0", "sanitize-html": "2.11.0", - "sass": "1.69.4", + "shiki": "^0.14.5", + "sass": "1.69.5", "strict-event-emitter-types": "2.0.0", "temml": "0.10.13", "textarea-caret": "3.1.0", - "three": "0.157.0", + "three": "0.158.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.8", @@ -74,39 +76,39 @@ "twemoji-parser": "14.0.0", "typescript": "5.2.2", "uuid": "9.0.1", - "v-code-diff": "1.7.1", + "v-code-diff": "1.7.2", "vanilla-tilt": "1.8.1", "vite": "4.5.0", - "vue": "3.3.5", + "vue": "3.3.7", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-actions": "7.5.1", - "@storybook/addon-essentials": "7.5.1", - "@storybook/addon-interactions": "7.5.1", - "@storybook/addon-links": "7.5.1", - "@storybook/addon-storysource": "7.5.1", - "@storybook/addons": "7.5.1", - "@storybook/blocks": "7.5.1", - "@storybook/core-events": "7.5.1", + "@storybook/addon-actions": "7.5.2", + "@storybook/addon-essentials": "7.5.2", + "@storybook/addon-interactions": "7.5.2", + "@storybook/addon-links": "7.5.2", + "@storybook/addon-storysource": "7.5.2", + "@storybook/addons": "7.5.2", + "@storybook/blocks": "7.5.2", + "@storybook/core-events": "7.5.2", "@storybook/jest": "0.2.3", - "@storybook/manager-api": "7.5.1", - "@storybook/preview-api": "7.5.1", - "@storybook/react": "7.5.1", - "@storybook/react-vite": "7.5.1", + "@storybook/manager-api": "7.5.2", + "@storybook/preview-api": "7.5.2", + "@storybook/react": "7.5.2", + "@storybook/react-vite": "7.5.2", "@storybook/testing-library": "0.2.2", - "@storybook/theming": "7.5.1", - "@storybook/types": "7.5.1", - "@storybook/vue3": "7.5.1", - "@storybook/vue3-vite": "7.5.1", - "@testing-library/vue": "7.0.0", + "@storybook/theming": "7.5.2", + "@storybook/types": "7.5.2", + "@storybook/vue3": "7.5.2", + "@storybook/vue3-vite": "7.5.2", + "@testing-library/vue": "8.0.0", "@types/autosize": "^4.0.1", "@types/escape-regexp": "0.0.2", - "@types/estree": "1.0.3", + "@types/estree": "1.0.4", "@types/matter-js": "0.19.2", "@types/micromatch": "4.0.4", - "@types/node": "20.8.7", + "@types/node": "20.8.10", "@types/prismjs": "^1.26.0", "@types/punycode": "2.1.1", "@types/sanitize-html": "2.9.3", @@ -115,34 +117,34 @@ "@types/uuid": "9.0.6", "@types/websocket": "1.0.8", "@types/ws": "8.5.8", - "@typescript-eslint/eslint-plugin": "6.8.0", - "@typescript-eslint/parser": "6.8.0", + "@typescript-eslint/eslint-plugin": "6.9.1", + "@typescript-eslint/parser": "6.9.1", "@vitest/coverage-v8": "0.34.6", - "@vue/runtime-core": "3.3.5", - "acorn": "8.10.0", + "@vue/runtime-core": "3.3.7", + "acorn": "8.11.2", "cross-env": "7.0.3", - "cypress": "13.3.2", - "eslint": "8.51.0", - "eslint-plugin-import": "2.28.1", + "cypress": "13.4.0", + "eslint": "8.52.0", + "eslint-plugin-import": "2.29.0", "eslint-plugin-storybook": "^0.6.13", - "eslint-plugin-vue": "9.17.0", + "eslint-plugin-vue": "9.18.1", "fast-glob": "3.3.1", "happy-dom": "10.0.3", "micromatch": "4.0.5", "msw": "1.3.2", - "msw-storybook-addon": "1.9.0", + "msw-storybook-addon": "1.10.0", "nodemon": "3.0.1", "prettier": "3.0.3", "react": "18.2.0", "react-dom": "18.2.0", "start-server-and-test": "2.0.1", - "storybook": "7.5.1", + "storybook": "7.5.2", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", "vite-plugin-turbosnap": "1.0.3", "vitest": "0.34.6", "vitest-fetch-mock": "0.2.2", "vue-eslint-parser": "9.3.2", - "vue-tsc": "1.8.19" + "vue-tsc": "1.8.22" } } diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index b801234000..f669fa668d 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -189,7 +189,7 @@ export async function common(createVue: () => App) { defaultStore.set('darkMode', isDeviceDarkmode()); } - window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { if (ColdDeviceStorage.get('syncDeviceDarkMode')) { defaultStore.set('darkMode', mql.matches); } diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 75059fbabc..7f388991f9 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -8,7 +8,7 @@ import { common } from './common.js'; import { version, ui, lang, updateLocale } from '@/config.js'; import { i18n, updateI18n } from '@/i18n.js'; import { confirm, alert, post, popup, welcomeToast } from '@/os.js'; -import { useStream } from '@/stream.js'; +import { useStream, isReloading } from '@/stream.js'; import * as sound from '@/scripts/sound.js'; import { $i, refreshAccount, login, updateAccount, signout } from '@/account.js'; import { defaultStore, ColdDeviceStorage } from '@/store.js'; @@ -42,6 +42,7 @@ export async function mainBoot() { let reloadDialogShowing = false; stream.on('_disconnected_', async () => { + if (isReloading) return; if (defaultStore.state.serverDisconnectedBehavior === 'reload') { location.reload(); } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { @@ -230,15 +231,15 @@ export async function mainBoot() { main.on('readAllNotifications', () => { updateAccount({ hasUnreadNotification: false, - unreadNotificationCount: 0, + unreadNotificationsCount: 0, }); }); main.on('unreadNotification', () => { - const unreadNotificationCount = ($i?.unreadNotificationCount ?? 0) + 1; + const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1; updateAccount({ hasUnreadNotification: true, - unreadNotificationCount, + unreadNotificationsCount, }); }); diff --git a/packages/frontend/src/components/MkChatPreview.vue b/packages/frontend/src/components/MkChatPreview.vue index f11339940b..f5ca5d09c3 100644 --- a/packages/frontend/src/components/MkChatPreview.vue +++ b/packages/frontend/src/components/MkChatPreview.vue @@ -149,7 +149,7 @@ function isMe(message) { @container (max-width: 500px) { .message { > div { - padding: 20px 30px; + padding: 14px 20px; font-size: .9em; } } diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 9bb11d7db8..232a8b66e4 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -5,21 +5,90 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a52b4fd41b..38a40f4dd5 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -4,11 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue new file mode 100644 index 0000000000..46acbc9f25 --- /dev/null +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -0,0 +1,166 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index f8184317cd..bed00859c4 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -47,6 +47,7 @@ export default defineComponent({ setup(props, { slots, expose }) { const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫 + function getDateText(time: string) { const date = new Date(time).getDate(); const month = new Date(time).getMonth() + 1; @@ -128,6 +129,7 @@ export default defineComponent({ el.style.top = `${el.offsetTop}px`; el.style.left = `${el.offsetLeft}px`; } + function onLeaveCanceled(el: HTMLElement) { el.style.top = ''; el.style.left = ''; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 09efe161b9..7173a1392d 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -162,6 +162,7 @@ async function ok() { function cancel() { done(true); } + /* function onBgClick() { if (props.cancelableByBgClick) cancel(); diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 4ac66261b8..34f908df1d 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -47,6 +47,7 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { useRouter } from '@/router.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; +import { deviceKind } from '@/scripts/device-kind.js'; const router = useRouter(); @@ -74,7 +75,11 @@ function onClick(ev: MouseEvent) { if (props.selectMode) { emit('chosen', props.file); } else { - router.push(`/my/drive/file/${props.file.id}`); + if (deviceKind === 'desktop') { + router.push(`/my/drive/file/${props.file.id}`); + } else { + os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + } } } diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index cbd7bb79c8..510888d58d 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -505,6 +505,7 @@ function appendFile(file: Misskey.entities.DriveFile) { function appendFolder(folderToAppend: Misskey.entities.DriveFolder) { addFolder(folderToAppend); } + /* function prependFile(file: Misskey.entities.DriveFile) { addFile(file, true); diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index 006b0662e6..42253a4f1c 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 6af2f5fbaa..d10c463250 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -256,10 +276,21 @@ async function later(later: boolean) { box-sizing: border-box; } +.pageRoot { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.pageMain { + flex-grow: 1; +} + .pageFooter { position: sticky; bottom: 0; left: 0; + flex-shrink: 0; padding: 12px; border-top: solid 0.5px var(--divider); -webkit-backdrop-filter: blur(15px); diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index bf9ed0087f..81cda75da5 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.visibility }}
-