diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fde7ec0f2..6accd4347 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,5 +7,18 @@ "ghcr.io/devcontainers-contrib/features/pnpm:2": {} }, "forwardPorts": [3000], - "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh" + "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", + "customizations": { + "vscode": { + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "Vue.volar", + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" + ] + } + } } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6ec3c86a4..8f8c5a13a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -16,7 +16,7 @@ services: - external_network redis: - restart: always + restart: unless-stopped image: redis:7-alpine networks: - internal_network diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 450c3920c..bcad3e6d8 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -4,6 +4,7 @@ set -xe sudo chown -R node /workspace git submodule update --init +pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml pnpm build diff --git a/.dockerignore b/.dockerignore index 8f984831e..151ede038 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,8 @@ fluent-emojis/ !.yarn/sdks !.yarn/versions +.pnpm-store + .idea/ packages/*/.vscode/ packages/backend/test/docker-compose.yml diff --git a/.github/PULL_REQUEST_TEMPLATE/01_bug.md b/.github/PULL_REQUEST_TEMPLATE/01_bug.md index 79ca97dfa..0739fee70 100644 --- a/.github/PULL_REQUEST_TEMPLATE/01_bug.md +++ b/.github/PULL_REQUEST_TEMPLATE/01_bug.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md index 79ca97dfa..0739fee70 100644 --- a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md +++ b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0739fee70 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ + + +## What + + + +## Why + + + +## Additional info (optional) + + + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 000000000..44b6b4ba7 --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,59 @@ +name: Test (backend) + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + jest: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + services: + postgres: + image: postgres:13 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:6 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.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: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: pnpm build + - name: Test + run: pnpm jest-and-coverage + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/coverage/coverage-final.json diff --git a/.github/workflows/test.yml b/.github/workflows/test-frontend.yml similarity index 89% rename from .github/workflows/test.yml rename to .github/workflows/test-frontend.yml index 9135b4f60..18c1a31ae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test-frontend.yml @@ -1,4 +1,4 @@ -name: Test +name: Test (frontend) on: push: @@ -8,26 +8,13 @@ on: pull_request: jobs: - jest: + vitest: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x] - services: - postgres: - image: postgres:13 - ports: - - 54312:5432 - env: - POSTGRES_DB: test-misskey - POSTGRES_HOST_AUTH_METHOD: trust - redis: - image: redis:6 - ports: - - 56312:6379 - steps: - uses: actions/checkout@v3.3.0 with: @@ -51,12 +38,12 @@ jobs: - name: Build run: pnpm build - name: Test - run: pnpm jest-and-coverage + run: pnpm --filter frontend test-and-coverage - name: Upload Coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./packages/backend/coverage/coverage-final.json + files: ./packages/frontend/coverage/coverage-final.json e2e: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 62b818c62..c413cd4da 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ packages/frontend/.yarn/cache packages/backend/.yarn/cache packages/sw/.yarn/cache +# pnpm +.pnpm-store + # Cypress cypress/screenshots cypress/videos diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 42264548e..baca8db24 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,11 @@ { "recommendations": [ "editorconfig.editorconfig", - "eg2.vscode-npm-script", "dbaeumer.vscode-eslint", "Vue.volar", - "Vue.vscode-typescript-vue-plugin" + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 6a0497946..baffbe18e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "typescript.tsdk": "node_modules/typescript/lib", "files.associations": { "*.test.ts": "typescript" - } + }, + "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a5ab3e1..e79bbbac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,39 @@ - ### Bugfixes -- +- You should also include the user name that made the change. --> +## 13.x.x (unreleased) + +### Improvements +- ユーザーごとにRenoteをミュートできるように +- ノートごとに絵文字リアクションを受け取るか設定できるように +- enhance(client): DM作成時にメンションも含むように +- enhance(client): フォロー申請のボタンのデザインを改善 + +### Bugfixes +- ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正 + +## 13.9.2 (2023/03/06) + +### Improvements +- クリップ、チャンネルページに共有ボタンを追加 +- チャンネルでタイムライン上部に投稿フォームを表示するかどうかのオプションを追加 +- ブラウザでメディアプロキシ(/proxy)からファイルを保存した際に、なるべくオリジナルのファイル名を継承するように +- ドライブの「URLからアップロード」で、content-dispositionのfilenameがあればそれをファイル名に +- Identiconがローカルとリモートで同じになるように + - これまでのIdenticonは異なる画像になります +- サーバーのパフォーマンスを改善 + +### Bugfixes +- ロールの権限で「一般ユーザー」のロールがいきなり設定できない問題を修正 +- ユーザーページのバッジ表示を適切に折り返すように @arrow2nd +- fix(client): みつけるのロール一覧でコンディショナルロールが含まれるのを修正 +- macOSでDev Containerが動作しない問題を修正 @RyotaK + ## 13.9.1 (2023/03/03) ### Bugfixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d93cd9f..887d17961 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. diff --git a/Dockerfile b/Dockerfile index b439716be..fd0b5e1c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ ARG NODE_VERSION=18.13.0-bullseye -FROM node:${NODE_VERSION} AS builder +# build assets & compile TypeScript + +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS native-builder RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ @@ -33,33 +35,49 @@ RUN git submodule update --init RUN pnpm build RUN rm -rf .git/ -FROM node:${NODE_VERSION}-slim AS runner +# build native dependencies for target platform + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION} AS target-builder + +RUN apt-get update \ + && apt-get install -yqq --no-install-recommends \ + build-essential + +RUN corepack enable + +WORKDIR /misskey + +COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] +COPY --link ["scripts", "./scripts"] +COPY --link ["packages/backend/package.json", "./packages/backend/"] + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm i --frozen-lockfile --aggregate-output + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION}-slim AS runner ARG UID="991" ARG GID="991" -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - rm -f /etc/apt/apt.conf.d/docker-clean \ - ; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ - && apt-get update \ +RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg tini curl \ && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ - && find / -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ - && find / -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; + && find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ + && find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists USER misskey WORKDIR /misskey -COPY --chown=misskey:misskey --from=builder /misskey/node_modules ./node_modules -COPY --chown=misskey:misskey --from=builder /misskey/built ./built -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/built ./packages/backend/built -COPY --chown=misskey:misskey --from=builder /misskey/packages/frontend/node_modules ./packages/frontend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/fluent-emojis /misskey/fluent-emojis +COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules +COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ ENV NODE_ENV=production diff --git a/chart/templates/Deployment.yml b/chart/templates/Deployment.yml index d16aece91..d5dd14f59 100644 --- a/chart/templates/Deployment.yml +++ b/chart/templates/Deployment.yml @@ -3,16 +3,16 @@ kind: Deployment metadata: name: {{ include "misskey.fullname" . }} labels: - {{- include "misskey.labels" . | nindent 4 }} + {{- include "misskey.labels" . | nindent 4 }} spec: selector: matchLabels: - {{- include "misskey.selectorLabels" . | nindent 6 }} + {{- include "misskey.selectorLabels" . | nindent 6 }} replicas: 1 template: metadata: labels: - {{- include "misskey.selectorLabels" . | nindent 8 }} + {{- include "misskey.selectorLabels" . | nindent 8 }} spec: containers: - name: misskey diff --git a/chart/templates/Service.yml b/chart/templates/Service.yml index 320958129..afd851a9f 100644 --- a/chart/templates/Service.yml +++ b/chart/templates/Service.yml @@ -11,4 +11,4 @@ spec: protocol: TCP name: http selector: - {{- include "misskey.selectorLabels" . | nindent 4 }} + {{- include "misskey.selectorLabels" . | nindent 4 }} diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index b1b856119..8dc07c180 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -52,13 +52,30 @@ describe('After setup instance', () => { cy.intercept('POST', '/api/signup').as('signup'); cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-username] input').type('alice'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('not.be.disabled'); cy.get('[data-cy-signup-submit]').click(); cy.wait('@signup'); }); + + it('signup with duplicated username', () => { + cy.registerUser('alice', 'alice1234'); + + cy.visitHome(); + + // ユーザー名が重複している場合の挙動確認 + cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-username] input').type('alice'); + cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); + }); }); describe('After user signup', () => { diff --git a/cypress/e2e/widgets.cy.js b/cypress/e2e/widgets.cy.js index 7d2039ff9..a39ea85e1 100644 --- a/cypress/e2e/widgets.cy.js +++ b/cypress/e2e/widgets.cy.js @@ -29,17 +29,17 @@ describe('After user signed in', () => { it('first widget should be removed', () => { cy.get('.mk-widget-edit').click(); - cy.get('.data-cy-customize-container:first-child .data-cy-customize-container-remove._button').click(); - cy.get('.data-cy-customize-container').should('have.length', 2); + cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click(); + cy.get('[data-cy-customize-container]').should('have.length', 2); }); function buildWidgetTest(widgetName) { it(`${widgetName} widget should get added`, () => { cy.get('.mk-widget-edit').click(); cy.get('.mk-widget-select select').select(widgetName, { force: true }); - cy.get('.data-cy-bg._modalBg.data-cy-transparent').click({ multiple: true, force: true }); + cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true }); cy.get('.mk-widget-add').click({ force: true }); - cy.get(`.data-cy-mkw-${widgetName}`).should('exist'); + cy.get(`[data-cy-mkw-${widgetName}]`).should('exist'); }); } diff --git a/locales/de-DE.yml b/locales/de-DE.yml index c5ddf334c..eae9389ac 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -345,7 +345,7 @@ basicInfo: "Grundlegende Informationen" pinnedUsers: "Angeheftete Benutzer" pinnedUsersDescription: "Gib durch Leerzeichen getrennte Benutzer an, die an die \"Erkunden\"-Seite angeheftet werden sollen." pinnedPages: "Angeheftete Seiten" -pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfäde zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen.\n" +pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfade zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen." pinnedClipId: "ID des anzuheftenden Clips" pinnedNotes: "Angeheftete Notizen" hcaptcha: "hCaptcha" @@ -404,7 +404,7 @@ securityKey: "Sicherheitsschlüssel" lastUsed: "Zuletzt benutzt" lastUsedAt: "Zuletzt verwendet: {t}" unregister: "Deaktivieren" -passwordLessLogin: "Passwortloses Anmelden einrichten" +passwordLessLogin: "Passwortloses Anmelden" passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey" resetPassword: "Passwort zurücksetzen" newPasswordIs: "Das neue Passwort ist „{password}“" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Bei Upload auf \"public-read\" stellen" serverLogs: "Serverprotokolle" deleteAll: "Alle löschen" showFixedPostForm: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen" +showFixedPostFormInChannel: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen (Kanäle)" newNoteRecived: "Es gibt neue Notizen" sounds: "Töne" sound: "Töne" @@ -955,6 +956,9 @@ exploreOtherServers: "Eine andere Instanz finden" letsLookAtTimeline: "Die Chronik durchstöbern" disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt." invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren." +emailNotSupported: "Diese Instanz unterstützt das Versenden von Emails nicht" +postToTheChannel: "In Kanal senden" +cannotBeChangedLater: "Kann später nicht mehr geändert werden." _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1163,7 +1167,7 @@ _achievements: description: "Du hast hier geklickt" _justPlainLucky: title: "Pures Glück" - description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.01% erhalten werden" + description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.005% erhalten werden" _setNameToSyuilo: title: "Gottkomplex" description: "Setze deinen Namen auf \"syuilo\"" @@ -1510,7 +1514,7 @@ _2fa: step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" step3Title: "Authentifizierungsscode eingeben" step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." - step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." + step4: "Alle folgenden Anmeldeversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens." registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren." securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." diff --git a/locales/en-US.yml b/locales/en-US.yml index 638c47091..53a30c741 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -197,7 +197,7 @@ clearQueueConfirmText: "Any undelivered notes remaining in the queue will not be clearCachedFiles: "Clear cache" clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" blockedInstances: "Blocked Instances" -blockedInstancesDescription: "List the hostnames of the instances that you want to block. Listed instances will no longer be able to communicate with this instance." +blockedInstancesDescription: "List the hostnames of the instances that you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Set \"public-read\" on upload" serverLogs: "Server logs" deleteAll: "Delete all" showFixedPostForm: "Display the posting form at the top of the timeline" +showFixedPostFormInChannel: "Display the posting form at the top of the timeline (Channels)" newNoteRecived: "There are new notes" sounds: "Sounds" sound: "Sounds" @@ -955,6 +956,9 @@ exploreOtherServers: "Look for another instance" letsLookAtTimeline: "Have a look at the timeline" disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting." invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." +emailNotSupported: "This instance does not support sending emails" +postToTheChannel: "Post to channel" +cannotBeChangedLater: "This cannot be changed later." _achievements: earnedAt: "Unlocked at" _types: @@ -1182,7 +1186,7 @@ _achievements: _loggedInOnNewYearsDay: title: "Happy New Year!" description: "Logged in on the first day of the year" - flavor: "To another great year!" + flavor: "To another great year on this instance" _cookieClicked: title: "A game in which you click cookies" description: "Clicked the cookie" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 89d456d1f..d5638aeb6 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -54,8 +54,8 @@ copyUsername: "Copia nome utente" searchUser: "Cerca utente" reply: "Rispondi" loadMore: "Mostra di più" -showMore: "Mostra di più" -showLess: "Chiudi" +showMore: "Espandi" +showLess: "Comprimi" youGotNewFollower: "Ha iniziato a seguirti" receiveFollowRequest: "Hai ricevuto una richiesta di follow" followRequestAccepted: "Richiesta di follow accettata" @@ -76,7 +76,7 @@ noLists: "Nessuna lista" note: "Nota" notes: "Note" following: "Follow" -followers: "Followers" +followers: "Follower" followsYou: "Ti segue" createList: "Aggiungi una nuova lista" manageLists: "Gestisci liste" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di cari serverLogs: "Log del server" deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" +showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" newNoteRecived: "Vedi le nuove note" sounds: "Impostazioni suoni" sound: "Impostazioni suoni" @@ -558,7 +559,7 @@ visibility: "Visibilità" poll: "Sondaggio" useCw: "Nascondere media" enablePlayer: "Apri in lettore video" -disablePlayer: "Chiudi lettore video" +disablePlayer: "Chiudi il lettore" expandTweet: "Espandi tweet" themeEditor: "Editor di temi" description: "Descrizione" @@ -955,6 +956,9 @@ exploreOtherServers: "Trova altre istanze" letsLookAtTimeline: "Sbircia la timeline" disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione." invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore." +emailNotSupported: "L'istanza non supporta l'invio di email" +postToTheChannel: "Pubblica sul canale" +cannotBeChangedLater: "Non sarà più modificabile" _achievements: earnedAt: "Data di conseguimento" _types: @@ -1613,7 +1617,7 @@ _widgets: clicker: "Cliccaggio" _cw: hide: "Nascondere" - show: "Mostra di più" + show: "Apri..." chars: "{count} caratteri" files: "{count} file" _poll: diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8fee2726e..13f8bc02e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "閲覧注意を解除する" enterFileName: "ファイル名を入力" mute: "ミュート" unmute: "ミュート解除" +renoteMute: "リノートをミュート" +renoteUnmute: "リノートのミュートを解除" block: "ブロック" unblock: "ブロック解除" suspend: "凍結" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "タイムラインにノートへの返信を表示す flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" addAccount: "アカウントを追加" +reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" general: "全般" @@ -506,6 +509,7 @@ objectStorageSetPublicRead: "アップロード時に'public-read'を設定す serverLogs: "サーバーログ" deleteAll: "全て削除" showFixedPostForm: "タイムライン上部に投稿フォームを表示する" +showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" newNoteRecived: "新しいノートがあります" sounds: "サウンド" sound: "サウンド" @@ -543,6 +547,10 @@ userSuspended: "このユーザーは凍結されています。" userSilenced: "このユーザーはサイレンスされています。" yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" +tokenRevoked: "トークンが無効です" +tokenRevokedDescription: "ログイントークンが失効しています。ログインし直してください。" +accountDeleted: "アカウントは削除されています" +accountDeletedDescription: "このアカウントは削除されています。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" @@ -955,6 +963,12 @@ exploreOtherServers: "他のサーバーを探す" letsLookAtTimeline: "タイムラインを見てみる" disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" +emailNotSupported: "このサーバーではメール配信はサポートされていません" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後から変更できません。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねのみ" +likeOnlyForRemote: "リモートからはいいねのみ" _achievements: earnedAt: "獲得日時" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 862453dd5..081457c2f 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "アップロードした時に'public-read'を設 serverLogs: "サーバーログ" deleteAll: "全て削除してや" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" +showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)" newNoteRecived: "新しいノートがあるで" sounds: "サウンド" sound: "サウンド" @@ -909,7 +910,7 @@ subscribePushNotification: "プッシュ通知をオンにするで" unsubscribePushNotification: "プッシュ通知を止めるで" pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで" pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。" -sendPushNotificationReadMessage: "通知やメッセージが既読担ったらプッシュ通知を消すで" +sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" windowMaximize: "最大化" windowRestore: "元に戻す" @@ -955,6 +956,9 @@ exploreOtherServers: "他のサーバー見てみる" letsLookAtTimeline: "タイムライン見てみーや" disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" +emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後からは変えられへんで。" _achievements: earnedAt: "貰った日ぃ" _types: @@ -1072,7 +1076,7 @@ _achievements: description: "プロフィールを設定した" _markedAsCat: title: "吾輩は猫やねん" - description: "アカウントがCatになってもうた" + description: "アカウントをCatにしたった" flavor: "名前はまだないねん。" _following1: title: "はじめてのフォロー" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index ce596d038..9c1a48c67 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -168,7 +168,9 @@ done: "ສຳເລັດ" processing: "ກຳລັງປະມວນຜົນ" preview: "ສະແດງເປັນຕົວຢ່າງ" default: "ຄ່າເລີ່ມຕົ້ນ" +federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " +suspended: "ໂຈະ" all: "ທັງໝົດ" subscribing: "ສະໝັກສະມາຊິກແລັວ" publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່" @@ -177,15 +179,35 @@ instanceFollowing: "ກຳລັງຕິດຕາມສຸດຕົວຢ່າ instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ" instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​" changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" +security: "ຄວາມປອດໄພ" +retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" +currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" +more: "ເພີ່ມເຕີມ!" featured: "ໄຮໄລທ໌" +usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" +noSuchUser: "ບໍ່ພົບຜູ້ໃຊ້" +lookup: "ຄົ້ນ​ຫາ" announcements: "ປະກາດ" +imageUrl: "URL ຮູບພາບ" remove: "ລຶບ" +removed: "ລຶບແລ້ວ" +resetAreYouSure: "ຣີ​ເຊັດບໍ?" +saved: "ບັນທຶກແລ້ວ" messaging: "ແຊ໋ດ" +upload: "ອັບໂຫຼດ" +keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" +fromUrl: "ຈາກ URL" +uploadFromUrl: "ອັບໂຫຼດຈາກ URL" +uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" +messageRead: "ອ່ານແລ້ວ" +startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" +nUsersRead: "ອ່ານໂດຍ {n}" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" images: "ຮູບພາບ" birthday: "ວັນເກີດ" +yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" location: "ທີ່ຕັ້ງ" theme: "ແທ໋ມ" @@ -193,17 +215,96 @@ light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" +drive: "ຂັບ" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" selectFiles: "ເລືອກໄຟລ໌" +selectFolder: "ເລືອກໂຟລເດີ" +selectFolders: "ເລືອກໂຟລເດີ" +renameFile: "ປ່ຽນຊື່ໄຟລ໌" +folderName: "ຊື່ໂຟນເດີ" +createFolder: "​ສ້າງ​ໂຟ​ລ​ເດີ" +renameFolder: "ປ່ຽນຊື່ໂຟນເດີນີ້" +deleteFolder: "ລົບໂຟ​ລ​ເດີ​" +addFile: "ເພີ່ມໄຟລ໌" +emptyDrive: "Drive ຂອງທ່ານຫວ່າງເປົ່າ" +emptyFolder: "ໂຟນເດີນີ້ເປົ່າຫວ່າງ" +unableToDelete: "ບໍ່​ສາ​ມາດລົບໄດ້" +inputNewFileName: "ໃສ່ຊື່ໄຟລ໌ໃໝ່" +inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່" +inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່" +circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ" +rename: "ປ່ຽນຊື່" nsfw: "NSFW" +watch: "ເບິ່ງ" +unwatch: "ຢຸດເບິ່ງ" accept: "ອະນຸຍາດ" +reject: "ປະຕິເສດ" +normal: "ປົກກະຕິ" +instanceName: "ຊື່ເຊີເວີ້" +instanceDescription: "ຄໍາອະທິບາຍຕົວຢ່າງ" +maintainerName: "ຜູ້ດູແລ" +maintainerEmail: "ອີເມວ admin" +tosUrl: "ເງື່ອນໄຂການໃຫ້ບໍລິການ URL" +thisYear: "ປີນີ້" +thisMonth: "ເດືອນນີ້" +today: "ມື້ນີ້" +dayX: "ວັນ {day}" +monthX: "ເດືອນ {month}" +yearX: "ປີ {year}" +pages: "ໜ້າ" +integration: "ຄວາມສຳພັນຂອງ" +connectService: "ເຊື່ອມຕໍ່" +disconnectService: "ຕັດການເຊື່ອມຕໍ່" +enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ່ນ" +enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ" +disablingTimelinesInfo: "ຜູ້ເບິ່ງແຍງລະບົບ ແລະຜູ້ຄວບຄຸມຈະມີການເຂົ້າເຖິງທຸກກຳນົດເວລາ, ເຖິງແມ່ນວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍຕາມ" +registration: "ລົງທະບຽນ" +enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່" +invite: "ເຊີນ" +driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" +driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" userList: "ລາຍການ" +about: "ກ່ຽວກັບ" +aboutMisskey: "ກ່ຽວກັບ Misskey" +administrator: "ຜູ້ບໍລິຫານ" +share: "ແບ່ງປັນ" +notFound: "ບໍ່ພົບ" +cacheClear: "ລຶບລ້າງແຄສ" +invites: "ເຊີນ" +title: "ຫົວຂໍ້" +text: "ຂໍ້ຄວາມ" +enable: "ເປີດໃຊ້" +next: "ຕໍ່ໄປ" +invitations: "ເຊີນ" +language: "ພາສາ" +native: "ພາ​ສາ​ແມ່" +category: "ຫມວດຫມູ່" +tags: "ແທ໋ກ" +createAccount: "ສ້າງບັນຊີ" +existingAccount: "ທີ່ມີຢູ່" +dashboard: "ໜ້າປັດ" +local: "ທ້ອງຖິ່ນ" +objectStorageRegion: "ພາກ​ພື້ນ" +sounds: "ສຽງ" +sound: "ສຽງ" +none: "ບໍ່ມີ" +volume: "ລະດັບສຽງ" +details: "ລາຍລະອຽດ" +install: "ຕິດຕັ້ງ" +uninstall: "ຖອນການຕິດຕັ້ງ" +state: "ສະຖານະ" +sort: "ຈັດຮຽງໂດຍ" +ascendingOrder: "ນ້ອຍໄປຫາໃຫຍ່" +descendingOrder: "ໃຫຍ່ຫານ້ອຍ" +output: "ຜົນຜະລິດ" +script: "ບົດ​ຄວາມ" smtpHost: "ໂຮດສ" smtpUser: "ຊື່ຜູ້ໃຊ້" smtpPass: "ລະຫັດຜ່ານ" clearCache: "ລຶບລ້າງແຄສ" +info: "ກ່ຽວກັບ" user: "ຜູ້ໃຊ້ຕ່າງໆ" searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" @@ -244,6 +345,8 @@ _charts: federation: "ສະຫະພັນ" _timelines: home: "ໜ້າຫຼັກ" +_play: + script: "ບົດ​ຄວາມ" _pages: blocks: image: "ຮູບພາບ" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 7dcc15824..cf33e6642 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -268,7 +268,7 @@ remoteUserCaution: "เนื่องจากผู้ใช้งานรา activity: "กิจกรรม" images: "รูปภาพ" birthday: "วันเกิด" -yearsOld: "{อายุ} ปี" +yearsOld: "{age} ปี" registeredDate: "วันที่สมัครสมาชิก" location: "ตำแหน่งที่ตั้ง" theme: "ธีม" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในกา serverLogs: "บันทึกของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" +showFixedPostFormInChannel: "แสดงแบบฟอร์มกำลังโพสต์ที่ด้านบนของไทม์ไลน์ (แชนแนล)" newNoteRecived: "มีโน้ตใหม่" sounds: "เสียง" sound: "เสียง" @@ -955,6 +956,9 @@ exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" +emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" +postToTheChannel: "โพสต์ลงช่อง" +cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 517f5a9ef..7798582db 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2,7 +2,7 @@ _lang_: "中文(简体)" headlineMisskey: "通过帖子连接在一起的网络" introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧!🚀" -poweredByMisskeyDescription: "{name} 由开源平台 Misskey 驱动(也被称为 Misskey 实例)" +poweredByMisskeyDescription: "{name} 由开源平台 Misskey 驱动(也被称为 Misskey 服务器)" monthAndDay: "{month}月 {day}日" search: "搜索" notifications: "通知" @@ -18,7 +18,7 @@ enterUsername: "输入用户名" renotedBy: "由 {user} 转贴" noNotes: "没有帖子" noNotifications: "无通知" -instance: "实例" +instance: "服务器" settings: "设置" basicSettings: "基本设置" otherSettings: "其他设置" @@ -144,7 +144,7 @@ emojiUrl: "表情符号地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "远程文件缓存" -cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程实例载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" +cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" flagAsBot: "这是一个机器人账号" flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" @@ -154,7 +154,7 @@ flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的 autoAcceptFollowed: "自动允许关注者的关注" addAccount: "添加账户" loginFailed: "登录失败" -showOnRemote: "转到所在实例显示" +showOnRemote: "转到所在服务器显示" general: "常规设置" wallpaper: "壁纸" setWallpaper: "设置壁纸" @@ -169,7 +169,7 @@ selectUser: "选择用户" recipient: "收件人" annotation: "注解" federation: "联合" -instances: "实例" +instances: "服务器" registeredAt: "初次观测" latestRequestReceivedAt: "上次收到的请求" latestStatus: "最后状态" @@ -178,7 +178,7 @@ charts: "图表" perHour: "每小时" perDay: "每天" stopActivityDelivery: "停止发送活动" -blockThisInstance: "阻止此实例向本实例推流" +blockThisInstance: "阻止此服务器向本服务器推流" operations: "操作" software: "软件" version: "版本" @@ -189,15 +189,15 @@ jobQueue: "作业队列" cpuAndMemory: "CPU和内存" network: "网络" disk: "存储" -instanceInfo: "实例信息" +instanceInfo: "服务器信息" statistics: "统计" clearQueue: "清除队列" clearQueueConfirmTitle: "确定清除队列?" clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。" clearCachedFiles: "清除缓存" clearCachedFilesConfirm: "确定要清除缓存文件?" -blockedInstances: "被阻拦的实例" -blockedInstancesDescription: "设定要阻拦的实例,以换行来进行分割。被阻拦的实例将无法与本实例进行交换通讯。" +blockedInstances: "被阻拦的服务器" +blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。" muteAndBlock: "屏蔽/拉黑" mutedUsers: "已屏蔽用户" blockedUsers: "被拉黑的用户" @@ -220,9 +220,9 @@ all: "全部" subscribing: "已订阅" publishing: "投递中" notResponding: "没有响应" -instanceFollowing: "关注实例" -instanceFollowers: "关注实例" -instanceUsers: "实例用户" +instanceFollowing: "关注服务器" +instanceFollowers: "关注的服务器" +instanceUsers: "服务器用户" changePassword: "修改密码" security: "安全" retypedNotMatch: "两次输入不一致!" @@ -264,7 +264,7 @@ basicNotesBeforeCreateAccount: "基本注意事项" tos: "服务条款" start: "开始" home: "首页" -remoteUserCaution: "由于此用户来自其它实例,显示的信息可能不完整。" +remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。" activity: "活动" images: "图片" birthday: "生日" @@ -314,8 +314,8 @@ unwatch: "取消关注" accept: "允许" reject: "拒绝" normal: "正常" -instanceName: "实例名称" -instanceDescription: "实例介绍" +instanceName: "服务器名称" +instanceDescription: "服务器简介" maintainerName: "管理员名称" maintainerEmail: "管理员电子邮箱" tosUrl: "服务条款URL" @@ -345,7 +345,7 @@ basicInfo: "基本信息" pinnedUsers: "置顶用户" pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。" pinnedPages: "固定页面" -pinnedPagesDescription: "输入您要固定到实例首页的页面路径,以换行符分隔。" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。" pinnedClipId: "置顶的便签ID" pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "上传时设置为public-read" serverLogs: "服务器日志" deleteAll: "全部删除" showFixedPostForm: "在时间线顶部显示发帖框" +showFixedPostFormInChannel: "在时间线顶部显示发帖对话框(频道)" newNoteRecived: "有新的帖子" sounds: "提示音" sound: "提示音" @@ -538,7 +539,7 @@ updateRemoteUser: "更新远程用户信息" deleteAllFiles: "删除所有文件" deleteAllFilesConfirm: "要删除所有文件吗?" removeAllFollowing: "取消所有关注" -removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。" +removeAllFollowingDescription: "取消{host}的所有关注者。当服务器不再存在时执行。" userSuspended: "该用户已被冻结。" userSilenced: "该用户已被禁言。" yourAccountSuspendedTitle: "账户已被冻结" @@ -635,15 +636,15 @@ abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" -forwardReport: "将该举报信息转发给远程实例" -forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。" +forwardReport: "将该举报信息转发给远程服务器" +forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。" send: "发送" abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" -instanceTicker: "帖子的实例信息" +instanceTicker: "帖子的服务器来源" waitingFor: "等待{x}" random: "随机" system: "系统" @@ -732,7 +733,7 @@ capacity: "容量" inUse: "已使用" editCode: "编辑代码" apply: "应用" -receiveAnnouncementFromInstance: "从实例接收通知" +receiveAnnouncementFromInstance: "从服务器接收通知" emailNotification: "邮件通知" publish: "发布" inChannelSearch: "频道内搜索" @@ -760,7 +761,7 @@ active: "活动" offline: "离线" notRecommended: "不推荐" botProtection: "Bot防御" -instanceBlocking: "被阻拦的实例" +instanceBlocking: "被阻拦的服务器" selectAccount: "选择账户" switchAccount: "切换账户" enabled: "已启用" @@ -844,8 +845,8 @@ themeColor: "主题颜色" size: "大小" numberOfColumn: "列数" searchByGoogle: "Google" -instanceDefaultLightTheme: "实例默认浅色主题" -instanceDefaultDarkTheme: "实例默认深色主题" +instanceDefaultLightTheme: "服务器默认浅色主题" +instanceDefaultDarkTheme: "服务器默认深色主题" instanceDefaultThemeDescription: "以对象格式键入主题代码" mutePeriod: "屏蔽期限" period: "截止时间" @@ -898,7 +899,7 @@ cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法 cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" beta: "测试" enableAutoSensitive: "自动 NSFW 识别" -enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。" +enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" navbar: "导航栏" shuffle: "随机" @@ -908,7 +909,7 @@ pushNotification: "推送通知" subscribePushNotification: "启用推送通知消息" unsubscribePushNotification: "停用推送通知消息" pushNotificationAlreadySubscribed: "推送通知消息已启用" -pushNotificationNotSupported: "浏览器或实例不支持推送通知消息" +pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" windowMaximize: "最大化" @@ -950,11 +951,14 @@ collapseRenotes: "省略显示已经看过的转发内容" internalServerError: "内部服务器错误" internalServerErrorDescription: "内部服务器发生了预期外的错误" copyErrorInfo: "复制错误信息" -joinThisServer: "在本实例上注册" -exploreOtherServers: "探索其他实例" +joinThisServer: "在本服务器上注册" +exploreOtherServers: "探索其他服务器" letsLookAtTimeline: "时间线" disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" -invitationRequiredToRegister: "此实例目前只允许拥有邀请码的人注册。" +invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人注册。" +emailNotSupported: "此服务器不支持发送邮件" +postToTheChannel: "发布到频道" +cannotBeChangedLater: "之后不能再更改。" _achievements: earnedAt: "达成时间" _types: @@ -1145,7 +1149,7 @@ _achievements: description: "在首页时间线的流速超过20npm" _viewInstanceChart: title: "分析师" - description: "查看了实例信息中的图表" + description: "查看了服务器信息中的图表" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "在AiScript控制台中输出 hello world" @@ -1182,7 +1186,7 @@ _achievements: _loggedInOnNewYearsDay: title: "恭贺新禧" description: "在元旦登入" - flavor: "今年也请对本实例多多指教!" + flavor: "今年也请对本服务器多多指教!" _cookieClicked: title: "点击饼干小游戏" description: "点击了可疑的饼干" @@ -1197,7 +1201,7 @@ _role: name: "角色名称" description: "角色描述" permission: "角色权限" - descriptionOfPermission: "监察员可以执行基本的审核操作。\n管理员可以更改实例的所有设置。" + descriptionOfPermission: "监察员可以执行基本地审核操作。\n管理员可以更改服务器的所有设置。" assignTarget: "授权对象" descriptionOfAssignTarget: "手动指手动选择谁被包括在这个角色中。\n符合条件指设置条件以自动包括符合条件的用户。" manual: "手动" @@ -1287,7 +1291,7 @@ _ad: _forgotPassword: enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。" - contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。" + contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" _gallery: my: "我的图库" liked: "喜欢的图片" @@ -1372,10 +1376,10 @@ _wordMute: hard: "硬屏蔽" mutedNotes: "被屏蔽的帖子" _instanceMute: - instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。" + instanceMuteDescription: "屏蔽配置服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" instanceMuteDescription2: "设置时用换行符来分隔" - title: "隐藏实例已设置的帖子。" - heading: "屏蔽实例" + title: "隐藏服务器已设置的帖子。" + heading: "屏蔽服务器" _theme: explore: "寻找主题" install: "安装主题" @@ -1583,7 +1587,7 @@ _weekday: saturday: "星期六" _widgets: profile: "个人资料" - instanceInfo: "实例信息" + instanceInfo: "服务器信息" memo: "便签" notifications: "通知" timeline: "时间线" @@ -1597,7 +1601,7 @@ _widgets: digitalClock: "数字时钟" unixClock: "UNIX时钟" federation: "联邦宇宙" - instanceCloud: "实例云" + instanceCloud: "服务器云" postForm: "投稿窗口" slideshow: "幻灯片展示" button: "按钮" @@ -1648,7 +1652,7 @@ _visibility: specified: "指定用户" specifiedDescription: "仅发送至指定用户" disableFederation: "不参与联合" - disableFederationDescription: "不发送到其他实例" + disableFederationDescription: "不发送到其他服务器" _postForm: replyPlaceholder: "回复这个帖子..." quotePlaceholder: "引用这个帖子..." diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 43ab334be..5c1512bd4 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "上傳時設定為\"public-read\"" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" +showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)" newNoteRecived: "發現新的貼文" sounds: "音效" sound: "音效" @@ -955,6 +956,8 @@ exploreOtherServers: "探索其他伺服器" letsLookAtTimeline: "看看時間軸" disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" +emailNotSupported: "這個伺服器不支援寄送郵件" +postToTheChannel: "發布到頻道" _achievements: earnedAt: "獲得日期" _types: diff --git a/package.json b/package.json index a2d04030f..d791e7821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.9.1", + "version": "13.9.2", "codename": "nasubi", "repository": { "type": "git", @@ -31,8 +31,8 @@ "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", - "test": "pnpm jest", - "test-and-coverage": "pnpm jest-and-coverage", + "test": "pnpm -r test", + "test-and-coverage": "pnpm -r test-and-coverage", "format": "pnpm exec gulp format", "clean": "node ./scripts/clean.js", "clean-all": "node ./scripts/clean-all.js", diff --git a/packages/backend/migration/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js new file mode 100644 index 000000000..d2ed2bd2e --- /dev/null +++ b/packages/backend/migration/1665091090561-add-renote-muting.js @@ -0,0 +1,16 @@ + +export class addRenoteMuting1665091090561 { + constructor() { + this.name = 'addRenoteMuting1665091090561'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js new file mode 100644 index 000000000..f1765dd14 --- /dev/null +++ b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js @@ -0,0 +1,11 @@ +export class perNoteReactionAcceptance1678164627293 { + name = 'perNoteReactionAcceptance1678164627293' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "reactionAcceptance" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAcceptance"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 42efb881e..35e8dc5c6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -124,6 +124,7 @@ "seedrandom": "3.0.5", "semver": "7.3.8", "sharp": "0.31.3", + "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 491d8ab11..1fd2d1500 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -82,6 +82,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; +import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; @@ -203,6 +204,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; +const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; @@ -325,6 +327,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -442,6 +445,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -559,6 +563,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -675,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 852c1f32e..bd999c67d 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr'; import PrivateIp from 'private-ip'; import chalk from 'chalk'; import got, * as Got from 'got'; +import { parse } from 'content-disposition'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -32,13 +33,18 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise { + public async downloadUrl(url: string, path: string): Promise<{ + filename: string; + }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); const timeout = 30 * 1000; const operationTimeout = 60 * 1000; const maxSize = this.config.maxFileSize ?? 262144000; + const urlObj = new URL(url); + let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; + const req = got.stream(url, { headers: { 'User-Agent': this.config.userAgent, @@ -77,6 +83,14 @@ export class DownloadService { req.destroy(); } } + + const contentDisposition = res.headers['content-disposition']; + if (contentDisposition != null) { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } }).on('downloadProgress', (progress: Got.Progress) => { if (progress.transferred > maxSize) { this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); @@ -95,6 +109,10 @@ export class DownloadService { } this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + + return { + filename, + }; } @bindThis diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index b15c967c8..f4a06faeb 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type S3 from 'aws-sdk/clients/s3.js'; +import { correctFilename } from '@/misc/correct-filename.js'; type AddFileArgs = { /** User who wish to add file */ @@ -168,7 +169,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, name), + this.upload(key, fs.createReadStream(path), type, ext, name), ]; if (alts.webpublic) { @@ -176,7 +177,7 @@ export class DriveService { webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); } if (alts.thumbnail) { @@ -184,7 +185,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); } await Promise.all(uploads); @@ -360,7 +361,7 @@ export class DriveService { * Upload to ObjectStorage */ @bindThis - private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; @@ -374,7 +375,12 @@ export class DriveService { CacheControl: 'max-age=31536000, immutable', } as S3.PutObjectRequest; - if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (filename) params.ContentDisposition = contentDisposition( + 'inline', + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + ext ? correctFilename(filename, ext) : filename, + ); if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; const s3 = this.s3Service.getS3(meta); @@ -466,7 +472,12 @@ export class DriveService { //} // detect name - const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + const detectedName = correctFilename( + // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 + // extを付加してデータベースの文字数制限に当たることはまずない + (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', + info.type.ext + ); if (user && !force) { // Check if there is a file with the same hash @@ -736,24 +747,19 @@ export class DriveService { requestIp = null, requestHeaders = null, }: UploadFromUrlArgs): Promise { - let name = new URL(url).pathname.split('/').pop() ?? null; - if (name == null || !this.driveFileEntityService.validateFileName(name)) { - name = null; - } - - // If the comment is same as the name, skip comment - // (image.name is passed in when receiving attachment) - if (comment !== null && name === comment) { - comment = null; - } - // Create temp file const [path, cleanup] = await createTemp(); try { // write content at URL to temp file - await this.downloadService.downloadUrl(url, path); - + const { filename: name } = await this.downloadService.downloadUrl(url, path); + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); this.downloaderLogger.succ(`Got: ${driveFile.id}`); return driveFile!; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4c4261ba7..8d8535ca5 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -125,6 +125,7 @@ type Option = { files?: DriveFile[] | null; poll?: IPoll | null; localOnly?: boolean | null; + reactionAcceptance?: Note['reactionAcceptance']; cw?: string | null; visibility?: string; visibleUsers?: MinimumUser[] | null; @@ -346,6 +347,7 @@ export class NoteCreateService implements OnApplicationShutdown { emojis, userId: user.id, localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, visibility: data.visibility as any, visibleUserIds: data.visibility === 'specified' ? data.visibleUsers diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c334d749e..0cee2076b 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -29,6 +29,9 @@ export class QueryService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, ) { } @@ -269,5 +272,24 @@ export class QueryService { q.setParameters({ meId: me.id }); } } -} + @bindThis + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') + .select('renote_muting.muteeId') + .where('renote_muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb.where('note.renoteId IS NOT NULL'); + qb.andWhere('note.text IS NULL'); + qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL'); + })); + + q.setParameters(mutingQuery.getParameters()); + } +} diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 9fccc14ee..3e644018d 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -99,8 +99,12 @@ export class ReactionService { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } - // TODO: cache - reaction = await this.toDbReaction(reaction, user.host); + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { + reaction = '❤️'; + } else { + // TODO: cache + reaction = await this.toDbReaction(reaction, user.host); + } const record: NoteReaction = { id: this.idService.genId(), diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 158fafa9d..b750606f1 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -21,6 +21,7 @@ type PackOptions = { }; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class DriveFileEntityService { @@ -88,9 +89,7 @@ export class DriveFileEntityService { if (file.type.startsWith('video')) { if (file.thumbnailUrl) return file.thumbnailUrl; - if (this.config.videoThumbnailGenerator == null) { - return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); - } + return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { // 動画ではなくリモートかつメディアプロキシ return this.getProxiedUrl(file.uri, 'static'); @@ -255,10 +254,33 @@ export class DriveFileEntityService { @bindThis public async packMany( - files: (DriveFile['id'] | DriveFile)[], + files: DriveFile[], options?: PackOptions, ): Promise[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); } + + @bindThis + public async packManyByIdsMap( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise['id'], Packed<'DriveFile'> | null>> { + const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); + const packedFiles = await this.packMany(files, options); + const map = new Map['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); + for (const id of fileIds) { + if (!map.has(id)) map.set(id, null); + } + return map; + } + + @bindThis + public async packManyByIds( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise[]> { + const filesMap = await this.packManyByIdsMap(fileIds, options); + return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index ab29e7dba..fb147ae18 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -41,7 +41,8 @@ export class GalleryPostEntityService { title: post.title, description: post.description, fileIds: post.fileIds, - files: this.driveFileEntityService.packMany(post.fileIds), + // TODO: packMany causes N+1 queries + files: this.driveFileEntityService.packManyByIds(post.fileIds), tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2ffe5f1c2..67850ad9a 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -248,6 +249,21 @@ export class NoteEntityService implements OnModuleInit { return true; } + @bindThis + public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map | null>): Promise[]> { + const missingIds = []; + for (const id of fileIds) { + if (!packedFiles.has(id)) missingIds.push(id); + } + if (missingIds.length) { + const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); + for (const [k, v] of additionalMap) { + packedFiles.set(k, v); + } + } + return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + } + @bindThis public async pack( src: Note['id'] | Note, @@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; _hint_?: { myReactions: Map; + packedFiles: Map | null>; }; }, ): Promise> { @@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -296,6 +314,7 @@ export class NoteEntityService implements OnModuleInit { cw: note.cw, visibility: note.visibility, localOnly: note.localOnly ?? undefined, + reactionAcceptance: note.reactionAcceptance, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, @@ -304,7 +323,7 @@ export class NoteEntityService implements OnModuleInit { emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, - files: this.driveFileEntityService.packMany(note.fileIds), + files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, channelId: note.channelId ?? undefined, @@ -388,11 +407,15 @@ export class NoteEntityService implements OnModuleInit { } await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく + const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); + const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, + packedFiles, }, }))); } diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts new file mode 100644 index 000000000..66ee7305a --- /dev/null +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class RenoteMutingEntityService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: RenoteMuting['id'] | RenoteMuting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + @bindThis + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 8c36e47f1..e7aa885f3 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -78,6 +78,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -195,6 +198,13 @@ export class UserEntityService implements OnModuleInit { }, take: 1, }).then(n => n > 0), + isRenoteMuted: this.renoteMutingsRepository.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), }); } @@ -278,27 +288,27 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getAvatarUrl(user: User): Promise { if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); } else if (user.avatarId) { const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user); } else { - return this.getIdenticonUrl(user.id); + return this.getIdenticonUrl(user); } } @bindThis public getAvatarUrlSync(user: User): string { if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); } else { - return this.getIdenticonUrl(user.id); + return this.getIdenticonUrl(user); } } @bindThis - public getIdenticonUrl(userId: User['id']): string { - return `${this.config.url}/identicon/${userId}`; + public getIdenticonUrl(user: User): string { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } public async pack( @@ -493,6 +503,7 @@ export class UserEntityService implements OnModuleInit { isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 05603093b..187f930ac 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -36,6 +36,7 @@ export const DI = { notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), + renoteMutingsRepository: Symbol('renoteMutingsRepository'), blockingsRepository: Symbol('blockingsRepository'), swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts new file mode 100644 index 000000000..3357d8c1b --- /dev/null +++ b/packages/backend/src/misc/correct-filename.ts @@ -0,0 +1,15 @@ +// 与えられた拡張子とファイル名が一致しているかどうかを確認し、 +// 一致していない場合は拡張子を付与して返す +export function correctFilename(filename: string, ext: string | null) { + const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; + if (filename.endsWith(dotExt)) { + return filename; + } + if (ext === 'jpg' && filename.endsWith('.jpeg')) { + return filename; + } + if (ext === 'tif' && filename.endsWith('.tiff')) { + return filename; + } + return `${filename}${dotExt}`; +} diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts index acf5c1ede..0b6d147dc 100644 --- a/packages/backend/src/misc/is-mime-image.ts +++ b/packages/backend/src/misc/is-mime-image.ts @@ -4,6 +4,8 @@ const dictionary = { 'safe-file': FILE_TYPE_BROWSERSAFE, 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], }; export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 6a0802f8a..9b8af6958 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -15,6 +15,7 @@ import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; import { packedFollowingSchema } from '@/models/schema/following.js'; import { packedMutingSchema } from '@/models/schema/muting.js'; +import { packedRenoteMutingSchema } from '@/models/schema/renote-muting.js'; import { packedBlockingSchema } from '@/models/schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/schema/hashtag.js'; @@ -48,6 +49,7 @@ export const refs = { DriveFolder: packedDriveFolderSchema, Following: packedFollowingSchema, Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, @@ -93,7 +95,7 @@ export interface Schema extends OfSchema { readonly example?: any; readonly format?: string; readonly ref?: keyof typeof refs; - readonly enum?: ReadonlyArray; + readonly enum?: ReadonlyArray; readonly default?: (this['type'] extends TypeStringef ? StringDefToType : any) | null; readonly maxLength?: number; readonly minLength?: number; @@ -159,7 +161,7 @@ export type SchemaTypeDef

= p['type'] extends 'integer' ? number : p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( - p['enum'] extends readonly string[] ? + p['enum'] extends readonly (string | null)[] ? p['enum'][number] : p['format'] extends 'date-time' ? string : // Dateにする?? string diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 311f875ba..d29b07b02 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -190,6 +190,12 @@ const $mutingsRepository: Provider = { inject: [DI.db], }; +const $renoteMutingsRepository: Provider = { + provide: DI.renoteMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(RenoteMuting), + inject: [DI.db], +}; + const $blockingsRepository: Provider = { provide: DI.blockingsRepository, useFactory: (db: DataSource) => db.getRepository(Blocking), @@ -423,6 +429,7 @@ const $roleAssignmentsRepository: Provider = { $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -489,6 +496,7 @@ const $roleAssignmentsRepository: Provider = { $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 82d042f0c..df508b4dc 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -87,6 +87,11 @@ export class Note { }) public localOnly: boolean; + @Column('varchar', { + length: 64, nullable: true, + }) + public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; + @Column('smallint', { default: 0, }) diff --git a/packages/backend/src/models/entities/RenoteMuting.ts b/packages/backend/src/models/entities/RenoteMuting.ts new file mode 100644 index 000000000..2f803a5fa --- /dev/null +++ b/packages/backend/src/models/entities/RenoteMuting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.', + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.', + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 25ed9b89d..4acb958b0 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -26,6 +26,7 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; @@ -93,6 +94,7 @@ export { ModerationLog, MutedNote, Muting, + RenoteMuting, Note, NoteFavorite, NoteReaction, @@ -159,6 +161,7 @@ export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; +export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts index 72c0c6228..58ef425dc 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/schema/note.ts @@ -141,6 +141,10 @@ export const packedNoteSchema = { type: 'boolean', optional: true, nullable: false, }, + reactionAcceptance: { + type: 'string', + optional: false, nullable: true, + }, reactions: { type: 'object', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/renote-muting.ts b/packages/backend/src/models/schema/renote-muting.ts new file mode 100644 index 000000000..69ed8510d --- /dev/null +++ b/packages/backend/src/models/schema/renote-muting.ts @@ -0,0 +1,26 @@ +export const packedRenoteMutingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + muteeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + mutee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index c390018b4..8c61ee1f5 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -234,6 +234,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + isRenoteMuted: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index c2ee14b0f..741985f3a 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -34,6 +34,7 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; @@ -139,6 +140,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index c65f0a97a..e9330772b 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol visibility: favorite.note.visibility, visibleUserIds: favorite.note.visibleUserIds, localOnly: favorite.note.localOnly, + reactionAcceptance: favorite.note.reactionAcceptance, uri: favorite.note.uri, url: favorite.note.url, user: { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 3f4f16a2e..2f74dd63c 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { Note } from '@/models/entities/Note.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; import type { DbUserJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class ExportNotesProcessorService { @@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record { - this.logger.error(err); - reply.code(500); - reply.header('Cache-Control', 'max-age=300'); - }; - } - @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.addHook('onRequest', (request, reply, done) => { @@ -140,7 +133,7 @@ export class FileServerService { let image: IImageStreamable | null = null; if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image')) { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { reply.header('Cache-Control', 'max-age=31536000, immutable'); const url = new URL(`${this.config.mediaProxy}/static.webp`); @@ -190,13 +183,19 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } if (file.fileRole !== 'original') { - const filename = rename(file.file.name, { + const filename = rename(file.filename, { suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : undefined, + extname: file.ext ? `.${file.ext}` : '.unknown', }).toString(); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); @@ -204,12 +203,10 @@ export class FileServerService { reply.header('Content-Disposition', contentDisposition('inline', filename)); return fs.createReadStream(file.path); } else { - const stream = fs.createReadStream(file.path); - stream.on('error', this.commonReadableHandlerGenerator(reply)); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.file.name)); - return stream; + reply.header('Content-Disposition', contentDisposition('inline', file.filename)); + return fs.createReadStream(file.path); } } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -261,8 +258,8 @@ export class FileServerService { } try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); if ( 'emoji' in request.query || @@ -286,7 +283,7 @@ export class FileServerService { type: file.mime, }; } else { - const data = sharp(file.path, { animated: !('static' in request.query) }) + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) .resize({ height: 'emoji' in request.query ? 128 : 320, withoutEnlargement: true, @@ -300,11 +297,11 @@ export class FileServerService { }; } } else if ('static' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280); } else if ('preview' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); } else if ('badge' in request.query) { - const mask = sharp(file.path) + const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { fit: 'inside', withoutEnlargement: false, @@ -360,6 +357,12 @@ export class FileServerService { reply.header('Content-Type', image.type); reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -369,8 +372,8 @@ export class FileServerService { @bindThis private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -386,11 +389,11 @@ export class FileServerService { @bindThis private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; } + { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } > { const [path, cleanup] = await createTemp(); try { - await this.downloadService.downloadUrl(url, path); + const { filename } = await this.downloadService.downloadUrl(url, path); const { mime, ext } = await this.fileInfoService.detectType(path); @@ -398,6 +401,7 @@ export class FileServerService { state: 'remote', mime, ext, path, cleanup, + filename, }; } catch (e) { cleanup(); @@ -407,8 +411,8 @@ export class FileServerService { @bindThis private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -432,6 +436,7 @@ export class FileServerService { url: file.uri, fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', file, + filename: file.name, }; } @@ -443,6 +448,7 @@ export class FileServerService { state: 'stored_internal', fileRole: isThumbnail ? 'thumbnail' : 'webpublic', file, + filename: file.name, mime, ext, path, }; @@ -452,6 +458,7 @@ export class FileServerService { state: 'stored_internal', fileRole: 'original', file, + filename: file.name, mime: file.type, ext: null, path, diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index f84a3aa59..bf5cb2091 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown { } this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -129,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown { }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -321,7 +321,7 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError) { + if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { const errId = uuid(); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index d3e2219bd..272464959 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -224,6 +224,9 @@ import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -545,6 +548,9 @@ const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: e const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; +const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; +const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; +const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; @@ -870,6 +876,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -1189,6 +1198,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 41e8365d0..fbabf47af 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; @@ -15,6 +15,7 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { bindThis } from '@/decorators.js'; import { SigninService } from './SigninService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; +import { IsNull } from 'typeorm'; @Injectable() export class SignupApiService { @@ -31,6 +32,9 @@ export class SignupApiService { @Inject(DI.userPendingsRepository) private userPendingsRepository: UserPendingsRepository, + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + @Inject(DI.registrationTicketsRepository) private registrationTicketsRepository: RegistrationTicketsRepository, @@ -124,12 +128,21 @@ export class SignupApiService { } if (instance.emailRequiredForSignup) { + if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + throw new FastifyReplyError(400, 'USED_USERNAME'); + } + const code = rndstr('a-z0-9', 16); - + // Generate hash of password const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - + await this.userPendingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), @@ -138,13 +151,13 @@ export class SignupApiService { username: username, password: hash, }); - + const link = `${this.config.url}/signup-complete/${code}`; - + this.emailService.sendEmail(emailAddress!, 'Signup', `To complete signup, please click this link:
${link}`, `To complete signup, please click this link: ${link}`); - + reply.code(204); return; } else { diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 487eef2d5..13526f277 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -3,17 +3,17 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; import { AuthenticateService } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; import type { ParsedUrlQuery } from 'querystring'; import type * as http from 'node:http'; -import { bindThis } from '@/decorators.js'; @Injectable() export class StreamingApiServerService { @@ -33,6 +33,9 @@ export class StreamingApiServerService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -84,6 +87,7 @@ export class StreamingApiServerService { const main = new MainStreamConnection( this.followingsRepository, this.mutingsRepository, + this.renoteMutingsRepository, this.blockingsRepository, this.channelFollowingsRepository, this.userProfilesRepository, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4f521148e..1f01865e0 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -224,6 +224,9 @@ import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -543,6 +546,9 @@ const eps = [ ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], ['mute/list', ep___mute_list], + ['renote-mute/create', ep___renoteMute_create], + ['renote-mute/delete', ep___renoteMute_delete], + ['renote-mute/list', ep___renoteMute_list], ['my/apps', ep___my_apps], ['notes', ep___notes], ['notes/children', ep___notes_children], diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index d006e89bd..a86cc2565 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -4,6 +4,7 @@ import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['channels'], @@ -61,7 +62,9 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private channelEntityService: ChannelEntityService, - ) { + + private roleService: RoleService, + ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, @@ -71,7 +74,8 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - if (channel.userId !== me.id) { + const iAmModerator = await this.roleService.isModerator(me); + if (channel.userId !== me.id && !iAmModerator) { throw new ApiError(meta.errors.accessDenied); } diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 60b24e958..061c6eb5b 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -76,9 +76,9 @@ export default class extends Endpoint { if (typeof ps.blocked === 'boolean') { const meta = await this.metaService.fetch(true); if (ps.blocked) { - query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); } else { - query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); } } diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 6beef5ab8..a3e3e02a1 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['account'], @@ -14,6 +15,15 @@ export const meta = { optional: false, nullable: false, ref: 'MeDetailed', }, + + errors: { + userIsDeleted: { + message: 'User is deleted.', + code: 'USER_IS_DELETED', + id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', + kind: 'permission', + }, + } } as const; export const paramDef = { @@ -41,13 +51,17 @@ export default class extends Endpoint { const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 - const userProfile = await this.userProfilesRepository.findOneOrFail({ + const userProfile = await this.userProfilesRepository.findOne({ where: { userId: user.id, }, relations: ['user'], }); + if (userProfile == null) { + throw new ApiError(meta.errors.userIsDeleted); + } + if (!userProfile.loggedInDates.includes(today)) { this.userProfilesRepository.update({ userId: user.id }, { loggedInDates: [...userProfile.loggedInDates, today], diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 786ad103b..69fafcb9c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -97,6 +97,7 @@ export const paramDef = { } }, cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, @@ -110,7 +111,7 @@ export const paramDef = { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, - nullable: false + nullable: false, }, fileIds: { type: 'array', @@ -280,6 +281,7 @@ export default class extends Endpoint { renote, cw: ps.cw, localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, visibleUsers, channel, diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index cf939f663..6bf17b222 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { let notes = await query .orderBy('note.score', 'DESC') - .take(50) + .take(100) .getMany(); notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 5d0cdc3fc..9118d3393 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -89,6 +89,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2819abb12..3802ae540 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -107,6 +107,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 18ed6d4e2..381001695 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -95,6 +95,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index e6de087c4..5ce436ee1 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -93,6 +93,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 000000000..051a005b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + limit: { + duration: ms('1hour'), + max: 20, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '37285718-52f7-4aef-b7de-c38b8e8a8420', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await this.renoteMutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + + // publishUserEvent(user.id, 'mute', mutee); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 000000000..51a895fb7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9b6728cf-638c-4aa1-bedb-e07d8101474d', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '619b1314-0850-4597-a242-e245f3da42af', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await this.renoteMutingsRepository.delete({ + id: exist.id, + }); + + // publishUserEvent(user.id, 'unmute', mutee); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 000000000..b2d7addb6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:mutes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'RenoteMuting', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private renoteMutingEntityService: RenoteMutingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await this.renoteMutingEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index ac9104bf9..3267c1884 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -50,6 +50,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, { @@ -91,6 +95,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 347d5650a..34f452160 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,4 +1,4 @@ -type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; export class ApiError extends Error { public message: string; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 3e67880b4..32935325a 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -27,6 +27,10 @@ export default abstract class Channel { return this.connection.muting; } + protected get renoteMuting() { + return this.connection.renoteMuting; + } + protected get blocking() { return this.connection.blocking; } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 18604d94f..e2a42fbfe 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -39,6 +39,8 @@ class AntennaChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index f5ef1d110..1234738ce 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -51,6 +51,8 @@ class ChannelChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index b8c0076ed..ab439a171 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -68,6 +68,8 @@ class GlobalTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 00f8d8ecd..63a2dd5b3 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -49,6 +49,8 @@ class HashtagChannel extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 04a9f2968..678fbe12d 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -75,6 +75,8 @@ class HomeTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index ab52aabb3..e33a28049 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -86,6 +86,8 @@ class HybridTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index d8532c477..341c4e32c 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -65,6 +65,8 @@ class LocalTimelineChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 7254d0a6d..e7899245b 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -93,6 +93,8 @@ class UserListChannel extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.send('note', note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index d3056aca5..0a4fd8393 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,6 +1,6 @@ import type { User } from '@/models/entities/User.js'; import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; -import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; +import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; import type { Packed } from '@/misc/schema.js'; @@ -22,6 +22,7 @@ export default class Connection { public userProfile?: UserProfile | null; public following: Set = new Set(); public muting: Set = new Set(); + public renoteMuting: Set = new Set(); public blocking: Set = new Set(); // "被"blocking public followingChannels: Set = new Set(); public token?: AccessToken; @@ -34,6 +35,7 @@ export default class Connection { constructor( private followingsRepository: FollowingsRepository, private mutingsRepository: MutingsRepository, + private renoteMutingsRepository: RenoteMutingsRepository, private blockingsRepository: BlockingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository, private userProfilesRepository: UserProfilesRepository, @@ -66,6 +68,7 @@ export default class Connection { if (this.user) { this.updateFollowing(); this.updateMuting(); + this.updateRenoteMuting(); this.updateBlocking(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -93,6 +96,7 @@ export default class Connection { this.muting.delete(data.body.id); break; + // TODO: renote mute events // TODO: block events case 'followChannel': @@ -342,6 +346,18 @@ export default class Connection { this.muting = new Set(mutings.map(x => x.muteeId)); } + @bindThis + private async updateRenoteMuting() { + const renoteMutings = await this.renoteMutingsRepository.find({ + where: { + muterId: this.user!.id, + }, + select: ['muteeId'], + }); + + this.renoteMuting = new Set(renoteMutings.map(x => x.muteeId)); + } + @bindThis private async updateBlocking() { // ここでいうBlockingは被Blockingの意 const blockings = await this.blockingsRepository.find({ diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index c6cb25e43..fd7f54da5 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -61,6 +61,13 @@ renderError('META_FETCH_V'); return; } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } + const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); if (localRes.status === 200) { localStorage.setItem('lang', lang); diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 4e9030f85..5fee2b93a 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -70,9 +70,9 @@ describe('Block', () => { // TODO: ユーザーリストから除外されるテスト test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, bob); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index e864eab6c..c13009373 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -206,7 +206,7 @@ describe('Endpoints', () => { describe('notes/reactions/create', () => { test('リアクションできる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: bobPost.id, @@ -224,7 +224,7 @@ describe('Endpoints', () => { }); test('自分の投稿にもリアクションできる', async () => { - const myPost = await post(alice); + const myPost = await post(alice, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: myPost.id, @@ -235,7 +235,7 @@ describe('Endpoints', () => { }); test('二重にリアクションすると上書きされる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); await api('/notes/reactions/create', { noteId: bobPost.id, @@ -410,11 +410,19 @@ describe('Endpoints', () => { }); test('ファイルに名前を付けられる', async () => { + const res = await uploadFile(alice, { name: 'Belmond.jpg' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Belmond.jpg'); + }); + + test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => { const res = await uploadFile(alice, { name: 'Belmond.png' }); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Belmond.png'); + assert.strictEqual(res.body.name, 'Belmond.png.jpg'); }); test('ファイル無しで怒られる', async () => { diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 6654a290b..5811d6baf 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -76,9 +76,9 @@ describe('Mute', () => { describe('Timeline', () => { test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, alice); @@ -90,8 +90,8 @@ describe('Mute', () => { }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { - const aliceNote = await post(alice); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id, }); @@ -108,7 +108,7 @@ describe('Mute', () => { describe('Notification', () => { test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { - const aliceNote = await post(alice); + const aliceNote = await post(alice, { text: 'hi' }); await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 98ee34d8d..1b5f9580d 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { Note } from '@/models/entities/Note.js'; -import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js'; +import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Note', () => { @@ -213,6 +213,122 @@ describe('Note', () => { assert.deepStrictEqual(noteDoc.mentions, [bob.id]); }); + describe('添付ファイル情報', () => { + test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const res = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.files.length, 1); + assert.strictEqual(res.body.createdNote.files[0].id, file.body.id); + }); + + test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const res = await api('/notes', { + withFiles: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.files.length, 1); + assert.strictEqual(myNote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const renoted = await api('/notes/create', { + renoteId: createdNote.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.files.length, 1); + assert.strictEqual(myNote.renote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const res = await api('/notes', { + reply: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.reply.files.length, 1); + assert.strictEqual(myNote.reply.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const renoted = await api('/notes/create', { + renoteId: reply.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.reply.files.length, 1); + assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id); + }); + }); + describe('notes/create', () => { test('投票を添付できる', async () => { const res = await api('/notes/create', { diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts new file mode 100644 index 000000000..32c8ebe2c --- /dev/null +++ b/packages/backend/test/e2e/renote-mute.ts @@ -0,0 +1,85 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Renote Mute', () => { + let p: INestApplicationContext; + + // alice mutes carol + let alice: any; + let bob: any; + let carol: any; + + beforeAll(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await p.close(); + }); + + test('ミュート作成', async () => { + const res = await api('/renote-mute/create', { + userId: carol.id, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, false); + }); + + test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, true); + }); +}); diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts new file mode 100644 index 000000000..c476aef33 --- /dev/null +++ b/packages/backend/test/unit/misc/others.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from '@jest/globals'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; + +describe('misc:content-disposition', () => { + test('inline', () => { + expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('attachment', () => { + expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('non ascii', () => { + expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D'); + }); +}); + +describe('misc:correct-filename', () => { + test('simple', () => { + expect(correctFilename('filename', 'jpg')).toBe('filename.jpg'); + }); + test('with same ext', () => { + expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg'); + }); + test('.ext', () => { + expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg'); + }); + test('with different ext', () => { + expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg'); + }); + test('non ascii with space', () => { + expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg'); + }); + test('jpeg', () => { + expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg'); + }); + test('tiff', () => { + expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff'); + }); + test('null ext', () => { + expect(correctFilename('filename', null)).toBe('filename.unknown'); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 8203e4935..37e5ae10d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -57,9 +57,7 @@ export const signup = async (params?: any): Promise => { }; export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { - const q = Object.assign({ - text: 'test', - }, params); + const q = params; const res = await api('notes/create', q, user); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e4c04f593..add5eabdd 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,6 +4,8 @@ "scripts": { "watch": "vite", "build": "vite build", + "test": "vitest --run", + "test-and-coverage": "vitest --run --coverage", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" @@ -70,6 +72,7 @@ "vuedraggable": "next" }, "devDependencies": { + "@testing-library/vue": "^6.6.1", "@types/escape-regexp": "0.0.1", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", @@ -85,13 +88,16 @@ "@types/ws": "8.5.4", "@typescript-eslint/eslint-plugin": "5.53.0", "@typescript-eslint/parser": "5.53.0", + "@vitest/coverage-c8": "^0.29.2", "@vue/runtime-core": "3.2.47", "cross-env": "7.0.3", "cypress": "12.7.0", "eslint": "8.35.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-vue": "9.9.0", + "happy-dom": "8.9.0", "start-server-and-test": "1.15.4", + "vitest": "^0.29.2", "vue-eslint-parser": "9.1.0", "vue-tsc": "1.2.0" } diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 610212b6e..9b104391d 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,4 +1,4 @@ -import { defineAsyncComponent, reactive } from 'vue'; +import { defineAsyncComponent, reactive, ref } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; @@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuButton } from './types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -26,11 +27,11 @@ export function incNotesCount() { } export async function signout() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); //#region Remove service worker registration @@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } -export async function removeAccount(id: Account['id']) { +export async function removeAccount(idOrToken: Account['id']) { const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === id), 1); + const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); + if (i !== -1) accounts.splice(i, 1); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); + if (accounts.length > 0) { + await set('accounts', accounts); + } else { + await del('accounts'); + } } -function fetchAccount(token: string): Promise { +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { return new Promise((done, fail) => { // Fetch user window.fetch(`${apiUrl}/i`, { @@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise { 'Content-Type': 'application/json', }, }) - .then(res => res.json()) - .then(res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - showSuspendedDialog().then(() => { - signout(); - }); - } else { - alert({ + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, }); } } else { - res.token = token; - done(res); + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); } - }) - .catch(fail); + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); + } else { + (res as Account).token = token; + done(res as Account); + } + }) + .catch(fail); }); } -export function updateAccount(accountData) { +export function updateAccount(accountData: Partial) { + if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } miLocalStorage.setItem('account', JSON.stringify($i)); } -export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); +export async function refreshAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id) + .then(updateAccount, reason => { + if (reason === true) return signout(); + return; + }); } export async function login(token: Account['token'], redirect?: string) { - waiting(); + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, {}, 'closed'); if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token); + const me = await fetchAccount(token, undefined, true) + .catch(reason => { + if (reason === true) { + // 削除対象の場合 + removeAccount(token); + } + + showing.value = false; + throw reason; + }); miLocalStorage.setItem('account', JSON.stringify(me)); document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う await addAccount(me.id, token); @@ -155,6 +210,8 @@ export async function openAccountMenu(opts: { active?: misskey.entities.UserDetailed['id']; onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { + if (!$i) return; + function showSigninDialog() { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { @@ -175,8 +232,9 @@ export async function openAccountMenu(opts: { async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); + const found = storedAccounts.find(x => x.id === account.id); + if (found == null) return; + switchAccountWithToken(found.token); } function switchAccountWithToken(token: string) { @@ -188,7 +246,7 @@ export async function openAccountMenu(opts: { function createItem(account: misskey.entities.UserDetailed) { return { - type: 'user', + type: 'user' as const, user: account, active: opts.active != null ? opts.active === account.id : false, action: () => { @@ -201,22 +259,29 @@ export async function openAccountMenu(opts: { }; } - const accountItemPromises = storedAccounts.map(a => new Promise(res => { + const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); + if (account == null) return res({ + type: 'button' as const, + text: a.id, + action: () => { + switchAccountWithToken(a.token); + }, + }); + res(createItem(account)); }); })); if (opts.withExtraOperation) { popupMenu([...[{ - type: 'link', + type: 'link' as const, text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ @@ -227,7 +292,7 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link', + type: 'link' as const, icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 4525d3a00..d6303f967 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,7 +1,9 @@ diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue index d8703a0b1..22a806380 100644 --- a/packages/frontend/src/components/MkSignup.vue +++ b/packages/frontend/src/components/MkSignup.vue @@ -9,6 +9,7 @@