Merge branch 'misskey-dev:develop' into develop
This commit is contained in:
commit
3703554e8b
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ services:
|
||||
- external_network
|
||||
|
||||
redis:
|
||||
restart: always
|
||||
restart: unless-stopped
|
||||
image: redis:7-alpine
|
||||
networks:
|
||||
- internal_network
|
||||
|
@ -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
|
||||
|
@ -25,6 +25,8 @@ fluent-emojis/
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
.pnpm-store
|
||||
|
||||
.idea/
|
||||
packages/*/.vscode/
|
||||
packages/backend/test/docker-compose.yml
|
||||
|
12
.github/PULL_REQUEST_TEMPLATE/01_bug.md
vendored
12
.github/PULL_REQUEST_TEMPLATE/01_bug.md
vendored
@ -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
|
||||
<!-- このPRで何をしたのか? どう変わるのか? -->
|
||||
<!-- What did you do with this PR? How will it change things? -->
|
||||
|
||||
# Why
|
||||
## Why
|
||||
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
|
||||
<!-- Why do you do it? What are your intentions? What is the problem? -->
|
||||
|
||||
# Additional info (optional)
|
||||
## Additional info (optional)
|
||||
<!-- テスト観点など -->
|
||||
<!-- Test perspective, etc -->
|
||||
|
||||
## 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
|
||||
|
12
.github/PULL_REQUEST_TEMPLATE/02_enhance.md
vendored
12
.github/PULL_REQUEST_TEMPLATE/02_enhance.md
vendored
@ -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
|
||||
<!-- このPRで何をしたのか? どう変わるのか? -->
|
||||
<!-- What did you do with this PR? How will it change things? -->
|
||||
|
||||
# Why
|
||||
## Why
|
||||
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
|
||||
<!-- Why do you do it? What are your intentions? What is the problem? -->
|
||||
|
||||
# Additional info (optional)
|
||||
## Additional info (optional)
|
||||
<!-- テスト観点など -->
|
||||
<!-- Test perspective, etc -->
|
||||
|
||||
## 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
|
||||
|
23
.github/pull_request_template.md
vendored
Normal file
23
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
<!-- ℹ お読みください / README
|
||||
PRありがとうございます! PRを作成する前に、コントリビューションガイドをご確認ください:
|
||||
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
|
||||
<!-- このPRで何をしたのか? どう変わるのか? -->
|
||||
<!-- What did you do with this PR? How will it change things? -->
|
||||
|
||||
## Why
|
||||
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
|
||||
<!-- Why do you do it? What are your intentions? What is the problem? -->
|
||||
|
||||
## Additional info (optional)
|
||||
<!-- テスト観点など -->
|
||||
<!-- Test perspective, etc -->
|
||||
|
||||
## 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
|
59
.github/workflows/test-backend.yml
vendored
Normal file
59
.github/workflows/test-backend.yml
vendored
Normal file
@ -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
|
@ -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
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -20,6 +20,9 @@ packages/frontend/.yarn/cache
|
||||
packages/backend/.yarn/cache
|
||||
packages/sw/.yarn/cache
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
# Cypress
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
|
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@ -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"
|
||||
]
|
||||
}
|
||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -5,5 +5,6 @@
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"files.associations": {
|
||||
"*.test.ts": "typescript"
|
||||
}
|
||||
},
|
||||
"jest.autoRun": "off"
|
||||
}
|
30
CHANGELOG.md
30
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
|
||||
|
@ -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.
|
||||
|
48
Dockerfile
48
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
|
||||
|
@ -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
|
||||
|
@ -11,4 +11,4 @@ spec:
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "misskey.selectorLabels" . | nindent 4 }}
|
||||
{{- include "misskey.selectorLabels" . | nindent 4 }}
|
||||
|
@ -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', () => {
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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."
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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: "獲得日時"
|
||||
|
@ -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: "はじめてのフォロー"
|
||||
|
@ -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: "ຮູບພາບ"
|
||||
|
@ -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:
|
||||
|
@ -2,7 +2,7 @@
|
||||
_lang_: "中文(简体)"
|
||||
headlineMisskey: "通过帖子连接在一起的网络"
|
||||
introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧!🚀"
|
||||
poweredByMisskeyDescription: "{name} 由开源平台 <b>Misskey</b> 驱动(也被称为 Misskey 实例)"
|
||||
poweredByMisskeyDescription: "{name} 由开源平台 <b>Misskey</b> 驱动(也被称为 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: "<b>监察员</b>可以执行基本的审核操作。\n<b>管理员</b>可以更改实例的所有设置。"
|
||||
descriptionOfPermission: "<b>监察员</b>可以执行基本地审核操作。\n<b>管理员</b>可以更改服务器的所有设置。"
|
||||
assignTarget: "授权对象"
|
||||
descriptionOfAssignTarget: "<b>手动</b>指手动选择谁被包括在这个角色中。\n<b>符合条件</b>指设置条件以自动包括符合条件的用户。"
|
||||
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: "引用这个帖子..."
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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) {
|
||||
}
|
||||
}
|
@ -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"`);
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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<void> {
|
||||
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
|
||||
|
@ -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<DriveFile> {
|
||||
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!;
|
||||
|
@ -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
|
||||
|
@ -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<any>, 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());
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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<Packed<'DriveFile'>[]> {
|
||||
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<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
|
||||
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
|
||||
const packedFiles = await this.packMany(files, options);
|
||||
const map = new Map<Packed<'DriveFile'>['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<Packed<'DriveFile'>[]> {
|
||||
const filesMap = await this.packManyByIdsMap(fileIds, options);
|
||||
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
|
||||
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<Note['id'], NoteReaction | null>;
|
||||
packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'Note'>> {
|
||||
@ -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,
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
@ -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<Packed<'RenoteMuting'>> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
@ -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<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 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<ExpectsMe extends boolean | null = null, D extends boolean = false>(
|
||||
@ -493,6 +503,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
isBlocking: relation.isBlocking,
|
||||
isBlocked: relation.isBlocked,
|
||||
isMuted: relation.isMuted,
|
||||
isRenoteMuted: relation.isRenoteMuted,
|
||||
} : {}),
|
||||
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;
|
||||
|
||||
|
@ -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'),
|
||||
|
15
packages/backend/src/misc/correct-filename.ts
Normal file
15
packages/backend/src/misc/correct-filename.ts
Normal file
@ -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}`;
|
||||
}
|
@ -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);
|
||||
|
@ -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<string>;
|
||||
readonly enum?: ReadonlyArray<string | null>;
|
||||
readonly default?: (this['type'] extends TypeStringef ? StringDefToType<this['type']> : any) | null;
|
||||
readonly maxLength?: number;
|
||||
readonly minLength?: number;
|
||||
@ -159,7 +161,7 @@ export type SchemaTypeDef<p extends Schema> =
|
||||
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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
})
|
||||
|
42
packages/backend/src/models/entities/RenoteMuting.ts
Normal file
42
packages/backend/src/models/entities/RenoteMuting.ts
Normal file
@ -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;
|
||||
}
|
@ -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<Meta>;
|
||||
export type ModerationLogsRepository = Repository<ModerationLog>;
|
||||
export type MutedNotesRepository = Repository<MutedNote>;
|
||||
export type MutingsRepository = Repository<Muting>;
|
||||
export type RenoteMutingsRepository = Repository<RenoteMuting>;
|
||||
export type NotesRepository = Repository<Note>;
|
||||
export type NoteFavoritesRepository = Repository<NoteFavorite>;
|
||||
export type NoteReactionsRepository = Repository<NoteReaction>;
|
||||
|
@ -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,
|
||||
|
26
packages/backend/src/models/schema/renote-muting.ts
Normal file
26
packages/backend/src/models/schema/renote-muting.ts
Normal file
@ -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;
|
@ -234,6 +234,10 @@ export const packedUserDetailedNotMeOnlySchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
isRenoteMuted: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
//#endregion
|
||||
},
|
||||
} as const;
|
||||
|
@ -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,
|
||||
|
@ -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: {
|
||||
|
@ -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<string, unknown
|
||||
visibility: note.visibility,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
};
|
||||
}
|
||||
|
@ -22,6 +22,8 @@ import { bindThis } from '@/decorators.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import sharp from 'sharp';
|
||||
import { sharpBmp } from 'sharp-read-bmp';
|
||||
import { correctFilename } from '@/misc/correct-filename.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
@ -51,15 +53,6 @@ export class FileServerService {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public commonReadableHandlerGenerator(reply: FastifyReply) {
|
||||
return (err: Error): void => {
|
||||
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,
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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:<br><a href="${link}">${link}</a>`,
|
||||
`To complete signup, please click this link: ${link}`);
|
||||
|
||||
|
||||
reply.code(204);
|
||||
return;
|
||||
} else {
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
|
@ -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<typeof meta, typeof paramDef> {
|
||||
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<typeof meta, typeof paramDef> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -76,9 +76,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<typeof meta, typeof paramDef> {
|
||||
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],
|
||||
|
@ -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<typeof meta, typeof paramDef> {
|
||||
renote,
|
||||
cw: ps.cw,
|
||||
localOnly: ps.localOnly,
|
||||
reactionAcceptance: ps.reactionAcceptance,
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
|
@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
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());
|
||||
|
@ -89,6 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
@ -107,6 +107,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
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 => {
|
||||
|
@ -95,6 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
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 != \'{}\'');
|
||||
|
@ -93,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
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 => {
|
||||
|
@ -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<typeof meta, typeof paramDef> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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<typeof meta, typeof paramDef> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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<typeof meta, typeof paramDef> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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レコードの追加処理はストリーミングに流す処理と並列で行われるため、
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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レコードの追加処理はストリーミングに流す処理と並列で行われるため、
|
||||
|
@ -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レコードの追加処理はストリーミングに流す処理と並列で行われるため、
|
||||
|
@ -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レコードの追加処理はストリーミングに流す処理と並列で行われるため、
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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<User['id']> = new Set();
|
||||
public muting: Set<User['id']> = new Set();
|
||||
public renoteMuting: Set<User['id']> = new Set();
|
||||
public blocking: Set<User['id']> = new Set(); // "被"blocking
|
||||
public followingChannels: Set<ChannelModel['id']> = 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<string>(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<string>(renoteMutings.map(x => x.muteeId));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async updateBlocking() { // ここでいうBlockingは被Blockingの意
|
||||
const blockings = await this.blockingsRepository.find({
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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 () => {
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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', {
|
||||
|
85
packages/backend/test/e2e/renote-mute.ts
Normal file
85
packages/backend/test/e2e/renote-mute.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
42
packages/backend/test/unit/misc/others.ts
Normal file
42
packages/backend/test/unit/misc/others.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
@ -57,9 +57,7 @@ export const signup = async (params?: any): Promise<any> => {
|
||||
};
|
||||
|
||||
export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise<misskey.entities.Note> => {
|
||||
const q = Object.assign({
|
||||
text: 'test',
|
||||
}, params);
|
||||
const q = params;
|
||||
|
||||
const res = await api('notes/create', q, user);
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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<Account> {
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
|
||||
return new Promise((done, fail) => {
|
||||
// Fetch user
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise<Account> {
|
||||
'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<Account | { error: Record<string, any> }>((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<Account>) {
|
||||
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<ReturnType<typeof createItem> | 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',
|
||||
|
@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, PropType, TransitionGroup, useCssModule } from 'vue';
|
||||
import MkAd from '@/components/global/MkAd.vue';
|
||||
import { isDebuggerEnabled, stackTraceInstances } from '@/debug';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list';
|
||||
|
||||
@ -46,7 +48,7 @@ export default defineComponent({
|
||||
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const renderChildren = () => props.items.map((item, i) => {
|
||||
const renderChildrenImpl = () => props.items.map((item, i) => {
|
||||
if (!slots || !slots.default) return;
|
||||
|
||||
const el = slots.default({
|
||||
@ -95,6 +97,21 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
|
||||
const renderChildren = () => {
|
||||
const children = renderChildrenImpl();
|
||||
if (isDebuggerEnabled(6864)) {
|
||||
const nodes = children.flatMap((node) => node ?? []);
|
||||
const keys = new Set(nodes.map((node) => node.key));
|
||||
if (keys.size !== nodes.length) {
|
||||
const id = crypto.randomUUID();
|
||||
const instances = stackTraceInstances();
|
||||
os.toast(instances.reduce((a, c) => `${a} at ${c.type.name}`, `[DEBUG_6864 (${id})]: ${nodes.length - keys.size} duplicated keys found`));
|
||||
console.warn({ id, debugId: 6864, stack: instances });
|
||||
}
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
function onBeforeLeave(el: HTMLElement) {
|
||||
el.style.top = `${el.offsetTop}px`;
|
||||
el.style.left = `${el.offsetLeft}px`;
|
||||
|
@ -1,41 +1,46 @@
|
||||
<template>
|
||||
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
|
||||
<div :class="$style.header" class="_button" @click="toggle">
|
||||
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||
<div :class="$style.headerText">
|
||||
<div :class="$style.headerTextMain">
|
||||
<slot name="label"></slot>
|
||||
</div>
|
||||
<div :class="$style.headerTextSub">
|
||||
<slot name="caption"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer :margin-min="14" :margin-max="22">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
|
||||
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||
<div :class="$style.headerText">
|
||||
<div :class="$style.headerTextMain">
|
||||
<slot name="label"></slot>
|
||||
</div>
|
||||
<div :class="$style.headerTextSub">
|
||||
<slot name="caption"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
|
||||
<Transition
|
||||
:enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer :margin-min="14" :margin-max="22">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -117,12 +122,6 @@ onMounted(() => {
|
||||
|
||||
.root {
|
||||
display: block;
|
||||
|
||||
&.opened {
|
||||
> .header {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
@ -132,6 +131,8 @@ onMounted(() => {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px 9px 12px;
|
||||
background: var(--buttonBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
border-radius: 6px;
|
||||
transition: border-radius 0.3s;
|
||||
|
||||
@ -144,6 +145,10 @@ onMounted(() => {
|
||||
color: var(--accent);
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&.opened {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.headerUpper {
|
||||
@ -153,7 +158,7 @@ onMounted(() => {
|
||||
|
||||
.headerLower {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
font-size: .85em;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
@ -202,7 +207,6 @@ onMounted(() => {
|
||||
background: var(--panel);
|
||||
border-radius: 0 0 6px 6px;
|
||||
container-type: inline-size;
|
||||
overflow: auto;
|
||||
|
||||
&.bgSame {
|
||||
background: var(--bg);
|
||||
|
@ -3,21 +3,24 @@
|
||||
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
|
||||
<div :class="$style.hiddenText">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
|
||||
<span style="display: block;">{{ $ts.clickToShow }}</span>
|
||||
<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b>
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.visible" :style="defaultStore.state.darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
|
||||
<div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
|
||||
<a
|
||||
:class="$style.imageContainer"
|
||||
:href="image.url"
|
||||
:title="image.name"
|
||||
>
|
||||
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/>
|
||||
<div v-if="image.type === 'image/gif'" :class="$style.gif">GIF</div>
|
||||
</a>
|
||||
<button v-tooltip="$ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
||||
<div :class="$style.indicators">
|
||||
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
|
||||
<div v-if="image.comment" :class="$style.indicator">ALT</div>
|
||||
</div>
|
||||
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -27,6 +30,7 @@ import * as misskey from 'misskey-js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
image: misskey.entities.DriveFile;
|
||||
@ -34,6 +38,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
let hide = $ref(true);
|
||||
let darkMode = $ref(defaultStore.state.darkMode);
|
||||
|
||||
const url = (props.raw || defaultStore.state.loadRawImages)
|
||||
? props.image.url
|
||||
@ -108,18 +113,25 @@ watch(() => props.image, () => {
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.gif {
|
||||
background-color: var(--fg);
|
||||
.indicators {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: .5;
|
||||
font-size: 14px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
/* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */
|
||||
background-color: black;
|
||||
border-radius: 6px;
|
||||
color: var(--accentLighten);
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
left: 12px;
|
||||
opacity: .5;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
top: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user