Merge remote-tracking branch 'msky/develop' into copilot/add-user-mute-settings

This commit is contained in:
kakkokari-gtyih 2025-12-16 22:09:06 +09:00
commit 368c40c653
112 changed files with 3699 additions and 2468 deletions

View File

@ -112,8 +112,18 @@ port: 3000
# Any format supported by Fastify is accepted.
# Default: trust all proxies (i.e. trustProxy: true)
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy
# To improve security, we recommend that you configure your settings appropriately.
# Incorrect configuration can cause issues such as difficulty signing in,
# so please configure your settings carefully.
#
# trustProxy: 1
#trustProxy: [
# '10.0.0.0/8'
# '172.16.0.0/12'
# '192.168.0.0/16'
# '127.0.0.1/32'
# '::1/128'
# 'fc00::/7'
#]
# ┌──────────────────────────┐
#───┘ PostgreSQL configuration └────────────────────────────────

View File

@ -5,7 +5,7 @@
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "24.10.0"
"version": "22.15.0"
},
"ghcr.io/devcontainers-extra/features/pnpm:2": {
"version": "10.10.0"

View File

@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Setup Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -12,9 +12,9 @@ jobs:
steps:
- name: Checkout head
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Setup Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'

View File

@ -18,7 +18,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
with:
submodules: true
persist-credentials: false
@ -29,7 +29,7 @@ jobs:
- name: setup node
id: setup-node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: pnpm
@ -53,7 +53,7 @@ jobs:
# packages/misskey-js/generator/built/autogen
- name: Upload Generated
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: generated-misskey-js
path: packages/misskey-js/generator/built/autogen
@ -66,14 +66,14 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
with:
submodules: true
persist-credentials: false
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- name: Upload From Merged
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: actual-misskey-js
path: packages/misskey-js/src/autogen
@ -86,13 +86,13 @@ jobs:
pull-requests: write
steps:
- name: download generated-misskey-js
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: generated-misskey-js
path: misskey-js-generated
- name: download actual-misskey-js
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: actual-misskey-js
path: misskey-js-actual
@ -113,9 +113,9 @@ jobs:
- name: send message
if: steps.check-changes.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v2
uses: thollander/actions-comment-pull-request@v3
with:
comment_tag: check-misskey-js-autogen
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
@ -127,9 +127,9 @@ jobs:
- name: send message
if: steps.check-changes.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v2
uses: thollander/actions-comment-pull-request@v3
with:
comment_tag: check-misskey-js-autogen
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false

View File

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Check version
run: |
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Check
run: |
counter=0

View File

@ -10,7 +10,7 @@ jobs:
check_copyright_year:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
- run: |
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
echo "Please change copyright year!"

View File

@ -28,7 +28,7 @@ jobs:
wait_time: ${{ steps.get-wait-time.outputs.wait_time }}
steps:
- name: Checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Check allowed users
id: check-allowed-users

View File

@ -27,7 +27,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@ -53,7 +53,7 @@ jobs:
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@ -66,7 +66,7 @@ jobs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*

View File

@ -32,7 +32,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
@ -64,7 +64,7 @@ jobs:
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@ -77,7 +77,7 @@ jobs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*

View File

@ -16,7 +16,7 @@ jobs:
DOCKLE_VERSION: 0.4.15
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
run: |

View File

@ -25,14 +25,14 @@ jobs:
ref: refs/pull/${{ github.event.number }}/merge
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -48,7 +48,7 @@ jobs:
- name: Copy API.json
run: cp packages/backend/built/api.json ${{ matrix.api-json-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: api-artifact-${{ matrix.api-json-name }}
path: ${{ matrix.api-json-name }}
@ -61,7 +61,7 @@ jobs:
PR_NUMBER: ${{ github.event.number }}
run: |
echo "$PR_NUMBER" > ./pr_number
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: api-artifact-pr-number
path: pr_number

View File

@ -0,0 +1,87 @@
# this name is used in report-backend-memory.yml so be careful when change name
name: Get backend memory usage
on:
pull_request:
branches:
- master
- develop
paths:
- packages/backend/**
- packages/misskey-js/**
- .github/workflows/get-backend-memory.yml
jobs:
get-memory-usage:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
memory-json-name: [memory-base.json, memory-head.json]
include:
- memory-json-name: memory-base.json
ref: ${{ github.base_ref }}
- memory-json-name: memory-head.json
ref: refs/pull/${{ github.event.number }}/merge
services:
postgres:
image: postgres:18
ports:
- 54312:5432
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:7
ports:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.1
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- 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/default.yml
- name: Compile Configure
run: pnpm compile-config
- name: Build
run: pnpm build
- name: Run migrations
run: pnpm --filter backend migrate
- name: Measure memory usage
run: |
# Start the server and measure memory usage
node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }}
- name: Upload Artifact
uses: actions/upload-artifact@v6
with:
name: memory-artifact-${{ matrix.memory-json-name }}
path: ${{ matrix.memory-json-name }}
save-pr-number:
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Save PR number
env:
PR_NUMBER: ${{ github.event.number }}
run: |
echo "$PR_NUMBER" > ./pr_number
- uses: actions/upload-artifact@v6
with:
name: memory-artifact-pr-number
path: pr_number

View File

@ -11,6 +11,6 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

View File

@ -36,13 +36,13 @@ jobs:
pnpm_install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- uses: actions/setup-node@v4.4.0
- uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -69,13 +69,13 @@ jobs:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- uses: actions/setup-node@v4.4.0
- uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -100,13 +100,13 @@ jobs:
- sw
- misskey-js
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- uses: actions/setup-node@v4.4.0
- uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- uses: actions/setup-node@v4.4.0
- uses: actions/setup-node@v6.1.0
with:
node-version-file: ".node-version"
cache: "pnpm"

View File

@ -16,13 +16,13 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -19,7 +19,7 @@ jobs:
edit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PR
run: |

View File

@ -36,7 +36,7 @@ jobs:
outputs:
pr_number: ${{ steps.get_pr.outputs.pr_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PRs
run: |

View File

@ -16,7 +16,7 @@ jobs:
# api-artifact
steps:
- name: Download artifact
uses: actions/github-script@v7.1.0
uses: actions/github-script@v8.0.0
with:
script: |
const fs = require('fs');
@ -60,7 +60,7 @@ jobs:
- name: Echo full diff
run: cat ./api-full.json.diff
- name: Upload full diff to Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: api-artifact
path: |
@ -73,9 +73,9 @@ jobs:
HEADER="このPRによるapi.jsonの差分"
FOOTER="[Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
DIFF_BYTES="$(stat ./api.json.diff -c '%s' | tr -d '\n')"
echo "$HEADER" > ./output.md
if (( "$DIFF_BYTES" <= 1 )); then
echo '差分はありません。' >> ./output.md
else
@ -87,18 +87,18 @@ jobs:
echo '```' >> ./output.md
echo '</details>' >> .output.md
fi
echo "$FOOTER" >> ./output.md
- uses: thollander/actions-comment-pull-request@v2
- uses: thollander/actions-comment-pull-request@v3
with:
pr_number: ${{ steps.load-pr-num.outputs.pr-number }}
comment_tag: show_diff
filePath: ./output.md
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_diff
file-path: ./output.md
- name: Tell error to PR
uses: thollander/actions-comment-pull-request@v2
uses: thollander/actions-comment-pull-request@v3
if: failure() && steps.load-pr-num.outputs.pr-number
with:
pr_number: ${{ steps.load-pr-num.outputs.pr-number }}
comment_tag: show_diff_error
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_diff_error
message: |
api.jsonの差分作成中にエラーが発生しました。詳細は[Workflowのログ](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})を確認してください。

View File

@ -0,0 +1,122 @@
name: Report backend memory
on:
workflow_run:
types: [completed]
workflows:
- Get backend memory usage # get-backend-memory.yml
jobs:
compare-memory:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
pull-requests: write
steps:
- name: Download artifact
uses: actions/github-script@v8.0.0
with:
script: |
const fs = require('fs');
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name.startsWith("memory-artifact-") || artifact.name == "memory-artifact"
});
await Promise.all(matchArtifacts.map(async (artifact) => {
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
archive_format: 'zip',
});
await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data));
}));
- name: Extract all artifacts
run: |
find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';'
ls -la artifacts/
- name: Load PR Number
id: load-pr-num
run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT"
- name: Output base
run: cat ./artifacts/memory-base.json
- name: Output head
run: cat ./artifacts/memory-head.json
- name: Compare memory usage
id: compare
run: |
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
BASE_RSS=$(echo "$BASE_MEMORY" | jq -r '.memory.rss // 0')
HEAD_RSS=$(echo "$HEAD_MEMORY" | jq -r '.memory.rss // 0')
# Calculate difference
if [ "$BASE_RSS" -gt 0 ] && [ "$HEAD_RSS" -gt 0 ]; then
DIFF=$((HEAD_RSS - BASE_RSS))
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE_RSS" | bc)
# Convert to MB for readability
BASE_MB=$(echo "scale=2; $BASE_RSS / 1048576" | bc)
HEAD_MB=$(echo "scale=2; $HEAD_RSS / 1048576" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1048576" | bc)
echo "base_mb=$BASE_MB" >> "$GITHUB_OUTPUT"
echo "head_mb=$HEAD_MB" >> "$GITHUB_OUTPUT"
echo "diff_mb=$DIFF_MB" >> "$GITHUB_OUTPUT"
echo "diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT"
echo "has_data=true" >> "$GITHUB_OUTPUT"
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$DIFF_PERCENT > 5" | bc)" -eq 1 ]; then
echo "significant_increase=true" >> "$GITHUB_OUTPUT"
else
echo "significant_increase=false" >> "$GITHUB_OUTPUT"
fi
else
echo "has_data=false" >> "$GITHUB_OUTPUT"
fi
- id: build-comment
name: Build memory comment
run: |
HEADER="## Backend Memory Usage Comparison"
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
echo "$HEADER" > ./output.md
echo >> ./output.md
if [ "${{ steps.compare.outputs.has_data }}" == "true" ]; then
echo "| Metric | base | head | Diff |" >> ./output.md
echo "|--------|------|------|------|" >> ./output.md
echo "| RSS | ${{ steps.compare.outputs.base_mb }} MB | ${{ steps.compare.outputs.head_mb }} MB | ${{ steps.compare.outputs.diff_mb }} MB (${{ steps.compare.outputs.diff_percent }}%) |" >> ./output.md
echo >> ./output.md
if [ "${{ steps.compare.outputs.significant_increase }}" == "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi
else
echo "Could not retrieve memory usage data." >> ./output.md
echo >> ./output.md
fi
echo "$FOOTER" >> ./output.md
- uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_memory_diff
file-path: ./output.md
- name: Tell error to PR
uses: thollander/actions-comment-pull-request@v3
if: failure() && steps.load-pr-num.outputs.pr-number
with:
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
comment-tag: show_memory_diff_error
message: |
An error occurred while comparing backend memory usage. See [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.

View File

@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Reply
uses: actions/github-script@v6
uses: actions/github-script@v8
with:
script: |
const body = `To dev team (@misskey-dev/dev):

View File

@ -22,12 +22,12 @@ jobs:
NODE_OPTIONS: "--max_old_space_size=7168"
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
if: github.event_name != 'pull_request_target'
with:
fetch-depth: 0
submodules: true
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
if: github.event_name == 'pull_request_target'
with:
fetch-depth: 0
@ -39,7 +39,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -90,7 +90,7 @@ jobs:
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
- name: Notify that Chromatic detects changes
uses: actions/github-script@v7.1.0
uses: actions/github-script@v8.0.0
if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@ -102,7 +102,7 @@ jobs:
body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).'
})
- name: Upload Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: storybook
path: packages/frontend/storybook-static

View File

@ -50,7 +50,7 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
@ -86,7 +86,7 @@ jobs:
fi
done
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
@ -129,13 +129,13 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
@ -173,7 +173,7 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
@ -182,7 +182,7 @@ jobs:
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'

View File

@ -32,7 +32,7 @@ jobs:
- .node-version
- .github/min.node-version
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Setup pnpm
@ -68,7 +68,7 @@ jobs:
fi
done
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'

View File

@ -28,13 +28,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -76,7 +76,7 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
# https://github.com/cypress-io/cypress-docker-images/issues/150
@ -88,7 +88,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@ -113,12 +113,12 @@ jobs:
wait-on: 'http://localhost:61812'
headed: true
browser: ${{ matrix.browser }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: failure()
with:
name: ${{ matrix.browser }}-cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: always()
with:
name: ${{ matrix.browser }}-cypress-videos

View File

@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.3.0
uses: actions/checkout@v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Setup Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -17,13 +17,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.3.0
- uses: actions/checkout@v6.0.1
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
- name: Use Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.1.0
with:
node-version-file: '.node-version'
cache: 'pnpm'

View File

@ -1 +1 @@
24.10.0
22.15.0

View File

@ -1,18 +1,46 @@
## 2025.12.0
## 2025.12.2
### Note
v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`に変更」について、正しく環境に応じた設定を行わないとサインインが困難になるといった状態を緩和するために、以前のデフォルト値に戻す暫定対応を行いました。
**セキュリティを向上させるためには適切な設定を行うことを推奨しますが、間違った設定値を入れると上述のような不具合の原因となりますので、慎重に行ってください。**
### General
- Enhance: ユーザーをミュートする範囲を選択可能に
- 新たに、タイムライン上と検索結果からのみ非表示とする(メンション等の通知は届く)オプションが追加されました
- 依存関係の更新
### Client
- Enhance: ミュートの付与期間を自由に設定できるように
- Enhance: ロールの付与期間を自由に設定できるように
- Fix: バージョン表記のないPlayが正しく動作しない問題を修正
## 2025.12.1
### Client
- Fix: 特定の条件下でMisskeyが起動せず空白のページが表示されることがある問題を軽減
- Fix: 初回読み込み時などに、言語設定で不整合が発生することがある問題を修正
- Fix: 削除されたノートのリノートが正しく動作されない問題を修正
- Fix: チャンネルオーナーが削除済みの時にチャンネルのヘッダーメニューが表示されない不具合を修正
- Fix: ドライブで登録日以外でソートする場合は月でグループ化して表示しないように
- Fix: `null` を返す note_view_intrruptor プラグインが動作しない問題を修正
### Server
- Fix: ジョブキューでSentryが有効にならない問題を修正
## 2025.12.0
### Note
- configの`trustProxy`のデフォルト値を`false`に変更しました。アップデート前に現在のconfigをご確認の上、必要に応じて値を変更してください。
### Client
- Fix: stacking router viewで連続して戻る操作を行うと何も表示されなくなる問題を修正
### Server
- Enhance: メモリ使用量を削減しました
- Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上
- Enhance: 依存関係の更新
- Fix: セキュリティに関する修正
## 2025.11.1

View File

@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.4
ARG NODE_VERSION=24.10.0-bookworm
ARG NODE_VERSION=22.15.0-bookworm
# build assets & compile TypeScript

View File

@ -24,6 +24,8 @@
<a href="https://www.patreon.com/syuilo">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a>
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/misskey-dev/misskey)
</div>
## Thanks

View File

@ -21,7 +21,7 @@ services:
env_file:
- .config/docker.env
volumes:
- ./db:/var/lib/postgresql/data
- ./db:/var/lib/postgresql
healthcheck:
test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
interval: 5s

View File

@ -43,7 +43,7 @@ services:
env_file:
- .config/docker.env
volumes:
- ./db:/var/lib/postgresql/data
- ./db:/var/lib/postgresql
healthcheck:
test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
interval: 5s

View File

@ -83,6 +83,7 @@ files: "Dateien"
download: "Herunterladen"
driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Einige Inhalte, die diese Datei verwenden, werden auch verschwinden."
unfollowConfirm: "Möchtest du {name} wirklich nicht mehr folgen?"
rejectFollowRequestConfirm: "Möchtest du die Follow-Anfrage von {name} ablehnen?"
exportRequested: "Du hast einen Export angefragt. Dies kann etwas Zeit in Anspruch nehmen. Sobald der Export abgeschlossen ist, wird er deiner Drive hinzugefügt."
importRequested: "Du hast einen Import angefragt. Dies kann etwas Zeit in Anspruch nehmen."
lists: "Listen"
@ -1018,6 +1019,7 @@ pushNotificationAlreadySubscribed: "Push-Benachrichtigungen sind bereits aktivie
pushNotificationNotSupported: "Entweder dein Browser oder deine Instanz unterstützt Push-Benachrichtigungen nicht"
sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald sie gelesen wurden"
sendPushNotificationReadMessageCaption: "Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen."
pleaseAllowPushNotification: "Bitte erlauben Sie Benachrichtigungen in Ihrem Browser."
windowMaximize: "Maximieren"
windowMinimize: "Minimieren"
windowRestore: "Wiederherstellen"
@ -1054,6 +1056,7 @@ permissionDeniedError: "Aktion verweigert"
permissionDeniedErrorDescription: "Dieses Benutzerkonto besitzt nicht die Berechtigung, um diese Aktion auszuführen."
preset: "Vorlage"
selectFromPresets: "Aus Vorlagen wählen"
custom: "Benutzerdefiniert"
achievements: "Errungenschaften"
gotInvalidResponseError: "Ungültige Antwort des Servers"
gotInvalidResponseErrorDescription: "Eventuell ist der Server momentan nicht erreichbar oder untergeht Wartungsarbeiten. Bitte versuche es später noch einmal."
@ -1243,6 +1246,7 @@ releaseToRefresh: "Zum Aktualisieren loslassen"
refreshing: "Wird aktualisiert..."
pullDownToRefresh: "Zum Aktualisieren ziehen"
useGroupedNotifications: "Benachrichtigungen gruppieren"
emailVerificationFailedError: "Es gab ein Problem bei der Überprüfung Ihrer E-Mail-Adresse. Der Link ist möglicherweise abgelaufen."
cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden."
doReaction: "Reagieren"
code: "Code"
@ -1370,7 +1374,12 @@ defaultImageCompressionLevel: "Standard-Bildkomprimierungsstufe"
defaultImageCompressionLevel_description: "Ein niedrigerer Wert erhält die Bildqualität, erhöht aber die Dateigröße. <br>Höhere Werte reduzieren die Dateigröße, verringern aber die Bildqualität."
inMinutes: "Minute(n)"
inDays: "Tag(en)"
safeModeEnabled: "Der abgesicherte Modus ist aktiviert."
schedule: "Planen"
scheduled: "Geplant"
widgets: "Widgets"
deviceInfo: "Geräteinformation"
youAreAdmin: "Sie sind ein Administrator"
presets: "Vorlage"
_imageEditing:
_vars:

View File

@ -83,6 +83,8 @@ files: "Files"
download: "Download"
driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted."
unfollowConfirm: "Are you sure you want to unfollow {name}?"
cancelFollowRequestConfirm: "Are you sure that you want to cancel your follow request to {name}?"
rejectFollowRequestConfirm: "Are you sure that you want to reject the follow request from {name}?"
exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed."
importRequested: "You've requested an import. This may take a while."
lists: "Lists"

View File

@ -319,10 +319,10 @@ remoteUserCaution: "Para el usuario remoto, la información está incompleta"
activity: "Actividad"
images: "Imágenes"
image: "Imágenes"
birthday: "Fecha de nacimiento"
birthday: "Cumpleaños"
yearsOld: "{age} años"
registeredDate: "Fecha de registro"
location: "Lugar"
location: "Ubicación"
theme: "Tema"
themeForLightMode: "Tema para usar en Modo Linterna"
themeForDarkMode: "Tema para usar en Modo Oscuro"
@ -579,7 +579,7 @@ objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir "
s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio."
serverLogs: "Registros del servidor"
deleteAll: "Eliminar todos"
showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo"
showFixedPostForm: "Visualizar la ventana de publicación en la parte superior de la línea de tiempo."
showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)"
withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo"
newNoteRecived: "Tienes una nota nueva"
@ -844,7 +844,7 @@ jumpToSpecifiedDate: "Saltar a una fecha específica"
showingPastTimeline: "Mostrar líneas de tiempo antiguas"
clear: "Limpiar"
markAllAsRead: "Marcar todo como leído"
goBack: "Deseleccionar"
goBack: "Anterior"
unlikeConfirm: "¿Quitar como favorito?"
fullView: "Vista completa"
quitFullView: "quitar vista completa"
@ -1252,7 +1252,7 @@ detachAll: "Quitar todo"
angle: "Ángulo"
flip: "Echar de un capirotazo"
showAvatarDecorations: "Mostrar decoraciones de avatar"
releaseToRefresh: "Soltar para recargar"
releaseToRefresh: "Suelta para recargar"
refreshing: "Recargando..."
pullDownToRefresh: "Tira hacia abajo para recargar"
useGroupedNotifications: "Mostrar notificaciones agrupadas"
@ -1511,7 +1511,7 @@ _emojiPalette:
palettes: "Paleta\n"
enableSyncBetweenDevicesForPalettes: "Activar la sincronización de paletas entre dispositivos"
paletteForMain: "Paleta principal"
paletteForReaction: "Paleta de reacción"
paletteForReaction: "Paleta utilizada para las reacciones"
_settings:
driveBanner: "Puedes gestionar y configurar la unidad, comprobar su uso y configurar los ajustes de carga de archivos."
pluginBanner: "Puedes ampliar las funciones del cliente con plugins. Puedes instalar plugins, configurarlos y gestionarlos individualmente."
@ -1523,7 +1523,7 @@ _settings:
accountData: "Datos de la cuenta"
accountDataBanner: "Exportación e importación para gestionar los datos de la cuenta."
muteAndBlockBanner: "Puedes configurar y gestionar ajustes para ocultar contenidos y restringir acciones a usuarios específicos."
accessibilityBanner: "Puedes personalizar los visuales y el comportamiento del cliente, y configurar los ajustes para optimizar el uso."
accessibilityBanner: "Puedes personalizar el aspecto y el comportamiento del cliente y configurar los ajustes para optimizar su uso."
privacyBanner: "Puedes configurar opciones relacionadas con la privacidad de la cuenta, como la visibilidad del contenido, la posibilidad de descubrir la cuenta y la aprobación de seguimiento."
securityBanner: "Puedes configurar opciones relacionadas con la seguridad de la cuenta, como la contraseña, los métodos de inicio de sesión, las aplicaciones de autenticación y Passkeys."
preferencesBanner: "Puedes configurar el comportamiento general del cliente según tus preferencias."
@ -1540,7 +1540,7 @@ _settings:
ifOff: "Si está desactivado"
enableSyncThemesBetweenDevices: "Sincronizar los temas instalados entre dispositivos."
enablePullToRefresh: "Tirar para actualizar"
enablePullToRefresh_description: "Si utiliza un ratón, arrastre mientras pulsa la rueda de desplazamiento."
enablePullToRefresh_description: "Si utilizas un ratón, arrastra mientras pulsas la rueda de desplazamiento."
realtimeMode_description: "Establece una conexión con el servidor y actualiza el contenido en tiempo real. Esto puede aumentar el tráfico y el consumo de memoria."
contentsUpdateFrequency: "Frecuencia de adquisición del contenido."
contentsUpdateFrequency_description: "Cuanto mayor sea el valor, más se actualiza el contenido, pero disminuye el rendimiento y aumenta el tráfico y el consumo de memoria."
@ -2156,7 +2156,7 @@ _accountDelete:
started: "El proceso de eliminación ha comenzado."
inProgress: "La eliminación está en proceso."
_ad:
back: "Deseleccionar"
back: "Anterior"
reduceFrequencyOfThisAd: "Mostrar menos este anuncio."
hide: "No mostrar"
timezoneinfo: "El día de la semana está determidado por la zona horaria del servidor."
@ -2610,10 +2610,10 @@ _profile:
name: "Nombre"
username: "Nombre de usuario"
description: "Descripción"
youCanIncludeHashtags: "Puedes añadir hashtags"
youCanIncludeHashtags: "También puedes incluir hashtags en tu biografía"
metadata: "información adicional"
metadataEdit: "Editar información adicional"
metadataDescription: "Muestra la información adicional en el perfil"
metadataDescription: "Usando esto puedes mostrar campos de información adicionales en tu perfil."
metadataLabel: "Etiqueta"
metadataContent: "Contenido"
changeAvatar: "Cambiar avatar"

View File

@ -83,6 +83,8 @@ files: "Allegati"
download: "Scarica"
driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?"
unfollowConfirm: "Vuoi davvero togliere il Following a {name}?"
cancelFollowRequestConfirm: "Vuoi annullare la tua richiesta di follow inviata a {name}?"
rejectFollowRequestConfirm: "Vuoi rifiutare la richiesta di follow ricevuta da {name}?"
exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive."
importRequested: "Hai richiesto un'importazione. Potrebbe richiedere un po' di tempo."
lists: "Liste"
@ -2350,13 +2352,13 @@ _ago:
yearsAgo: "{n} anni fa"
invalid: "Niente da visualizzare"
_timeIn:
seconds: "Dopo {n} secondi"
minutes: "Dopo {n} minuti"
hours: "Dopo {n} ore"
days: "Dopo {n} giorni"
weeks: "Dopo {n} settimane"
months: "Dopo {n} mesi"
years: "Dopo {n} anni"
seconds: "Tra {n} secondi"
minutes: "Tra {n} minuti"
hours: "Tra {n} ore"
days: "Tra {n} giorni"
weeks: "Tra {n} settimane"
months: "Tra {n} mesi"
years: "Tra {n} anni"
_time:
second: "s"
minute: "min"

View File

@ -53,7 +53,7 @@ copyRemoteLink: "复制远程链接"
copyLinkRenote: "复制转帖链接"
delete: "删除"
deleteAndEdit: "删除并编辑"
deleteAndEditConfirm: "要删除此帖并再次编辑吗?对此帖的所有回应、转发和回复也将被删除。"
deleteAndEditConfirm: "要删除此帖并再次编辑吗?此帖下所有的回应、转发和回复也将被删除。"
addToList: "添加至列表"
addToAntenna: "添加到天线"
sendMessage: "发送消息"
@ -136,7 +136,7 @@ emojiPicker: "表情符号选择器"
pinnedEmojisForReactionSettingDescription: "可以设置发表回应时置顶显示的表情符号"
pinnedEmojisSettingDescription: "可以设置输入表情符号时置顶显示的表情符号"
emojiPickerDisplay: "选择器显示设置"
overwriteFromPinnedEmojisForReaction: "「置顶(回应)」设置覆盖"
overwriteFromPinnedEmojisForReaction: "使用「置顶(回应)」设置覆盖"
overwriteFromPinnedEmojis: "从全局设置覆盖"
reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。"
rememberNoteVisibility: "保存上次设置的可见性"
@ -153,8 +153,8 @@ block: "拉黑"
unblock: "取消拉黑"
suspend: "冻结"
unsuspend: "解除冻结"
blockConfirm: "确定要拉黑吗?"
unblockConfirm: "确定要取消拉黑吗?"
blockConfirm: "确定要屏蔽吗?"
unblockConfirm: "确定要取消屏蔽吗?"
suspendConfirm: "要冻结吗?"
unsuspendConfirm: "要解除冻结吗?"
selectList: "选择列表"
@ -174,7 +174,7 @@ emojiUrl: "emoji 地址"
addEmoji: "添加表情符号"
settingGuide: "推荐配置"
cacheRemoteFiles: "缓存远程文件"
cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。"
cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。"
youCanCleanRemoteFilesCache: "可以使用文件管理的🗑️按钮来删除所有的缓存。"
cacheRemoteSensitiveFiles: "缓存远程敏感媒体文件"
cacheRemoteSensitiveFilesDescription: "如果禁用这项设定,远程服务器的敏感媒体将不会被缓存,而是直接链接。"
@ -184,7 +184,7 @@ flagAsCat: "喵!!!!!!!!!!!!"
flagAsCatDescription: "喵喵喵??"
flagShowTimelineReplies: "在时间线上显示帖子的回复"
flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。"
autoAcceptFollowed: "自动允许来自我关注的用户对我的关注请求"
autoAcceptFollowed: "自动允许回关请求"
addAccount: "添加账户"
reloadAccountsList: "更新账户列表"
loginFailed: "登录失败"
@ -247,8 +247,8 @@ mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,
federationAllowedHosts: "允许联合的服务器"
federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。"
muteAndBlock: "屏蔽/拉黑"
mutedUsers: "已屏蔽用户"
blockedUsers: "已拉黑的用户"
mutedUsers: "已静音的用户"
blockedUsers: "已屏蔽的用户"
noUsers: "无用户"
editProfile: "编辑资料"
noteDeleteConfirm: "确定要删除该帖子吗?"
@ -1344,7 +1344,7 @@ skip: "跳过"
restore: "恢复"
syncBetweenDevices: "设备间同步"
preferenceSyncConflictTitle: "服务器上已存在设定值"
preferenceSyncConflictText: "服务器上已有此设置的设定值。要覆盖哪个设定值?"
preferenceSyncConflictText: "即将保存设定值到服务器,但检测到服务器上已有此设置的设定值。要使用哪个设定值?"
preferenceSyncConflictChoiceMerge: "合并"
preferenceSyncConflictChoiceServer: "服务器上的设定值"
preferenceSyncConflictChoiceDevice: "设备上的设定值"
@ -3270,7 +3270,7 @@ _watermarkEditor:
driveFileTypeWarn: "不支持此文件"
driveFileTypeWarnDescription: "请选择图像文件"
title: "编辑水印"
cover: "覆盖全体"
cover: "覆盖所有"
repeat: "平铺"
preserveBoundingRect: "调整为旋转时不超出范围"
opacity: "不透明度"

View File

@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "2025.12.0-alpha.0",
"version": "2025.12.2-beta.0",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@10.23.0",
"packageManager": "pnpm@10.25.0",
"workspaces": [
"packages/misskey-js",
"packages/i18n",
@ -22,14 +22,15 @@
],
"private": true,
"scripts": {
"compile-config": "cd packages/backend && pnpm compile-config",
"build-pre": "node ./scripts/build-pre.js",
"build-assets": "node ./scripts/build-assets.mjs",
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
"build-storybook": "pnpm --filter frontend build-storybook",
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js",
"start:inspect": "cd packages/backend && node --inspect ./built/boot/entry.js",
"start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
"start": "pnpm check:connect && cd packages/backend && pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "cd packages/backend && pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"cli": "cd packages/backend && pnpm cli",
"init": "pnpm migrate",
"migrate": "cd packages/backend && pnpm migrate",
@ -52,15 +53,13 @@
"cleanall": "pnpm clean-all"
},
"resolutions": {
"chokidar": "4.0.3",
"chokidar": "5.0.0",
"lodash": "4.17.21"
},
"dependencies": {
"cssnano": "7.1.2",
"esbuild": "0.27.0",
"execa": "9.6.0",
"fast-glob": "3.3.3",
"glob": "13.0.0",
"esbuild": "0.27.1",
"execa": "9.6.1",
"ignore-walk": "8.0.0",
"js-yaml": "4.1.1",
"postcss": "8.5.6",
@ -72,15 +71,15 @@
"@eslint/js": "9.39.1",
"@misskey-dev/eslint-plugin": "2.2.0",
"@types/js-yaml": "4.0.9",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"cross-env": "10.1.0",
"cypress": "15.7.0",
"cypress": "15.7.1",
"eslint": "9.39.1",
"globals": "16.5.0",
"ncp": "2.0.0",
"pnpm": "10.23.0",
"pnpm": "10.25.0",
"start-server-and-test": "2.1.3"
},
"optionalDependencies": {

View File

@ -3,14 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js";
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
export class CompositeNoteIndex1745378064470 {
name = 'CompositeNoteIndex1745378064470';
transaction = isConcurrentIndexMigrationEnabled() ? false : undefined;
transaction = isConcurrentIndexMigrationEnabled ? false : undefined;
async up(queryRunner) {
const concurrently = isConcurrentIndexMigrationEnabled();
const concurrently = isConcurrentIndexMigrationEnabled;
if (concurrently) {
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
@ -29,7 +29,7 @@ export class CompositeNoteIndex1745378064470 {
}
async down(queryRunner) {
const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : '';
const mayConcurrently = isConcurrentIndexMigrationEnabled ? 'CONCURRENTLY' : '';
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
}

View File

@ -3,17 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {loadConfig} from "./js/migration-config.js";
export class MigrateSomeConfigFileSettingsToMeta1746949539915 {
name = 'MigrateSomeConfigFileSettingsToMeta1746949539915'
async up(queryRunner) {
const config = loadConfig();
// $1 cannot be used in ALTER TABLE queries
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT ${config.proxyRemoteFiles}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT ${config.signToActivityPubGet}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT ${!config.disallowExternalApRedirect}`);
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT TRUE`);
}
async down(queryRunner) {

View File

@ -1,31 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { path as configYamlPath } from '../../built/config.js';
import * as yaml from 'js-yaml';
import fs from "node:fs";
export function isConcurrentIndexMigrationEnabled() {
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
}
let loadedConfigCache = undefined;
function loadConfigInternal() {
const config = yaml.load(fs.readFileSync(configYamlPath, 'utf-8'));
return {
disallowExternalApRedirect: Boolean(config.disallowExternalApRedirect ?? false),
proxyRemoteFiles: Boolean(config.proxyRemoteFiles ?? false),
signToActivityPubGet: Boolean(config.signToActivityPubGet ?? true),
}
}
export function loadConfig() {
if (loadedConfigCache === undefined) {
loadedConfigCache = loadConfigInternal();
}
return loadedConfigCache;
}

View File

@ -1,7 +1,8 @@
import { DataSource } from 'typeorm';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js';
import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js";
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
const config = loadConfig();
@ -15,5 +16,5 @@ export default new DataSource({
extra: config.db.extra,
entities: entities,
migrations: ['migration/*.js'],
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all',
migrationsTransactionMode: isConcurrentIndexMigrationEnabled ? 'each' : 'all',
});

View File

@ -7,36 +7,37 @@
"node": "^22.15.0 || ^24.10.0"
},
"scripts": {
"start": "node ./built/boot/entry.js",
"start:inspect": "node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"cli": "node ./built/boot/cli.js",
"check:connect": "node ./scripts/check_connect.js",
"start": "pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
"cli": "pnpm compile-config && node ./built/boot/cli.js",
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
"build": "swc src -d built -D --strip-leading-paths",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node ./scripts/watch.mjs",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "node ./scripts/dev.mjs",
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache",
"jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "pnpm compile-config && node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --clearCache",
"test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test:fed": "pnpm jest:fed",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"check-migrations": "node scripts/check_migrations_clean.js",
"generate-api-json": "node ./scripts/generate_api_json.js"
"generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
@ -70,26 +71,25 @@
"utf-8-validate": "6.0.5"
},
"dependencies": {
"@aws-sdk/client-s3": "3.937.0",
"@aws-sdk/lib-storage": "3.937.0",
"@aws-sdk/client-s3": "3.947.0",
"@aws-sdk/lib-storage": "3.947.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.3",
"@fastify/cookie": "11.0.2",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.1.0",
"@fastify/express": "4.0.2",
"@fastify/http-proxy": "11.3.0",
"@fastify/http-proxy": "11.4.1",
"@fastify/multipart": "9.3.0",
"@fastify/static": "8.3.0",
"@kitajs/html": "4.2.11",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5",
"@napi-rs/canvas": "0.1.82",
"@napi-rs/canvas": "0.1.84",
"@nestjs/common": "11.1.9",
"@nestjs/core": "11.1.9",
"@nestjs/testing": "11.1.9",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.26.0",
"@sentry/profiling-node": "10.26.0",
"@sentry/node": "10.29.0",
"@sentry/profiling-node": "10.29.0",
"@simplewebauthn/server": "13.2.2",
"@sinonjs/fake-timers": "15.0.0",
"@smithy/node-http-handler": "4.4.5",
@ -103,13 +103,12 @@
"async-mutex": "0.5.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"body-parser": "2.2.0",
"bullmq": "5.64.1",
"body-parser": "2.2.1",
"bullmq": "5.65.1",
"cacheable-lookup": "7.0.0",
"cbor": "10.0.11",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
"chokidar": "4.0.3",
"chokidar": "5.0.0",
"color-convert": "3.1.3",
"content-disposition": "1.0.1",
"date-fns": "4.1.0",
@ -120,18 +119,16 @@
"file-type": "21.1.1",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.5",
"got": "14.6.4",
"got": "14.6.5",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
"ioredis": "5.8.2",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0",
"ipaddr.js": "2.3.0",
"is-svg": "6.1.0",
"js-yaml": "4.1.1",
"json5": "2.2.3",
"jsonld": "9.0.0",
"jsrsasign": "11.1.0",
"juice": "11.0.3",
"meilisearch": "0.54.0",
"mfm-js": "0.25.0",
@ -143,15 +140,14 @@
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.0.1",
"nodemailer": "7.0.10",
"nodemailer": "7.0.11",
"nsfwjs": "4.2.0",
"oauth": "0.10.2",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.4.1",
"pg": "8.16.3",
"pkce-challenge": "5.0.0",
"pkce-challenge": "5.0.1",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"qrcode": "1.5.4",
@ -170,14 +166,13 @@
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.27.11",
"systeminformation": "5.27.12",
"tinycolor2": "1.6.0",
"tmp": "0.2.5",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.27",
"typeorm": "0.3.28",
"typescript": "5.9.3",
"ulid": "3.0.1",
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.18.3",
@ -187,7 +182,7 @@
"@jest/globals": "29.7.0",
"@kitajs/ts-html-plugin": "4.1.3",
"@nestjs/platform-express": "11.1.9",
"@sentry/vue": "10.26.0",
"@sentry/vue": "10.29.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
@ -198,14 +193,11 @@
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.10.1",
"@types/node": "24.10.2",
"@types/nodemailer": "7.0.4",
"@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.15.6",
@ -223,20 +215,21 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.11",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.0",
"execa": "9.6.1",
"fkill": "10.0.1",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"jest-util": "29.7.0",
"js-yaml": "4.1.1",
"nodemon": "3.1.11",
"pid-port": "2.0.0",
"simple-oauth2": "5.1.0",
"supertest": "7.1.4",
"vite": "7.2.4"
"vite": "7.2.7"
}
}

View File

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* YAMLファイルをJSONファイルに変換するスクリプト
* ビルド前に実行しランタイムにjs-yamlを含まないようにする
*/
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const configDir = resolve(_dirname, '../../../.config');
const OUTPUT_PATH = resolve(_dirname, '../../../built/.config.json');
// TODO: yamlのパースに失敗したときのエラーハンドリング
/**
* YAMLファイルをJSONファイルに変換
* @param {string} ymlPath - YAMLファイルのパス
*/
function yamlToJson(ymlPath) {
if (!fs.existsSync(ymlPath)) {
console.warn(`YAML file not found: ${ymlPath}`);
return;
}
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify({
'_NOTE_': 'This file is auto-generated from YAML file. DO NOT EDIT.',
...jsonContent,
}), 'utf-8');
}
if (process.env.MISSKEY_CONFIG_YML) {
const customYmlPath = resolve(configDir, process.env.MISSKEY_CONFIG_YML);
yamlToJson(customYmlPath);
} else {
yamlToJson(resolve(configDir, process.env.NODE_ENV === 'test' ? 'test.yml' : 'default.yml'));
}
console.log('Configuration compiled ✓');

View File

@ -0,0 +1,152 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* This script starts the Misskey backend server, waits for it to be ready,
* measures memory usage, and outputs the result as JSON.
*
* Usage: node scripts/measure-memory.mjs
*/
import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
async function measureMemory() {
const startTime = Date.now();
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), [], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'test',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let serverReady = false;
// Listen for the 'ok' message from the server indicating it's ready
serverProcess.on('message', (message) => {
if (message === 'ok') {
serverReady = true;
}
});
// Handle server output
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
// Get memory usage from the server process via /proc
const pid = serverProcess.pid;
let memoryInfo;
try {
const fs = await import('node:fs/promises');
// Read /proc/[pid]/status for detailed memory info
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
memoryInfo = {
rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
};
} catch (err) {
// Fallback: use ps command
process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
const { execSync } = await import('node:child_process');
try {
const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
const rssKb = parseInt(ps.trim(), 10);
memoryInfo = {
rss: rssKb * 1024,
heapUsed: null,
vmSize: null,
};
} catch {
memoryInfo = {
rss: null,
heapUsed: null,
vmSize: null,
error: 'Could not measure memory',
};
}
}
// Stop the server
serverProcess.kill('SIGTERM');
// Wait for process to exit
let exited = false;
await new Promise((resolve) => {
serverProcess.on('exit', () => {
exited = true;
resolve(undefined);
});
// Force kill after 10 seconds if not exited
setTimeout(10000).then(() => {
if (!exited) {
serverProcess.kill('SIGKILL');
}
resolve(undefined);
});
});
const result = {
timestamp: new Date().toISOString(),
startupTimeMs: startupTime,
memory: memoryInfo,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
measureMemory().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
}));
process.exit(1);
});

View File

@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
@ -218,21 +217,15 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/**
* Path of configuration directory
*/
const dir = `${_dirname}/../../../.config`;
const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json');
/**
* Path of configuration file
*/
export const path = process.env.MISSKEY_CONFIG_YML
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
: process.env.NODE_ENV === 'test'
? resolve(dir, 'test.yml')
: resolve(dir, 'default.yml');
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json');
export function loadConfig(): Config {
if (!fs.existsSync(compiledConfigFilePath)) {
throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.');
}
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
@ -244,7 +237,7 @@ export function loadConfig(): Config {
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source;
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
const version = meta.version;

View File

@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos
import { bindThis } from '@/decorators.js';
import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit {
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null)
&& (
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
)
) {
if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) {
packedNote.visibility = 'followers';
}
}
@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit {
if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if ((hiddenBefore != null)
&& (
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
)
) {
if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
hide = true;
}
}

View File

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
*
* @param hiddenBefore 負の値: 作成からの経過秒数正の値: UNIXタイムスタンプ秒null:
* @param createdAt ISO 8601 Date
* @returns true
*/
export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean {
if (hiddenBefore == null) {
return false;
}
const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime();
if (hiddenBefore <= 0) {
// 負の値: 作成からの経過時間(秒)で判定
const elapsedSeconds = (Date.now() - createdAtTime) / 1000;
const hideAfterSeconds = Math.abs(hiddenBefore);
return elapsedSeconds >= hideAfterSeconds;
} else {
// 正の値: 絶対的なタイムスタンプ(秒)で判定
const createdAtSeconds = createdAtTime / 1000;
return createdAtSeconds <= hiddenBefore;
}
}

View File

@ -157,7 +157,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
}
let Sentry: typeof import('@sentry/node') | undefined;
if (Sentry != null) {
if (this.config.sentryForBackend) {
import('@sentry/node').then((mod) => {
Sentry = mod;
});

View File

@ -5,21 +5,20 @@
import * as fs from 'node:fs';
import { Writable } from 'node:stream';
import { Inject, Injectable, StreamableFile } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js';
import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import { createTemp } from '@/misc/create-temp.js';
import type { MiPoll } from '@/models/Poll.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@ -43,6 +42,7 @@ export class ExportClipsProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService,
private notificationService: NotificationService,
) {
@ -100,16 +100,16 @@ export class ExportClipsProcessorService {
});
while (true) {
const clips = await this.clipsRepository.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
});
const query = this.clipsRepository.createQueryBuilder('clip')
.where('clip.userId = :userId', { userId: user.id })
.orderBy('clip.id', 'ASC')
.take(100);
if (cursor) {
query.andWhere('clip.id > :cursor', { cursor });
}
const clips = await query.getMany();
if (clips.length === 0) {
job.updateProgress(100);
@ -124,7 +124,7 @@ export class ExportClipsProcessorService {
const isFirst = exportedClipsCount === 0;
await writer.write(isFirst ? content : ',\n' + content);
await this.processClipNotes(writer, clip.id);
await this.processClipNotes(writer, clip.id, user.id);
await writer.write(']}');
exportedClipsCount++;
@ -134,22 +134,25 @@ export class ExportClipsProcessorService {
}
}
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise<void> {
async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise<void> {
let exportedClipNotesCount = 0;
let cursor: MiClipNote['id'] | null = null;
while (true) {
const clipNotes = await this.clipNotesRepository.find({
where: {
clipId,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
relations: ['note', 'note.user'],
}) as (MiClipNote & { note: MiNote & { user: MiUser } })[];
const query = this.clipNotesRepository.createQueryBuilder('clipNote')
.leftJoinAndSelect('clipNote.note', 'note')
.leftJoinAndSelect('note.user', 'user')
.where('clipNote.clipId = :clipId', { clipId })
.orderBy('clipNote.id', 'ASC')
.take(100);
if (cursor) {
query.andWhere('clipNote.id > :cursor', { cursor });
}
this.queryService.generateVisibilityQuery(query, { id: userId });
const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[];
if (clipNotes.length === 0) {
break;
@ -158,6 +161,11 @@ export class ExportClipsProcessorService {
cursor = clipNotes.at(-1)?.id ?? null;
for (const clipNote of clipNotes) {
const noteCreatedAt = this.idService.parse(clipNote.note.id).date;
if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined;
if (clipNote.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id });

View File

@ -5,7 +5,6 @@
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { format as dateFormat } from 'date-fns';
import { DI } from '@/di-symbols.js';
import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js';
@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { QueryService } from '@/core/QueryService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbJobDataWithUser } from '../types.js';
@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService {
private driveService: DriveService,
private queueLoggerService: QueueLoggerService,
private queryService: QueryService,
private idService: IdService,
private notificationService: NotificationService,
) {
@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService {
});
while (true) {
const favorites = await this.noteFavoritesRepository.find({
where: {
userId: user.id,
...(cursor ? { id: MoreThan(cursor) } : {}),
},
take: 100,
order: {
id: 1,
},
relations: ['note', 'note.user'],
}) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
const query = this.noteFavoritesRepository.createQueryBuilder('favorite')
.leftJoinAndSelect('favorite.note', 'note')
.leftJoinAndSelect('note.user', 'user')
.where('favorite.userId = :userId', { userId: user.id })
.orderBy('favorite.id', 'ASC')
.take(100);
if (cursor) {
query.andWhere('favorite.id > :cursor', { cursor });
}
this.queryService.generateVisibilityQuery(query, { id: user.id });
const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[];
if (favorites.length === 0) {
job.updateProgress(100);
@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService {
cursor = favorites.at(-1)?.id ?? null;
for (const favorite of favorites) {
const noteCreatedAt = this.idService.parse(favorite.note.id).date;
if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) {
continue;
}
let poll: MiPoll | undefined;
if (favorite.note.hasPoll) {
poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id });

View File

@ -313,12 +313,16 @@ export class ApiCallService implements OnApplicationShutdown {
}
if (ep.meta.limit) {
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
let limitActor: string;
let limitActor: string | null;
if (user) {
limitActor = user.id;
} else {
limitActor = getIpHash(request.ip);
if (request.ip === '::1' || request.ip === '127.0.0.1') {
console.warn('request ip is localhost, maybe caused by misconfiguration of trustProxy or reverse proxy');
limitActor = null;
} else {
limitActor = getIpHash(request.ip);
}
}
const limit = Object.assign({}, ep.meta.limit);
@ -330,7 +334,7 @@ export class ApiCallService implements OnApplicationShutdown {
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
if (factor > 0) {
if (limitActor != null && factor > 0) {
// Rate limit
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
if (rateLimit != null) {

View File

@ -89,17 +89,21 @@ export class SigninApiService {
return { error };
}
if (request.ip === '::1' || request.ip === '127.0.0.1') {
console.warn('request ip is localhost, maybe caused by misconfiguration of trustProxy or reverse proxy');
} else {
// not more than 1 attempt per second and not more than 10 attempts per hour
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
if (rateLimit != null) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
}
if (typeof username !== 'string') {

View File

@ -84,19 +84,23 @@ export class SigninWithPasskeyApiService {
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
};
try {
if (request.ip === '::1' || request.ip === '127.0.0.1') {
console.warn('request ip is localhost, maybe caused by misconfiguration of trustProxy or reverse proxy');
} else {
try {
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
// NOTE: 1 Sign-in require 2 API calls
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
} catch (err) {
reply.code(429);
return {
error: {
message: 'Too many failed attempts to sign in. Try again later.',
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
},
};
}
}
// Initiate Passkey Auth challenge with context

View File

@ -0,0 +1,2 @@
url: https://example.com/
port: 3000

View File

@ -0,0 +1,29 @@
{
"url": "https://${HOST}/",
"port": 3000,
"db": {
"host": "db.${HOST}",
"port": 5432,
"db": "misskey",
"user": "postgres",
"pass": "postgres"
},
"dbReplications": false,
"trustProxy": true,
"redis": {
"host": "redis.test",
"port": 6379
},
"id": "aidx",
"proxyBypassHosts": [
"api.deepl.com",
"api-free.deepl.com",
"www.recaptcha.net",
"hcaptcha.com",
"challenges.cloudflare.com"
],
"allowedPrivateNetworks": [
"127.0.0.1/32",
"172.20.0.0/16"
]
}

View File

@ -1,22 +0,0 @@
url: https://${HOST}/
port: 3000
db:
host: db.${HOST}
port: 5432
db: misskey
user: postgres
pass: postgres
dbReplications: false
redis:
host: redis.test
port: 6379
id: 'aidx'
proxyBypassHosts:
- api.deepl.com
- api-free.deepl.com
- www.recaptcha.net
- hcaptcha.com
- challenges.cloudflare.com
allowedPrivateNetworks:
- 127.0.0.1/32
- 172.20.0.0/16

View File

@ -37,8 +37,8 @@ services:
- internal_network_a
volumes:
- type: bind
source: ./.config/a.test.default.yml
target: /misskey/.config/default.yml
source: ./.config/a.test.config.json
target: /misskey/built/._config_.json
read_only: true
db.a.test:
@ -50,7 +50,7 @@ services:
volumes:
- type: bind
source: ./volumes/db.a
target: /var/lib/postgresql/data
target: /var/lib/postgresql
bind:
create_host_path: true

View File

@ -37,8 +37,8 @@ services:
- internal_network_b
volumes:
- type: bind
source: ./.config/b.test.default.yml
target: /misskey/.config/default.yml
source: ./.config/b.test.config.json
target: /misskey/built/._config_.json
read_only: true
db.b.test:
@ -50,7 +50,7 @@ services:
volumes:
- type: bind
source: ./volumes/db.b
target: /var/lib/postgresql/data
target: /var/lib/postgresql
bind:
create_host_path: true

View File

@ -21,6 +21,10 @@ services:
- type: bind
source: ../../../built
target: /misskey/built
read_only: false
- type: bind
source: ./.config/dummy.yml
target: /misskey/.config/default.yml
read_only: true
- type: bind
source: ../assets
@ -42,6 +46,10 @@ services:
source: ../package.json
target: /misskey/packages/backend/package.json
read_only: true
- type: bind
source: ../scripts/compile_config.js
target: /misskey/packages/backend/scripts/compile_config.js
read_only: true
- type: bind
source: ../../misskey-js/built
target: /misskey/packages/misskey-js/built

View File

@ -54,6 +54,10 @@ services:
source: ../jest.js
target: /misskey/packages/backend/jest.js
read_only: true
- type: bind
source: ../scripts/compile_config.js
target: /misskey/packages/backend/scripts/compile_config.js
read_only: true
- type: bind
source: ../../misskey-js/built
target: /misskey/packages/misskey-js/built

View File

@ -28,7 +28,7 @@ function generate {
-days 500
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
}
generate a.test

View File

@ -168,7 +168,36 @@ describe('export-clips', () => {
assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2');
});
test('Clipping other user\'s note', async () => {
test('Clipping other user\'s note (followers only notes are excluded when not following)', async () => {
const res = await api('clips/create', {
name: 'kawaii',
description: 'kawaii',
}, alice);
assert.strictEqual(res.status, 200);
const clip = res.body;
const note = await post(bob, {
text: 'baz',
visibility: 'followers',
});
const res2 = await api('clips/add-note', {
clipId: clip.id,
noteId: note.id,
}, alice);
assert.strictEqual(res2.status, 204);
const res3 = await api('i/export-clips', {}, alice);
assert.strictEqual(res3.status, 204);
const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].clipNotes.length, 0);
});
test('Clipping other user\'s note (followers only notes are included when following)', async () => {
// Alice follows Bob
await api('following/create', { userId: bob.id }, alice);
const res = await api('clips/create', {
name: 'kawaii',
description: 'kawaii',

View File

@ -0,0 +1,136 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
import * as lolex from '@sinonjs/fake-timers';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
describe('misc:should-hide-note-by-time', () => {
let clock: lolex.InstalledClock;
const epoch = Date.UTC(2000, 0, 1, 0, 0, 0);
beforeEach(() => {
clock = lolex.install({
// https://github.com/sinonjs/sinon/issues/2620
toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[],
now: new Date(epoch),
shouldClearNativeTimers: true,
});
});
afterEach(() => {
clock.uninstall();
});
describe('hiddenBefore が null または undefined の場合', () => {
test('hiddenBefore が null のときは false を返す(非表示機能が有効でない)', () => {
const createdAt = new Date(epoch - 86400000); // 1 day ago
expect(shouldHideNoteByTime(null, createdAt)).toBe(false);
});
test('hiddenBefore が undefined のときは false を返す(非表示機能が有効でない)', () => {
const createdAt = new Date(epoch - 86400000); // 1 day ago
expect(shouldHideNoteByTime(undefined, createdAt)).toBe(false);
});
});
describe('相対時間モード (hiddenBefore <= 0)', () => {
test('閾値内に作成されたノートは false を返す(作成からの経過時間がまだ短い→表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(epoch - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false);
});
test('閾値を超えて作成されたノートは true を返す(指定期間以上経過している→非表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(epoch - 172800000); // 2 days ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('ちょうど閾値で作成されたノートは true を返す(閾値に達したら非表示)', () => {
const hiddenBefore = -86400; // 1 day in seconds
const createdAt = new Date(epoch - 86400000); // exactly 1 day ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('異なる相対時間値で判定できる1時間設定と3時間設定の異なる結果', () => {
const createdAt = new Date(epoch - 7200000); // 2 hours ago
expect(shouldHideNoteByTime(-3600, createdAt)).toBe(true); // 1時間経過→非表示
expect(shouldHideNoteByTime(-10800, createdAt)).toBe(false); // 3時間未経過→表示
});
test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => {
const createdAtString = new Date(epoch - 86400000).toISOString();
const hiddenBefore = -86400; // 1 day in seconds
expect(shouldHideNoteByTime(hiddenBefore, createdAtString)).toBe(true);
});
test('hiddenBefore が 0 の場合に対応できる0秒以上経過で非表示→ほぼ全て非表示', () => {
const hiddenBefore = 0;
const createdAt = new Date(epoch - 1); // 1ms ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
});
describe('絶対時間モード (hiddenBefore > 0)', () => {
test('閾値タイムスタンプより後に作成されたノートは false を返す(指定日時より後→表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAt = new Date(epoch + 3600000); // 1 hour from epoch
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(false);
});
test('閾値タイムスタンプより前に作成されたノートは true を返す(指定日時より前→非表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAt = new Date(epoch - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('ちょうど閾値タイムスタンプで作成されたノートは true を返す(指定日時に達したら非表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAt = new Date(epoch); // exactly epoch
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => {
const thresholdSeconds = Math.floor(epoch / 1000);
const createdAtString = new Date(epoch - 3600000).toISOString();
expect(shouldHideNoteByTime(thresholdSeconds, createdAtString)).toBe(true);
});
test('異なる閾値タイムスタンプで判定できる2021年設定と現在より1時間前設定の異なる結果', () => {
const thresholdSeconds = Math.floor((epoch - 86400000) / 1000); // 1 day ago
const createdAtBefore = new Date(epoch - 172800000); // 2 days ago
const createdAtAfter = new Date(epoch - 3600000); // 1 hour ago
expect(shouldHideNoteByTime(thresholdSeconds, createdAtBefore)).toBe(true); // 閾値より前→非表示
expect(shouldHideNoteByTime(thresholdSeconds, createdAtAfter)).toBe(false); // 閾値より後→表示
});
});
describe('エッジケース', () => {
test('相対時間モードで非常に古いノートに対応できる(非常に古い→閾値超→非表示)', () => {
const hiddenBefore = -1; // hide notes older than 1 second
const createdAt = new Date(epoch - 1000000); // very old
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
test('相対時間モードで非常に新しいノートに対応できる(非常に新しい→閾値未満→表示)', () => {
const hiddenBefore = -86400; // 1 day
const createdAt = new Date(epoch - 1); // 1ms ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false);
});
test('大きなタイムスタンプ値に対応できる(未来の日時を指定→現在のノートは全て非表示)', () => {
const thresholdSeconds = Math.floor(epoch / 1000) + 86400; // 1 day from epoch
const createdAt = new Date(epoch); // created epoch
expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true);
});
test('小さな相対時間値に対応できる1秒設定で2秒前→非表示', () => {
const hiddenBefore = -1; // 1 second
const createdAt = new Date(epoch - 2000); // 2 seconds ago
expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true);
});
});
});

View File

@ -11,9 +11,9 @@
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"rollup": "4.53.3",
"typescript": "5.9.3"
},
@ -21,6 +21,6 @@
"i18n": "workspace:*",
"estree-walker": "3.0.3",
"magic-string": "0.30.21",
"vite": "7.2.4"
"vite": "7.2.7"
}
}

View File

@ -17,8 +17,6 @@
"@rollup/pluginutils": "5.3.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.2",
"@vue/compiler-sfc": "3.5.24",
"astring": "1.9.0",
"buraha": "0.0.1",
"estree-walker": "3.0.3",
"frontend-shared": "workspace:*",
@ -28,15 +26,12 @@
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.53.3",
"sass": "1.94.2",
"shiki": "3.15.0",
"sass": "1.95.0",
"shiki": "3.19.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.9.3",
"uuid": "13.0.0",
"vite": "7.2.4",
"vue": "3.5.24"
"vite": "7.2.7",
"vue": "3.5.25"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.5",
@ -44,30 +39,30 @@
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.8",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.1",
"@types/node": "24.10.2",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@vitest/coverage-v8": "4.0.13",
"@vue/runtime-core": "3.5.24",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"@vitest/coverage-v8": "4.0.15",
"@vue/runtime-core": "3.5.25",
"acorn": "8.15.0",
"cross-env": "10.1.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.6.0",
"fast-glob": "3.3.3",
"happy-dom": "20.0.10",
"eslint-plugin-vue": "10.6.2",
"happy-dom": "20.0.11",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"msw": "2.12.2",
"msw": "2.12.4",
"nodemon": "3.1.11",
"prettier": "3.6.2",
"prettier": "3.7.4",
"start-server-and-test": "2.1.3",
"tsx": "4.20.6",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.1.5",
"vue-component-type-helpers": "3.1.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.5"
"vue-tsc": "3.1.7"
}
}

View File

@ -70,6 +70,8 @@
importAppScript();
});
}
localStorage.setItem('lang', lang);
//#endregion
async function addStyle(styleText) {

View File

@ -3,14 +3,13 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { globSync } from 'glob';
import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const entryPoints = globSync('./js/**/**.{ts,tsx}');
const entryPoints = fs.globSync('./js/**/**.{ts,tsx}');
/** @type {import('esbuild').BuildOptions} */
const options = {

View File

@ -21,11 +21,11 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"esbuild": "0.27.0",
"eslint-plugin-vue": "10.6.0",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"esbuild": "0.27.1",
"eslint-plugin-vue": "10.6.2",
"nodemon": "3.1.11",
"typescript": "5.9.3",
"vue-eslint-parser": "10.2.0"
@ -36,6 +36,6 @@
"dependencies": {
"i18n": "workspace:*",
"misskey-js": "workspace:*",
"vue": "3.5.24"
"vue": "3.5.25"
}
}

View File

@ -3,12 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, globSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { basename, dirname } from 'node:path/posix';
import { GENERATOR, type State, generate } from 'astring';
import type * as estree from 'estree';
import glob from 'fast-glob';
import { format } from 'prettier';
interface SatisfiesExpression extends estree.BaseExpression {
@ -439,38 +438,37 @@ function toStories(component: string): Promise<string> {
// glob('src/{components,pages,ui,widgets}/**/*.vue')
(async () => {
const globs = await Promise.all([
glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/MkAbuseReportWindow.vue'),
glob('src/components/MkAccountMoved.vue'),
glob('src/components/MkAchievements.vue'),
glob('src/components/MkAnalogClock.vue'),
glob('src/components/MkAnimBg.vue'),
glob('src/components/MkAnnouncementDialog.vue'),
glob('src/components/MkAntennaEditor.vue'),
glob('src/components/MkAntennaEditorDialog.vue'),
glob('src/components/MkAsUi.vue'),
glob('src/components/MkAutocomplete.vue'),
glob('src/components/MkAvatars.vue'),
glob('src/components/Mk[B-E]*.vue'),
glob('src/components/MkFlashPreview.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/components/MkImgPreviewDialog.vue'),
glob('src/components/MkInstanceCardMini.vue'),
glob('src/components/MkInviteCode.vue'),
glob('src/components/MkTagItem.vue'),
glob('src/components/MkRoleSelectDialog.vue'),
glob('src/components/grid/MkGrid.vue'),
glob('src/pages/admin/custom-emojis-manager2.vue'),
glob('src/pages/admin/overview.ap-requests.vue'),
glob('src/pages/user/home.vue'),
glob('src/pages/search.vue'),
]);
const components = globs.flat();
const components = [
globSync('src/components/global/Mk*.vue'),
globSync('src/components/global/RouterView.vue'),
globSync('src/components/MkAbuseReportWindow.vue'),
globSync('src/components/MkAccountMoved.vue'),
globSync('src/components/MkAchievements.vue'),
globSync('src/components/MkAnalogClock.vue'),
globSync('src/components/MkAnimBg.vue'),
globSync('src/components/MkAnnouncementDialog.vue'),
globSync('src/components/MkAntennaEditor.vue'),
globSync('src/components/MkAntennaEditorDialog.vue'),
globSync('src/components/MkAsUi.vue'),
globSync('src/components/MkAutocomplete.vue'),
globSync('src/components/MkAvatars.vue'),
globSync('src/components/Mk[B-E]*.vue'),
globSync('src/components/MkFlashPreview.vue'),
globSync('src/components/MkGalleryPostPreview.vue'),
globSync('src/components/MkSignupServerRules.vue'),
globSync('src/components/MkUserSetupDialog.vue'),
globSync('src/components/MkUserSetupDialog.*.vue'),
globSync('src/components/MkImgPreviewDialog.vue'),
globSync('src/components/MkInstanceCardMini.vue'),
globSync('src/components/MkInviteCode.vue'),
globSync('src/components/MkTagItem.vue'),
globSync('src/components/MkRoleSelectDialog.vue'),
globSync('src/components/grid/MkGrid.vue'),
globSync('src/pages/admin/custom-emojis-manager2.vue'),
globSync('src/pages/admin/overview.ap-requests.vue'),
globSync('src/pages/user/home.vue'),
globSync('src/pages/search.vue'),
].flat();
await Promise.all(components.map(async (component) => {
const stories = component.replace(/\.vue$/, '.stories.ts');
await writeFile(stories, await toStories(component));

View File

@ -16,7 +16,6 @@ import {
type PluginOption
} from 'vite';
import fs from 'node:fs';
import { glob } from 'glob';
import JSON5 from 'json5';
import MagicString, { SourceMap } from 'magic-string';
import path from 'node:path'
@ -724,7 +723,7 @@ export function pluginCreateSearchIndexVirtualModule(options: Options, asigner:
async load(id) {
if (id == '\0' + allSearchIndexFile) {
const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat());
const files = options.targetFilePaths.map((filePathPattern) => fs.globSync(filePathPattern)).flat();
let generatedFile = '';
let arrayElements = '';
for (let file of files) {

View File

@ -19,21 +19,19 @@
"@analytics/google-analytics": "1.1.0",
"@discordapp/twemoji": "16.0.1",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
"i18n": "workspace:*",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.3",
"@rollup/pluginutils": "5.3.0",
"@sentry/vue": "10.26.0",
"@sentry/vue": "10.29.0",
"@syuilo/aiscript": "1.2.0",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.2",
"@vue/compiler-sfc": "3.5.24",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
"analytics": "0.8.19",
"astring": "1.9.0",
"broadcast-channel": "7.2.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.4",
@ -46,10 +44,9 @@
"compare-versions": "6.1.1",
"cropperjs": "2.1.0",
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"execa": "9.6.0",
"exifreader": "4.32.0",
"execa": "9.6.1",
"exifreader": "4.33.1",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2",
@ -57,9 +54,8 @@
"ios-haptics": "0.1.4",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
"magic-string": "0.30.21",
"matter-js": "0.20.0",
"mediabunny": "1.25.1",
"mediabunny": "1.25.7",
"mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
@ -70,19 +66,15 @@
"qr-scanner": "1.4.2",
"rollup": "4.53.3",
"sanitize-html": "2.17.0",
"sass": "1.94.2",
"shiki": "3.15.0",
"strict-event-emitter-types": "2.0.0",
"sass": "1.95.0",
"shiki": "3.19.0",
"textarea-caret": "3.1.0",
"three": "0.181.2",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.9.3",
"v-code-diff": "1.13.1",
"vite": "7.2.4",
"vue": "3.5.24",
"vite": "7.2.7",
"vue": "3.5.25",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
@ -90,7 +82,7 @@
"@misskey-dev/summaly": "5.2.5",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-links": "10.1.4",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/addon-storysource": "8.6.14",
"@storybook/blocks": "8.6.14",
@ -98,58 +90,59 @@
"@storybook/core-events": "8.6.14",
"@storybook/manager-api": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@storybook/react": "10.1.4",
"@storybook/react-vite": "10.1.4",
"@storybook/test": "8.6.14",
"@storybook/theming": "8.6.14",
"@storybook/types": "8.6.14",
"@storybook/vue3": "10.0.8",
"@storybook/vue3-vite": "10.0.8",
"@storybook/vue3": "10.1.4",
"@storybook/vue3-vite": "10.1.4",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.8",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.10",
"@types/node": "24.10.1",
"@types/node": "24.10.2",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@vitest/coverage-v8": "4.0.13",
"@vue/compiler-core": "3.5.24",
"@vue/runtime-core": "3.5.24",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"@vitest/coverage-v8": "4.0.15",
"@vue/compiler-core": "3.5.25",
"acorn": "8.15.0",
"astring": "1.9.0",
"cross-env": "10.1.0",
"cypress": "15.7.0",
"cypress": "15.7.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.6.0",
"fast-glob": "3.3.3",
"happy-dom": "20.0.10",
"eslint-plugin-vue": "10.6.2",
"estree-walker": "3.0.3",
"happy-dom": "20.0.11",
"intersection-observer": "0.12.2",
"magic-string": "0.30.21",
"micromatch": "4.0.8",
"minimatch": "10.1.1",
"msw": "2.12.2",
"msw": "2.12.4",
"msw-storybook-addon": "2.0.6",
"nodemon": "3.1.11",
"prettier": "3.6.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"prettier": "3.7.4",
"react": "19.2.1",
"react-dom": "19.2.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.1.3",
"storybook": "10.0.8",
"storybook": "10.1.4",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.20.6",
"vite-plugin-glsl": "1.5.4",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vite-plugin-glsl": "1.5.5",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "4.0.13",
"vitest": "4.0.15",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "3.1.5",
"vue-component-type-helpers": "3.1.7",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.5"
"vue-tsc": "3.1.8"
}
}

View File

@ -42,6 +42,8 @@
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
localStorage.setItem('lang', lang);
//#endregion
//#region Script

View File

@ -83,34 +83,58 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkButton v-if="foldersPaginator.canFetchOlder.value" primary rounded @click="foldersPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
<MkStickyContainer v-for="(item, i) in filesTimeline" :key="`${item.date.getFullYear()}/${item.date.getMonth() + 1}`">
<template #header>
<div :class="$style.date">
<span><i class="ti ti-chevron-down"></i> {{ item.date.getFullYear() }}/{{ item.date.getMonth() + 1 }}</span>
</div>
</template>
<template v-if="shouldBeGroupedByDate">
<MkStickyContainer v-for="(item, i) in filesTimeline" :key="`${item.date.getFullYear()}/${item.date.getMonth() + 1}`">
<template #header>
<div :class="$style.date">
<span><i class="ti ti-chevron-down"></i> {{ item.date.getFullYear() }}/{{ item.date.getMonth() + 1 }}</span>
</div>
</template>
<TransitionGroup
tag="div"
:enterActiveClass="prefer.s.animation ? $style.transition_files_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_files_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_files_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_files_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_files_move : ''"
:class="$style.files"
>
<XFile
v-for="file in item.items" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@click="onFileClick($event, file)"
@dragstart="onFileDragstart(file, $event)"
@dragend="isDragSource = false"
/>
</TransitionGroup>
</MkStickyContainer>
</template>
<TransitionGroup
v-else
tag="div"
:enterActiveClass="prefer.s.animation ? $style.transition_files_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_files_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_files_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_files_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_files_move : ''"
:class="$style.files"
>
<XFile
v-for="file in filesPaginator.items.value" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@click="onFileClick($event, file)"
@dragstart="onFileDragstart(file, $event)"
@dragend="isDragSource = false"
/>
</TransitionGroup>
<TransitionGroup
tag="div"
:enterActiveClass="prefer.s.animation ? $style.transition_files_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_files_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_files_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_files_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_files_move : ''"
:class="$style.files"
>
<XFile
v-for="file in item.items" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@click="onFileClick($event, file)"
@dragstart="onFileDragstart(file, $event)"
@dragend="isDragSource = false"
/>
</TransitionGroup>
</MkStickyContainer>
<MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
<div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
@ -217,6 +241,7 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', {
}));
const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month');
const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value));
watch(folder, () => emit('cd', folder.value));
watch(sortModeSelect, () => {

View File

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkModalWindow>
</template>
<script lang="ts" setup>
import { defineProps, ref } from 'vue';
import { ref } from 'vue';
import MkModalWindow from './MkModalWindow.vue';
import type * as Misskey from 'misskey-js';

View File

@ -233,16 +233,18 @@ function showMenu(ev: MouseEvent) {
.hide {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--MI_THEME-fg);
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 0 0 0 9px;
color: #fff;
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
top: 0;
right: 0;
}
.hiddenTextWrapper {
@ -272,17 +274,17 @@ html[data-color-scheme=light] .visible {
.menu {
display: block;
position: absolute;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 9px 0 0 0;
color: #fff;
font-size: 0.8em;
width: 28px;
height: 28px;
text-align: center;
bottom: 10px;
right: 10px;
bottom: 0;
right: 0;
}
.imageContainer {

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!hardMuted && muted === false"
v-if="!hardMuted && !hideByPlugin && muted === false"
ref="rootEl"
v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]"
@ -38,7 +38,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
</div>
</div>
<div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<div v-if="isRenote && note.renote == null" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<div v-else-if="renoteCollapsed" :class="$style.collapsedRenoteTarget">
<MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/>
<Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/>
</div>
@ -158,7 +161,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</article>
</div>
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<div v-else-if="!hardMuted && !hideByPlugin" :class="$style.muted" @click="muted = false">
<I18n v-if="muted === 'sensitiveMute'" :src="i18n.ts.userSaysSomethingSensitive" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
@ -267,6 +270,7 @@ let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
const hideByPlugin = ref(false);
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
@ -276,7 +280,11 @@ if (noteViewInterruptors.length > 0) {
console.error(err);
}
}
note = result as Misskey.entities.Note;
if (result == null) {
hideByPlugin.value = true;
} else {
note = result as Misskey.entities.Note;
}
}
const isRenote = Misskey.note.isPureRenote(note);
@ -1144,4 +1152,14 @@ function emitUpdReaction(emoji: string, delta: number) {
opacity: .8;
font-size: 95%;
}
.deleted {
text-align: center;
padding: 32px;
margin: 6px 32px 28px;
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
border-radius: 8px;
}
</style>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
v-if="!muted && !isDeleted"
v-if="!muted && !hideByPlugin && !isDeleted"
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
@ -43,178 +43,183 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
<article :class="$style.note" @contextmenu.stop="onContextmenu">
<header :class="$style.noteHeader">
<MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
<div :class="$style.noteHeaderBody">
<div>
<MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
<MkUserName :nowrap="false" :user="appearNote.user"/>
<div v-if="isRenote && note.renote == null" :class="$style.deleted">
{{ i18n.ts.deletedNote }}
</div>
<template v-else>
<article :class="$style.note" @contextmenu.stop="onContextmenu">
<header :class="$style.noteHeader">
<MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
<div :class="$style.noteHeaderBody">
<div>
<MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
<MkUserName :nowrap="false" :user="appearNote.user"/>
</MkA>
<span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
<div :class="$style.noteHeaderInfo">
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
<div :class="$style.noteHeaderUsernameAndBadgeRoles">
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'respect'"
:emojiUrls="appearNote.emojis"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
class="_selectable"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll
v-if="appearNote.poll"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer>
<div :class="$style.noteFooterInfo">
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
<span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
<div :class="$style.noteHeaderInfo">
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
</div>
</div>
<div :class="$style.noteHeaderUsernameAndBadgeRoles">
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
</div>
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
:author="appearNote.user"
:nyaize="'respect'"
:emojiUrls="appearNote.emojis"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
class="_selectable"
/>
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
<div v-if="translating || translation" :class="$style.translation">
<MkLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" class="_selectable"/>
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll
v-if="appearNote.poll"
<MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
:multiple="appearNote.poll.multiple"
:expiresAt="appearNote.poll.expiresAt"
:choices="$appearNote.pollChoices"
:author="appearNote.user"
:emojiUrls="appearNote.emojis"
:class="$style.poll"
:maxNumber="16"
@mockUpdateMyReaction="emitUpdReaction"
/>
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer>
<div :class="$style.noteFooterInfo">
<MkA :to="notePage(appearNote)">
<MkTime :time="appearNote.createdAt" mode="detail" colored/>
</MkA>
</div>
<MkReactionsViewer
v-if="appearNote.reactionAcceptance !== 'likeOnly'"
style="margin-top: 6px;"
:reactions="$appearNote.reactions"
:reactionEmojis="$appearNote.reactionEmojis"
:myReaction="$appearNote.myReaction"
:noteId="appearNote.id"
:maxNumber="16"
@mockUpdateMyReaction="emitUpdReaction"
/>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
<button
v-if="canRenote"
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
</article>
<div :class="$style.tabs">
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ti ti-arrow-back-up"></i> {{ i18n.ts.replies }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ti ti-repeat"></i> {{ i18n.ts.renotes }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ti ti-icons"></i> {{ i18n.ts.reactions }}</button>
</div>
<div>
<div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :paginator="renotesPaginator">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.repliesCount) }}</p>
</button>
</div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :paginator="reactionsPaginator">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
<button
v-if="canRenote"
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
</button>
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && $appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--MI_THEME-love);"></i>
<i v-else-if="$appearNote.myReaction != null" class="ti ti-minus" style="color: var(--MI_THEME-accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || prefer.s.showReactionsCount) && $appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number($appearNote.reactionCount) }}</p>
</button>
<button v-if="prefer.s.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
</article>
<div :class="$style.tabs">
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ti ti-arrow-back-up"></i> {{ i18n.ts.replies }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ti ti-repeat"></i> {{ i18n.ts.renotes }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ti ti-icons"></i> {{ i18n.ts.reactions }}</button>
</div>
</div>
<div>
<div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :paginator="renotesPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
<MkReactionIcon :reaction="reaction"/>
<span style="margin-left: 4px;">{{ $appearNote.reactions[reaction] }}</span>
</button>
</div>
<MkPagination v-if="reactionTabType" :key="reactionTabType" :paginator="reactionsPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-gap: 12px;">
<MkA v-for="item in items" :key="item.id" :to="userPage(item.user)">
<MkUserCardMini :user="item.user" :withChart="false"/>
</MkA>
</div>
</template>
</MkPagination>
</div>
</div>
</template>
</div>
<div v-else-if="muted" class="_panel" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
@ -289,6 +294,7 @@ let note = deepClone(props.note);
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
const hideByPlugin = ref(false);
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
@ -298,7 +304,11 @@ if (noteViewInterruptors.length > 0) {
console.error(err);
}
}
note = result as Misskey.entities.Note;
if (result == null) {
hideByPlugin.value = true;
} else {
note = result as Misskey.entities.Note;
}
}
const isRenote = Misskey.note.isPureRenote(note);
@ -944,4 +954,14 @@ function loadConversation() {
text-align: center;
opacity: 0.7;
}
.deleted {
text-align: center;
padding: 32px;
margin: 6px 32px 32px;
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
border-radius: 8px;
}
</style>

View File

@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="showDecoration">
<img
v-for="decoration in decorations ?? user.avatarDecorations"
:class="[$style.decoration, { [$style.decorationBlink]: decoration.blink }]"
:class="[$style.decoration, { [$style.decorationBlink]: getDecorationIsBrink(decoration) }]"
:src="getDecorationUrl(decoration)"
:style="{
rotate: getDecorationAngle(decoration),
@ -56,13 +56,16 @@ import { prefer } from '@/preferences.js';
const animation = ref(prefer.s.animation);
const squareAvatars = ref(prefer.s.squareAvatars);
type Decoration = Misskey.entities.UserDetailed['avatarDecorations'][number];
type DecorationEditorDecoration = Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'> & { blink?: boolean; };
const props = withDefaults(defineProps<{
user: Misskey.entities.User;
target?: string | null;
link?: boolean;
preview?: boolean;
indicator?: boolean;
decorations?: (Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'> & { blink?: boolean; })[];
decorations?: DecorationEditorDecoration[];
forceShowDecoration?: boolean;
}>(), {
target: null,
@ -93,27 +96,31 @@ function onClick(ev: MouseEvent): void {
emit('click', ev);
}
function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
function getDecorationUrl(decoration: Decoration | DecorationEditorDecoration) {
if (prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar) return getStaticImageUrl(decoration.url);
return decoration.url;
}
function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
function getDecorationAngle(decoration: Decoration | DecorationEditorDecoration) {
const angle = decoration.angle ?? 0;
return angle === 0 ? undefined : `${angle * 360}deg`;
}
function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
function getDecorationScale(decoration: Decoration | DecorationEditorDecoration) {
const scaleX = decoration.flipH ? -1 : 1;
return scaleX === 1 ? undefined : `${scaleX} 1`;
}
function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
function getDecorationOffset(decoration: Decoration | DecorationEditorDecoration) {
const offsetX = decoration.offsetX ?? 0;
const offsetY = decoration.offsetY ?? 0;
return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`;
}
function getDecorationIsBrink(decoration: Decoration | DecorationEditorDecoration) {
return 'blink' in decoration && decoration.blink === true;
}
const color = ref<string | undefined>();
watch(() => props.user.avatarBlurhash, () => {

View File

@ -74,6 +74,7 @@ function mount() {
}
function back() {
if (tabs.value.length <= 1) return; // transition1back
const prev = tabs.value[tabs.value.length - 2];
tabs.value = [...tabs.value.slice(0, tabs.value.length - 1)];
router?.replaceByPath(prev.fullPath);

View File

@ -77,7 +77,7 @@ export const components = {
SearchIcon: SearchIcon,
};
declare module '@vue/runtime-core' {
declare module 'vue' {
export interface GlobalComponents {
I18n: typeof I18n;
RouterView: typeof RouterView;

View File

@ -239,7 +239,7 @@ async function search() {
}
const headerActions = computed(() => {
if (channel.value && channel.value.userId) {
if (channel.value) {
const headerItems: PageHeaderItem[] = [];
headerItems.push({

View File

@ -193,7 +193,7 @@ function start() {
}
function getIsLegacy(version: string | null): boolean {
if (version == null) return false;
if (version == null) return true;
try {
return compareVersions(version, '1.0.0') < 0;
} catch {
@ -206,7 +206,7 @@ async function run() {
if (!flash.value) return;
const version = utils.getLangVersion(flash.value.script);
const isLegacy = version != null && getIsLegacy(version);
const isLegacy = getIsLegacy(version);
const { Interpreter, Parser, values } = isLegacy ? (await import('@syuilo/aiscript-0-19-0') as any) : await import('@syuilo/aiscript');

View File

@ -63,7 +63,7 @@ async function setAntenna() {
})),
} : undefined),
],
default: props.column.antennaId,
default: antennas.find(x => x.id === props.column.antennaId)?.id,
});
if (canceled || antennaIdOrOperation == null) return;

View File

@ -63,7 +63,7 @@ async function setChannel() {
items: channels.map(x => ({
value: x.id, label: x.name,
})),
default: props.column.channelId,
default: channels.find(x => x.id === props.column.channelId)?.id,
});
if (canceled || chosenChannelId == null) return;
const chosenChannel = channels.find(x => x.id === chosenChannelId)!;

View File

@ -70,7 +70,7 @@ async function setList() {
})),
} : undefined),
],
default: props.column.listId,
default: lists.find(x => x.id === props.column.listId)?.id,
});
if (canceled || listIdOrOperation == null) return;

View File

@ -54,7 +54,7 @@ async function setRole() {
items: roles.map(x => ({
value: x.id, label: x.name,
})),
default: props.column.roleId,
default: roles.find(x => x.id === props.column.roleId)?.id,
});
if (canceled || roleId == null) return;
const role = roles.find(x => x.id === roleId)!;

View File

@ -104,6 +104,7 @@ async function setType() {
}, {
value: 'global', label: i18n.ts._timelines.global,
}],
default: props.column.tl,
});
if (canceled) {
if (props.column.tl == null) {

View File

@ -10,7 +10,6 @@ import { watch as chokidarWatch } from 'chokidar';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { execa } from 'execa';
import { globSync } from 'glob';
import { generateLocaleInterface } from './scripts/generateLocaleInterface.js';
import type { BuildOptions, BuildResult, Plugin, PluginBuild } from 'esbuild';
@ -22,7 +21,7 @@ const _rootPackage = JSON.parse(fs.readFileSync(resolve(_rootPackageDir, 'packag
const _frontendLocalesDir = resolve(_dirname, '../../built/_frontend_dist_/locales');
const _localesDir = resolve(_rootPackageDir, 'locales');
const entryPoints = globSync('./src/**/**.{ts,tsx}');
const entryPoints = fs.globSync('./src/**/**.{ts,tsx}');
const options: BuildOptions = {
entryPoints,

View File

@ -29,15 +29,14 @@
],
"devDependencies": {
"@types/js-yaml": "4.0.9",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"chokidar": "4.0.3",
"esbuild": "0.27.0",
"execa": "9.6.0",
"glob": "11.1.0",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"chokidar": "5.0.0",
"esbuild": "0.27.1",
"execa": "9.6.1",
"nodemon": "3.1.11",
"tsx": "4.20.6",
"tsx": "4.21.0",
"typescript": "5.9.3"
},
"dependencies": {

View File

@ -11,16 +11,15 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "24.10.1",
"@types/node": "24.10.2",
"@types/wawoff2": "1.0.2",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0"
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0"
},
"dependencies": {
"@tabler/icons-webfont": "3.35.0",
"harfbuzzjs": "0.4.13",
"tiny-glob": "0.2.9",
"tsx": "4.20.6",
"tsx": "4.21.0",
"typescript": "5.9.3",
"wawoff2": "2.0.1"
},

View File

@ -3,9 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fsp, existsSync } from 'fs';
import path from 'path';
import glob from 'tiny-glob';
import { promises as fsp, existsSync } from 'node:fs';
import path from 'node:path';
import { generateSubsettedFont } from './subsetter.js';
const filesToScan = {
@ -47,8 +46,8 @@ async function main() {
const iconsToPack = new Set<string>();
const cwd = path.resolve(process.cwd(), '../../');
const files = await glob(dir, { cwd });
for (const file of files) {
const files = fsp.glob(dir, { cwd });
for await (const file of files) {
//console.log(`Scanning ${file}`);
const content = await fsp.readFile(path.resolve(cwd, file), 'utf-8');
const classRegex = /ti-[a-z0-9-]+/g;

View File

@ -3,14 +3,13 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { globSync } from 'glob';
import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const entryPoints = globSync('./src/**/**.{ts,tsx}');
const entryPoints = fs.globSync('./src/**/**.{ts,tsx}');
/** @type {import('esbuild').BuildOptions} */
const options = {

View File

@ -25,13 +25,12 @@
},
"devDependencies": {
"@types/matter-js": "0.20.2",
"@types/node": "24.10.1",
"@types/node": "24.10.2",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"esbuild": "0.27.0",
"execa": "9.6.0",
"glob": "11.1.0",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"esbuild": "0.27.1",
"execa": "9.6.1",
"nodemon": "3.1.11",
"typescript": "5.9.3"
},

View File

@ -3,14 +3,13 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as esbuild from 'esbuild';
import { build } from 'esbuild';
import { globSync } from 'glob';
import { execa } from 'execa';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const entryPoints = globSync('./src/**/**.{ts,tsx}');
const entryPoints = fs.globSync('./src/**/**.{ts,tsx}');
/** @type {import('esbuild').BuildOptions} */
const options = {

View File

@ -8,13 +8,13 @@
},
"devDependencies": {
"@readme/openapi-parser": "5.2.1",
"@types/node": "24.10.1",
"@typescript-eslint/eslint-plugin": "8.47.0",
"@typescript-eslint/parser": "8.47.0",
"@types/node": "24.10.2",
"@typescript-eslint/eslint-plugin": "8.49.0",
"@typescript-eslint/parser": "8.49.0",
"openapi-types": "12.1.3",
"openapi-typescript": "7.10.1",
"ts-case-convert": "2.1.0",
"tsx": "4.20.6",
"tsx": "4.21.0",
"typescript": "5.9.3",
"eslint": "9.39.1"
},

Some files were not shown because too many files have changed in this diff Show More