diff --git a/.github/workflows/check-spdx-license-id.yml b/.github/workflows/check-spdx-license-id.yml deleted file mode 100644 index 05582008b5f2..000000000000 --- a/.github/workflows/check-spdx-license-id.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Check SPDX-License-Identifier - -on: - push: - branches: - - master - - develop - pull_request: - -jobs: - check-spdx-license-id: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - - name: Check - run: | - counter=0 - - search() { - local directory="$1" - find "$directory" -type f \ - '(' \ - -name "*.cjs" -and -not -name '*.config.cjs' -o \ - -name "*.html" -o \ - -name "*.js" -and -not -name '*.config.js' -o \ - -name "*.mjs" -and -not -name '*.config.mjs' -o \ - -name "*.scss" -o \ - -name "*.ts" -and -not -name '*.config.ts' -o \ - -name "*.vue" \ - ')' -and \ - -not -name '*eslint*' - } - - check() { - local file="$1" - if ! ( - grep -q "SPDX-FileCopyrightText: syuilo and misskey-project" "$file" || - grep -q "SPDX-License-Identifier: AGPL-3.0-only" "$file" - ); then - echo "Missing: $file" - ((counter++)) - fi - } - - directories=( - "cypress/e2e" - "packages/backend/migration" - "packages/backend/src" - "packages/backend/test" - "packages/frontend-shared/@types" - "packages/frontend-shared/js" - "packages/frontend/.storybook" - "packages/frontend/@types" - "packages/frontend/lib" - "packages/frontend/public" - "packages/frontend/src" - "packages/frontend/test" - "packages/frontend-embed/@types" - "packages/frontend-embed/src" - "packages/misskey-bubble-game/src" - "packages/misskey-reversi/src" - "packages/sw/src" - "scripts" - ) - - for directory in "${directories[@]}"; do - for file in $(search $directory); do - check "$file" - done - done - - if [ $counter -gt 0 ]; then - echo "SPDX-License-Identifier is missing in $counter files." - exit 1 - else - echo "SPDX-License-Identifier is certainly described in all target files!" - exit 0 - fi diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml deleted file mode 100644 index ac2b1b4d358a..000000000000 --- a/.github/workflows/docker-develop.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Publish Docker image (develop) - -on: - push: - branches: - - develop - workflow_dispatch: - -env: - REGISTRY_IMAGE: misskey/misskey - -jobs: - # see https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners - build: - name: Build - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - platform: - - linux/amd64 - - linux/arm64 - if: github.repository == 'misskey-dev/misskey' - steps: - - name: Prepare - run: | - platform=${{ matrix.platform }} - echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - - name: Check out the repo - uses: actions/checkout@v4.1.1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push by digest - id: build - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: ${{ matrix.platform }} - provenance: false - labels: develop - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true - - name: Export digest - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - name: Upload digest - uses: actions/upload-artifact@v4 - with: - name: digests-${{ env.PLATFORM_PAIR }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - runs-on: ubuntu-latest - needs: - - build - steps: - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Create manifest list and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create --tag ${{ env.REGISTRY_IMAGE }}:develop \ - $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - - name: Inspect image - run: | - docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:develop diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index db899ba386be..e24089a09cfe 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,7 +16,6 @@ env: type=semver,pattern={{major}} jobs: - # see https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners build: name: Build runs-on: ubuntu-latest @@ -26,80 +25,27 @@ jobs: platform: - linux/amd64 - linux/arm64 + if: github.repository == 'team-shahu/misskey' steps: - - name: Prepare - run: | - platform=${{ matrix.platform }} - echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - - name: Check out the repo - uses: actions/checkout@v4.1.1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY_IMAGE }} - tags: ${{ env.TAGS }} - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and Push to Docker Hub - id: build - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: ${{ matrix.platform }} - provenance: false - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true - - name: Export digest - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - name: Upload digest - uses: actions/upload-artifact@v4 - with: - name: digests-${{ env.PLATFORM_PAIR }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - runs-on: ubuntu-latest - needs: - - build - steps: - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - uses: actions/checkout@v3 - name: Docker meta id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY_IMAGE }} tags: ${{ env.TAGS }} - - name: Login to Docker Hub - uses: docker/login-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Create manifest list and push - working-directory: /tmp/digests + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build Docker Image run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - - name: Inspect image + docker build --tag ghcr.io/${{ github.repository_owner }}/misskey:latest \ + --tag ghcr.io/${{ github.repository_owner }}/misskey:$${{ steps.meta.outputs.version }} \ + . + - name: Push Docker Image run: | - docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} + docker push ghcr.io/${{ github.repository_owner }}/misskey:latest + docker push ghcr.io/${{ github.repository_owner }}/misskey:$${{ steps.meta.outputs.version }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml deleted file mode 100644 index c02f38ee0b61..000000000000 --- a/.github/workflows/storybook.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Storybook - -on: - push: - branches: - - master - - develop - - dev/storybook8 # for testing - pull_request_target: - branches-ignore: - # Since pull requests targets master mostly is the "develop" branch. - # Storybook CI is checked on the "push" event of "develop" branch so it would cause a duplicate build. - # This is a waste of chromatic build quota, so we don't run storybook CI on pull requests targets master. - - master - -jobs: - build: - runs-on: ubuntu-latest - - env: - NODE_OPTIONS: "--max_old_space_size=7168" - - steps: - - uses: actions/checkout@v4.1.1 - if: github.event_name != 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - - uses: actions/checkout@v4.1.1 - if: github.event_name == 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - ref: "refs/pull/${{ github.event.number }}/merge" - - name: Checkout actual HEAD - if: github.event_name == 'pull_request_target' - id: rev - run: | - echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT - git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3) - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.4 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: corepack enable - - run: pnpm i --frozen-lockfile - - name: Check pnpm-lock.yaml - run: git diff --exit-code pnpm-lock.yaml - - name: Build misskey-js - run: pnpm --filter misskey-js build - - name: Build storybook - run: pnpm --filter frontend build-storybook - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref != 'refs/heads/master' - id: chromatic_push - run: | - DIFF="${{ github.event.before }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - if pnpm --filter frontend chromatic -d storybook-static $(echo "$CHROMATIC_PARAMETER"); then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name == 'pull_request_target' - id: chromatic_pull_request - run: | - DIFF="${{ steps.rev.outputs.base }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - BRANCH="${{ github.event.pull_request.head.user.login }}:$HEAD_REF" - if [ "$BRANCH" = "misskey-dev:$HEAD_REF" ]; then - BRANCH="$HEAD_REF" - fi - pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name "$BRANCH" $(echo "$CHROMATIC_PARAMETER") - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Notify that Chromatic detects changes - uses: actions/github-script@v7.0.1 - if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false' - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - github.rest.repos.createCommitComment({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha, - body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).' - }) - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: storybook - path: packages/frontend/storybook-static diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa756c0f050..3d51fc283d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -450,6 +450,20 @@ - Fix: もともとセンシティブではないと連合されていたファイルがセンシティブとして連合された場合にセンシティブとしてそのファイルを扱うように - センシティブとして連合したファイルは非センシティブとして連合されてもセンシティブとして扱われます +## 独自機能 +- Feat: rootの切り替え機能 [#1](https://github.com/n1lsqn/misskey/pull/1) +- Feat: KaTeXの実装 [#2](https://github.com/n1lsqn/misskey/pull/2) +- Feat: ロールにチャンネルが使えるかどうかの権限を追加 +- Feat: CWに何も書かなくても投稿できるようにする +- Feat: リモートユーザーのアイコンデコレーションを表示する +- Feat: 文字数制限を9000に緩和 +- Feat: アンチスパムモードの追加 +- Feat: 他のサーバーのLTLを覗けるようにする [#62](https://github.com/n1lsqn/misskey/pull/62) +- Fix: 通知バグの解消 [#64](https://github.com/n1lsqn/misskey/pull/64) +- Feat: 日の入り/日の出に合わてダークモードを変える [#65](https://github.com/n1lsqn/misskey/pull/65) +- Feat: ファイル名をランダム化できるように [#65](https://github.com/n1lsqn/misskey/pull/65) +- Feat: 自動的にデータセーバーを切り替える機能 [#65](https://github.com/n1lsqn/misskey/pull/65) + ## 2024.3.1 ### General diff --git a/README.md b/README.md index 92e8fef6396e..bf459baa29ab 100644 --- a/README.md +++ b/README.md @@ -8,42 +8,43 @@ [Learn more](https://misskey-hub.net/) --- - - - find an instance - - - create an instance - - - become a contributor - - - join the community - - - become a patron - -## Thanks - -Sentry - -Thanks to [Sentry](https://sentry.io/) for providing the error tracking platform that helps us catch unexpected errors. - -Chromatic - -Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. - -Codecov - -Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage. - -Crowdin - -Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages. - -Docker +# これは何? +[しゃふすきー](https://shahu.ski)で使われている[Misskey](https://github.com/misskey-dev/misskey)のフォークです。 + +## 独自機能 +- ノートを一定期間で自動消去する「すぐ消す」機能 不明, https://github.com/team-shahu/misskey/pull/32 +- チャンネル内お知らせ機能 https://github.com/team-shahu/misskey/pull/2 +- 他インスタンスの絵文字でもローカルに存在すればリアクションできるように +- フォローリクエストを自動的に拒否する機能 +- 絵文字のクリックメニューに「絵文字ピッカーに追加」を追加 +- 投稿フォームのツールバーを任意にカスタマイズできるように +- 下書き機能 +- 同じ音源が短時間で重複して流れないように +- 通知にフォロバボタンを表示 https://github.com/team-shahu/misskey/pull/5 +- 二要素認証のバックアップコードを保存するように促すダイアログを表示するように +- カスタムフォント機能 +- 絵文字を登録したユーザーがアカウントを消去しても継続して絵文字の使用ができるように https://github.com/team-shahu/misskey/pull/11 +- アバターデコレーションを登録したユーザーがアカウントを消去しても継続して使用ができるように +- アバターデコレーションをmisskeyUI上から登録できるように https://github.com/team-shahu/misskey/pull/12 +- TL上のサーバー情報をアイコン表示に切り替えられるように https://github.com/team-shahu/misskey/pull/13 https://github.com/team-shahu/misskey/pull/24 +- 特定のロールにのみお知らせを発行する機能 https://github.com/team-shahu/misskey/pull/18 +- リアクションした人一覧がブロック・ミュートを考慮するようにする設定 https://github.com/team-shahu/misskey/pull/23 https://github.com/team-shahu/misskey/pull/27 +- 誰がリアクションをしたのかを非表示にできる機能 https://github.com/hideki0403/kakurega.app/commit/65d85bb4fe724dc0737f1ac7958bc13c96cc926d +- 誰がリアクションをしたのかを非表示にできる機能 https://github.com/team-shahu/misskey/pull/35 (https://github.com/team-shahu/misskey/commit/5b2923c8127336d7fd2ee39c76d16f8a30d1b9e1) +- 任意のTLを非表示にできるように https://github.com/team-shahu/misskey/pull/36 +- プロフィールからアクティビティとファイルを隠せるようにする https://github.com/team-shahu/misskey/pull/37 +- フォローしているユーザーなら鍵ノートでもアンテナにひっかかるように https://github.com/team-shahu/misskey/pull/38 +- nyaizeを無効化できるように https://github.com/team-shahu/misskey/pull/39 +- 新着ノート通知があった時まとめるように https://github.com/team-shahu/misskey/pull/40 +- いいねボタンの実装 https://github.com/team-shahu/misskey/pull/41 https://github.com/team-shahu/misskey/pull/44 https://github.com/team-shahu/misskey/pull/45 +- 独自機能ページの追加 https://github.com/team-shahu/misskey/pull/42 +- 予約投稿機能 https://github.com/team-shahu/misskey/pull/46 + +## Special Thanks +- [Misskey](https://github.com/misskey-dev/misskey) +- [にる村](https://github.com/n1lsqn/misskey) +- [隠れ家](https://github.com/hideki0403/kakurega.app) +- [Misskey.io](https://github.com/MisskeyIO/misskey) -Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. diff --git a/locales/en-US.yml b/locales/en-US.yml index 69e6da1a6f44..3910e4b21eec 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -73,6 +73,7 @@ receiveFollowRequest: "Follow request received" followRequestAccepted: "Follow request accepted" mention: "Mention" mentions: "Mentions" +newNotes: "New Notes" directNotes: "Direct notes" importAndExport: "Import / Export" import: "Import" @@ -381,6 +382,9 @@ disconnectService: "Disconnect" enableLocalTimeline: "Enable local timeline" enableGlobalTimeline: "Enable global timeline" disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all timelines, even if they are not enabled." +hideLocalTimeLine: "Hide local timeline" +hideSocialTimeLine: "Hide social timeline" +hideGlobalTimeLine: "Hide global timeline" registration: "Register" invite: "Invite" driveCapacityPerLocalAccount: "Drive capacity per local user" @@ -533,6 +537,7 @@ createAccount: "Create account" existingAccount: "Existing account" regenerate: "Regenerate" fontSize: "Font size" +customFont: "Custom Fonts" mediaListWithOneImageAppearance: "Height of media lists with one image only" limitTo: "Limit to {x}" noFollowRequests: "You don't have any pending follow requests" @@ -703,6 +708,8 @@ notificationSetting: "Notification settings" notificationSettingDesc: "Select the types of notification to display." useGlobalSetting: "Use global settings" useGlobalSettingDesc: "If turned on, your account's notification settings will be used. If turned off, individual configurations can be made." +autoRejectFollowRequest: "Automatic rejection of follow requests" +autoRejectFollowRequestDescription: "Enables automatic rejection of follow requests. If \"Automatically approve follow requests from users you are following\" is turned on, follow requests from users you are following will be automatically approved, and follow requests from other users will be automatically rejected." other: "Other" regenerateLoginToken: "Regenerate login token" regenerateLoginTokenDescription: "Regenerates the token used internally during login. Normally this action is not necessary. If regenerated, all devices will be logged out." @@ -726,6 +733,7 @@ openInSideView: "Open in side view" defaultNavigationBehaviour: "Default navigation behavior" editTheseSettingsMayBreakAccount: "Editing these settings may damage your account." instanceTicker: "Instance information of notes" +instanceIcon: "Display only the instance's logo on the timeline" waitingFor: "Waiting for {x}" random: "Random" system: "System" @@ -993,6 +1001,7 @@ cannotUploadBecauseInappropriate: "This file could not be uploaded because parts cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity." cannotUploadBecauseExceedsFileSizeLimit: "This file cannot be uploaded as it exceeds the file size limit." beta: "Beta" +originalFeature: "Original feature" enableAutoSensitive: "Automatic marking as sensitive" enableAutoSensitiveDescription: "Allows automatic detection and marking of sensitive media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide." activeEmailValidationDescription: "Enables stricter validation of email addresses, which includes checking for disposable addresses and by whether it can actually be communicated with. When unchecked, only the format of the email is validated." @@ -1217,6 +1226,7 @@ feedback: "Feedback" feedbackUrl: "Feedback URL" impressum: "Impressum" impressumUrl: "Impressum URL" +shahuPortal: "shahu.ski Portal" impressumDescription: "In some countries, like germany, the inclusion of operator contact information (an Impressum) is legally required for commercial websites." privacyPolicy: "Privacy Policy" privacyPolicyUrl: "Privacy Policy URL" @@ -1328,6 +1338,22 @@ _delivery: manuallySuspended: "Manually suspended" goneSuspended: "Server is suspended due to server deletion" autoSuspendedForNotResponding: "Server is suspended due to no responding" +selectReaction: "Select reactions to use with the Like button" +scheduledNoteDelete: "Scheduled note deletion" +noteDeletationAt: "This note will be deleted at {time}." +addToEmojiPicker: "Add to Emoji Picker" +channelAnnouncementDescription: "This announcement will appear at the top of the channel's timeline. The first line is displayed as the title, and the second and subsequent lines are displayed by tapping the announcement." +postForm: "Post Form" +postFormBottomSettingsDescription: "You can reorder the items displayed at the bottom of the submission form. Click on an item to delete it." +drafts: "Drafts" +draftSavingBehavior: "Behavior related to saving drafts." +saveAsDraft: "Save as Drafts" +draftOverwriteConfirm: "Applying a draft will reset what is currently entered. Are you sure?" +defaultScheduledNoteDelete: "Make notes disappear by default." +cannotScheduleLaterThanOneYear: "Scheduled note deletion” is enabled." +defaultScheduledNoteDeleteTime: "Default value for “Scheduled note deletion”" +scheduledNoteDeleteEnabled: "“Scheduled note deletion” is enabled." + _bubbleGame: howToPlay: "How to play" hold: "Hold" @@ -1343,6 +1369,7 @@ _bubbleGame: section1: "Adjust the position and drop the object into the box." section2: "When two objects of the same type touch each other, they will change into a different object and you score points." section3: "The game is over when objects overflow from the box. Aim for a high score by fusing objects together while you avoid overflowing the box!" +disableNoteNyaize: "Disable nyaize" _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." @@ -2079,9 +2106,12 @@ _2fa: renewTOTPCancel: "Cancel" checkBackupCodesBeforeCloseThisWizard: "Before you close this window, please note the following backup codes." backupCodes: "Backup codes" - backupCodesDescription: "You can use these codes to gain access to your account in case of becoming unable to use your two-factor authentificator app. Each can only be used once. Please keep them in a safe place." - backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentification as soon as possible if you are no longer able to use it." - backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentification app, you will be unable to access this account. Please reconfigure two-factor authentification." + backupCodesDescription: "You can use these codes to gain access to your account in case of becoming unable to use your two-factor authenticator app. Each can only be used once. Please keep them in a safe place." + backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentication as soon as possible if you are no longer able to use it." + backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentication app, you will be unable to access this account. Please reconfigure two-factor authentication." + backupCodesSavedConfirmTitle: "Did you save your backup codes?" + backupCodesSavedConfirmDescription: "If you lose both your two-factor authentication app and backup codes, YOU WILL LOSE ACCESS TO YOUR ACCOUNT.\nKeep them safe and secure, and do not share them with anyone.\n\n$[x2 Two-factor authentication settings CANNOT be changed by anyone other than yourself, $[fg.color=red AND THE ADMINISTRATOR CANNOT DISABLE IT EITHER.]]" + backupCodesSavedConfirmChecked: "I have saved my backup codes" moreDetailedGuideHere: "Here is detailed guide" _permissions: "read:account": "View your account information" @@ -2099,6 +2129,8 @@ _permissions: "read:mutes": "View your list of muted users" "write:mutes": "Edit your list of muted users" "write:notes": "Compose or delete notes" + "read:notes-schedule": "View your list of scheduled notes" + "write:notes-schedule": "Compose or delete scheduled notes" "read:notifications": "View your notifications" "write:notifications": "Manage your notifications" "read:reactions": "View your reactions" @@ -2713,11 +2745,30 @@ _mediaControls: pip: "Picture in Picture" playbackRate: "Playback Speed" loop: "Loop playback" +_hideReactionCount: + none: "Do not hide" + self: "Own notes only" + others: "Only notes other than your own" + all: "All" +_profileHiddenSettings: + hiddenProfile: "Ability to hide profile" + hiddenPinnedNotes: "Hide pinned notes from your profile" + hiddenPinnedNotesDescription: "You can hide pinned notes to keep your profile page clear." + hiddenActivity: "Hide pinned notes to keep your profile page clear." + hiddenActivityDescription: "Hiding activities from your profile will keep your profile page clean and uncluttered." + hiddenFiles: "Hide the file from your profile." + hiddenFilesDescription: "Hiding the file will make your profile page look cleaner." +_draftSavingBehavior: + auto: "Automatically save" + manual: "confirm each time" _contextMenu: title: "Context menu" app: "Application" appWithShift: "Application with shift key" native: "Native" +_reactionChecksMuting: + title: "Check mutings when get reactions" + caption: "Check mutings when get reactions, but cache does not work and may increase traffic" _embedCodeGen: title: "Customize embed code" header: "Show header" diff --git a/locales/index.d.ts b/locales/index.d.ts index 0ae188f1f7f8..6d14f780bffa 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -310,6 +310,10 @@ export interface Locale extends ILocale { * あなた宛て */ "mentions": string; + /** + * 新規投稿 + */ + "newNotes": string; /** * ダイレクト投稿 */ @@ -1542,6 +1546,18 @@ export interface Locale extends ILocale { * これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。 */ "disablingTimelinesInfo": string; + /** + * ローカルタイムラインを非表示にする + */ + "hideLocalTimeLine": string; + /** + * ソーシャルタイムラインを非表示にする + */ + "hideSocialTimeLine": string; + /** + * グローバルタイムラインを非表示にする + */ + "hideGlobalTimeLine": string; /** * 登録 */ @@ -2150,6 +2166,10 @@ export interface Locale extends ILocale { * フォントサイズ */ "fontSize": string; + /** + * カスタムフォント + */ + "customFont": string; /** * 画像が1枚のみのメディアリストの高さ */ @@ -2830,6 +2850,14 @@ export interface Locale extends ILocale { * オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。 */ "useGlobalSettingDesc": string; + /** + * フォローリクエストを自動で拒否する + */ + "autoRejectFollowRequest": string; + /** + * フォローリクエストを自動で拒否するようにします。「フォロー中ユーザーからのフォロリクを自動承認」がONになっている場合は、フォロー中ユーザーからのフォローリクエストは自動的に承認され、それ以外のユーザーからのフォローリクエストは自動的に拒否されるようになります。 + */ + "autoRejectFollowRequestDescription": string; /** * その他 */ @@ -2922,6 +2950,10 @@ export interface Locale extends ILocale { * ノートのサーバー情報 */ "instanceTicker": string; + /** + * サーバー情報をアイコンのみにする + */ + "instanceIcon": string; /** * {x}を待っています */ @@ -3990,6 +4022,10 @@ export interface Locale extends ILocale { * ベータ */ "beta": string; + /** + * 独自機能 + */ + "originalFeature": string; /** * 自動センシティブ判定 */ @@ -4734,6 +4770,10 @@ export interface Locale extends ILocale { * あなたへ */ "forYou": string; + /** + * あなたのロールへ + */ + "forYourRoles": string; /** * 現在のお知らせ */ @@ -4886,6 +4926,10 @@ export interface Locale extends ILocale { * 運営者情報URL */ "impressumUrl": string; + /** + * しゃふすきーポータル + */ + "shahuPortal": string; /** * ドイツなどの一部の国と地域では表示が義務付けられています(Impressum)。 */ @@ -5066,6 +5110,50 @@ export interface Locale extends ILocale { * リトライ */ "gameRetry": string; + /** + * アクティビティを非公開にする + */ + "hideActivity": string; + /** + * 自分のプロフィールのアクティビティ (概要/アクティビティタブ) を他人が見れないようにします。このオプションを有効にしても、自分であればプロフィールのアクティビティタブから引き続き閲覧できます。 + */ + "hideActivityDescription": string; + /** + * 投稿フォームをリセット + */ + "clearPost": string; + /** + * 絵文字ピッカーに追加 + */ + "addToEmojiPicker": string; + /** + * リアクション数の非表示 + */ + "hideReactionCount": string; + /** + * 誰がリアクションをしたのかを非表示にする + */ + "hideReactionUsers": string; + /** + * リアクションをホバーした際のユーザー一覧と、ノート詳細ページのリアクションタブにあるリアクションをしたユーザー一覧を非表示にします + */ + "hideReactionUsersDescription": string; + /** + * デフォルトでノートが消えるように + */ + "defaultScheduledNoteDelete": string; + /** + * 1年以上先の日時を指定することはできません + */ + "cannotScheduleLaterThanOneYear": string; + /** + * 「すぐ消す」の初期値 + */ + "defaultScheduledNoteDeleteTime": string; + /** + * 「すぐ消す」が有効になっています + */ + "scheduledNoteDeleteEnabled": string; /** * 使用しない場合は空欄にしてください */ @@ -5106,6 +5194,42 @@ export interface Locale extends ILocale { * お問い合わせ */ "inquiry": string; + /** + * すぐ消す + */ + "scheduledNoteDelete": string; + /** + * このノートは{time}に削除されます + */ + "noteDeletationAt": ParameterizedString<"time">; + /** + * このお知らせはチャンネルのタイムライン上部に表示されます。最初の1行がタイトルとして表示され、2行目以降はお知らせをタップすることで表示されるようになります。 + */ + "channelAnnouncementDescription": string; + /** + * 投稿フォーム + */ + "postForm": string; + /** + * 投稿フォームの下部に表示される項目の並び替えが出来ます。項目をクリックすると削除できます。 + */ + "postFormBottomSettingsDescription": string; + /** + * 下書き + */ + "drafts": string; + /** + * 下書きの保存に関する動作 + */ + "draftSavingBehavior": string; + /** + * 下書きとして保存 + */ + "saveAsDraft": string; + /** + * 下書きを適用すると現在入力されている内容はリセットされます。よろしいですか? + */ + "draftOverwriteConfirm": string; /** * もう一度お試しください。 */ @@ -5327,6 +5451,14 @@ export interface Locale extends ILocale { "autoSuspendedForNotResponding": string; }; }; + /** + * いいねボタンで使うリアクションを選択 + */ + "selectReaction": string; + /** + * いいねボタンを表示する + */ + "showLikeButton": string; "_bubbleGame": { /** * 遊び方 @@ -5381,6 +5513,10 @@ export interface Locale extends ILocale { "section3": string; }; }; + /** + * nayizeを無効化する + */ + "disableNoteNyaize": string; "_announcement": { /** * 既存ユーザーのみ @@ -7001,6 +7137,10 @@ export interface Locale extends ILocale { * リストのインポートを許可 */ "canImportUserLists": string; + /** + * 予約投稿の最大数 + */ + "scheduleNoteMax": string; }; "_condition": { /** @@ -8096,6 +8236,21 @@ export interface Locale extends ILocale { * バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。 */ "backupCodesExhaustedWarning": string; + /** + * バックアップコードを保存しましたか? + */ + "backupCodesSavedConfirmTitle": string; + /** + * 二要素認証アプリとバックアップコードの両方を紛失した場合、アカウントにアクセスできなくなります。 + * 誰とも共有せず、適切な方法で保管してください。 + * + * $[x2 二要素認証設定は自分以外の誰にも変更できませんので、$[fg.color=red 運営チームも無効化することはできません。]] + */ + "backupCodesSavedConfirmDescription": string; + /** + * バックアップコードを保存しました + */ + "backupCodesSavedConfirmChecked": string; /** * 詳細なガイドはこちら */ @@ -8438,6 +8593,14 @@ export interface Locale extends ILocale { * 違反を報告する */ "write:report-abuse": string; + /** + * 予約投稿を見る + */ + "read:notes-schedule": string; + /** + * 予約投稿を作成・削除する + */ + "write:notes-schedule": string; }; "_auth": { /** @@ -9400,6 +9563,14 @@ export interface Locale extends ILocale { * ロールが付与されました */ "roleAssigned": string; + /** + * 予約投稿に失敗しました + */ + "scheduledNoteFailed": string; + /** + * 予約投稿をノートしました + */ + "scheduledNotePosted": string; /** * プッシュ通知の更新をしました */ @@ -9436,6 +9607,10 @@ export interface Locale extends ILocale { * {n}人がリノートしました */ "renotedBySomeUsers": ParameterizedString<"n">; + /** + * {n}件の新しい投稿 + */ + "notedBySomeUsers": ParameterizedString<"n">; /** * {n}人にフォローされました */ @@ -10497,6 +10672,46 @@ export interface Locale extends ILocale { */ "loop": string; }; + "_profileHiddenSettings": { + /** + * プロフィールを非表示にする機能 + */ + "hiddenProfile": string; + /** + * プロフィール上からピン留めしたノートを非表示にします + */ + "hiddenPinnedNotes": string; + /** + * ピン留めしたノートを非表示にすることで、プロフィールページをスッキリさせることができます。 + */ + "hiddenPinnedNotesDescription": string; + /** + * プロフィール上からアクティビティを非表示にします + */ + "hiddenActivity": string; + /** + * プロフィール上からアクティビティを非表示にすることで、プロフィールページをスッキリさせることができます。 + */ + "hiddenActivityDescription": string; + /** + * プロフィール上からファイルを非表示にします。 + */ + "hiddenFiles": string; + /** + * ファイルを非表示にすることで、プロフィールページをスッキリさせることができます。 + */ + "hiddenFilesDescription": string; + }; + "_draftSavingBehavior": { + /** + * 自動的に保存する + */ + "auto": string; + /** + * 都度確認する + */ + "manual": string; + }; "_contextMenu": { /** * コンテキストメニュー @@ -10515,6 +10730,16 @@ export interface Locale extends ILocale { */ "native": string; }; + "_reactionChecksMuting": { + /** + * リアクションでミュートを考慮する + */ + "title": string; + /** + * リアクションがミュートを考慮しますが、キャッシュが効かず通信量が増えることがあります。 + */ + "caption": string; + }; "_embedCodeGen": { /** * 埋め込みコードをカスタマイズ @@ -10601,6 +10826,32 @@ export interface Locale extends ILocale { */ "sent": string; }; + "_hideReactionCount": { + /** + * 非表示にしない + */ + "none": string; + /** + * 自分のノートのみ + */ + "self": string; + /** + * 自分以外のノートのみ + */ + "others": string; + /** + * 全てのノート + */ + "all": string; + }; + /** + * 予約投稿 + */ + "schedulePost": string; + /** + * 予約投稿一覧 + */ + "schedulePostList": string; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1b59708d8530..a23278c21267 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -73,6 +73,7 @@ receiveFollowRequest: "フォローリクエストされました" followRequestAccepted: "フォローが承認されました" mention: "メンション" mentions: "あなた宛て" +newNotes: "新規投稿" directNotes: "ダイレクト投稿" importAndExport: "インポートとエクスポート" import: "インポート" @@ -381,6 +382,9 @@ disconnectService: "切断する" enableLocalTimeline: "ローカルタイムラインを有効にする" enableGlobalTimeline: "グローバルタイムラインを有効にする" disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。" +hideLocalTimeLine: "ローカルタイムラインを非表示にする" +hideSocialTimeLine: "ソーシャルタイムラインを非表示にする" +hideGlobalTimeLine: "グローバルタイムラインを非表示にする" registration: "登録" invite: "招待" driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" @@ -533,6 +537,7 @@ createAccount: "アカウントを作成" existingAccount: "既存のアカウント" regenerate: "再生成" fontSize: "フォントサイズ" +customFont: "カスタムフォント" mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" limitTo: "{x}を上限に" noFollowRequests: "フォロー申請はありません" @@ -703,6 +708,8 @@ notificationSetting: "通知設定" notificationSettingDesc: "表示する通知の種別を選択してください。" useGlobalSetting: "グローバル設定を使う" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。" +autoRejectFollowRequest: "フォローリクエストを自動で拒否する" +autoRejectFollowRequestDescription: "フォローリクエストを自動で拒否するようにします。「フォロー中ユーザーからのフォロリクを自動承認」がONになっている場合は、フォロー中ユーザーからのフォローリクエストは自動的に承認され、それ以外のユーザーからのフォローリクエストは自動的に拒否されるようになります。" other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。" @@ -726,6 +733,7 @@ openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" instanceTicker: "ノートのサーバー情報" +instanceIcon: "サーバー情報をアイコンのみにする" waitingFor: "{x}を待っています" random: "ランダム" system: "システム" @@ -993,6 +1001,7 @@ cannotUploadBecauseInappropriate: "不適切な内容を含む可能性がある cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" beta: "ベータ" +originalFeature: "独自機能" enableAutoSensitive: "自動センシティブ判定" enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにセンシティブフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" @@ -1179,6 +1188,7 @@ iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意しま dialog: "ダイアログ" icon: "アイコン" forYou: "あなたへ" +forYourRoles: "あなたのロールへ" currentAnnouncements: "現在のお知らせ" pastAnnouncements: "過去のお知らせ" youHaveUnreadAnnouncements: "未読のお知らせがあります。" @@ -1217,6 +1227,7 @@ feedback: "フィードバック" feedbackUrl: "フィードバックURL" impressum: "運営者情報" impressumUrl: "運営者情報URL" +shahuPortal: "しゃふすきーポータル" impressumDescription: "ドイツなどの一部の国と地域では表示が義務付けられています(Impressum)。" privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" @@ -1262,6 +1273,17 @@ enableHorizontalSwipe: "スワイプしてタブを切り替える" loading: "読み込み中" surrender: "やめる" gameRetry: "リトライ" +hideActivity: "アクティビティを非公開にする" +hideActivityDescription: "自分のプロフィールのアクティビティ (概要/アクティビティタブ) を他人が見れないようにします。このオプションを有効にしても、自分であればプロフィールのアクティビティタブから引き続き閲覧できます。" +clearPost: "投稿フォームをリセット" +addToEmojiPicker: "絵文字ピッカーに追加" +hideReactionCount: "リアクション数の非表示" +hideReactionUsers: "誰がリアクションをしたのかを非表示にする" +hideReactionUsersDescription: "リアクションをホバーした際のユーザー一覧と、ノート詳細ページのリアクションタブにあるリアクションをしたユーザー一覧を非表示にします" +defaultScheduledNoteDelete: "デフォルトでノートが消えるように" +cannotScheduleLaterThanOneYear: "1年以上先の日時を指定することはできません" +defaultScheduledNoteDeleteTime: "「すぐ消す」の初期値" +scheduledNoteDeleteEnabled: "「すぐ消す」が有効になっています" notUsePleaseLeaveBlank: "使用しない場合は空欄にしてください" useTotp: "ワンタイムパスワードを使う" useBackupCode: "バックアップコードを使う" @@ -1272,6 +1294,15 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ noDescription: "説明文はありません" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "お問い合わせ" +scheduledNoteDelete: "すぐ消す" +noteDeletationAt: "このノートは{time}に削除されます" +channelAnnouncementDescription: "このお知らせはチャンネルのタイムライン上部に表示されます。最初の1行がタイトルとして表示され、2行目以降はお知らせをタップすることで表示されるようになります。" +postForm: "投稿フォーム" +postFormBottomSettingsDescription: "投稿フォームの下部に表示される項目の並び替えが出来ます。項目をクリックすると削除できます。" +drafts: "下書き" +draftSavingBehavior: "下書きの保存に関する動作" +saveAsDraft: "下書きとして保存" +draftOverwriteConfirm: "下書きを適用すると現在入力されている内容はリセットされます。よろしいですか?" tryAgain: "もう一度お試しください。" confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する" sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" @@ -1332,6 +1363,8 @@ _delivery: manuallySuspended: "手動停止中" goneSuspended: "サーバー削除のため停止中" autoSuspendedForNotResponding: "サーバー応答なしのため停止中" +selectReaction: "いいねボタンで使うリアクションを選択" +showLikeButton: "いいねボタンを表示する" _bubbleGame: howToPlay: "遊び方" @@ -1348,6 +1381,7 @@ _bubbleGame: section1: "位置を調整してハコにモノを落とします。" section2: "同じ種類のモノがくっつくと別のモノに変化して、スコアが得られます。" section3: "モノがハコからあふれるとゲームオーバーです。ハコからあふれないようにしつつモノを融合させてハイスコアを目指そう!" +disableNoteNyaize: "nayizeを無効化する" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1809,6 +1843,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" + scheduleNoteMax: "予約投稿の最大数" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -2126,6 +2161,9 @@ _2fa: backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。" backupCodeUsedWarning: "バックアップコードが使用されました。認証アプリが使えなくなっている場合、なるべく早く認証アプリを再設定してください。" backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。" + backupCodesSavedConfirmTitle: "バックアップコードを保存しましたか?" + backupCodesSavedConfirmDescription: "二要素認証アプリとバックアップコードの両方を紛失した場合、アカウントにアクセスできなくなります。\n誰とも共有せず、適切な方法で保管してください。\n\n$[x2 二要素認証設定は自分以外の誰にも変更できませんので、$[fg.color=red 運営チームも無効化することはできません。]]" + backupCodesSavedConfirmChecked: "バックアップコードを保存しました" moreDetailedGuideHere: "詳細なガイドはこちら" _permissions: @@ -2213,6 +2251,8 @@ _permissions: "read:clip-favorite": "クリップのいいねを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" + "read:notes-schedule": "予約投稿を見る" + "write:notes-schedule": "予約投稿を作成・削除する" _auth: shareAccessTitle: "アプリへのアクセス許可" @@ -2482,6 +2522,8 @@ _notification: newNote: "新しい投稿" unreadAntennaNote: "アンテナ {name}" roleAssigned: "ロールが付与されました" + scheduledNoteFailed: "予約投稿に失敗しました" + scheduledNotePosted: "予約投稿をノートしました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" testNotification: "通知テスト" @@ -2491,6 +2533,7 @@ _notification: reactedBySomeUsers: "{n}人がリアクションしました" likedBySomeUsers: "{n}人がいいねしました" renotedBySomeUsers: "{n}人がリノートしました" + notedBySomeUsers: "{n}件の新しい投稿" followedBySomeUsers: "{n}人にフォローされました" flushNotification: "通知の履歴をリセットする" exportOfXCompleted: "{x}のエクスポートが完了しました" @@ -2795,12 +2838,29 @@ _mediaControls: playbackRate: "再生速度" loop: "ループ再生" +_profileHiddenSettings: + hiddenProfile: "プロフィールを非表示にする機能" + hiddenPinnedNotes: "プロフィール上からピン留めしたノートを非表示にします" + hiddenPinnedNotesDescription: "ピン留めしたノートを非表示にすることで、プロフィールページをスッキリさせることができます。" + hiddenActivity: "プロフィール上からアクティビティを非表示にします" + hiddenActivityDescription: "プロフィール上からアクティビティを非表示にすることで、プロフィールページをスッキリさせることができます。" + hiddenFiles: "プロフィール上からファイルを非表示にします。" + hiddenFilesDescription: "ファイルを非表示にすることで、プロフィールページをスッキリさせることができます。" + +_draftSavingBehavior: + auto: "自動的に保存する" + manual: "都度確認する" + _contextMenu: title: "コンテキストメニュー" app: "アプリケーション" appWithShift: "Shiftキーでアプリケーション" native: "ブラウザのUI" +_reactionChecksMuting: + title: "リアクションでミュートを考慮する" + caption: "リアクションがミュートを考慮しますが、キャッシュが効かず通信量が増えることがあります。" + _embedCodeGen: title: "埋め込みコードをカスタマイズ" header: "ヘッダーを表示" @@ -2826,3 +2886,11 @@ _selfXssPrevention: _followRequest: recieved: "受け取った申請" sent: "送った申請" +_hideReactionCount: + none: "非表示にしない" + self: "自分のノートのみ" + others: "自分以外のノートのみ" + all: "全てのノート" +schedulePost: "予約投稿" +schedulePostList: "予約投稿一覧" + diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index c3e00969261e..c9a3531af0bd 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -702,6 +702,8 @@ notificationSetting: "通知設定" notificationSettingDesc: "出す通知の種類えらんでや。" useGlobalSetting: "グローバル設定を使ってや" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使われるで。オフにすると、別々に設定できるようになるで。" +autoRejectFollowRequest: "フォローリクエストを自動で拒否するで" +autoRejectFollowRequestDescription: "フォローリクエストを自動で拒否するようになるで。「フォロー中ユーザーからのフォロリクを自動承認」がONになってはると、フォロー中ユーザーからフォローリクエストが自動で承認されはり、それ以外のユーザーからのフォローリクエストは自動的に拒否されるで。" other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" @@ -725,6 +727,7 @@ openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "このへんの設定をようわからんままイジるとアカウントが壊れて使えんくなるかも知れへんで?" instanceTicker: "ノートのサーバー情報" +instanceIcon: "サーバー情報をアイコンだけにするで" waitingFor: "{x}を待っとるで" random: "ランダム" system: "システム" @@ -1308,6 +1311,17 @@ _delivery: manuallySuspended: "手動停止中" goneSuspended: "サーバー削除のため停止中" autoSuspendedForNotResponding: "サーバー応答せえへんから停止中" +scheduledNoteDelete: "ほな消す" +noteDeletationAt: "このノートは{time}に削除されるで" +addToEmojiPicker: "絵文字ピッカーに追加する" +channelAnnouncementDescription: "このお知らせはチャンネルのタイムライン上部に表示されるで。最初の1行がタイトル、2行目以降はお知らせをタップすることで表示される感じや!" +postForm: "投稿フォーム" +postFormBottomSettingsDescription: "投稿フォームの下に表示されとる項目の並び替えができるで。項目をクリックすると削除もできるんや。" +drafts: "下書き" +draftSavingBehavior: "下書き保存とかの動作" +saveAsDraft: "下書きとして保存" +draftOverwriteConfirm: "下書きをやるといま入力されてる内容はリセットされるけどいい?" + _bubbleGame: howToPlay: "遊び方" hold: "ホールド" @@ -1323,6 +1337,7 @@ _bubbleGame: section1: "位置を調整してハコにモノを落とすで。" section2: "同じもんがくっついたら別のやつになって、スコアがもらえるで。" section3: "モノがハコからあふれたらゲームオーバーや。ハコからあふれんようにしながらモノを融合させてハイスコアを目指しいや!" +disableNoteNyaize: "nayizeしなくする" _announcement: forExistingUsers: "もうおるユーザーのみ" forExistingUsersDescription: "オンにしたらこのお知らせができた時点でおる人らにだけお知らせが行くで。切ったらこの知らせが行ったあとにアカウント作った人にもちゃんとお知らせが行くで。" @@ -2709,3 +2724,6 @@ _embedCodeGen: generateCode: "埋め込みコード作る" codeGenerated: "コード作ったで" codeGeneratedDescription: "作ったコードはウェブサイトに貼っつけて使ってや。" +_draftSavingBehavior: + auto: "かってに保存する" + manual: "まいど確認する" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index d694d2dbaed4..5b958fd0b08b 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -726,6 +726,7 @@ openInSideView: "사이드뷰로 열기" defaultNavigationBehaviour: "기본 탐색 동작" editTheseSettingsMayBreakAccount: "이 설정을 변경하면 계정이 손상될 수 있습니다." instanceTicker: "노트의 서버 정보" +instanceIcon: "타임라인에 인스턴스의 아이콘만 표시하기" waitingFor: "{x}을(를) 기다리고 있습니다" random: "무작위" system: "시스템" @@ -2082,9 +2083,12 @@ _2fa: renewTOTPCancel: "취소" checkBackupCodesBeforeCloseThisWizard: "이 위자드를 닫기 전에 아래 백업 코드를 확인하십시오" backupCodes: "백업 코드" - backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있습니다.이 코드들은 반드시 안전한 장소에 보관하십시오.각 코드는 한 번만 사용할 수 있습니다." - backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오." - backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요." + backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있습니다. 이 코드들은 반드시 안전한 장소에 보관하십시오. 각 코드는 한 번만 사용할 수 있습니다." + backupCodeUsedWarning: "백업 코드가 사용되었습니다. 인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오." + backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다. 인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다. 인증 앱을 다시 등록해 주세요." + backupCodesSavedConfirmTitle: "백업 코드를 저장했습니까?" + backupCodesSavedConfirmDescription: "인증 앱과 백업 코드를 모두 분실하면\n계정에 액세스할 수 없게 됩니다.\n자신만이 알 수 있도록 안전한 장소에 보관해 주십시오.\n\n$[x2 2단계 인증 설정은\n본인만이 변경할 수 있으며, $[fg.color=red 운영팀도 해제할 수 없습니다.]]" + backupCodesSavedConfirmChecked: "백업 코드를 저장했습니다" moreDetailedGuideHere: "여기에 자세한 설명이 있습니다" _permissions: "read:account": "계정의 정보를 봅니다" @@ -2102,6 +2106,8 @@ _permissions: "read:mutes": "뮤트 여부를 확인합니다" "write:mutes": "뮤트를 하거나 해제합니다" "write:notes": "노트를 작성하거나 삭제합니다" + "read:notes-schedule": "게시를 예약한 노트를 봅니다" + "write:notes-schedule": "노트 게시를 예약하거나 삭제합니다" "read:notifications": "알림을 확인합니다" "write:notifications": "알림을 모두 읽음 처리합니다" "read:reactions": "리액션을 확인합니다" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 537e99036c07..e0d7df8278c2 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -713,6 +713,7 @@ openInSideView: "Открывать в боковой колонке" defaultNavigationBehaviour: "Поведение навигации по умолчанию" editTheseSettingsMayBreakAccount: "От изменений в этих настройках ваша учётная запись может поломаться." instanceTicker: "Строка с названием инстанса в заметках" +instanceIcon: "Отображение на временной шкале только значка экземпляра" waitingFor: "Ждём, когда {x} ответит" random: "Случайные" system: "Система" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 1b2185465058..096e1b8b00c0 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -635,6 +635,7 @@ openInSideView: "Відкрити збоку" defaultNavigationBehaviour: "Поведінка навігації за замовчуванням" editTheseSettingsMayBreakAccount: "Зміна цих параметрів може призвести до пошкодження вашого акаунта." instanceTicker: "Мітка з назвою інстанса в нотатках" +instanceIcon: "Відображати лише піктограму екземпляра на часовій шкалі" waitingFor: "Чекаємо на {x}" random: "Випадковий" system: "Система" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e6232070d76b..c380b52ba229 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -726,6 +726,7 @@ openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" instanceTicker: "帖子的服务器来源" +instanceIcon: "在时间轴上只显示实例图标" waitingFor: "等待 {x}" random: "随机" system: "系统" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index d4ffb28c7629..b984db8e32da 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -726,6 +726,7 @@ openInSideView: "在側欄中開啟" defaultNavigationBehaviour: "預設導航" editTheseSettingsMayBreakAccount: "修改這些設定可能會毀損您的帳戶" instanceTicker: "貼文的伺服器資訊" +instanceIcon: "僅在時間軸上顯示實例的圖標" waitingFor: "等待{x}" random: "隨機" system: "系統" diff --git a/package.json b/package.json index 60de6f4e15ec..94c533d21917 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "misskey", - "version": "2024.11.0", + "version": "2024.11.0-shahu.1.7.0", "codename": "nasubi", "repository": { "type": "git", - "url": "https://github.com/misskey-dev/misskey.git" + "url": "https://github.com/team-shahu/misskey" }, "packageManager": "pnpm@9.6.0", "workspaces": [ @@ -51,6 +51,7 @@ "lodash": "4.17.21" }, "dependencies": { + "@isaacs/ttlcache": "^1.4.1", "cssnano": "6.1.2", "execa": "8.0.1", "fast-glob": "3.3.2", diff --git a/packages/backend/assets/apple-touch-icon.png b/packages/backend/assets/apple-touch-icon.png index 06ad3f1bb4d5..fba23720ac62 100644 Binary files a/packages/backend/assets/apple-touch-icon.png and b/packages/backend/assets/apple-touch-icon.png differ diff --git a/packages/backend/assets/favicon.ico b/packages/backend/assets/favicon.ico index 9be1ff62956c..c0f4e5b850ee 100644 Binary files a/packages/backend/assets/favicon.ico and b/packages/backend/assets/favicon.ico differ diff --git a/packages/backend/assets/favicon.png b/packages/backend/assets/favicon.png index b4eb18a5cb8d..cef40cf0d52e 100644 Binary files a/packages/backend/assets/favicon.png and b/packages/backend/assets/favicon.png differ diff --git a/packages/backend/assets/icons/192.png b/packages/backend/assets/icons/192.png index 15fd1e373121..4afa52e23ff5 100644 Binary files a/packages/backend/assets/icons/192.png and b/packages/backend/assets/icons/192.png differ diff --git a/packages/backend/assets/icons/512.png b/packages/backend/assets/icons/512.png index f2169ec9b040..e87c669d59db 100644 Binary files a/packages/backend/assets/icons/512.png and b/packages/backend/assets/icons/512.png differ diff --git a/packages/backend/assets/splash.png b/packages/backend/assets/splash.png index 3430e6efe7c2..5da06f9d7b00 100644 Binary files a/packages/backend/assets/splash.png and b/packages/backend/assets/splash.png differ diff --git a/packages/backend/migration/1653288122820-emailWhitelist.js b/packages/backend/migration/1653288122820-emailWhitelist.js new file mode 100644 index 000000000000..27297ef2136f --- /dev/null +++ b/packages/backend/migration/1653288122820-emailWhitelist.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: lqvp + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class emailWhitelist1653288122820 { + constructor() { + this.name = 'emailWhitelist1653288122820'; + } + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "emailWhitelist" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "emailWhitelist"`); + } +} diff --git a/packages/backend/migration/1697683129062-feat-auto-reject-follow-request.js b/packages/backend/migration/1697683129062-feat-auto-reject-follow-request.js new file mode 100644 index 000000000000..a41ea57856d7 --- /dev/null +++ b/packages/backend/migration/1697683129062-feat-auto-reject-follow-request.js @@ -0,0 +1,11 @@ +export class FeatAutoRejectFollowRequest1697683129062 { + name = 'FeatAutoRejectFollowRequest1697683129062' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "autoRejectFollowRequest" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "autoRejectFollowRequest"`); + } +} diff --git a/packages/backend/migration/1699437894737-scheduleNote.js b/packages/backend/migration/1699437894737-scheduleNote.js new file mode 100644 index 000000000000..28dc290f25c0 --- /dev/null +++ b/packages/backend/migration/1699437894737-scheduleNote.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ScheduleNote1699437894737 { + name = 'ScheduleNote1699437894737' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "note_schedule" ("id" character varying(32) NOT NULL, "note" jsonb NOT NULL, "userId" character varying(260) NOT NULL, "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_3a1ae2db41988f4994268218436" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e798958c40009bf0cdef4f28b5" ON "note_schedule" ("userId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "note_schedule"`); + } +} diff --git a/packages/backend/migration/1709421948931-ScgeduledNoteDelete.js b/packages/backend/migration/1709421948931-ScgeduledNoteDelete.js new file mode 100644 index 000000000000..92c8984fee0b --- /dev/null +++ b/packages/backend/migration/1709421948931-ScgeduledNoteDelete.js @@ -0,0 +1,11 @@ +export class ScheduledNoteDelete1709187210308 { + name = 'ScheduledNoteDelete1709187210308' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "deleteAt" TIMESTAMP WITH TIME ZONE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "deleteAt"`); + } +} diff --git a/packages/backend/migration/1710854614048-feat-channnel-announcement.js b/packages/backend/migration/1710854614048-feat-channnel-announcement.js new file mode 100644 index 000000000000..6a8254fad0c2 --- /dev/null +++ b/packages/backend/migration/1710854614048-feat-channnel-announcement.js @@ -0,0 +1,11 @@ +export class FeatChannnelAnnouncement1710854614048 { + name = 'FeatChannnelAnnouncement1710854614048' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "announcement" character varying(2048)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "announcement"`); + } +} diff --git a/packages/backend/migration/1720899566130-addtargetRolesToAnnouncements.js b/packages/backend/migration/1720899566130-addtargetRolesToAnnouncements.js new file mode 100644 index 000000000000..8c6884142a01 --- /dev/null +++ b/packages/backend/migration/1720899566130-addtargetRolesToAnnouncements.js @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: taichan and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddTargetRolesToAnnouncements1720899566130 { + name = 'AddTargetRolesToAnnouncements1720899566130' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "announcement_role" ("id" character varying(32) NOT NULL, "announcementId" character varying(32) NOT NULL, "roleId" character varying(32) NOT NULL, CONSTRAINT "PK_cb76dfa429c742b1a273ef18d71983ea" PRIMARY KEY ("announcementId", "roleId"))`); + await queryRunner.query(`CREATE INDEX "IDX_56b0c35e2d1449e987b1c43a779b14ce" ON "announcement_role" ("announcementId") `); + await queryRunner.query(`CREATE INDEX "IDX_53351cbca4544b04937d10f64f98f682" ON "announcement_role" ("roleId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "090a6806f09446228a3ddd501eb63270" ON "announcement_role" ("announcementId", "roleId") `); + await queryRunner.query(`ALTER TABLE "announcement_role" ADD CONSTRAINT "FK_56b0c35e2d1449e987b1c43a779" FOREIGN KEY ("announcementId") REFERENCES "announcement"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "announcement_role" ADD CONSTRAINT "FK_53351cbca4544b04937d10f64f9" FOREIGN KEY ("roleId") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + + await queryRunner.query(`ALTER TABLE "announcement" ADD "isRoleSpecified" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_b2dbc3e04c3443eca1ff9c488e904660" ON "announcement" ("isRoleSpecified") `); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement_role" DROP CONSTRAINT "FK_53351cbca4544b04937d10f64f9"`); + await queryRunner.query(`ALTER TABLE "announcement_role" DROP CONSTRAINT "FK_56b0c35e2d1449e987b1c43a779"`); + await queryRunner.query(`DROP INDEX "public"."090a6806f09446228a3ddd501eb63270"`); + await queryRunner.query(`DROP INDEX "public"."IDX_53351cbca4544b04937d10f64f98f682"`); + await queryRunner.query(`DROP INDEX "public"."IDX_56b0c35e2d1449e987b1c43a779b14ce"`); + await queryRunner.query(`DROP TABLE "announcement_role"`); + + await queryRunner.query(`DROP INDEX "public"."IDX_b2dbc3e04c3443eca1ff9c488e904660"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "isRoleSpecified"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index a9f673197736..14e4747ecaf1 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -7,13 +7,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, EntityNotFoundError } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; +import type { MiRole } from '@/models/Role.js'; +import type { AnnouncementReadsRepository, AnnouncementsRepository, AnnouncementRolesRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; @Injectable() export class AnnouncementService { @@ -21,6 +23,9 @@ export class AnnouncementService { @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + @Inject(DI.announcementRolesRepository) + private announcementRolesRepository: AnnouncementRolesRepository, + @Inject(DI.announcementReadsRepository) private announcementReadsRepository: AnnouncementReadsRepository, @@ -31,6 +36,7 @@ export class AnnouncementService { private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, private announcementEntityService: AnnouncementEntityService, + private roleService: RoleService, ) { } @@ -47,18 +53,40 @@ export class AnnouncementService { .select('read.announcementId') .where('read.userId = :userId', { userId: user.id }); + const userRoles = await this.roleService.getUserRoles(user.id); + + const announcementRolesQuery = this.announcementRolesRepository.createQueryBuilder('ar') + .select('ar.announcementId') + .where('ar.roleId IN (:...roles)', { roles: userRoles.map(x => x.id) }); + 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'); + qb.orWhere(new Brackets(qb2 => { + qb2.orWhere('announcement.userId = :userId', { userId: user.id }); + qb2.orWhere('announcement.userId IS NULL'); + })); + qb.andWhere(new Brackets(qb2 => { + if (userRoles.length > 0) { + qb2.orWhere(new Brackets(qb3 => { + qb3.andWhere('announcement.isRoleSpecified = true'); + qb3.andWhere(`announcement.id IN (${ announcementRolesQuery.getQuery() })`); + })); + } + qb2.orWhere('announcement.isRoleSpecified = false'); + })); })) .andWhere(new Brackets(qb => { qb.orWhere('announcement.forExistingUsers = false'); qb.orWhere('announcement.id > :userId', { userId: user.id }); })) - .andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`); + .andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`) + .setParameters({ + ...announcementRolesQuery.getParameters(), + ...readsQuery.getParameters(), + userId: user.id, + }); q.setParameters(readsQuery.getParameters()); @@ -66,7 +94,7 @@ export class AnnouncementService { } @bindThis - public async create(values: Partial, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> { + public async create(values: Partial, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> { const announcement = await this.announcementsRepository.insertOne({ id: this.idService.gen(), updatedAt: null, @@ -79,6 +107,7 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, userId: values.userId, + isRoleSpecified: values.isRoleSpecified ?? false, }); const packed = await this.announcementEntityService.pack(announcement); @@ -98,6 +127,33 @@ export class AnnouncementService { userHost: user.host, }); } + } else if (values.isRoleSpecified === true) { + if (values.roleIds == null) return { raw: announcement, packed: packed }; + + const roleIds = values.roleIds; + + this.announcementRolesRepository.insert(roleIds.map(roleId => ({ + id: this.idService.gen(), + announcementId: announcement.id, + roleId: roleId, + })).flat()); + + const users = new Set(...(await Promise.all(roleIds.map(async (roleId) => await this.roleService.getRoleUsers(roleId)).flat()))); + users.forEach(async (user) => { + this.globalEventService.publishMainStream(user.id, 'announcementCreated', { + announcement: packed, + }); + }); + + if (moderator) { + const roles = (await this.roleService.getRoles()).filter(role => roleIds.includes(role.id)); + this.moderationLogService.log(moderator, 'createRolesAnnouncement', { + announcementId: announcement.id, + announcement: announcement, + roleIds: roleIds, + roles: roles, + }); + } } else { this.globalEventService.publishBroadcastStream('announcementCreated', { announcement: packed, @@ -118,7 +174,7 @@ export class AnnouncementService { } @bindThis - public async update(announcement: MiAnnouncement, values: Partial, moderator?: MiUser): Promise { + public async update(announcement: MiAnnouncement, values: Partial, moderator?: MiUser): Promise { await this.announcementsRepository.update(announcement.id, { updatedAt: new Date(), title: values.title, @@ -131,10 +187,46 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, isActive: values.isActive, + isRoleSpecified: values.isRoleSpecified, }); const after = await this.announcementsRepository.findOneByOrFail({ id: announcement.id }); + if (values.isRoleSpecified === true) { + const roleIds = values.roleIds ?? []; + const currentRoles = await this.announcementRolesRepository.findBy({ + announcementId: announcement.id, + }); + + const removedRoles = currentRoles.filter(x => !roleIds.includes(x.roleId)); + const addedRoles = roleIds.filter(x => !currentRoles.map(x => x.roleId).includes(x)); + + if (removedRoles.length > 0) { + await this.announcementRolesRepository.delete(removedRoles.map(x => x.id)); + } + if (addedRoles.length > 0) { + await this.announcementRolesRepository.insert(addedRoles.map(roleId => ({ + id: this.idService.gen(), + announcementId: announcement.id, + roleId: roleId, + })).flat()); + } + + if (moderator) { + const roleIds = values.roleIds ?? []; + const roles = (await this.roleService.getRoles()).filter(role => roleIds.includes(role.id)); + this.moderationLogService.log(moderator, 'updateRolesAnnouncement', { + announcementId: announcement.id, + before: announcement, + after: after, + roleIds: roleIds, + roles: roles, + }); + } + + return; + } + if (moderator) { if (announcement.userId) { const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId }); @@ -158,6 +250,7 @@ export class AnnouncementService { @bindThis public async delete(announcement: MiAnnouncement, moderator?: MiUser): Promise { + const announcementRoles = await this.announcementRolesRepository.findBy({ announcementId: announcement.id }); await this.announcementsRepository.delete(announcement.id); if (moderator) { @@ -170,6 +263,14 @@ export class AnnouncementService { userUsername: user.username, userHost: user.host, }); + } else if (announcementRoles.length > 0) { + const roles = await this.roleService.getRoles(); + this.moderationLogService.log(moderator, 'deleteRolesAnnouncement', { + announcementId: announcement.id, + announcement: announcement, + roleIds: announcementRoles.map(x => x.roleId), + roles: roles.filter(x => announcementRoles.map(x => x.roleId).includes(x.id)), + }); } else { this.moderationLogService.log(moderator, 'deleteGlobalAnnouncement', { announcementId: announcement.id, diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index e827ffa68c0f..dabd1bb27b04 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -12,7 +12,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import * as Acct from '@/misc/acct.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; +import type { AntennasRepository, UserListMembershipsRepository, FollowingsRepository } from '@/models/_.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; @@ -37,6 +37,9 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private utilityService: UtilityService, private globalEventService: GlobalEventService, private fanoutTimelineService: FanoutTimelineService, @@ -111,8 +114,22 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise { - if (note.visibility === 'specified') return false; - if (note.visibility === 'followers') return false; + if (note.visibility === 'specified') { + if (note.userId !== antenna.userId) { + if (note.visibleUserIds == null) return false; + if (!note.visibleUserIds.includes(antenna.userId)) return false; + } + } + if (note.visibility === 'followers') { + const isFollowing = await this.followingsRepository.count({ + where: { + followerId: antenna.userId, + followeeId: note.userId, + }, + take: 1, + }).then(n => n > 0); + if (!isFollowing && antenna.userId !== note.userId) return false; + } if (antenna.excludeBots && noteUser.isBot) return false; diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 45661134491e..a961797028a5 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -19,6 +19,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { DriveService } from '@/core/DriveService.js'; const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @@ -39,6 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown { private emojiEntityService: EmojiEntityService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, + private driveService: DriveService, ) { this.emojisCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h @@ -68,6 +70,19 @@ export class CustomEmojiService implements OnApplicationShutdown { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; }, moderator?: MiUser): Promise { + const originalDriveData: MiDriveFile = data.driveFile; + + // システムユーザーとして再アップロード + if (!data.driveFile.user?.isRoot) { + data.driveFile = await this.driveService.uploadFromUrl({ + url: data.driveFile.url, + user: null, + force: true, + }); + + // 元データの削除 + this.driveService.deleteFile(originalDriveData); + } const emoji = await this.emojisRepository.insertOne({ id: this.idService.gen(), updatedAt: new Date(), diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index da198d0e4200..d6bed8a09ff0 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -215,14 +215,27 @@ export class EmailService { } const emailDomain: string = emailAddress.split('@')[1]; - const isBanned = this.utilityService.isBlockedHost(this.meta.bannedEmailDomains, emailDomain); - - if (isBanned) { - return { - available: false, - reason: 'banned', - }; - } + // emailWhitelistがtrueの場合、bannedEmailDomainsをホワイトリストとして扱う + if (this.meta.emailWhitelist) { + const isWhitelisted = this.utilityService.isBlockedHost(this.meta.bannedEmailDomains, emailDomain); + + if (!isWhitelisted) { + return { + available: false, + reason: 'banned', + }; + } + } else { + // 従来のブラックリストとしての動作 + const isBanned = this.utilityService.isBlockedHost(this.meta.bannedEmailDomains, emailDomain); + + if (isBanned) { + return { + available: false, + reason: 'banned', + }; + } + } return { available: true, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 56ddcefd7c46..8f7d1c9ff5f3 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -55,6 +55,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { Data } from 'ws'; import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { CacheService } from '@/core/CacheService.js'; @@ -143,6 +144,7 @@ type Option = { uri?: string | null; url?: string | null; app?: MiApp | null; + deleteAt?: Date | null; }; @Injectable() @@ -418,6 +420,7 @@ export class NoteCreateService implements OnApplicationShutdown { name: data.name, text: data.text, hasPoll: data.poll != null, + deleteAt: data.deleteAt, cw: data.cw ?? null, tags: tags.map(tag => normalizeForSearch(tag)), emojis, @@ -579,6 +582,16 @@ export class NoteCreateService implements OnApplicationShutdown { }); } + if (data.deleteAt) { + const delay = data.deleteAt.getTime() - Date.now(); + this.queueService.scheduledNoteDeleteQueue.add(note.id, { + noteId: note.id + }, { + delay, + removeOnComplete: true, + }); + } + if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index b10b8e589955..99513998ee7b 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -16,11 +16,13 @@ import { RelationshipJobData, UserWebhookDeliverJobData, SystemWebhookDeliverJobData, + ScheduleNotePostJobData, } from '../queue/types.js'; import type { Provider } from '@nestjs/common'; export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; +export type ScheduledNoteDeleteQueue = Bull.Queue export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; export type DbQueue = Bull.Queue; @@ -28,6 +30,7 @@ export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; export type UserWebhookDeliverQueue = Bull.Queue; export type SystemWebhookDeliverQueue = Bull.Queue; +export type ScheduleNotePostQueue = Bull.Queue; const $system: Provider = { provide: 'queue:system', @@ -41,6 +44,12 @@ const $endedPollNotification: Provider = { inject: [DI.config], }; +const $scheduledNoteDeleted: Provider = { + provide: 'queue:scheduledNoteDelete', + useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULED_NOTE_DELETE, baseQueueOptions(config, QUEUE.SCHEDULED_NOTE_DELETE)), + inject: [DI.config], +}; + const $deliver: Provider = { provide: 'queue:deliver', useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), @@ -83,12 +92,19 @@ const $systemWebhookDeliver: Provider = { inject: [DI.config], }; +const $scheduleNotePost: Provider = { + provide: 'queue:scheduleNotePost', + useFactory: (config: Config) => new Bull.Queue(QUEUE.SCHEDULE_NOTE_POST, baseQueueOptions(config, QUEUE.SCHEDULE_NOTE_POST)), + inject: [DI.config], +}; + @Module({ imports: [ ], providers: [ $system, $endedPollNotification, + $scheduledNoteDeleted, $deliver, $inbox, $db, @@ -96,10 +112,12 @@ const $systemWebhookDeliver: Provider = { $objectStorage, $userWebhookDeliver, $systemWebhookDeliver, + $scheduleNotePost, ], exports: [ $system, $endedPollNotification, + $scheduledNoteDeleted, $deliver, $inbox, $db, @@ -107,12 +125,14 @@ const $systemWebhookDeliver: Provider = { $objectStorage, $userWebhookDeliver, $systemWebhookDeliver, + $scheduleNotePost, ], }) export class QueueModule implements OnApplicationShutdown { constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -120,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) {} public async dispose(): Promise { @@ -129,6 +150,7 @@ export class QueueModule implements OnApplicationShutdown { await Promise.all([ this.systemQueue.close(), this.endedPollNotificationQueue.close(), + this.scheduledNoteDeleteQueue.close(), this.deliverQueue.close(), this.inboxQueue.close(), this.dbQueue.close(), @@ -136,6 +158,7 @@ export class QueueModule implements OnApplicationShutdown { this.objectStorageQueue.close(), this.userWebhookDeliverQueue.close(), this.systemWebhookDeliverQueue.close(), + this.scheduleNotePostQueue.close(), ]); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index da76dd12847f..0759ab6b7680 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -34,6 +34,7 @@ import type { SystemQueue, SystemWebhookDeliverQueue, UserWebhookDeliverQueue, + ScheduleNotePostQueue, } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -46,6 +47,7 @@ export class QueueService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -53,6 +55,7 @@ export class QueueService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduleNotePost') public ScheduleNotePostQueue: ScheduleNotePostQueue, ) { this.systemQueue.add('tickCharts', { }, { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 5af6b0594253..7083ea639a25 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import { In } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import type { MiMeta, @@ -35,6 +35,7 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + scheduleNoteMax: number; mentionLimit: number; canInvite: boolean; inviteLimit: number; @@ -69,6 +70,7 @@ export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + scheduleNoteMax: 15, mentionLimit: 20, canInvite: false, inviteLimit: 0, @@ -104,6 +106,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private rootUserIdCache: MemorySingleCache; private rolesCache: MemorySingleCache; private roleAssignmentByUserIdCache: MemoryKVCache; + private roleAssignmentByRoleIdCache: MemoryKVCache; + private conditionalRoleUserIdsCache: MemoryKVCache; private notificationService: NotificationService; public static AlreadyAssignedError = class extends Error {}; @@ -140,6 +144,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { this.rootUserIdCache = new MemorySingleCache(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.roleAssignmentByRoleIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); + this.conditionalRoleUserIdsCache = new MemoryKVCache(1000 * 60 * 60 * 1); this.redisForSub.on('message', this.onMessage); } @@ -189,6 +195,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } case 'userRoleAssigned': { const cached = this.roleAssignmentByUserIdCache.get(body.userId); + const roleCached = this.roleAssignmentByRoleIdCache.get(body.roleId); if (cached) { cached.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい ...body, @@ -197,13 +204,25 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { role: null, // joinなカラムは通常取ってこないので }); } + if (roleCached) { + roleCached.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, + user: null, // joinなカラムは通常取ってこないので + role: null, // joinなカラムは通常取ってこないので + }); + } break; } case 'userRoleUnassigned': { const cached = this.roleAssignmentByUserIdCache.get(body.userId); + const roleCached = this.roleAssignmentByRoleIdCache.get(body.roleId); if (cached) { this.roleAssignmentByUserIdCache.set(body.userId, cached.filter(x => x.id !== body.id)); } + if (roleCached) { + this.roleAssignmentByRoleIdCache.set(body.roleId, roleCached.filter(x => x.id !== body.id)); + } break; } default: @@ -326,6 +345,38 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return [...assignedRoles, ...matchedCondRoles]; } + @bindThis + public async getRoleAssigns(roleId: MiRole['id']) { + const now = Date.now(); + let assigns = await this.roleAssignmentByRoleIdCache.fetch(roleId, () => this.roleAssignmentsRepository.findBy({ roleId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); + return assigns; + } + + @bindThis + public async getRoleUsers(roleId: MiRole['id']) : Promise { + const role = (await this.getRoles()).find(r => r.id === roleId); + if (role == null) return []; + const assigns = await this.getRoleAssigns(roleId); + const assignedUsers = (await Promise.all(assigns.map(async assign => { + const user = await this.cacheService.findUserById(assign.userId); + return user; + }))); + + const matchedCondUsers = role.target === 'conditional' ? await (async () => { + // このロールにマッチする条件を持ったユーザーを取得 + return await this.conditionalRoleUserIdsCache.fetch(roleId, (async () => { + // TODO: 全件取得は重いので、条件に合致するユーザーを取得するようにする + // 現状はユーザー情報から判定しているため、ロール側からユーザーを取得するのが難しい + // せめてローカルユーザーのみを対象にするようにした + const users = (await this.usersRepository.findBy({ host: IsNull() })).filter((u) => this.evalCond(u, [role], role.condFormula)); + return users; + })); + })() : [] as MiUser[]; + return [...assignedUsers, ...matchedCondUsers]; + } + /** * 指定ユーザーのバッジロール一覧取得 */ @@ -374,6 +425,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + scheduleNoteMax: calc('scheduleNoteMax', vs => Math.max(...vs)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 8963003057cf..da3a25143a2b 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -212,6 +212,20 @@ export class UserFollowingService implements OnModuleInit { } if (!autoAccept) { + // autoAcceptが無効かつautoRejectが有効な場合はフォローリクエストを拒否する + if (this.userEntityService.isLocalUser(followee) && followeeProfile.autoRejectFollowRequest) { + if (this.userEntityService.isRemoteUser(follower)) { + // リモートからならRejectを返す + const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); + this.queueService.deliver(followee, content, follower.inbox, false); + } + + // ローカルユーザーに対しては敢えてpublishUnfollowせずにお茶を濁す + // フォローできない不具合と勘違いされたりフォローリクエストを連打される可能性があるため + + return; + } + await this.createFollowRequest(follower, followee, requestId, withReplies); return; } @@ -584,7 +598,9 @@ export class UserFollowingService implements OnModuleInit { }); if (!requestExist) { - throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); + // throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); + // 本来ならエラーを返すが、フォローリクエストの自動拒否機能の関係上、エラーを返さずに無視する + return; } await this.followRequestsRepository.delete({ diff --git a/packages/backend/src/core/entities/AnnouncementEntityService.ts b/packages/backend/src/core/entities/AnnouncementEntityService.ts index 90b04d0229a6..e86411b35273 100644 --- a/packages/backend/src/core/entities/AnnouncementEntityService.ts +++ b/packages/backend/src/core/entities/AnnouncementEntityService.ts @@ -53,6 +53,7 @@ export class AnnouncementEntityService { icon: announcement.icon, display: announcement.display, forYou: announcement.userId === me?.id, + forYourRoles: announcement.isRoleSpecified === true, needConfirmationToRead: announcement.needConfirmationToRead, silence: announcement.silence, isRead: announcement.isRead !== null ? announcement.isRead : undefined, diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 1ba7ca8e57e7..a0347b6fdeec 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -95,6 +95,7 @@ export class ChannelEntityService { ...(detailed ? { pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)), + announcement: channel.announcement, } : {}), }; } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 96cc6b028ec0..c439593087b0 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -447,6 +447,7 @@ export class NoteEntityService implements OnModuleInit { }) : undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, + deleteAt: note.deleteAt?.toISOString() ?? undefined, ...(meId && Object.keys(reactions).length > 0 ? { myReaction: this.populateMyReaction({ diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index dff6968f9c9a..679eb226b45b 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); @Injectable() export class NotificationEntityService implements OnModuleInit { @@ -136,6 +136,27 @@ export class NotificationEntityService implements OnModuleInit { note: noteIfNeed, users, }); + } else if (notification.type === 'note:grouped') { + const users = (await Promise.all(notification.notifierIds.map(notifier => { + const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(notifier) : null; + if (packedUser) { + return packedUser; + } + + return this.userEntityService.pack(notifier, { id: meId }); + }))).filter(x => x != null); + // if all users have been deleted, don't show this notification + if (users.length === 0) { + return null; + } + + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + noteIds: notification.noteIds, + users, + }); } // #endregion @@ -169,6 +190,9 @@ export class NotificationEntityService implements OnModuleInit { exportedEntity: notification.exportedEntity, fileId: notification.fileId, } : {}), + ...(notification.type === 'scheduledNoteFailed' ? { + reason: notification.reason, + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader, @@ -204,6 +228,7 @@ export class NotificationEntityService implements OnModuleInit { 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); + if (notification.type === 'note:grouped') userIds.push(...notification.notifierIds); } const users = userIds.length > 0 ? await this.usersRepository.find({ where: { id: In(userIds) }, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index d3c087a15376..512a5a896650 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -582,6 +582,7 @@ export class UserEntityService implements OnModuleInit { autoSensitive: profile!.autoSensitive, carefulBot: profile!.carefulBot, autoAcceptFollowed: profile!.autoAcceptFollowed, + autoRejectFollowRequest: profile!.autoRejectFollowRequest, noCrawle: profile!.noCrawle, preventAiLearning: profile!.preventAiLearning, isExplorable: user.isExplorable, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index e599fc7b3737..d59c4335d38d 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -18,6 +18,7 @@ export const DI = { usersRepository: Symbol('usersRepository'), notesRepository: Symbol('notesRepository'), announcementsRepository: Symbol('announcementsRepository'), + announcementRolesRepository: Symbol('announcementRolesRepository'), announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), @@ -84,5 +85,6 @@ export const DI = { userMemosRepository: Symbol('userMemosRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), + noteScheduleRepository: Symbol('noteScheduleRepository'), //#endregion }; diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index d0c59fff50ae..9dcccae2684f 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -54,6 +54,12 @@ export class MiAnnouncement { }) public needConfirmationToRead: boolean; + @Index() + @Column('boolean', { + default: false, + }) + public isRoleSpecified: boolean; + @Index() @Column('boolean', { default: true, diff --git a/packages/backend/src/models/AnnouncementRole.ts b/packages/backend/src/models/AnnouncementRole.ts new file mode 100644 index 000000000000..eea93fdd5c33 --- /dev/null +++ b/packages/backend/src/models/AnnouncementRole.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiAnnouncement } from './Announcement.js'; +import { MiRole } from './Role.js'; + +@Entity('announcement_role') +@Index(['announcementId', 'roleId'], { unique: true }) +export class MiAnnouncementRole { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public announcementId: MiAnnouncement['id']; + + @ManyToOne(type => MiAnnouncement, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public announcement: MiAnnouncement | null; + + @Index() + @Column(id()) + public roleId: MiRole['id']; + + @ManyToOne(type => MiRole, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public role: MiRole | null; +} diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index f5e9b17e3e1e..a629252b5e3a 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -63,6 +63,12 @@ export class MiChannel { }) public pinnedNoteIds: string[]; + @Column('varchar', { + length: 2048, + nullable: true, + }) + public announcement: string | null; + @Column('varchar', { length: 16, default: '#86b300', diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ad5e31ad6ff2..583a259069d3 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -569,6 +569,11 @@ export class MiMeta { }) public bannedEmailDomains: string[]; + @Column('boolean', { + default: false, + }) + public emailWhitelist: boolean; + @Column('varchar', { length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', }) diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab9b..c25f46be4314 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -182,6 +182,11 @@ export class MiNote { }) public hasPoll: boolean; + @Column('timestamp with time zone', { + nullable: true, + }) + public deleteAt: Date | null; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts new file mode 100644 index 000000000000..dde0af6ad763 --- /dev/null +++ b/packages/backend/src/models/NoteSchedule.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { MiNote } from '@/models/Note.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChannel } from './Channel.js'; +import type { MiDriveFile } from './DriveFile.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type MiScheduleNoteType={ + visibility: 'public' | 'home' | 'followers' | 'specified'; + visibleUsers: MinimumUser[]; + channel?: MiChannel['id']; + poll: { + multiple: boolean; + choices: string[]; + /** Date.toISOString() */ + expiresAt: string | null + } | undefined; + renote?: MiNote['id']; + localOnly: boolean; + cw?: string | null; + reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; + files: MiDriveFile['id'][]; + text?: string | null; + reply?: MiNote['id']; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; +} + +@Entity('note_schedule') +export class MiNoteSchedule { + @PrimaryColumn(id()) + public id: string; + + @Column('jsonb') + public note: MiScheduleNoteType; + + @Index() + @Column('varchar', { + length: 260, + }) + public userId: MiUser['id']; + + @Column('timestamp with time zone') + public scheduledAt: Date; +} diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index b7f8e94d691d..e4bdd33bd53e 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -120,6 +120,16 @@ export type MiNotification = { type: 'test'; id: string; createdAt: string; +} | { + type: 'scheduledNoteFailed'; + id: string; + createdAt: string; + reason: string; +} | { + type: 'scheduledNotePosted'; + id: string; + createdAt: string; + noteId: MiNote['id']; }; export type MiGroupedNotification = MiNotification | { @@ -137,4 +147,10 @@ export type MiGroupedNotification = MiNotification | { createdAt: string; noteId: MiNote['id']; userIds: string[]; +} | { + type: 'note:grouped'; + id: string; + createdAt: string; + noteIds: string[]; + notifierIds: MiUser['id'][]; }; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index ea0f88babaa7..e62c1fe23fcb 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -12,6 +12,7 @@ import { MiAccessToken, MiAd, MiAnnouncement, + MiAnnouncementRole, MiAnnouncementRead, MiAntenna, MiApp, @@ -42,6 +43,7 @@ import { MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteSchedule, MiNoteThreadMuting, MiNoteUnread, MiPage, @@ -79,6 +81,7 @@ import { MiUserSecurityKey, MiWebhook } from './_.js'; + import type { DataSource } from 'typeorm'; const $usersRepository: Provider = { @@ -99,6 +102,12 @@ const $announcementsRepository: Provider = { inject: [DI.db], }; +const $announcementsRolesRepository: Provider = { + provide: DI.announcementRolesRepository, + useFactory: (db: DataSource) => db.getRepository(MiAnnouncementRole).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $announcementReadsRepository: Provider = { provide: DI.announcementReadsRepository, useFactory: (db: DataSource) => db.getRepository(MiAnnouncementRead).extend(miRepository as MiRepository), @@ -495,12 +504,19 @@ const $reversiGamesRepository: Provider = { inject: [DI.db], }; +const $noteScheduleRepository: Provider = { + provide: DI.noteScheduleRepository, + useFactory: (db: DataSource) => db.getRepository(MiNoteSchedule).extend(miRepository as MiRepository), + inject: [DI.db], +}; + @Module({ imports: [], providers: [ $usersRepository, $notesRepository, $announcementsRepository, + $announcementsRolesRepository, $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, @@ -567,11 +583,13 @@ const $reversiGamesRepository: Provider = { $userMemosRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $noteScheduleRepository, ], exports: [ $usersRepository, $notesRepository, $announcementsRepository, + $announcementsRolesRepository, $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, @@ -638,6 +656,7 @@ const $reversiGamesRepository: Provider = { $userMemosRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, + $noteScheduleRepository, ], }) export class RepositoryModule { diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 554455529611..43369f930b40 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -173,6 +173,11 @@ export class MiUserProfile { }) public autoAcceptFollowed: boolean; + @Column('boolean', { + default: false, + }) + public autoRejectFollowRequest: boolean; + @Column('boolean', { default: false, comment: 'Whether reject index by crawler.', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c72bdaa72726..b3d44f718efe 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -15,6 +15,7 @@ import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotifica import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; +import { MiAnnouncementRole } from '@/models/AnnouncementRole.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; @@ -79,6 +80,7 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository { @@ -130,6 +132,7 @@ export { MiAccessToken, MiAd, MiAnnouncement, + MiAnnouncementRole, MiAnnouncementRead, MiAntenna, MiApp, @@ -157,6 +160,7 @@ export { MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteSchedule, MiNoteThreadMuting, MiNoteUnread, MiPage, @@ -201,6 +205,7 @@ export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; +export type AnnouncementRolesRepository = Repository & MiRepository; export type AnnouncementReadsRepository = Repository & MiRepository; export type AntennasRepository = Repository & MiRepository; export type AppsRepository = Repository & MiRepository; @@ -265,3 +270,4 @@ export type FlashLikesRepository = Repository & MiRepository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; +export type NoteScheduleRepository = Repository; diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts index b9352bd31e61..1a39ad975eb5 100644 --- a/packages/backend/src/models/json-schema/announcement.ts +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -56,6 +56,10 @@ export const packedAnnouncementSchema = { type: 'boolean', optional: false, nullable: false, }, + forYourRoles: { + type: 'boolean', + optional: false, nullable: false, + }, isRead: { type: 'boolean', optional: true, nullable: false, diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index d233f7858d9a..8d462777ed4a 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -89,5 +89,9 @@ export const packedChannelSchema = { ref: 'Note', }, }, + announcement: { + type: 'string', + optional: false, nullable: true, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 432c096e484c..65cedbcf3a7d 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -152,6 +152,11 @@ export const packedNoteSchema = { }, }, }, + deleteAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, emojis: { type: 'object', optional: true, nullable: false, diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index cddaf4bc8370..48db48cb1db6 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -354,6 +354,45 @@ export const packedNotificationSchema = { optional: false, nullable: true, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNoteFailed'], + }, + reason: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNotePosted'], + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c891..35d63f2a34f1 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + scheduleNoteMax: { + type: 'integer', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 251a03c303a7..004005e2f849 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -78,10 +78,12 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiNoteSchedule } from '@/models/NoteSchedule.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { MiAnnouncementRole } from './models/AnnouncementRole.js'; pg.types.setTypeParser(20, Number); @@ -130,6 +132,7 @@ class MyCustomLogger implements Logger { export const entities = [ MiAnnouncement, + MiAnnouncementRole, MiAnnouncementRead, MiMeta, MiInstance, @@ -155,6 +158,7 @@ export const entities = [ MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteSchedule, MiNoteThreadMuting, MiNoteUnread, MiPage, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 9044285bf67f..11d3aee9dda5 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -41,6 +41,8 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; +import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; @Module({ imports: [ @@ -78,12 +80,14 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor UserWebhookDeliverProcessorService, SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, + ScheduledNoteDeleteProcessorService, DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, CheckExpiredMutingsProcessorService, CheckModeratorsActivityProcessorService, QueueProcessorService, + ScheduleNotePostProcessorService, ], exports: [ QueueProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 6940e1c18896..7c689bf8f832 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -43,8 +43,10 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; +import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QUEUE, baseQueueOptions } from './const.js'; +import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js'; // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 function httpRelatedBackoff(attemptsMade: number) { @@ -65,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { : age > 10000 ? `${Math.floor(age / 1000)}s` : `${age}ms`; - // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする + // onActive??onCompleted?attemptsMade????0??????????????? const currentAttempts = job.attemptsMade + (increment ? 1 : 0); const maxAttempts = job.opts.attempts ?? 0; @@ -84,6 +86,8 @@ export class QueueProcessorService implements OnApplicationShutdown { private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; + private scheduledNoteDeleteQueueWorker: Bull.Worker; + private schedulerNotePostQueueWorker: Bull.Worker; constructor( @Inject(DI.config) @@ -93,6 +97,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, + private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, @@ -123,11 +128,12 @@ export class QueueProcessorService implements OnApplicationShutdown { private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private cleanProcessorService: CleanProcessorService, + private scheduleNotePostProcessorService: ScheduleNotePostProcessorService, ) { this.logger = this.queueLoggerService.logger; function renderError(e?: Error) { - // 何故かeがundefinedで来ることがある + // ???e?undefined???????? if (!e) return '?'; if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') { @@ -517,6 +523,21 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } //#endregion + + //#region scheduled note delete + this.scheduledNoteDeleteQueueWorker = new Bull.Worker(QUEUE.SCHEDULED_NOTE_DELETE, (job) => this.scheduledNoteDeleteProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.SCHEDULED_NOTE_DELETE), + autorun: false, + }); + + //#region schedule note post + { + this.schedulerNotePostQueueWorker = new Bull.Worker(QUEUE.SCHEDULE_NOTE_POST, (job) => this.scheduleNotePostProcessorService.process(job), { + ...baseQueueOptions(this.config, QUEUE.SCHEDULE_NOTE_POST), + autorun: false, + }); + } + //#endregion } @bindThis @@ -531,6 +552,8 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), + this.scheduledNoteDeleteQueueWorker.run(), + this.schedulerNotePostQueueWorker.run(), ]); } @@ -546,6 +569,8 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), + this.scheduledNoteDeleteQueueWorker.close(), + this.schedulerNotePostQueueWorker.close(), ]); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 67f689b61849..a49da69d4284 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -11,11 +11,13 @@ export const QUEUE = { INBOX: 'inbox', SYSTEM: 'system', ENDED_POLL_NOTIFICATION: 'endedPollNotification', + SCHEDULED_NOTE_DELETE: 'scheduledNoteDelete', DB: 'db', RELATIONSHIP: 'relationship', OBJECT_STORAGE: 'objectStorage', USER_WEBHOOK_DELIVER: 'userWebhookDeliver', SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver', + SCHEDULE_NOTE_POST: 'scheduleNotePost', }; export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts new file mode 100644 index 000000000000..f281b0ed7bc2 --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { ScheduleNotePostJobData } from '../types.js'; + +@Injectable() +export class ScheduleNotePostProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.noteScheduleRepository.findOneBy({ id: job.data.scheduleNoteId }).then(async (data) => { + if (!data) { + this.logger.warn(`Schedule note ${job.data.scheduleNoteId} not found`); + } else { + const me = await this.usersRepository.findOneBy({ id: data.userId }); + const note = data.note; + + //idの形式でキューに積んであったのをDBから取り寄せる + const reply = note.reply ? await this.notesRepository.findOneBy({ id: note.reply }) : undefined; + const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined; + const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined; + let files: MiDriveFile[] = []; + const fileIds = note.files; + + if (fileIds.length > 0 && me) { + 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(); + } + + if (!data.userId || !me) { + this.logger.warn('Schedule Note Failed Reason: User Not Found'); + await this.noteScheduleRepository.remove(data); + return; + } + + if (note.files.length !== files.length) { + this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive'); + this.notificationService.createNotification(me.id, 'scheduledNoteFailed', { + reason: 'Some attached files on your scheduled note no longer exist', + }); + await this.noteScheduleRepository.remove(data); + return; + } + + if (note.reply && !reply) { + this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist'); + this.notificationService.createNotification(me.id, 'scheduledNoteFailed', { + reason: 'Replied to note on your scheduled note no longer exists', + }); + await this.noteScheduleRepository.remove(data); + return; + } + + if (note.renote && !renote) { + this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists'); + this.notificationService.createNotification(me.id, 'scheduledNoteFailed', { + reason: 'A quoted note from one of your scheduled notes no longer exists', + }); + await this.noteScheduleRepository.remove(data); + return; + } + + if (note.channel && !channel) { + this.logger.warn('Schedule Note Failed Reason: Channel does not exist'); + this.notificationService.createNotification(me.id, 'scheduledNoteFailed', { + reason: 'An attached channel on your scheduled note no longer exists', + }); + await this.noteScheduleRepository.remove(data); + return; + } + + const createdNote = await this.noteCreateService.create(me, { + ...note, + createdAt: new Date(), + files, + poll: note.poll ? { + choices: note.poll.choices, + multiple: note.poll.multiple, + expiresAt: note.poll.expiresAt ? new Date(note.poll.expiresAt) : null, + } : undefined, + reply, + renote, + channel, + }); + await this.noteScheduleRepository.remove(data); + this.notificationService.createNotification(me.id, 'scheduledNotePosted', { + noteId: createdNote.id, + }); + } + }); + } +} diff --git a/packages/backend/src/queue/processors/ScheduledNoteDeleteProcessorService.ts b/packages/backend/src/queue/processors/ScheduledNoteDeleteProcessorService.ts new file mode 100644 index 000000000000..6809eb8d8d12 --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduledNoteDeleteProcessorService.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { ScheduledNoteDeleteJobData } from '../types.js'; + +@Injectable() +export class ScheduledNoteDeleteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private noteDeleteService: NoteDeleteService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('scheduled-note-delete'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + const note = await this.notesRepository.findOneBy({ id: job.data.noteId }); + if (note == null) { + return; + } + + const user = await this.usersRepository.findOneBy({ id: note.userId }); + if (user == null) { + return; + } + + await this.noteDeleteService.delete(user, note); + this.logger.info(`Deleted note ${note.id}`); + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index a4077a0547ea..654b208ec3b0 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -106,6 +106,10 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type ScheduledNoteDeleteJobData = { + noteId: MiNote['id']; +} + export type SystemWebhookDeliverJobData = { type: string; content: unknown; @@ -130,3 +134,7 @@ export type UserWebhookDeliverJobData = { export type ThinUser = { id: MiUser['id']; }; + +export type ScheduleNotePostJobData = { + scheduleNoteId: MiNote['id']; +} diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bb194313d2c..d692d80cbbc0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -298,6 +298,9 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -686,6 +689,9 @@ const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create' const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; +const $notes_schedule_create: Provider = { provide: 'ep:notes/schedule/create', useClass: ep___notes_schedule_create.default }; +const $notes_schedule_delete: Provider = { provide: 'ep:notes/schedule/delete', useClass: ep___notes_schedule_delete.default }; +const $notes_schedule_list: Provider = { provide: 'ep:notes/schedule/list', useClass: ep___notes_schedule_list.default }; const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; @@ -1078,6 +1084,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_reactions_delete, $notes_renotes, $notes_replies, + $notes_schedule_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_searchByTag, $notes_search, $notes_show, @@ -1463,6 +1472,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_reactions_delete, $notes_renotes, $notes_replies, + $notes_schedule_create, + $notes_schedule_delete, + $notes_schedule_list, $notes_searchByTag, $notes_search, $notes_show, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 15809b2678ad..732f4469b532 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -304,6 +304,9 @@ import * as ep___notes_reactions_create from './endpoints/notes/reactions/create import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; import * as ep___notes_renotes from './endpoints/notes/renotes.js'; import * as ep___notes_replies from './endpoints/notes/replies.js'; +import * as ep___notes_schedule_create from './endpoints/notes/schedule/create.js'; +import * as ep___notes_schedule_delete from './endpoints/notes/schedule/delete.js'; +import * as ep___notes_schedule_list from './endpoints/notes/schedule/list.js'; import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; import * as ep___notes_search from './endpoints/notes/search.js'; import * as ep___notes_show from './endpoints/notes/show.js'; @@ -690,6 +693,9 @@ const eps = [ ['notes/reactions/delete', ep___notes_reactions_delete], ['notes/renotes', ep___notes_renotes], ['notes/replies', ep___notes_replies], + ['notes/schedule/create', ep___notes_schedule_create], + ['notes/schedule/delete', ep___notes_schedule_delete], + ['notes/schedule/list', ep___notes_schedule_list], ['notes/search-by-tag', ep___notes_searchByTag], ['notes/search', ep___notes_search], ['notes/show', ep___notes_show], 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 b8bfda73a42a..0224af633259 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -62,6 +62,8 @@ export const paramDef = { silence: { type: 'boolean', default: false }, needConfirmationToRead: { type: 'boolean', default: false }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, + isRoleSpecified: { type: 'boolean', default: false }, + roleIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], } as const; @@ -84,6 +86,8 @@ export default class extends Endpoint { // eslint- silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, userId: ps.userId, + isRoleSpecified: ps.isRoleSpecified, + roleIds: ps.roleIds ?? [], }, me); return packed; 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 7596bf44e370..5ec3cfe631cc 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -4,12 +4,15 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; +import type { AnnouncementsRepository, AnnouncementRolesRepository, AnnouncementReadsRepository } from '@/models/_.js'; +import type { MiRole } from '@/models/Role.js'; import type { MiAnnouncement } from '@/models/Announcement.js'; +import type { MiAnnouncementRole } from '@/models/AnnouncementRole.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 { RoleEntityService } from '@/core/entities/RoleEntityService.js'; export const meta = { tags: ['admin'], @@ -53,6 +56,26 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + isRoleSpecified: { + type: 'boolean', + optional: false, nullable: false, + }, + roles: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + }, + name: { + type: 'string', + }, + }, + }, + }, reads: { type: 'number', optional: false, nullable: false, @@ -80,11 +103,15 @@ export default class extends Endpoint { // eslint- @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + @Inject(DI.announcementRolesRepository) + private announcementRolesRepository: AnnouncementRolesRepository, + @Inject(DI.announcementReadsRepository) private announcementReadsRepository: AnnouncementReadsRepository, private queryService: QueryService, private idService: IdService, + private roleEntityService: RoleEntityService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); @@ -110,6 +137,14 @@ export default class extends Endpoint { // eslint- announcementId: announcement.id, })); } + const announcementRoleIds = await this.announcementRolesRepository + .createQueryBuilder('announcement_role') + .where('announcement_role.announcementId IN (:...announcementIds)', { announcementIds: announcements.map(announcement => announcement.id) }) + .select('announcement_role.announcementId') + .addSelect('announcement_role.roleId') + .getMany() as Pick[]; + + const announcementRoles = await Promise.all(announcementRoleIds.map(async ar => { const role = await this.roleEntityService.pack(ar.roleId); return { ...ar, role }; })); return announcements.map(announcement => ({ id: announcement.id, @@ -125,6 +160,8 @@ export default class extends Endpoint { // eslint- silence: announcement.silence, needConfirmationToRead: announcement.needConfirmationToRead, userId: announcement.userId, + isRoleSpecified: announcement.isRoleSpecified, + roles: announcementRoles.filter(announcementRole => announcementRole.announcementId === announcement.id).map(announcementRole => announcementRole.role).filter((role): role is MiRole => role !== null), 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 6fce6e4e0a47..33113c571e5c 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -39,6 +39,8 @@ export const paramDef = { silence: { type: 'boolean' }, needConfirmationToRead: { type: 'boolean' }, isActive: { type: 'boolean' }, + isRoleSpecified: { type: 'boolean' }, + roleIds: { type: 'array', items: { type: 'string', format: 'misskey:id' } }, }, required: ['id'], } as const; @@ -68,6 +70,8 @@ export default class extends Endpoint { // eslint- silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, isActive: ps.isActive, + isRoleSpecified: ps.isRoleSpecified, + roleIds: ps.roleIds, }, 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 index 87d80cbe8032..03f0cb713833 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { DriveService } from '@/core/DriveService.js'; import { IdService } from '@/core/IdService.js'; export const meta = { @@ -76,13 +77,24 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private avatarDecorationService: AvatarDecorationService, + private driveService: DriveService, private idService: IdService, ) { - super(meta, paramDef, async (ps, me) => { + super(meta, paramDef, async (ps: any, me) => { + // システムユーザーとして再アップロード + const sysFileData = await this.driveService.uploadFromUrl({ + url: ps.url, + user: null, + force: true, + }); + + // 元ファイルの削除 + this.driveService.deleteFile(ps); + const created = await this.avatarDecorationService.create({ name: ps.name, description: ps.description, - url: ps.url, + url: sysFileData.url, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, }, me); 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 index 34b3b5a11f50..2454f60b84c8 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -7,6 +7,7 @@ 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 { DriveService } from '@/core/DriveService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -38,12 +39,27 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private avatarDecorationService: AvatarDecorationService, + private driveService: DriveService, ) { - super(meta, paramDef, async (ps, me) => { + super(meta, paramDef, async (ps: any, me) => { + let fileUrl = ps.url; + // URLに変更があるか + if (typeof ps.url !== 'undefined' || typeof ps.url === 'string' ) { + // システムユーザーとして再アップロード + const sysFileData = await this.driveService.uploadFromUrl({ + url: ps.url, + user: null, + force: true, + }); + fileUrl = sysFileData.url; + + // 元ファイルの削除 + this.driveService.deleteFile(ps); + } await this.avatarDecorationService.update(ps.id, { name: ps.name, description: ps.description, - url: ps.url, + url: fileUrl, 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 64e3cc33bd2c..e5194e8dbb2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -192,6 +192,10 @@ export const meta = { optional: false, nullable: false, }, }, + emailWhitelist: { + type: 'string', + optional: false, nullable: true, + }, preservedUsernames: { type: 'array', optional: false, nullable: false, @@ -643,6 +647,7 @@ export default class extends Endpoint { // eslint- enableServerMachineStats: instance.enableServerMachineStats, enableIdenticonGeneration: instance.enableIdenticonGeneration, bannedEmailDomains: instance.bannedEmailDomains, + emailWhitelist: instance.emailWhitelist, policies: { ...DEFAULT_POLICIES, ...instance.policies }, manifestJsonOverride: instance.manifestJsonOverride, enableFanoutTimeline: instance.enableFanoutTimeline, diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index d7f9e4eaa301..e2bd38aac6a4 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, ScheduleNotePostQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -55,6 +55,7 @@ export default class extends Endpoint { // eslint- @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) { super(meta, paramDef, async (ps, me) => { const deliverJobCounts = await this.deliverQueue.getJobCounts(); 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 38ef0d1de837..e3cb2c2b09fe 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -141,6 +141,7 @@ export const paramDef = { enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, bannedEmailDomains: { type: 'array', items: { type: 'string' } }, + emailWhitelist: { type: 'boolean' }, preservedUsernames: { type: 'array', items: { type: 'string' } }, manifestJsonOverride: { type: 'string' }, enableFanoutTimeline: { type: 'boolean' }, @@ -448,6 +449,7 @@ export default class extends Endpoint { // eslint- } if (ps.repositoryUrl !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion set.repositoryUrl = URL.canParse(ps.repositoryUrl!) ? ps.repositoryUrl : null; } @@ -639,6 +641,10 @@ export default class extends Endpoint { // eslint- set.bannedEmailDomains = ps.bannedEmailDomains; } + if (ps.emailWhitelist !== undefined) { + set.emailWhitelist = ps.emailWhitelist; + } + if (ps.urlPreviewEnabled !== undefined) { set.urlPreviewEnabled = ps.urlPreviewEnabled; } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index ff8dd73605c5..7b36cf05cb2a 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -7,9 +7,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { DI } from '@/di-symbols.js'; -import type { AnnouncementsRepository } from '@/models/_.js'; +import type { AnnouncementsRepository, AnnouncementRolesRepository } from '@/models/_.js'; export const meta = { tags: ['meta'], @@ -44,16 +45,36 @@ export default class extends Endpoint { // eslint- @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + @Inject(DI.announcementRolesRepository) + private announcementRolesRepository: AnnouncementRolesRepository, + private queryService: QueryService, + private roleService: RoleService, private announcementEntityService: AnnouncementEntityService, ) { super(meta, paramDef, async (ps, me) => { + const userRoles = me != null ? await this.roleService.getUserRoles(me.id) : []; + + const announcementRolesQuery = this.announcementRolesRepository.createQueryBuilder('ar') + .select('ar.announcementId') + .where('ar.roleId IN (:...roles)', { roles: userRoles.map(x => x.id) }); + const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) .andWhere('announcement.isActive = :isActive', { isActive: ps.isActive }) .andWhere(new Brackets(qb => { if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); qb.orWhere('announcement.userId IS NULL'); - })); + })) + .andWhere(new Brackets(qb => { + if (userRoles.length > 0) { + qb.orWhere(new Brackets(qb2 => { + qb2.andWhere('announcement.isRoleSpecified = true'); + qb2.andWhere(`announcement.id IN (${ announcementRolesQuery.getQuery() })`); + })); + } + qb.orWhere('announcement.isRoleSpecified = false'); + })) + .setParameters(announcementRolesQuery.getParameters()); const announcements = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index e3a6d2d670e6..15a056bc71b2 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -47,6 +47,7 @@ export const paramDef = { properties: { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + announcement: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, @@ -89,6 +90,7 @@ export default class extends Endpoint { // eslint- isSensitive: ps.isSensitive ?? false, ...(ps.color !== undefined ? { color: ps.color } : {}), allowRenoteToExternal: ps.allowRenoteToExternal ?? true, + announcement: ps.announcement ?? null, } as MiChannel); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index dba2938b3993..15f4a725ecff 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -59,6 +59,7 @@ export const paramDef = { type: 'string', format: 'misskey:id', }, }, + announcement: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, allowRenoteToExternal: { type: 'boolean', nullable: true }, @@ -112,6 +113,7 @@ export default class extends Endpoint { // eslint- ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), + ...(ps.announcement !== undefined ? { announcement: ps.announcement } : {}), ...(ps.color !== undefined ? { color: ps.color } : {}), ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(banner ? { bannerId: banner.id } : {}), diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index dc6ffd3e02e2..833a74fe1240 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -157,6 +157,24 @@ export default class extends Endpoint { // eslint- prevGroupedNotification.id = notification.id; continue; } + if (prev.type === 'note' && notification.type === 'note') { + if (prevGroupedNotification.type !== 'note:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'note:grouped', + id: '', + createdAt: notification.createdAt, + noteIds: [notification.noteId], + notifierIds: [prev.notifierId!], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + if (!(prevGroupedNotification as FilterUnionByProperty).notifierIds.includes(notification.notifierId)) { + (prevGroupedNotification as FilterUnionByProperty).notifierIds.push(notification.notifierId!); + } + (prevGroupedNotification as FilterUnionByProperty).noteIds.push(notification.noteId!); + prevGroupedNotification.id = notification.id; + continue; + } groupedNotifications.push(notification); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index d3eeb75b27c7..db8cff377434 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -177,6 +177,7 @@ export const paramDef = { publicReactions: { type: 'boolean' }, carefulBot: { type: 'boolean' }, autoAcceptFollowed: { type: 'boolean' }, + autoRejectFollowRequest: { type: 'boolean' }, noCrawle: { type: 'boolean' }, preventAiLearning: { type: 'boolean' }, requireSigninToViewContents: { type: 'boolean' }, @@ -335,6 +336,7 @@ export default class extends Endpoint { // eslint- if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.autoRejectFollowRequest === 'boolean') profileUpdates.autoRejectFollowRequest = ps.autoRejectFollowRequest; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning; if (typeof ps.requireSigninToViewContents === 'boolean') updates.requireSigninToViewContents = ps.requireSigninToViewContents; 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 6097f9c562e7..9b4e40636dec 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -68,7 +68,7 @@ describe('api:notes/create', () => { test('0 characters cw', () => { expect(v({ text: 'Body', cw: '' })) - .toBe(INVALID); + .toBe(VALID); }); 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 253a36081514..bd182ccae8fa 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -95,6 +95,12 @@ export const meta = { id: '04da457d-b083-4055-9082-955525eda5a5', }, + cannotScheduleDeleteEarlierThanNow: { + message: 'Scheduled delete time is earlier than now.', + code: 'CANNOT_SCHEDULE_DELETE_EARLIER_THAN_NOW', + id: '9576c3c8-d8f3-11ee-ac15-00155d19d35d', + }, + noSuchChannel: { message: 'No such channel.', code: 'NO_SUCH_CHANNEL', @@ -189,6 +195,14 @@ export const paramDef = { }, required: ['choices'], }, + scheduledDelete: { + type: 'object', + nullable: true, + properties: { + deleteAt: { type: 'integer', nullable: true }, + deleteAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + }, }, // (re)note with text, files and poll are optional if: { @@ -351,6 +365,16 @@ export default class extends Endpoint { // eslint- } } + if (ps.scheduledDelete) { + if (typeof ps.scheduledDelete.deleteAt === 'number') { + if (ps.scheduledDelete.deleteAt < Date.now()) { + throw new ApiError(meta.errors.cannotScheduleDeleteEarlierThanNow); + } + } else if (typeof ps.scheduledDelete.deleteAfter === 'number') { + ps.scheduledDelete.deleteAt = Date.now() + ps.scheduledDelete.deleteAfter; + } + } + let channel: MiChannel | null = null; if (ps.channelId != null) { channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); @@ -382,6 +406,7 @@ export default class extends Endpoint { // eslint- apMentions: ps.noExtractMentions ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined, + deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : null, }); return { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 97b12ab7f765..f362f46d41e9 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -4,13 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets, type FindOptionsWhere } from 'typeorm'; import type { NoteReactionsRepository } from '@/models/_.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReactionEntityService } from '@/core/entities/NoteReactionEntityService.js'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['notes', 'reactions'], @@ -59,6 +58,7 @@ export default class extends Endpoint { // eslint- private noteReactionEntityService: NoteReactionEntityService, private queryService: QueryService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId) @@ -66,6 +66,18 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reaction.user', 'user') .leftJoinAndSelect('reaction.note', 'note'); + if (me != null) { + const [userIdsWhoMeMuting, userIdsWhoBlockingMe] = await Promise.all([ + this.cacheService.userMutingsCache.get(me.id), + this.cacheService.userBlockedCache.get(me.id), + ]); + + const userIds = Array.from(userIdsWhoMeMuting ?? []).concat(Array.from(userIdsWhoBlockingMe ?? [])); + if (userIds.length > 0 ) { + query.andWhere('reaction.userId NOT IN (:...userIds)', { userIds: Array.from(userIdsWhoMeMuting ?? []).concat(Array.from(userIdsWhoBlockingMe ?? [])) }); + } + } + if (ps.type) { // ローカルリアクションはホスト名が . とされているが // DB 上ではそうではないので、必要に応じて変換 diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts new file mode 100644 index 000000000000..520837ce94d2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts @@ -0,0 +1,372 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { In } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { isPureRenote } from 'misskey-js/note.js'; +import type { MiUser } from '@/models/User.js'; +import type { + UsersRepository, + NotesRepository, + BlockingsRepository, + DriveFilesRepository, + ChannelsRepository, + NoteScheduleRepository, +} from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiNote } from '@/models/Note.js'; +import type { MiChannel } from '@/models/Channel.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { MiScheduleNoteType } from '@/models/NoteSchedule.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: 'write:notes-schedule', + + errors: { + scheduleNoteMax: { + message: 'Schedule note max.', + code: 'SCHEDULE_NOTE_MAX', + id: '168707c3-e7da-4031-989e-f42aa3a274b2', + }, + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4', + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', + }, + + cannotRenoteDueToVisibility: { + message: 'You can not Renote due to target visibility.', + code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY', + id: 'be9529e9-fe72-4de0-ae43-0b363c4938af', + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c', + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + + cannotCreateAlreadyExpiredSchedule: { + message: 'Schedule is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE', + id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07', + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb', + }, + noSuchSchedule: { + message: 'No such schedule.', + code: 'NO_SUCH_SCHEDULE', + id: '44dee229-8da1-4a61-856d-e3a4bbc12032', + }, + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', + }, + + noSuchFile: { + message: 'Some files are not found.', + 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; + +export const paramDef = { + type: 'object', + properties: { + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibleUserIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + noExtractMentions: { type: 'boolean', default: false }, + noExtractHashtags: { type: 'boolean', default: false }, + noExtractEmojis: { type: 'boolean', default: false }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + // See https://github.com/misskey-dev/misskey/pull/10082 + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: true, + }, + 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'], + }, + scheduleNote: { + type: 'object', + nullable: false, + properties: { + scheduledAt: { type: 'integer', nullable: false }, + }, + }, + }, + // (re)note with text, files and poll are optional + anyOf: [ + { required: ['text'] }, + { required: ['renoteId'] }, + { required: ['fileIds'] }, + { required: ['mediaIds'] }, + { required: ['poll'] }, + ], + required: ['scheduleNote'], +} as const; + +@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.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private queueService: QueueService, + private roleService: RoleService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id }); + const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax; + if (scheduleNoteCount >= scheduleNoteMax) { + throw new ApiError(meta.errors.scheduleNoteMax); + } + let visibleUsers: MiUser[] = []; + if (ps.visibleUserIds) { + visibleUsers = await this.usersRepository.findBy({ + id: In(ps.visibleUserIds), + }); + } + + 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(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + let renote: MiNote | null = null; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (isPureRenote(renote)) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== me.id) { + const blockExist = await this.blockingsRepository.exist({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + if (renote.visibility === 'followers' && renote.userId !== me.id) { + // 他人のfollowers noteはreject + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + } else if (renote.visibility === 'specified') { + // specified / direct noteはreject + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + } + } + + let reply: MiNote | null = null; + if (ps.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } else if (isPureRenote(reply)) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== me.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + let scheduleNote_scheduledAt = Date.now(); + if (typeof ps.scheduleNote.scheduledAt === 'number') { + scheduleNote_scheduledAt = ps.scheduleNote.scheduledAt; + } + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < scheduleNote_scheduledAt) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = scheduleNote_scheduledAt + ps.poll.expiredAfter; + } + } + if (typeof ps.scheduleNote.scheduledAt === 'number') { + if (ps.scheduleNote.scheduledAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); + } + } else { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule); + } + const note: MiScheduleNoteType = { + files: files.map(f => f.id), + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt).toISOString() : null, + } : undefined, + text: ps.text ?? undefined, + reply: reply?.id, + renote: renote?.id, + cw: ps.cw, + localOnly: false, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUsers, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }; + + if (ps.scheduleNote.scheduledAt) { + me.token = null; + const noteId = this.idService.gen(new Date().getTime()); + await this.noteScheduleRepository.insert({ + id: noteId, + note: note, + userId: me.id, + scheduledAt: new Date(ps.scheduleNote.scheduledAt), + }); + + const delay = new Date(ps.scheduleNote.scheduledAt).getTime() - Date.now(); + await this.queueService.ScheduleNotePostQueue.add(String(delay), { + scheduleNoteId: noteId, + }, { + delay, + removeOnComplete: true, + jobId: noteId, + }); + } + + return ''; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts new file mode 100644 index 000000000000..df406f99f0eb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NoteScheduleRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'write:notes-schedule', + + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a58056ba-8ba1-4323-8ebf-e0b585bc244f', + }, + permissionDenied: { + message: 'Permission denied.', + code: 'PERMISSION_DENIED', + id: 'c0da2fed-8f61-4c47-a41d-431992607b5c', + httpStatusCode: 403, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.noteScheduleRepository.findOneBy({ id: ps.noteId }); + if (note === null) { + throw new ApiError(meta.errors.noSuchNote); + } + if (note.userId !== me.id) { + throw new ApiError(meta.errors.permissionDenied); + } + await this.noteScheduleRepository.delete({ id: ps.noteId }); + await this.queueService.ScheduleNotePostQueue.remove(ps.noteId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts new file mode 100644 index 000000000000..4895733d4e21 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey 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 { DI } from '@/di-symbols.js'; +import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { noteVisibilities } from '@/types.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + kind: 'read:notes-schedule', + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { type: 'string', format: 'misskey:id', optional: false, nullable: false }, + note: { + type: 'object', + optional: false, nullable: false, + properties: { + createdAt: { type: 'string', optional: false, nullable: false }, + text: { type: 'string', optional: true, nullable: false }, + cw: { type: 'string', optional: true, nullable: true }, + fileIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', format: 'misskey:id', optional: false, nullable: false } }, + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], optional: false, nullable: false }, + visibleUsers: { + type: 'array', optional: false, nullable: false, items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'User', + }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + isSchedule: { type: 'boolean', optional: false, nullable: false }, + }, + }, + userId: { type: 'string', optional: false, nullable: false }, + scheduledAt: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteScheduleRepository) + private noteScheduleRepository: NoteScheduleRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.noteScheduleRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.userId = :userId', { userId: me.id }); + const scheduleNotes = await query.limit(ps.limit).getMany(); + const user = await this.userEntityService.pack(me, me); + const scheduleNotesPack: { + id: string; + note: { + text?: string; + cw?: string|null; + fileIds: string[]; + visibility: typeof noteVisibilities[number]; + visibleUsers: Packed<'UserLite'>[]; + reactionAcceptance: MiNote['reactionAcceptance']; + user: Packed<'User'>; + createdAt: string; + isSchedule: boolean; + }; + userId: string; + scheduledAt: string; + }[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => { + return { + ...item, + scheduledAt: item.scheduledAt.toISOString(), + note: { + ...item.note, + text: item.note.text ?? '', + user: user, + visibility: item.note.visibility ?? 'public', + reactionAcceptance: item.note.reactionAcceptance ?? null, + visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [], + fileIds: item.note.files ? item.note.files : [], + files: await this.driveFileEntityService.packManyByIds(item.note.files), + createdAt: item.scheduledAt.toISOString(), + isSchedule: true, + id: item.id, + }, + }; + })); + + return scheduleNotesPack; + }); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 1b8873214b4f..e77db8a9d4fa 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -34,6 +34,7 @@ import type { SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, + ScheduleNotePostQueue, } from '@/core/QueueModule.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -136,6 +137,7 @@ export class ClientServerService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -143,6 +145,7 @@ export class ClientServerService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduleNotePost') public scheduleNotePostQueue: ScheduleNotePostQueue, ) { //this.createServer = this.createServer.bind(this); } @@ -264,6 +267,7 @@ export class ClientServerService { queues: [ this.systemQueue, this.endedPollNotificationQueue, + this.scheduledNoteDeleteQueue, this.deliverQueue, this.inboxQueue, this.dbQueue, @@ -271,6 +275,7 @@ export class ClientServerService { this.objectStorageQueue, this.userWebhookDeliverQueue, this.systemWebhookDeliverQueue, + this.scheduleNotePostQueue, ].map(q => new BullMQAdapter(q)), serverAdapter: bullBoardServerAdapter, }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index df3cfee17103..9bff5c503bd7 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -18,6 +18,8 @@ * achievementEarned - 実績を獲得 * exportCompleted - エクスポートが完了 * login - ログイン + * scheduledNoteFailed - 予約投稿に失敗 + * scheduledNotePosted - 予約投稿をノート * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -36,6 +38,8 @@ export const notificationTypes = [ 'achievementEarned', 'exportCompleted', 'login', + 'scheduledNoteFailed', + 'scheduledNotePosted', 'app', 'test', ] as const; @@ -44,6 +48,7 @@ export const groupedNotificationTypes = [ ...notificationTypes, 'reaction:grouped', 'renote:grouped', + 'note:grouped', ] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; @@ -88,10 +93,13 @@ export const moderationLogTypes = [ 'deleteNote', 'createGlobalAnnouncement', 'createUserAnnouncement', + 'createRolesAnnouncement', 'updateGlobalAnnouncement', 'updateUserAnnouncement', + 'updateRolesAnnouncement', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', + 'deleteRolesAnnouncement', 'resetPassword', 'suspendRemoteInstance', 'unsuspendRemoteInstance', @@ -211,6 +219,12 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + createRolesAnnouncement: { + announcementId: string; + announcement: any; + roleIds: string[]; + roles: any[]; + }; updateGlobalAnnouncement: { announcementId: string; before: any; @@ -224,6 +238,13 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + updateRolesAnnouncement: { + announcementId: string; + before: any; + after: any; + roleIds: string[]; + roles: any[]; + }; deleteGlobalAnnouncement: { announcementId: string; announcement: any; @@ -235,6 +256,12 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + deleteRolesAnnouncement: { + announcementId: string; + announcement: any; + roleIds: string[]; + roles: any[]; + }; resetPassword: { userId: string; userUsername: string; diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 4fe5cbb205a7..fe87bd496fd0 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -71,6 +71,10 @@ export const notificationTypes = [ 'login', 'test', 'app', + 'test', + 'scheduledNoteFailed', + 'scheduledNotePosted', + 'pollVote', ] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; @@ -78,6 +82,7 @@ export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', 'canPublicNote', + 'scheduleNoteMax', 'mentionLimit', 'canInvite', 'inviteLimit', diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index bfe5c4f5f708..be2f318a6771 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -24,6 +24,7 @@ import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { setupRouter } from '@/router/main.js'; import { createMainRouter } from '@/router/definition.js'; +import { applyFont } from '@/scripts/font'; export async function common(createVue: () => App) { console.info(`Misskey v${version}`); @@ -165,6 +166,15 @@ export async function common(createVue: () => App) { } }); + //# Custom font + if (defaultStore.state.customFont) { + applyFont(defaultStore.state.customFont); + } + + watch(defaultStore.reactiveState.customFont, (font) => { + applyFont(font); + }); + //#region Sync dark mode if (ColdDeviceStorage.get('syncDeviceDarkMode')) { defaultStore.set('darkMode', isDeviceDarkmode()); diff --git a/packages/frontend/src/components/MkDeleteScheduleEditor.vue b/packages/frontend/src/components/MkDeleteScheduleEditor.vue new file mode 100644 index 000000000000..110bc3469b6b --- /dev/null +++ b/packages/frontend/src/components/MkDeleteScheduleEditor.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index b095a1cd4a5d..1ef6694cee1a 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -44,8 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only + {{ switchLabel }}
- {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} + {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} ({{ sec }}) {{ cancelText ?? i18n.ts.cancel }}
@@ -56,11 +57,12 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 32766f2029d1..c8228391e31e 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -37,7 +37,7 @@ import XBanner from '@/components/MkMediaBanner.vue'; import XImage from '@/components/MkMediaImage.vue'; import XVideo from '@/components/MkMediaVideo.vue'; import * as os from '@/os.js'; -import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js'; +import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@@/js/const.js'; import { defaultStore } from '@/store.js'; import { focusParent } from '@/scripts/focus.js'; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 1a8814b7cbdb..f6651e81684d 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -47,10 +47,10 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
- +

+

+
-
@@ -290,6 +294,7 @@ const menuButton = shallowRef(); const renoteButton = shallowRef(); const renoteTime = shallowRef(); const reactButton = shallowRef(); +const heartReactButton = shallowRef(); const clipButton = shallowRef(); const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = shallowRef>(); @@ -305,6 +310,15 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS const conversation = ref([]); const replies = ref([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); +const hideReactionCount = computed(() => { + switch (defaultStore.state.hideReactionCount) { + case 'none': return false; + case 'all': return true; + case 'self': return props.note.userId === $i?.id; + case 'others': return props.note.userId !== $i?.id; + default: return false; + } +}); const pleaseLoginContext = computed(() => ({ type: 'lookup', @@ -466,6 +480,32 @@ function react(): void { } } +function heartReact(): void { + pleaseLogin(undefined, pleaseLoginContext.value); + showMovedDialog(); + + sound.playMisskeySfx('reaction'); + + const selectreact = defaultStore.state.selectReaction; + + misskeyApi('notes/reactions/create', { + noteId: appearNote.value.id, + reaction: selectreact, + }); + + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } + + const el = heartReactButton.value; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } +} + function undoReact(targetNote: Misskey.entities.Note): void { const oldReaction = targetNote.myReaction; if (!oldReaction) return; diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 750e32a9ff37..a93552e13910 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
@@ -40,10 +41,14 @@ import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { notePage } from '@/filters/note.js'; import { userPage } from '@/filters/user.js'; +import { dateTimeFormat } from '@/scripts/intl-const.js'; import { defaultStore } from '@/store.js'; defineProps<{ - note: Misskey.entities.Note; + note: Misskey.entities.Note & { + isSchedule?: boolean + }; + scheduled?: boolean; }>(); const mock = inject('mock', false); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index e684cf2a30d0..5501b6ac8c47 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -36,6 +36,8 @@ import * as sound from '@/scripts/sound.js'; import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; +const reactionChecksMuting = computed(defaultStore.makeGetterSetter('reactionChecksMuting')); + const props = defineProps<{ reaction: string; count: number; @@ -54,38 +56,57 @@ const buttonEl = shallowRef(); const emojiName = computed(() => props.reaction.replace(/:/g, '').replace(/@\./, '')); const emoji = computed(() => customEmojisMap.get(emojiName.value) ?? getUnicodeEmoji(props.reaction)); +function getReactionName(reaction: string, formated = false) { + const r = reaction.replaceAll(':', '').replace(/@.*/, ''); + return formated ? `:${r}:` : r; +} + +const isLocal = computed(() => !props.reaction.match(/@\w/)); +const isAvailable = computed(() => isLocal.value ? true : customEmojisMap.has(getReactionName(props.reaction))); + const canToggle = computed(() => { - return !props.reaction.match(/@\w/) && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); + return isAvailable.value && $i && emoji.value && checkReactionPermissions($i, props.note, emoji.value); }); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); +const hideReactionCount = computed(() => { + switch (defaultStore.state.hideReactionCount) { + case 'none': return false; + case 'all': return true; + case 'self': return props.note.userId === $i?.id; + case 'others': return props.note.userId !== $i?.id; + default: return false; + } +}); + async function toggleReaction() { if (!canToggle.value) return; - const oldReaction = props.note.myReaction; + const reaction = getReactionName(props.reaction, true); + const oldReaction = props.note.myReaction ? getReactionName(props.note.myReaction, true) : null; if (oldReaction) { const confirm = await os.confirm({ type: 'warning', - text: oldReaction !== props.reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm, + text: oldReaction !== reaction ? i18n.ts.changeReactionConfirm : i18n.ts.cancelReactionConfirm, }); if (confirm.canceled) return; - if (oldReaction !== props.reaction) { + if (oldReaction !== reaction) { sound.playMisskeySfx('reaction'); } if (mock) { - emit('reactionToggled', props.reaction, (props.count - 1)); + emit('reactionToggled', reaction, (props.count - 1)); return; } misskeyApi('notes/reactions/delete', { noteId: props.note.id, }).then(() => { - if (oldReaction !== props.reaction) { + if (oldReaction !== reaction) { misskeyApi('notes/reactions/create', { noteId: props.note.id, - reaction: props.reaction, + reaction: reaction, }); } }); @@ -93,14 +114,15 @@ async function toggleReaction() { sound.playMisskeySfx('reaction'); if (mock) { - emit('reactionToggled', props.reaction, (props.count + 1)); + emit('reactionToggled', reaction, (props.count + 1)); return; } misskeyApi('notes/reactions/create', { noteId: props.note.id, - reaction: props.reaction, + reaction: reaction, }); + if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } @@ -146,20 +168,23 @@ onMounted(() => { if (!mock) { useTooltip(buttonEl, async (showing) => { - const reactions = await misskeyApiGet('notes/reactions', { + const useGet = !reactionChecksMuting.value; + const apiCall = useGet ? misskeyApiGet : misskeyApi; + const reactions = !defaultStore.state.hideReactionUsers ? await apiCall('notes/reactions', { noteId: props.note.id, type: props.reaction, limit: 10, _cacheKey_: props.count, - }); + }) : []; const users = reactions.map(x => x.user); + const count = users.length; const { dispose } = os.popup(XDetails, { showing, reaction: props.reaction, users, - count: props.count, + count, targetElement: buttonEl.value, }, { closed: () => dispose(), @@ -187,7 +212,21 @@ if (!mock) { } } - &:not(.canToggle) { + &.canToggleFallback:not(.canToggle):not(.reacted) { + box-sizing: border-box; + border: 2px dashed var(--MI_THEME-switchBg); + + &.small { + border-width: 1px; + border-color: var(--MI_THEME-buttonBgSub); + } + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + &:not(.canToggle):not(.canToggleFallback) { cursor: default; } diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index 3f14c5b5e0d6..bbaa16486013 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -49,6 +50,7 @@ import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { acct, userPage } from '@/filters/user.js'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store.js'; +import MkInstanceIcon from '@/components/MkInstanceIcon.vue'; const animation = ref(defaultStore.state.animation); const squareAvatars = ref(defaultStore.state.squareAvatars); @@ -62,6 +64,7 @@ const props = withDefaults(defineProps<{ indicator?: boolean; decorations?: (Omit & { blink?: boolean; })[]; forceShowDecoration?: boolean; + showInstance?: boolean; }>(), { target: null, link: false, @@ -69,6 +72,7 @@ const props = withDefaults(defineProps<{ indicator: false, decorations: undefined, forceShowDecoration: false, + showInstance: false, }); const emit = defineEmits<{ @@ -343,4 +347,30 @@ watch(() => props.user.avatarBlurhash, () => { filter: brightness(1); } } + +.instanceicon { + height: 25px; + z-index: 2; + position: absolute; + left: 0; + bottom: 0; +} + +@container (max-width: 580px) { + .instanceicon { + height: 21px; + } +} + +@container (max-width: 450px) { + .instanceicon { + height: 19px; + } +} + +@container (max-width: 300px) { + .instanceicon { + height: 17px; + } +} diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index ec1d8590808f..7b78a25cca37 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -54,6 +54,7 @@ const react = inject<((name: string) => void) | null>('react', null); const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@'))); +const canReact = computed(() => isLocal.value || customEmojisMap.has(customEmojiName.value)); const rawUrl = computed(() => { if (props.url) { @@ -86,7 +87,7 @@ const alt = computed(() => `:${customEmojiName.value}:`); const errored = ref(url.value == null); function onClick(ev: MouseEvent) { - if (props.menu) { + if (props.menu && canReact.value) { const menuItems: MenuItem[] = []; menuItems.push({ @@ -112,6 +113,16 @@ function onClick(ev: MouseEvent) { }); } + if ( !defaultStore.state.reactions.includes(`:${props.name}:`) ) { + menuItems.push({ + text: i18n.ts.addToEmojiPicker, + icon: 'ti ti-plus', + action: () => { + defaultStore.set('reactions', [...defaultStore.state.reactions, `:${props.name}:`]); + }, + }); + } + menuItems.push({ text: i18n.ts.info, icon: 'ti ti-info-circle', diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 0d138d1f1c3c..ad261ce3a5dd 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -61,7 +61,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext= 2.5, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, })]; } } diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 08ff0c58dd3a..6adc62b1f524 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -19,7 +19,8 @@ content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/; worker-src 'self'; script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh; - style-src 'self' 'unsafe-inline'; + style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; + font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index ea1b673de9a6..c109a2238f67 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -223,8 +223,12 @@ export function toast(message: string) { export function alert(props: { type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string; - text?: string; + title?: string | null; + text?: string | null; + switchLabel?: string | null; + details?: Record; + okWaitInitiate?: 'dialog' | 'input' | 'switch'; + okWaitDuration?: number; }): Promise { return new Promise(resolve => { const { dispose } = popup(MkDialog, props, { @@ -238,9 +242,13 @@ export function alert(props: { export function confirm(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string; - text?: string; + title?: string | null; + text?: string | null; + switchLabel?: string | null; + details?: Record; okText?: string; + okWaitInitiate?: 'dialog' | 'input' | 'switch'; + okWaitDuration?: number; cancelText?: string; }): Promise<{ canceled: boolean }> { return new Promise(resolve => { @@ -569,6 +577,19 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool }); } +export async function selectRole(opts: { admin?: boolean; } = {}): Promise { + return new Promise(resolve => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), { + admin: opts.admin, + }, { + ok: role => { + resolve(role); + }, + closed: () => dispose(), + }); + }); +} + export async function selectDriveFile(multiple: boolean): Promise { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { @@ -731,3 +752,4 @@ export function checkExistence(fileData: ArrayBuffer): Promise { }); }); }*/ + diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue index e5e57c05c403..40880f90d78f 100644 --- a/packages/frontend/src/pages/about.overview.vue +++ b/packages/frontend/src/pages/about.overview.vue @@ -67,6 +67,10 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + {{ i18n.ts.shahuPortal }} + diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e42058601721..6f7290b5f1ef 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -4,23 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only -->