mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-04 14:24:55 +02:00
Compare commits
33 Commits
frontend-b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97082efd99 | ||
|
|
187610d516 | ||
|
|
00b2cb2076 | ||
|
|
426f6748e5 | ||
|
|
c423bf92a8 | ||
|
|
f59bf35615 | ||
|
|
0f056c4955 | ||
|
|
eb2c7ff6c6 | ||
|
|
9f614517c0 | ||
|
|
5432984af8 | ||
|
|
c29a3d902b | ||
|
|
721b1b06a0 | ||
|
|
97e54a1ee8 | ||
|
|
96d6a09ebc | ||
|
|
1eedf04d9a | ||
|
|
2d67380f2a | ||
|
|
174fb434cc | ||
|
|
7e29f04287 | ||
|
|
bf88122140 | ||
|
|
4daa1ffe05 | ||
|
|
62f8589c05 | ||
|
|
6193c35f9f | ||
|
|
1220f05903 | ||
|
|
7544ebf7a3 | ||
|
|
ffe65caf10 | ||
|
|
4f993cef1b | ||
|
|
ba3fb4aa14 | ||
|
|
0137b1c406 | ||
|
|
797dec7d0e | ||
|
|
42ff280163 | ||
|
|
554339aaa1 | ||
|
|
529c4d4d0e | ||
|
|
cec23e5756 |
@@ -5,7 +5,7 @@
|
||||
"workspaceFolder": "/workspace",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22.15.0"
|
||||
"version": "22.23.1"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {
|
||||
"version": "10.10.0"
|
||||
|
||||
2
.github/min.node-version
vendored
2
.github/min.node-version
vendored
@@ -1 +1 @@
|
||||
22.15.0
|
||||
22.22.2
|
||||
|
||||
18
.github/scripts/backend-memory-report.mts
vendored
18
.github/scripts/backend-memory-report.mts
vendored
@@ -54,7 +54,7 @@ const metrics = [
|
||||
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
return `${util.formatNumber(valueKiB / 1024)} MB`;
|
||||
return `${util.formatNumber(valueKiB / 1000)} MB`;
|
||||
}
|
||||
|
||||
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof metrics[number]) {
|
||||
@@ -79,8 +79,8 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase:
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
function formatDeltaMemory(diffKiB: number) {
|
||||
return util.formatColoredDelta(formatMemoryMb(Math.abs(diffKiB)), diffKiB);
|
||||
function formatDeltaMemory(deltaKiB: number) {
|
||||
return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold
|
||||
}
|
||||
|
||||
for (const metric of metrics) {
|
||||
@@ -91,7 +91,7 @@ function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase:
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`;
|
||||
const deltaMedian = summary == null ? '-' : `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
|
||||
lines.push(`| **${metric}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${summary?.mad == null ? '-' : formatMemoryMb(summary.mad)} | ${summary == null ? '-' : formatDeltaMemory(summary.min)} | ${summary == null ? '-' : formatDeltaMemory(summary.max)} |`);
|
||||
}
|
||||
@@ -183,7 +183,7 @@ function renderJsFootprintMetricTable(base: RuntimeLoadedJsFootprintReport, head
|
||||
const headValue = getJsFootprintValue(head, 'afterRequest', key);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(formatter(headValue - baseValue), headValue - baseValue)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push(`| **${title}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${util.formatColoredDelta(headValue - baseValue, v => formatter(v))} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
@@ -278,7 +278,7 @@ function renderLargestPackageIncreases(base: RuntimeLoadedJsFootprintReport, hea
|
||||
];
|
||||
|
||||
for (const packageSummary of increases) {
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(util.formatBytes(packageSummary.sourceBytes - packageSummary.baseSourceBytes), packageSummary.sourceBytes - packageSummary.baseSourceBytes)} | ${util.formatColoredDelta(util.formatNumber(packageSummary.modules - packageSummary.baseModules), packageSummary.modules - packageSummary.baseModules)} |`);
|
||||
lines.push(`| ${packageDisplayName(packageSummary)} | ${util.formatBytes(packageSummary.baseSourceBytes)} | ${util.formatBytes(packageSummary.sourceBytes)} | ${util.formatColoredDelta(packageSummary.sourceBytes - packageSummary.baseSourceBytes, v => util.formatBytes(v))} | ${util.formatColoredDelta(packageSummary.modules - packageSummary.baseModules, v => util.formatNumber(v))} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
@@ -406,15 +406,15 @@ function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: type
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
if (baseValue == null || headValue == null) return false;
|
||||
|
||||
const diff = headValue - baseValue;
|
||||
if (diff <= 0) return false;
|
||||
const delta = headValue - baseValue;
|
||||
if (delta <= 0) return false;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return diff > combinedSpread * 3;
|
||||
return delta > combinedSpread * 3;
|
||||
}
|
||||
|
||||
const warningMetric = getWarningMetric(base, head);
|
||||
|
||||
16
.github/scripts/frontend-js-size.mts
vendored
16
.github/scripts/frontend-js-size.mts
vendored
@@ -299,13 +299,13 @@ function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualize
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
@@ -357,16 +357,16 @@ function chunkMarkdownTable(rows: ReturnType<typeof getChunkComparisonRows>, tot
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
if (total != null) {
|
||||
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push('| | | | | |');
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
|
||||
} else if (row.changeType === 'removed') {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
|
||||
8
.github/scripts/heap-snapshot-util.mts
vendored
8
.github/scripts/heap-snapshot-util.mts
vendored
@@ -63,20 +63,20 @@ export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnap
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
|
||||
if (category === 'total') {
|
||||
const deltaMedian = `${util.formatDeltaBytes(summary.median)}<br>${util.formatDeltaPercent(percent).replaceAll('\\%', '\\\\%')}`;
|
||||
const deltaMedian = `${util.formatDeltaBytes(summary.median, 100000)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
const baseText = `${util.formatBytes(baseValue)} <br> ± ${util.formatBytes(baseSpread)}`;
|
||||
const headText = `${util.formatBytes(headValue)} <br> ± ${util.formatBytes(headSpread)}`;
|
||||
const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
|
||||
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`);
|
||||
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
|
||||
lines.push('| | | | | | | |');
|
||||
} else {
|
||||
const deltaMedian = util.formatDeltaBytes(summary.median);
|
||||
const deltaMedian = util.formatDeltaBytes(summary.median, 100000);
|
||||
const baseText = util.formatBytes(baseValue);
|
||||
const headText = util.formatBytes(headValue);
|
||||
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
|
||||
const headPercent = util.formatPercent((headValue * 100) / headTotal);
|
||||
const metricText = `<details><summary>$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**</summary>${basePercent} → ${headPercent}</details>`;
|
||||
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min)} | ${util.formatDeltaBytes(summary.max)} |`);
|
||||
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
.github/scripts/utility.mts
vendored
34
.github/scripts/utility.mts
vendored
@@ -94,11 +94,12 @@ export function escapeLatex(text: string) {
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
export function formatColoredDelta(text: string, delta: number) {
|
||||
if (delta === 0) return text;
|
||||
const color = delta > 0 ? 'orange' : 'green';
|
||||
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
|
||||
if (delta === 0) return text(0);
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
|
||||
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
|
||||
const color = delta > 0 ? 'orange' : 'green';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
@@ -114,8 +115,8 @@ export function formatBytes(value: number) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
@@ -123,35 +124,34 @@ export function formatBytes(value: number) {
|
||||
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaNumber(before: number, after: number) {
|
||||
export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatColoredDelta(formatNumber(Math.abs(delta)), delta);
|
||||
return formatColoredDelta(delta, v => formatNumber(v), colorThreshold);
|
||||
}
|
||||
|
||||
export function formatDeltaBytes(deltaBytes: number) {
|
||||
return formatColoredDelta(formatBytes(Math.abs(deltaBytes)), deltaBytes);
|
||||
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
|
||||
return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaBytes(before: number, after: number) {
|
||||
export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaBytes(delta);
|
||||
return formatDeltaBytes(delta, colorThreshold);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(deltaPercent: number) {
|
||||
if (deltaPercent === 0) return '0%';
|
||||
return formatColoredDelta(formatPercent(Math.abs(deltaPercent)), deltaPercent);
|
||||
export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
|
||||
return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaPercent(before: number, after: number) {
|
||||
export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) {
|
||||
if (before == null || before === 0 || after == null || after === 0) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaPercent(delta / before * 100);
|
||||
return formatDeltaPercent(delta / before * 100, colorThreshold);
|
||||
}
|
||||
|
||||
export function commandName(command: string) {
|
||||
|
||||
4
.github/workflows/api-misskey-js.yml
vendored
4
.github/workflows/api-misskey-js.yml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
|
||||
2
.github/workflows/changelog-check.yml
vendored
2
.github/workflows/changelog-check.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Check version
|
||||
run: |
|
||||
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then
|
||||
|
||||
2
.github/workflows/check-spdx-license-id.yml
vendored
2
.github/workflows/check-spdx-license-id.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Check
|
||||
run: |
|
||||
counter=0
|
||||
|
||||
2
.github/workflows/check_copyright_year.yml
vendored
2
.github/workflows/check_copyright_year.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
check_copyright_year:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- run: |
|
||||
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
|
||||
echo "Please change copyright year!"
|
||||
|
||||
2
.github/workflows/docker-develop.yml
vendored
2
.github/workflows/docker-develop.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to Docker Hub
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Docker meta
|
||||
|
||||
2
.github/workflows/dockle.yml
vendored
2
.github/workflows/dockle.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
DOCKLE_VERSION: 0.4.15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
|
||||
run: |
|
||||
|
||||
6
.github/workflows/frontend-bundle-report.yml
vendored
6
.github/workflows/frontend-bundle-report.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
FRONTEND_JS_SIZE_LOCALE: ja-JP
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
|
||||
4
.github/workflows/get-api-diff.yml
vendored
4
.github/workflows/get-api-diff.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
6
.github/workflows/get-backend-memory.yml
vendored
6
.github/workflows/get-backend-memory.yml
vendored
@@ -39,19 +39,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
path: base
|
||||
submodules: true
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
path: head
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: head/package.json
|
||||
- name: Use Node.js
|
||||
|
||||
35
.github/workflows/lint.yml
vendored
35
.github/workflows/lint.yml
vendored
@@ -17,7 +17,9 @@ on:
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
- package.json
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/backend/**
|
||||
@@ -31,17 +33,19 @@ on:
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
- package.json
|
||||
jobs:
|
||||
pnpm_install:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
@@ -69,12 +73,12 @@ jobs:
|
||||
eslint-cache-version: v1
|
||||
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
@@ -100,6 +104,25 @@ jobs:
|
||||
- sw
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
|
||||
|
||||
check-dts:
|
||||
needs: [pnpm_install]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -111,5 +134,5 @@ jobs:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: pnpm --filter "${{ matrix.workspace }}^..." run build
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
|
||||
- run: node --test scripts/check-dts.test.mjs
|
||||
- run: pnpm check-dts
|
||||
|
||||
4
.github/workflows/locale.yml
vendored
4
.github/workflows/locale.yml
vendored
@@ -16,12 +16,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
|
||||
4
.github/workflows/on-release-created.yml
vendored
4
.github/workflows/on-release-created.yml
vendored
@@ -16,11 +16,11 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
2
.github/workflows/report-backend-memory.yml
vendored
2
.github/workflows/report-backend-memory.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
6
.github/workflows/storybook.yml
vendored
6
.github/workflows/storybook.yml
vendored
@@ -22,12 +22,12 @@ jobs:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
if: github.event_name != 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
if: github.event_name == 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)"
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
14
.github/workflows/test-backend.yml
vendored
14
.github/workflows/test-backend.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
ports:
|
||||
- 56312:6379
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.42.1
|
||||
image: getmeili/meilisearch:v1.48.1
|
||||
ports:
|
||||
- 57712:7700
|
||||
env:
|
||||
@@ -51,11 +51,11 @@ jobs:
|
||||
MEILI_ENV: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Install FFmpeg
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
@@ -103,11 +103,11 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
@@ -147,11 +147,11 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
2
.github/workflows/test-federation.yml
vendored
2
.github/workflows/test-federation.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Install FFmpeg
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
|
||||
10
.github/workflows/test-frontend.yml
vendored
10
.github/workflows/test-frontend.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
# https://github.com/cypress-io/cypress-docker-images/issues/150
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
#- uses: browser-actions/setup-firefox@latest
|
||||
# if: ${{ matrix.browser == 'firefox' }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
- name: Cypress install
|
||||
run: pnpm exec cypress install
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v7.1.9
|
||||
uses: cypress-io/github-action@v7.4.0
|
||||
timeout-minutes: 15
|
||||
with:
|
||||
install: false
|
||||
|
||||
4
.github/workflows/test-misskey-js.yml
vendored
4
.github/workflows/test-misskey-js.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
|
||||
4
.github/workflows/test-production.yml
vendored
4
.github/workflows/test-production.yml
vendored
@@ -16,11 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
4
.github/workflows/validate-api-json.yml
vendored
4
.github/workflows/validate-api-json.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.2
|
||||
- uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.3
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.18.0
|
||||
26.4.0
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,21 +1,36 @@
|
||||
## 2026.6.1
|
||||
## 2026.7.0
|
||||
|
||||
### Note
|
||||
|
||||
**今回のリリースではMisskeyの各種動作要件が変更されます。必ずアップグレード前にお使いの環境をご確認ください。**
|
||||
|
||||
- センシティブメディアの判定 (NSFW検出) が、本体に内蔵された nsfwjs による推論から、外部サービス [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) への HTTP 呼び出し方式に変更されました。
|
||||
- これに伴い、本体から `nsfwjs` / `@tensorflow/tfjs` / `@tensorflow/tfjs-node` および同梱の NSFW 判定モデルが削除され、インストール要件 (ネイティブ ML スタック) が緩和されました。
|
||||
- **センシティブ判定機能を利用しているサーバーは対応が必要です。** 別途 [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) サービスを立ち上げ、コントロールパネルの「モデレーション > センシティブなメディアの検出」で接続先 URL を設定してください。接続先が未設定の場合、センシティブ判定は行われません (すべて非センシティブ扱い)。
|
||||
- 画像の正規化・動画フレームの抽出・しきい値判定・集約は引き続き本体側で行われ、外部サービスには正規化済み画像の推論のみを委譲します。
|
||||
- Node.js v24, v26 をサポートしました。**Node.js v22 でも動作しますが、今後のリリースで v22 のサポートを終了する予定**ですので、Node.js のアップデートをご検討ください。
|
||||
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
|
||||
- Docker Image は Node.js 26.4.0-trixie に更新されています。
|
||||
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
|
||||
|
||||
### General
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
|
||||
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
|
||||
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
|
||||
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
|
||||
|
||||
## 2026.6.0
|
||||
|
||||
|
||||
@@ -600,6 +600,90 @@ TypeScriptでjsonをimportすると、tscでコンパイルするときにその
|
||||
コンポーネント自身がmarginを設定するのは問題の元となることはよく知られている
|
||||
marginはそのコンポーネントを使う側が設定する
|
||||
|
||||
### 命名規則
|
||||
|
||||
本来それが略称であっても、通常それでひとつのワードとして用いられるものは、略称として扱わない。
|
||||
|
||||
#### 例: IP address
|
||||
|
||||
Good: `ipAddress` / `IpAddress`
|
||||
|
||||
Bad: `IPAddress`
|
||||
|
||||
#### 例: User ID
|
||||
|
||||
Good: `userId` / `UserId`
|
||||
|
||||
Bad: `userID` / `UserID`
|
||||
|
||||
#### 例: XMLなHTTPのRequest
|
||||
|
||||
Good: `xmlHttpRequest` / `XmlHttpRequest`
|
||||
|
||||
Bad: `XMLHttpRequest` / `XMLHTTPRequest`
|
||||
|
||||
### 関数化の基準
|
||||
|
||||
汎用性が低く(例えばそれを関数化したとしてもその呼び出しが元の場所一か所しか存在しない)、内容も短い処理(例えば10行以下)は、かえって読みにくくなるため、関数化しない。
|
||||
|
||||
また、関数化する場合でも、呼び出しがある特定のスコープに限られる場合は、そのスコープ内に閉じ込めた方が分かりやすく簡潔になる場合がある(ただし本来その処理に不要であっても、構造上親のスコープにある関係のない変数や引数にもアクセスできるようになるため、必ずしもそうすれば設計上綺麗になるというわけでもない。状況に応じて判断すべし)。
|
||||
|
||||
Bad:
|
||||
|
||||
``` ts
|
||||
function withBrankets(x) {
|
||||
return `(${x})`;
|
||||
}
|
||||
|
||||
function formatPercent(x) {
|
||||
return `${x}%`;
|
||||
}
|
||||
|
||||
function formatValue(x) {
|
||||
return withBrankets(formatPercent(x));
|
||||
}
|
||||
|
||||
function showData(a, b) {
|
||||
console.log(formatValue(a));
|
||||
console.log(formatValue(b));
|
||||
}
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
``` ts
|
||||
function formatValue(x) {
|
||||
return `(${x}%)`;
|
||||
}
|
||||
|
||||
function showData(a, b) {
|
||||
console.log(formatValue(a));
|
||||
console.log(formatValue(b));
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
``` ts
|
||||
function showData(a, b) {
|
||||
function formatValue(x) {
|
||||
return `(${x}%)`;
|
||||
}
|
||||
|
||||
console.log(formatValue(a));
|
||||
console.log(formatValue(b));
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
``` ts
|
||||
function showData(a, b) {
|
||||
console.log(`(${a}%)`);
|
||||
console.log(`(${b}%)`);
|
||||
}
|
||||
```
|
||||
|
||||
## その他
|
||||
### HTMLのクラス名で follow という単語は使わない
|
||||
広告ブロッカーで誤ってブロックされる
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.23
|
||||
|
||||
ARG NODE_VERSION=22.22.2-bookworm
|
||||
ARG NODE_VERSION=26.4.0-trixie
|
||||
|
||||
# build assets & compile TypeScript
|
||||
|
||||
|
||||
@@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
|
||||
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
|
||||
append: "末尾に追加"
|
||||
prepend: "先頭に追加"
|
||||
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
||||
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.6.1-alpha.0",
|
||||
"version": "2026.7.0-alpha.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -39,7 +39,8 @@
|
||||
"migrateandstart": "pnpm migrate && pnpm start",
|
||||
"watch": "pnpm dev",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"lint": "pnpm --no-bail -r lint",
|
||||
"check-dts": "node scripts/check-dts.mjs",
|
||||
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
|
||||
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
|
||||
"cy:run": "pnpm cypress run",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
@@ -53,22 +54,19 @@
|
||||
"cleanall": "pnpm clean-all"
|
||||
},
|
||||
"dependencies": {
|
||||
"cssnano": "8.0.1",
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "8.0.0",
|
||||
"ignore-walk": "9.0.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"postcss": "8.5.15",
|
||||
"tar": "7.5.16",
|
||||
"terser": "5.48.0"
|
||||
"tar": "7.5.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.4",
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.13.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@types/node": "26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260426.1",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.17.0",
|
||||
@@ -76,7 +74,7 @@
|
||||
"globals": "17.6.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "11.8.0",
|
||||
"start-server-and-test": "3.0.9",
|
||||
"start-server-and-test": "3.0.11",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class UrlPreviewSensitiveList1782581064131 {
|
||||
name = 'UrlPreviewSensitiveList1782581064131'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewSensitiveList" character varying(3072) array NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewSensitiveList"`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.15.0 || ^24.10.0"
|
||||
"node": "^22.22.2 || ^24.17.0 || ^26.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "pnpm compile-config && node ./built/entry.js",
|
||||
@@ -51,8 +51,8 @@
|
||||
"utf-8-validate": "6.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1065.0",
|
||||
"@aws-sdk/lib-storage": "3.1065.0",
|
||||
"@aws-sdk/client-s3": "3.1073.0",
|
||||
"@aws-sdk/lib-storage": "3.1073.0",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/http-proxy": "11.5.0",
|
||||
@@ -61,25 +61,25 @@
|
||||
"@kitajs/html": "4.2.13",
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/sharp-read-bmp": "1.3.1",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@napi-rs/canvas": "1.0.0",
|
||||
"@nestjs/common": "11.1.26",
|
||||
"@nestjs/core": "11.1.26",
|
||||
"@nestjs/testing": "11.1.26",
|
||||
"@oxc-project/runtime": "0.135.0",
|
||||
"@nestjs/common": "11.1.27",
|
||||
"@nestjs/core": "11.1.27",
|
||||
"@nestjs/testing": "11.1.27",
|
||||
"@oxc-project/runtime": "0.137.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.57.0",
|
||||
"@sentry/profiling-node": "10.57.0",
|
||||
"@sentry/node": "10.59.0",
|
||||
"@sentry/profiling-node": "10.59.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@sinonjs/fake-timers": "15.4.0",
|
||||
"@smithy/node-http-handler": "4.7.7",
|
||||
"@smithy/node-http-handler": "4.8.1",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.20.0",
|
||||
"archiver": "8.0.0",
|
||||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bullmq": "5.78.0",
|
||||
"bullmq": "5.79.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"chalk": "5.6.2",
|
||||
"chalk-template": "1.1.2",
|
||||
@@ -103,36 +103,35 @@
|
||||
"is-svg": "6.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "9.0.0",
|
||||
"juice": "12.1.0",
|
||||
"juice": "12.1.1",
|
||||
"meilisearch": "0.58.0",
|
||||
"mfm-js": "0.26.0",
|
||||
"mime-types": "3.0.2",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"ms": "3.0.0-canary.202508261828",
|
||||
"nanoid": "5.1.11",
|
||||
"nanoid": "5.1.14",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "7.1.0",
|
||||
"nodemailer": "8.0.10",
|
||||
"nodemailer": "9.0.1",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.5.1",
|
||||
"pg": "8.21.0",
|
||||
"pg": "8.22.0",
|
||||
"pkce-challenge": "6.0.0",
|
||||
"probe-image-size": "7.3.0",
|
||||
"promise-limit": "2.7.0",
|
||||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.24.1",
|
||||
"re2": "1.25.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.2",
|
||||
"sanitize-html": "2.17.4",
|
||||
"sanitize-html": "2.17.5",
|
||||
"secure-json-parse": "4.1.0",
|
||||
"semver": "7.8.4",
|
||||
"sharp": "0.33.5",
|
||||
"semver": "7.8.5",
|
||||
"sharp": "0.35.2",
|
||||
"slacc": "0.1.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
@@ -149,9 +148,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kitajs/ts-html-plugin": "4.1.4",
|
||||
"@nestjs/platform-express": "11.1.26",
|
||||
"@nestjs/platform-express": "11.1.27",
|
||||
"@rollup/plugin-esm-shim": "0.1.8",
|
||||
"@sentry/vue": "10.57.0",
|
||||
"@sentry/vue": "10.59.0",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "8.0.0",
|
||||
"@types/fluent-ffmpeg": "2.1.28",
|
||||
@@ -160,8 +159,8 @@
|
||||
"@types/jsonld": "1.5.15",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "24.13.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/random-seed": "0.3.5",
|
||||
@@ -177,22 +176,22 @@
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cbor": "10.0.12",
|
||||
"cbor2": "2.3.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "9.6.1",
|
||||
"fkill": "10.0.3",
|
||||
"js-yaml": "4.2.0",
|
||||
"pid-port": "2.1.1",
|
||||
"rolldown": "1.1.0",
|
||||
"rolldown": "1.1.2",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.2.2",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.8",
|
||||
"vite": "8.1.0",
|
||||
"vitest": "4.1.9",
|
||||
"vitest-mock-extended": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,33 +46,20 @@ const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH;
|
||||
const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||
const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
|
||||
|
||||
const typedArrayNames = new Set([
|
||||
'ArrayBuffer',
|
||||
'SharedArrayBuffer',
|
||||
'DataView',
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float16Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'BigInt64Array',
|
||||
'BigUint64Array',
|
||||
'system / JSArrayBufferData',
|
||||
]);
|
||||
|
||||
const otherJsNodeTypes = new Set([
|
||||
'object',
|
||||
'closure',
|
||||
'regexp',
|
||||
'number',
|
||||
'symbol',
|
||||
'bigint',
|
||||
]);
|
||||
type GcMessage = 'gc ok' | 'gc unavailable';
|
||||
type RuntimeMemoryUsageMessage = {
|
||||
type: 'memory usage';
|
||||
value: NodeJS.MemoryUsage;
|
||||
};
|
||||
type HeapSnapshotMessage = {
|
||||
type: 'heap snapshot';
|
||||
path?: string;
|
||||
};
|
||||
type HeapSnapshotErrorMessage = {
|
||||
type: 'heap snapshot error';
|
||||
message: string;
|
||||
};
|
||||
type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
|
||||
|
||||
function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string, required: boolean): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
@@ -92,29 +79,6 @@ function bytesToKiB(value: number) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
function isTypedArrayNode(type, name) {
|
||||
return typedArrayNames.has(name) ||
|
||||
(type === 'native' && (name.includes('ArrayBuffer') || name.includes('TypedArray')));
|
||||
}
|
||||
|
||||
function isSystemNode(type, name) {
|
||||
return type === 'hidden' ||
|
||||
type === 'synthetic' ||
|
||||
type === 'object shape' ||
|
||||
name.startsWith('system /') ||
|
||||
name.startsWith('(system ');
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotNode(type, name): keyof typeof heapSnapshotCategory {
|
||||
if (type === 'code') return 'code';
|
||||
if (type === 'string' || type === 'concatenated string' || type === 'sliced string') return 'strings';
|
||||
if (isTypedArrayNode(type, name)) return 'typedArrays';
|
||||
if (type === 'array' || (type === 'object' && name === 'Array')) return 'jsArrays';
|
||||
if (isSystemNode(type, name)) return 'systemObjects';
|
||||
if (otherJsNodeTypes.has(type)) return 'otherJsObjects';
|
||||
return 'otherNonJsObjects';
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
@@ -126,17 +90,13 @@ function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCatego
|
||||
if (category === 'strings') return type;
|
||||
|
||||
if (category === 'jsArrays') {
|
||||
if (type === 'array') return 'array nodes';
|
||||
if (type === 'array elements') return 'Array elements';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'typedArrays') {
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
if (name === 'Uint8Array') return 'Uint8Array / Buffer';
|
||||
if (typedArrayNames.has(name)) return name;
|
||||
if (type === 'native' && name.includes('ArrayBuffer')) return 'native ArrayBuffer';
|
||||
if (type === 'native' && name.includes('TypedArray')) return 'native TypedArray';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
@@ -152,6 +112,7 @@ function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCatego
|
||||
}
|
||||
|
||||
if (category === 'otherNonJsObjects') {
|
||||
if (type === 'extra native bytes') return 'Extra native bytes';
|
||||
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
@@ -189,32 +150,56 @@ function collapseHeapSnapshotBreakdown(breakdowns: Record<string, Record<string,
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
|
||||
function analyzeHeapSnapshot(snapshot) {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const nodes = snapshot?.nodes;
|
||||
const edges = snapshot?.edges;
|
||||
const strings = snapshot?.strings;
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(strings)) {
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
const edgeFields = meta.edge_fields;
|
||||
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0) {
|
||||
const edgeCountOffset = nodeFields.indexOf('edge_count');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required node fields');
|
||||
}
|
||||
const edgeTypeOffset = edgeFields.indexOf('type');
|
||||
const edgeNameOffset = edgeFields.indexOf('name_or_index');
|
||||
const edgeToNodeOffset = edgeFields.indexOf('to_node');
|
||||
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required edge fields');
|
||||
}
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
|
||||
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
|
||||
|
||||
function createEmptyHeapSnapshotCategoryMap() {
|
||||
return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record<keyof typeof heapSnapshotCategory, number>;
|
||||
}
|
||||
|
||||
const fieldCount = nodeFields.length;
|
||||
const nodeFieldCount = nodeFields.length;
|
||||
const edgeFieldCount = edgeFields.length;
|
||||
const nativeType = nodeTypeNames.indexOf('native');
|
||||
const codeType = nodeTypeNames.indexOf('code');
|
||||
const hiddenType = nodeTypeNames.indexOf('hidden');
|
||||
const stringTypes = new Set([
|
||||
nodeTypeNames.indexOf('string'),
|
||||
nodeTypeNames.indexOf('concatenated string'),
|
||||
nodeTypeNames.indexOf('sliced string'),
|
||||
]);
|
||||
const internalEdgeType = edgeTypeNames.indexOf('internal');
|
||||
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
|
||||
const categories = createEmptyHeapSnapshotCategoryMap();
|
||||
const nodeCounts = createEmptyHeapSnapshotCategoryMap();
|
||||
const breakdowns = Object.fromEntries(
|
||||
@@ -227,17 +212,104 @@ function analyzeHeapSnapshot(snapshot) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
|
||||
const type = nodeTypeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||
const name = strings[nodes[offset + nameOffset]] ?? '';
|
||||
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
|
||||
const category = classifyHeapSnapshotNode(type, name);
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
let edgeIndex = 0;
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
edgeStartIndexes.set(nodeIndex, edgeIndex);
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
|
||||
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
|
||||
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
categories[category] += selfSize;
|
||||
const jsArrayElementNodeIndexes = new Set<number>();
|
||||
|
||||
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
|
||||
if (value <= 0) return;
|
||||
categories[category] += value;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
|
||||
if (nodeIndex != null) nodeCounts[category]++;
|
||||
}
|
||||
|
||||
function addJsArrayElementSize(nodeIndex: number) {
|
||||
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
|
||||
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
|
||||
if (edgeType !== internalEdgeType) continue;
|
||||
|
||||
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
|
||||
if (edgeName !== 'elements') continue;
|
||||
|
||||
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
|
||||
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
|
||||
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
|
||||
jsArrayElementNodeIndexes.add(elementsNodeIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (extraNativeBytes > 0) {
|
||||
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
|
||||
}
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
categories.total += selfSize;
|
||||
nodeCounts[category]++;
|
||||
nodeCounts.total++;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), selfSize);
|
||||
|
||||
if (typeId === hiddenType) {
|
||||
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === nativeType) {
|
||||
if (name === 'system / JSArrayBufferData') {
|
||||
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
|
||||
} else {
|
||||
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === codeType) {
|
||||
addCategoryValue('code', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stringTypes.has(typeId)) {
|
||||
addCategoryValue('strings', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'Array') {
|
||||
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
|
||||
addJsArrayElementSize(nodeIndex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
categories.total += extraNativeBytes;
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
|
||||
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
|
||||
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
if (name === 'Array') continue;
|
||||
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -259,14 +331,32 @@ async function getSmapsRollupMemoryUsage(pid: number) {
|
||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path, false);
|
||||
}
|
||||
|
||||
function waitForMessage(serverProcess: ChildProcess, predicate: (message: any) => boolean, description: string, timeout = IPC_TIMEOUT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isGcMessage(message: unknown): message is GcMessage {
|
||||
return message === 'gc ok' || message === 'gc unavailable';
|
||||
}
|
||||
|
||||
function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage {
|
||||
return isRecord(message) && message.type === 'memory usage' && isRecord(message.value);
|
||||
}
|
||||
|
||||
function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage {
|
||||
if (!isRecord(message)) return false;
|
||||
if (message.type === 'heap snapshot') return true;
|
||||
return message.type === 'heap snapshot error' && typeof message.message === 'string';
|
||||
}
|
||||
|
||||
function waitForMessage<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
serverProcess.off('message', onMessage);
|
||||
reject(new Error(`Timed out waiting for ${description}`));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (message: any) => {
|
||||
const onMessage = (message: unknown) => {
|
||||
if (!predicate(message)) return;
|
||||
globalThis.clearTimeout(timer);
|
||||
serverProcess.off('message', onMessage);
|
||||
@@ -280,7 +370,7 @@ function waitForMessage(serverProcess: ChildProcess, predicate: (message: any) =
|
||||
async function getRuntimeMemoryUsage(serverProcess: ChildProcess) {
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && message.type === 'memory usage',
|
||||
isRuntimeMemoryUsageMessage,
|
||||
'memory usage',
|
||||
);
|
||||
|
||||
@@ -303,7 +393,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise<H
|
||||
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
message => message != null && typeof message === 'object' && (message.type === 'heap snapshot' || message.type === 'heap snapshot error'),
|
||||
isHeapSnapshotResponseMessage,
|
||||
'heap snapshot',
|
||||
HEAP_SNAPSHOT_TIMEOUT,
|
||||
);
|
||||
@@ -385,7 +475,7 @@ async function measureMemory() {
|
||||
async function triggerGc() {
|
||||
const ok = waitForMessage(
|
||||
serverProcess,
|
||||
message => message === 'gc ok' || message === 'gc unavailable',
|
||||
isGcMessage,
|
||||
'GC completion',
|
||||
);
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ declare module 'probe-image-size' {
|
||||
function probeImageSize(src: string | ReadStream, callback: (err: Error | null, result?: ProbeResult) => void): void;
|
||||
function probeImageSize(src: string | ReadStream, options: ProbeOptions, callback: (err: Error | null, result?: ProbeResult) => void): void;
|
||||
|
||||
namespace probeImageSize {} // Hack
|
||||
|
||||
export = probeImageSize;
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { probeImageSize as default };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { loadConfig } from '@/config.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { showMachineInfo } from '@/misc/show-machine-info.js';
|
||||
import { envOption } from '@/env.js';
|
||||
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
|
||||
import { initExtraThreadPool, jobQueue, server } from './common.js';
|
||||
|
||||
const logger = new Logger('core', 'cyan');
|
||||
@@ -66,26 +67,7 @@ export async function masterMain() {
|
||||
|
||||
initExtraThreadPool(config);
|
||||
|
||||
if (config.sentryForBackend) {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
|
||||
],
|
||||
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 1.0,
|
||||
|
||||
maxBreadcrumbs: 0,
|
||||
|
||||
...config.sentryForBackend.options,
|
||||
});
|
||||
}
|
||||
await initTelemetry(config);
|
||||
|
||||
bootLogger.info(
|
||||
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import cluster from 'node:cluster';
|
||||
import { envOption } from '@/env.js';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { initTelemetry } from '@/core/telemetry/telemetry-registry.js';
|
||||
import { initExtraThreadPool, jobQueue, server } from './common.js';
|
||||
|
||||
/**
|
||||
@@ -16,26 +17,7 @@ export async function workerMain() {
|
||||
|
||||
initExtraThreadPool(config);
|
||||
|
||||
if (config.sentryForBackend) {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
|
||||
],
|
||||
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 1.0,
|
||||
|
||||
maxBreadcrumbs: 0,
|
||||
|
||||
...config.sentryForBackend.options,
|
||||
});
|
||||
}
|
||||
await initTelemetry(config);
|
||||
|
||||
if (envOption.onlyServer) {
|
||||
await server();
|
||||
|
||||
@@ -137,7 +137,8 @@ export class AiService {
|
||||
try {
|
||||
const form = new FormData();
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
form.append(`image${i}`, new Blob([chunk[i]], { type: 'image/png' }), `${i}.png`);
|
||||
const image = Uint8Array.from(chunk[i]);
|
||||
form.append(`image${i}`, new Blob([image], { type: 'image/png' }), `${i}.png`);
|
||||
}
|
||||
|
||||
// Content-Type は FormData から boundary 付きで自動設定させるため、手動設定はしない。
|
||||
|
||||
@@ -154,10 +154,12 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
|
||||
import { QueueModule } from './QueueModule.js';
|
||||
import { QueueService } from './QueueService.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
import { TelemetryService } from './telemetry/TelemetryService.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
|
||||
const $TelemetryService: Provider = { provide: 'TelemetryService', useExisting: TelemetryService };
|
||||
const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService };
|
||||
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
|
||||
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
|
||||
@@ -458,6 +460,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
ApPersonService,
|
||||
ApQuestionService,
|
||||
QueueService,
|
||||
TelemetryService,
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$LoggerService,
|
||||
@@ -606,6 +609,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$ApNoteService,
|
||||
$ApPersonService,
|
||||
$ApQuestionService,
|
||||
$TelemetryService,
|
||||
//#endregion
|
||||
],
|
||||
exports: [
|
||||
@@ -757,6 +761,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
ApPersonService,
|
||||
ApQuestionService,
|
||||
QueueService,
|
||||
TelemetryService,
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$LoggerService,
|
||||
@@ -903,6 +908,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$ApNoteService,
|
||||
$ApPersonService,
|
||||
$ApQuestionService,
|
||||
$TelemetryService,
|
||||
//#endregion
|
||||
],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import type { Sharp } from 'sharp';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
|
||||
@@ -300,7 +301,7 @@ export class DriveService {
|
||||
};
|
||||
}
|
||||
|
||||
let img: sharp.Sharp | null = null;
|
||||
let img: Sharp | null = null;
|
||||
let satisfyWebpublic: boolean;
|
||||
let isAnimated: boolean;
|
||||
|
||||
|
||||
@@ -5,20 +5,28 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiHashtag } from '@/models/Hashtag.js';
|
||||
import { MiHashtag } from '@/models/Hashtag.js';
|
||||
import type { HashtagsRepository, MiMeta } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
const logger = new Logger('hashtag/create');
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@@ -60,19 +68,74 @@ export class HashtagService {
|
||||
// TODO: サンプリング
|
||||
this.updateHashtagsRanking(tag, user.id);
|
||||
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
{
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (index == null && !inc) return;
|
||||
if (index == null && inc) {
|
||||
try {
|
||||
if (isUserAttached) {
|
||||
await this.hashtagsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
name: tag,
|
||||
mentionedUserIds: [],
|
||||
mentionedUsersCount: 0,
|
||||
mentionedLocalUserIds: [],
|
||||
mentionedLocalUsersCount: 0,
|
||||
mentionedRemoteUserIds: [],
|
||||
mentionedRemoteUsersCount: 0,
|
||||
attachedUserIds: [user.id],
|
||||
attachedUsersCount: 1,
|
||||
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
} as MiHashtag);
|
||||
} else {
|
||||
await this.hashtagsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
name: tag,
|
||||
mentionedUserIds: [user.id],
|
||||
mentionedUsersCount: 1,
|
||||
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
attachedUserIds: [],
|
||||
attachedUsersCount: 0,
|
||||
attachedLocalUserIds: [],
|
||||
attachedLocalUsersCount: 0,
|
||||
attachedRemoteUserIds: [],
|
||||
attachedRemoteUsersCount: 0,
|
||||
} as MiHashtag);
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
if (isDuplicateKeyValueError(err)) {
|
||||
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (index != null) {
|
||||
const q = this.hashtagsRepository.createQueryBuilder('tag').update()
|
||||
.where('name = :name', { name: tag });
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const transactionalHashtagRepository = transactionalEntityManager
|
||||
.getRepository(MiHashtag);
|
||||
|
||||
const index = await transactionalHashtagRepository
|
||||
.createQueryBuilder()
|
||||
.setLock('pessimistic_write')
|
||||
.where('name = :name', { name: tag })
|
||||
.getOne();
|
||||
|
||||
if (index == null) return;
|
||||
|
||||
const set = {} as any;
|
||||
|
||||
if (isUserAttached) {
|
||||
if (inc) {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.attachedUserIds.some(id => id === user.id)) {
|
||||
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
|
||||
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
|
||||
@@ -117,46 +180,14 @@ export class HashtagService {
|
||||
}
|
||||
|
||||
if (Object.keys(set).length > 0) {
|
||||
q.set(set);
|
||||
q.execute();
|
||||
await transactionalHashtagRepository
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.where('id = :id', { id: index.id })
|
||||
.set(set)
|
||||
.execute();
|
||||
}
|
||||
} else {
|
||||
if (isUserAttached) {
|
||||
this.hashtagsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
name: tag,
|
||||
mentionedUserIds: [],
|
||||
mentionedUsersCount: 0,
|
||||
mentionedLocalUserIds: [],
|
||||
mentionedLocalUsersCount: 0,
|
||||
mentionedRemoteUserIds: [],
|
||||
mentionedRemoteUsersCount: 0,
|
||||
attachedUserIds: [user.id],
|
||||
attachedUsersCount: 1,
|
||||
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
} as MiHashtag);
|
||||
} else {
|
||||
this.hashtagsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
name: tag,
|
||||
mentionedUserIds: [user.id],
|
||||
mentionedUsersCount: 1,
|
||||
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
attachedUserIds: [],
|
||||
attachedUsersCount: 0,
|
||||
attachedLocalUserIds: [],
|
||||
attachedLocalUsersCount: 0,
|
||||
attachedRemoteUserIds: [],
|
||||
attachedRemoteUsersCount: 0,
|
||||
} as MiHashtag);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import type { Sharp, WebpOptions, AvifOptions } from 'sharp';
|
||||
|
||||
export type IImage = {
|
||||
data: Buffer;
|
||||
@@ -19,14 +20,14 @@ export type IImageStream = {
|
||||
};
|
||||
|
||||
export type IImageSharp = {
|
||||
data: sharp.Sharp;
|
||||
data: Sharp;
|
||||
ext: string | null;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type IImageStreamable = IImage | IImageStream | IImageSharp;
|
||||
|
||||
export const webpDefault: sharp.WebpOptions = {
|
||||
export const webpDefault: WebpOptions = {
|
||||
quality: 77,
|
||||
alphaQuality: 95,
|
||||
lossless: false,
|
||||
@@ -37,7 +38,7 @@ export const webpDefault: sharp.WebpOptions = {
|
||||
loop: 0,
|
||||
};
|
||||
|
||||
export const avifDefault: sharp.AvifOptions = {
|
||||
export const avifDefault: AvifOptions = {
|
||||
quality: 60,
|
||||
lossless: false,
|
||||
effort: 2,
|
||||
@@ -57,12 +58,12 @@ export class ImageProcessingService {
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
@bindThis
|
||||
public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
|
||||
public async convertToWebp(path: string, width: number, height: number, options: WebpOptions = webpDefault): Promise<IImage> {
|
||||
return this.convertSharpToWebp(sharp(path), width, height, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
|
||||
public async convertSharpToWebp(sharp: Sharp, width: number, height: number, options: WebpOptions = webpDefault): Promise<IImage> {
|
||||
const result = this.convertSharpToWebpStream(sharp, width, height, options);
|
||||
|
||||
return {
|
||||
@@ -73,12 +74,12 @@ export class ImageProcessingService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp {
|
||||
public convertToWebpStream(path: string, width: number, height: number, options: WebpOptions = webpDefault): IImageSharp {
|
||||
return this.convertSharpToWebpStream(sharp(path), width, height, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp {
|
||||
public convertSharpToWebpStream(sharp: Sharp, width: number, height: number, options: WebpOptions = webpDefault): IImageSharp {
|
||||
const data = sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
@@ -99,12 +100,12 @@ export class ImageProcessingService {
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
@bindThis
|
||||
public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
|
||||
public async convertToAvif(path: string, width: number, height: number, options: AvifOptions = avifDefault): Promise<IImage> {
|
||||
return this.convertSharpToAvif(sharp(path), width, height, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> {
|
||||
public async convertSharpToAvif(sharp: Sharp, width: number, height: number, options: AvifOptions = avifDefault): Promise<IImage> {
|
||||
const result = this.convertSharpToAvifStream(sharp, width, height, options);
|
||||
|
||||
return {
|
||||
@@ -115,12 +116,12 @@ export class ImageProcessingService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp {
|
||||
public convertToAvifStream(path: string, width: number, height: number, options: AvifOptions = avifDefault): IImageSharp {
|
||||
return this.convertSharpToAvifStream(sharp(path), width, height, options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp {
|
||||
public convertSharpToAvifStream(sharp: Sharp, width: number, height: number, options: AvifOptions = avifDefault): IImageSharp {
|
||||
const data = sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
@@ -146,7 +147,7 @@ export class ImageProcessingService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
|
||||
public async convertSharpToPng(sharp: Sharp, width: number, height: number): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
|
||||
28
packages/backend/src/core/telemetry/TelemetryService.ts
Normal file
28
packages/backend/src/core/telemetry/TelemetryService.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
|
||||
@Injectable()
|
||||
export class TelemetryService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
|
||||
captureMessage(message, opts);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public startSpan<T>(name: string, fn: () => T): T {
|
||||
return startSpan(name, fn);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(_signal?: string): Promise<void> {
|
||||
await shutdownTelemetry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
|
||||
export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
private constructor(
|
||||
private readonly Sentry: typeof import('@sentry/node'),
|
||||
) {
|
||||
}
|
||||
|
||||
public static async create(config: NonNullable<Config['sentryForBackend']>): Promise<SentryTelemetryAdapter> {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
...(config.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
|
||||
],
|
||||
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 1.0,
|
||||
|
||||
maxBreadcrumbs: 0,
|
||||
|
||||
...config.options,
|
||||
});
|
||||
|
||||
return new SentryTelemetryAdapter(Sentry);
|
||||
}
|
||||
|
||||
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
|
||||
this.Sentry.captureMessage(message, {
|
||||
level: opts.level,
|
||||
...(opts.userId != null ? { user: { id: opts.userId } } : {}),
|
||||
extra: opts.extra,
|
||||
});
|
||||
}
|
||||
|
||||
public startSpan<T>(name: string, fn: () => T): T {
|
||||
return this.Sentry.startSpan({ name }, fn);
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
await this.Sentry.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface TelemetryCaptureMessageOptions {
|
||||
level: 'error';
|
||||
userId?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
|
||||
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
|
||||
* telemetry-registry.tsのinitTelemetry内で登録する。
|
||||
*/
|
||||
export interface TelemetryAdapter {
|
||||
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
|
||||
startSpan<T>(name: string, fn: () => T): T;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
39
packages/backend/src/core/telemetry/telemetry-registry.ts
Normal file
39
packages/backend/src/core/telemetry/telemetry-registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
|
||||
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
|
||||
/**
|
||||
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
|
||||
* DIを介さないモジュールレベルの状態として有効なアダプタを保持する。
|
||||
* TelemetryServiceはこの状態への薄いラッパーとして振る舞う。
|
||||
*/
|
||||
const adapters: TelemetryAdapter[] = [];
|
||||
|
||||
export async function initTelemetry(config: Config): Promise<void> {
|
||||
if (config.sentryForBackend) {
|
||||
adapters.push(await SentryTelemetryAdapter.create(config.sentryForBackend));
|
||||
}
|
||||
}
|
||||
|
||||
export function captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
|
||||
for (const adapter of adapters) {
|
||||
adapter.captureMessage(message, opts);
|
||||
}
|
||||
}
|
||||
|
||||
export function startSpan<T>(name: string, fn: () => T): T {
|
||||
const wrapped = adapters.reduceRight<() => T>(
|
||||
(inner, adapter) => () => adapter.startSpan(name, inner),
|
||||
fn,
|
||||
);
|
||||
return wrapped();
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
await Promise.all(adapters.map(adapter => adapter.shutdown()));
|
||||
}
|
||||
@@ -672,6 +672,11 @@ export class MiMeta {
|
||||
})
|
||||
public urlPreviewUserAgent: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 3072, array: true, default: '{}',
|
||||
})
|
||||
public urlPreviewSensitiveList: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
default: 'none',
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
@@ -92,6 +93,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private config: Config,
|
||||
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private telemetryService: TelemetryService,
|
||||
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
|
||||
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
|
||||
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
|
||||
@@ -156,13 +158,6 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
}
|
||||
|
||||
let Sentry: typeof import('@sentry/node') | undefined;
|
||||
if (this.config.sentryForBackend) {
|
||||
import('@sentry/node').then((mod) => {
|
||||
Sentry = mod;
|
||||
});
|
||||
}
|
||||
|
||||
//#region system
|
||||
{
|
||||
const processer = (job: Bull.Job) => {
|
||||
@@ -181,11 +176,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
|
||||
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job));
|
||||
} else {
|
||||
return processer(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: System: ' + job.name, () => processer(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.SYSTEM),
|
||||
autorun: false,
|
||||
@@ -198,12 +189,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
|
||||
.on('failed', (job, err: Error) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -238,11 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
|
||||
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job));
|
||||
} else {
|
||||
return processer(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: DB: ' + job.name, () => processer(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.DB),
|
||||
autorun: false,
|
||||
@@ -255,12 +240,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -270,11 +253,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
//#region deliver
|
||||
{
|
||||
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job));
|
||||
} else {
|
||||
return this.deliverProcessorService.process(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: Deliver', () => this.deliverProcessorService.process(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.DELIVER),
|
||||
autorun: false,
|
||||
@@ -295,12 +274,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -310,11 +287,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
//#region inbox
|
||||
{
|
||||
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job));
|
||||
} else {
|
||||
return this.inboxProcessorService.process(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: Inbox', () => this.inboxProcessorService.process(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.INBOX),
|
||||
autorun: false,
|
||||
@@ -335,12 +308,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -350,11 +321,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
//#region user-webhook deliver
|
||||
{
|
||||
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job));
|
||||
} else {
|
||||
return this.userWebhookDeliverProcessorService.process(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: UserWebhookDeliver', () => this.userWebhookDeliverProcessorService.process(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER),
|
||||
autorun: false,
|
||||
@@ -375,12 +342,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -390,11 +355,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
//#region system-webhook deliver
|
||||
{
|
||||
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job));
|
||||
} else {
|
||||
return this.systemWebhookDeliverProcessorService.process(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: SystemWebhookDeliver', () => this.systemWebhookDeliverProcessorService.process(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER),
|
||||
autorun: false,
|
||||
@@ -415,12 +376,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -440,11 +399,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
|
||||
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job));
|
||||
} else {
|
||||
return processer(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: Relationship: ' + job.name, () => processer(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP),
|
||||
autorun: false,
|
||||
@@ -462,12 +417,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -485,11 +438,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
|
||||
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job));
|
||||
} else {
|
||||
return processer(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: ObjectStorage: ' + job.name, () => processer(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE),
|
||||
autorun: false,
|
||||
@@ -503,12 +452,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
|
||||
.on('failed', (job, err) => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
|
||||
if (Sentry != null) {
|
||||
Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
}
|
||||
this.telemetryService.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
extra: { job, err },
|
||||
});
|
||||
})
|
||||
.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
|
||||
@@ -518,11 +465,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
//#region ended poll notification
|
||||
{
|
||||
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job));
|
||||
} else {
|
||||
return this.endedPollNotificationProcessorService.process(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: EndedPollNotification', () => this.endedPollNotificationProcessorService.process(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
|
||||
autorun: false,
|
||||
@@ -533,11 +476,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
//#region post scheduled note
|
||||
{
|
||||
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
|
||||
if (Sentry != null) {
|
||||
return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
|
||||
} else {
|
||||
return this.postScheduledNoteProcessorService.process(job);
|
||||
}
|
||||
return this.telemetryService.startSpan('Queue: PostScheduledNote', () => this.postScheduledNoteProcessorService.process(job));
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
|
||||
autorun: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { MiMeta, UserIpsRepository } from '@/models/_.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApiError } from './error.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
@@ -36,7 +37,6 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
private logger: Logger;
|
||||
private userIpHistories: Map<MiUser['id'], Set<string>>;
|
||||
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
|
||||
private Sentry: typeof import('@sentry/node') | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
@@ -52,6 +52,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private roleService: RoleService,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
private telemetryService: TelemetryService,
|
||||
) {
|
||||
this.logger = this.apiLoggerService.logger;
|
||||
this.userIpHistories = new Map<MiUser['id'], Set<string>>();
|
||||
@@ -59,12 +60,6 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
this.userIpHistoriesClearIntervalId = setInterval(() => {
|
||||
this.userIpHistories.clear();
|
||||
}, 1000 * 60 * 60);
|
||||
|
||||
if (this.config.sentryForBackend) {
|
||||
import('@sentry/node').then((Sentry) => {
|
||||
this.Sentry = Sentry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#sendApiError(reply: FastifyReply, err: ApiError): void {
|
||||
@@ -126,24 +121,20 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
},
|
||||
});
|
||||
|
||||
if (this.Sentry != null) {
|
||||
this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
user: {
|
||||
id: userId,
|
||||
this.telemetryService.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
|
||||
level: 'error',
|
||||
userId,
|
||||
extra: {
|
||||
ep: ep.name,
|
||||
ps: data,
|
||||
e: {
|
||||
message: err.message,
|
||||
code: err.name,
|
||||
stack: err.stack,
|
||||
id: errId,
|
||||
},
|
||||
extra: {
|
||||
ep: ep.name,
|
||||
ps: data,
|
||||
e: {
|
||||
message: err.message,
|
||||
code: err.name,
|
||||
stack: err.stack,
|
||||
id: errId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
throw new ApiError(null, {
|
||||
e: {
|
||||
@@ -441,15 +432,8 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
// API invoking
|
||||
if (this.Sentry != null) {
|
||||
return await this.Sentry.startSpan({
|
||||
name: 'API: ' + ep.name,
|
||||
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
|
||||
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
|
||||
} else {
|
||||
return await ep.exec(data, user, token, file, request.ip, request.headers)
|
||||
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
|
||||
}
|
||||
return await this.telemetryService.startSpan('API: ' + ep.name, () => ep.exec(data, user, token, file, request.ip, request.headers)
|
||||
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -544,6 +544,14 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
urlPreviewSensitiveList: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
federation: {
|
||||
type: 'string',
|
||||
enum: ['all', 'specified', 'none'],
|
||||
@@ -760,6 +768,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
|
||||
urlPreviewUserAgent: instance.urlPreviewUserAgent,
|
||||
urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
|
||||
urlPreviewSensitiveList: instance.urlPreviewSensitiveList,
|
||||
federation: instance.federation,
|
||||
federationHosts: instance.federationHosts,
|
||||
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
|
||||
|
||||
@@ -189,6 +189,12 @@ export const paramDef = {
|
||||
urlPreviewRequireContentLength: { type: 'boolean' },
|
||||
urlPreviewUserAgent: { type: 'string', nullable: true },
|
||||
urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
|
||||
urlPreviewSensitiveList: {
|
||||
type: 'array', nullable: true,
|
||||
items: {
|
||||
type: 'string',
|
||||
}
|
||||
},
|
||||
federation: {
|
||||
type: 'string',
|
||||
enum: ['all', 'none', 'specified'],
|
||||
@@ -734,6 +740,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
|
||||
}
|
||||
|
||||
if (Array.isArray(ps.urlPreviewSensitiveList)) {
|
||||
set.urlPreviewSensitiveList = ps.urlPreviewSensitiveList.filter(Boolean);
|
||||
}
|
||||
|
||||
if (ps.federation !== undefined) {
|
||||
set.federation = ps.federation;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ export const meta = {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
reactionsCount: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
//originalReactionsCount: {
|
||||
// type: 'number',
|
||||
// optional: false, nullable: false,
|
||||
//},
|
||||
instances: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
@@ -29,6 +30,7 @@ export class UrlPreviewService {
|
||||
private meta: MiMeta,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
private utilityService: UtilityService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('url-preview');
|
||||
@@ -95,6 +97,10 @@ export class UrlPreviewService {
|
||||
summary.icon = this.wrap(summary.icon);
|
||||
summary.thumbnail = this.wrap(summary.thumbnail);
|
||||
|
||||
if (summary.sensitive !== true) {
|
||||
summary.sensitive = this.utilityService.isKeyWordIncluded(summary.url, this.meta.urlPreviewSensitiveList);
|
||||
}
|
||||
|
||||
// Cache 1day
|
||||
reply.header('Cache-Control', 'max-age=86400, immutable');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as crypto from 'node:crypto';
|
||||
import cbor from 'cbor';
|
||||
import { encode as encodeToCbor } from 'cbor2';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { api, signup, sendEnvUpdateRequest } from '../utils.js';
|
||||
@@ -61,7 +61,7 @@ describe('2要素認証', () => {
|
||||
const keyDoneParam = (param: {
|
||||
token: string,
|
||||
keyName: string,
|
||||
credentialId: Buffer,
|
||||
credentialId: Uint8Array,
|
||||
creationOptions: PublicKeyCredentialCreationOptionsJSON,
|
||||
}): {
|
||||
token: string,
|
||||
@@ -70,10 +70,10 @@ describe('2要素認証', () => {
|
||||
credential: RegistrationResponseJSON,
|
||||
} => {
|
||||
// A COSE encoded public key
|
||||
const credentialPublicKey = cbor.encode(new Map<number, unknown>([
|
||||
const credentialPublicKey = encodeToCbor(new Map<number, unknown>([
|
||||
[-1, coseEc2CrvP256],
|
||||
[-2, Buffer.from(coseEc2X, 'hex')],
|
||||
[-3, Buffer.from(coseEc2Y, 'hex')],
|
||||
[-2, Uint8Array.from(Buffer.from(coseEc2X, 'hex'))],
|
||||
[-3, Uint8Array.from(Buffer.from(coseEc2Y, 'hex'))],
|
||||
[1, coseKtyEc2],
|
||||
[2, coseKid],
|
||||
[3, coseAlgEs256],
|
||||
@@ -85,21 +85,23 @@ describe('2要素認証', () => {
|
||||
credentialIdLength.writeUInt16BE(param.credentialId.length, 0);
|
||||
const authData = Buffer.concat([
|
||||
rpIdHash(), // rpIdHash(32)
|
||||
Buffer.from([0x45]), // flags(1)
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4)
|
||||
Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16)
|
||||
new Uint8Array([0x45]), // flags(1)
|
||||
new Uint8Array(4), // signCount(4)
|
||||
new Uint8Array(16), // AAGUID(16)
|
||||
credentialIdLength,
|
||||
param.credentialId,
|
||||
credentialPublicKey,
|
||||
]);
|
||||
|
||||
const credentialIdBase64url = Buffer.from(param.credentialId).toString('base64url');
|
||||
|
||||
return {
|
||||
password,
|
||||
token: param.token,
|
||||
name: param.keyName,
|
||||
credential: <RegistrationResponseJSON>{
|
||||
id: param.credentialId.toString('base64url'),
|
||||
rawId: param.credentialId.toString('base64url'),
|
||||
id: credentialIdBase64url,
|
||||
rawId: credentialIdBase64url,
|
||||
response: <AuthenticatorAttestationResponseJSON>{
|
||||
clientDataJSON: Buffer.from(JSON.stringify({
|
||||
type: 'webauthn.create',
|
||||
@@ -107,11 +109,11 @@ describe('2要素認証', () => {
|
||||
origin: config.scheme + '://' + config.host,
|
||||
androidPackageName: 'org.mozilla.firefox',
|
||||
}), 'utf-8').toString('base64url'),
|
||||
attestationObject: cbor.encode({
|
||||
attestationObject: Buffer.from(encodeToCbor({
|
||||
fmt: 'none',
|
||||
attStmt: {},
|
||||
authData,
|
||||
}).toString('base64url'),
|
||||
authData: new Uint8Array(authData),
|
||||
})).toString('base64url'),
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
type: 'public-key',
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { describe, beforeAll, beforeEach, test } from 'vitest';
|
||||
import { describe, beforeAll, beforeEach, test, vi } from 'vitest';
|
||||
import { UserToken, api, post, signup } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
|
||||
|
||||
describe('API visibility', () => {
|
||||
describe('Note visibility', () => {
|
||||
//#region vars
|
||||
@@ -409,10 +411,12 @@ describe('API visibility', () => {
|
||||
|
||||
//#region HTL
|
||||
test('[HTL] public-post が 自分が見れる', async () => {
|
||||
const res = await api('notes/timeline', { limit: 100 }, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter(n => n.id === pub.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/timeline', { limit: 100 }, alice);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter(n => n.id === pub.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
|
||||
test('[HTL] public-post が 非フォロワーから見れない', async () => {
|
||||
@@ -423,10 +427,12 @@ describe('API visibility', () => {
|
||||
});
|
||||
|
||||
test('[HTL] followers-post が フォロワーから見れる', async () => {
|
||||
const res = await api('notes/timeline', { limit: 100 }, follower);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter(n => n.id === fol.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/timeline', { limit: 100 }, follower);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const notes = res.body.filter(n => n.id === fol.id);
|
||||
assert.strictEqual(notes[0].text, 'x');
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { describe, beforeAll, test } from 'vitest';
|
||||
import { describe, beforeAll, test, vi } from 'vitest';
|
||||
import { api, castAsError, post, signup } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
|
||||
|
||||
describe('Block', () => {
|
||||
// alice blocks bob
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
@@ -75,13 +77,15 @@ describe('Block', () => {
|
||||
const bobNote = await post(bob, { text: 'hi' });
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
|
||||
const res = await api('notes/local-timeline', {}, bob);
|
||||
const body = res.body as misskey.entities.Note[];
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/local-timeline', {}, bob);
|
||||
const body = res.body as misskey.entities.Note[];
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
|
||||
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(body.some(note => note.id === aliceNote.id), false);
|
||||
assert.strictEqual(body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(body.some(note => note.id === carolNote.id), true);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { describe, beforeAll, test, expect } from 'vitest';
|
||||
import { describe, beforeAll, test, expect, vi } from 'vitest';
|
||||
// node-fetch only supports it's own Blob yet
|
||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||
import { Blob } from 'node-fetch';
|
||||
@@ -14,6 +14,8 @@ import { api, castAsError, initTestDb, post, role, signup, simpleGet, uploadFile
|
||||
import type * as misskey from 'misskey-js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
|
||||
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
|
||||
|
||||
describe('Endpoints', () => {
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
let bob: misskey.entities.SignupResponse;
|
||||
@@ -1149,12 +1151,14 @@ describe('Endpoints', () => {
|
||||
visibility: 'followers',
|
||||
});
|
||||
|
||||
const res = await api('notes/timeline', {}, dave);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/timeline', {}, dave);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.length, 1);
|
||||
assert.strictEqual(res.body[0].id, carolPost.id);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.length, 1);
|
||||
assert.strictEqual(res.body[0].id, carolPost.id);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ import { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import * as assert from 'assert';
|
||||
import { afterAll, beforeAll, afterEach, describe, test } from 'vitest';
|
||||
import { afterAll, beforeAll, afterEach, describe, test, vi } from 'vitest';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
@@ -17,6 +16,11 @@ import { jobQueue } from '@/boot/common.js';
|
||||
import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
// i/move 呼び出しで大部分が同期的に完了する後続ジョブ (フォロー解除/ブロック・ミュート引き継ぎ/リスト更新等) を待つ
|
||||
const waitForMoveJobOptions = { timeout: 5000, interval: 50 };
|
||||
// AccountMoveService がテスト環境向けに 10000ms に短縮している遅延アンフォロージョブを待つ (安全マージンを載せた上限)
|
||||
const waitForDelayedUnfollowJobOptions = { timeout: 15000, interval: 100 };
|
||||
|
||||
describe('Account Move', () => {
|
||||
let jq: INestApplicationContext;
|
||||
let url: URL;
|
||||
@@ -273,53 +277,63 @@ describe('Account Move', () => {
|
||||
|
||||
assert.strictEqual(move.status, 200);
|
||||
|
||||
await setTimeout(1000 * 3); // wait for jobs to finish
|
||||
|
||||
// Unfollow delayed?
|
||||
const aliceFollowings = await api('users/following', {
|
||||
userId: alice.id,
|
||||
}, alice);
|
||||
assert.strictEqual(aliceFollowings.status, 200);
|
||||
assert.ok(aliceFollowings);
|
||||
assert.strictEqual(aliceFollowings.body.length, 3);
|
||||
await vi.waitFor(async () => {
|
||||
const aliceFollowings = await api('users/following', {
|
||||
userId: alice.id,
|
||||
}, alice);
|
||||
assert.strictEqual(aliceFollowings.status, 200);
|
||||
assert.ok(aliceFollowings);
|
||||
assert.strictEqual(aliceFollowings.body.length, 3);
|
||||
}, waitForMoveJobOptions);
|
||||
|
||||
const carolFollowings = await api('users/following', {
|
||||
userId: carol.id,
|
||||
}, carol);
|
||||
assert.strictEqual(carolFollowings.status, 200);
|
||||
assert.ok(carolFollowings);
|
||||
assert.strictEqual(carolFollowings.body.length, 2);
|
||||
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
|
||||
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
|
||||
await vi.waitFor(async () => {
|
||||
const carolFollowings = await api('users/following', {
|
||||
userId: carol.id,
|
||||
}, carol);
|
||||
assert.strictEqual(carolFollowings.status, 200);
|
||||
assert.ok(carolFollowings);
|
||||
assert.strictEqual(carolFollowings.body.length, 2);
|
||||
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
|
||||
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
|
||||
}, waitForMoveJobOptions);
|
||||
|
||||
const blockings = await api('blocking/list', {}, dave);
|
||||
assert.strictEqual(blockings.status, 200);
|
||||
assert.ok(blockings);
|
||||
assert.strictEqual(blockings.body.length, 2);
|
||||
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
|
||||
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
|
||||
await vi.waitFor(async () => {
|
||||
const blockings = await api('blocking/list', {}, dave);
|
||||
assert.strictEqual(blockings.status, 200);
|
||||
assert.ok(blockings);
|
||||
assert.strictEqual(blockings.body.length, 2);
|
||||
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
|
||||
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
|
||||
}, waitForMoveJobOptions);
|
||||
|
||||
const mutings = await api('mute/list', {}, dave);
|
||||
assert.strictEqual(mutings.status, 200);
|
||||
assert.ok(mutings);
|
||||
assert.strictEqual(mutings.body.length, 2);
|
||||
assert.strictEqual(mutings.body[0].muteeId, bob.id);
|
||||
assert.strictEqual(mutings.body[1].muteeId, alice.id);
|
||||
await vi.waitFor(async () => {
|
||||
const mutings = await api('mute/list', {}, dave);
|
||||
assert.strictEqual(mutings.status, 200);
|
||||
assert.ok(mutings);
|
||||
assert.strictEqual(mutings.body.length, 2);
|
||||
assert.strictEqual(mutings.body[0].muteeId, bob.id);
|
||||
assert.strictEqual(mutings.body[1].muteeId, alice.id);
|
||||
}, waitForMoveJobOptions);
|
||||
|
||||
const rootLists = await api('users/lists/list', {}, root);
|
||||
assert.strictEqual(rootLists.status, 200);
|
||||
assert.ok(rootLists);
|
||||
assert.ok(rootLists.body[0].userIds);
|
||||
assert.strictEqual(rootLists.body[0].userIds.length, 2);
|
||||
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
|
||||
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
|
||||
await vi.waitFor(async () => {
|
||||
const rootLists = await api('users/lists/list', {}, root);
|
||||
assert.strictEqual(rootLists.status, 200);
|
||||
assert.ok(rootLists);
|
||||
assert.ok(rootLists.body[0].userIds);
|
||||
assert.strictEqual(rootLists.body[0].userIds.length, 2);
|
||||
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
|
||||
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
|
||||
}, waitForMoveJobOptions);
|
||||
|
||||
const eveLists = await api('users/lists/list', {}, eve);
|
||||
assert.strictEqual(eveLists.status, 200);
|
||||
assert.ok(eveLists);
|
||||
assert.ok(eveLists.body[0].userIds);
|
||||
assert.strictEqual(eveLists.body[0].userIds.length, 1);
|
||||
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
|
||||
await vi.waitFor(async () => {
|
||||
const eveLists = await api('users/lists/list', {}, eve);
|
||||
assert.strictEqual(eveLists.status, 200);
|
||||
assert.ok(eveLists);
|
||||
assert.ok(eveLists.body[0].userIds);
|
||||
assert.strictEqual(eveLists.body[0].userIds.length, 1);
|
||||
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
|
||||
}, waitForMoveJobOptions);
|
||||
});
|
||||
|
||||
test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => {
|
||||
@@ -340,14 +354,14 @@ describe('Account Move', () => {
|
||||
});
|
||||
|
||||
test('Unfollowed after 10 sec (24 hours in production).', async () => {
|
||||
await setTimeout(1000 * 8);
|
||||
await vi.waitFor(async () => {
|
||||
const following = await api('users/following', {
|
||||
userId: alice.id,
|
||||
}, alice);
|
||||
|
||||
const following = await api('users/following', {
|
||||
userId: alice.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(following.status, 200);
|
||||
assert.strictEqual(following.body.length, 0);
|
||||
assert.strictEqual(following.status, 200);
|
||||
assert.strictEqual(following.body.length, 0);
|
||||
}, waitForDelayedUnfollowJobOptions);
|
||||
});
|
||||
|
||||
test('Unable to move if the destination account has already moved.', async () => {
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { beforeAll, describe, test } from 'vitest';
|
||||
import { beforeAll, describe, test, vi } from 'vitest';
|
||||
import { api, post, react, signup, waitFire } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
|
||||
|
||||
describe('Mute', () => {
|
||||
// alice mutes carol
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
@@ -67,13 +69,15 @@ describe('Mute', () => {
|
||||
const bobNote = await post(bob, { text: 'hi' });
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
|
||||
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
|
||||
@@ -83,13 +87,15 @@ describe('Mute', () => {
|
||||
renoteId: carolNote.id,
|
||||
});
|
||||
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { beforeAll, describe, test } from 'vitest';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { beforeAll, describe, test, vi } from 'vitest';
|
||||
import { api, post, signup, waitFire } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
|
||||
|
||||
describe('Renote Mute', () => {
|
||||
// alice mutes carol
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
@@ -36,16 +37,15 @@ describe('Renote Mute', () => {
|
||||
const carolRenote = await post(carol, { renoteId: bobNote.id });
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
|
||||
// redisに追加されるのを待つ
|
||||
await setTimeout(100);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
|
||||
test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
|
||||
@@ -53,16 +53,15 @@ describe('Renote Mute', () => {
|
||||
const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' });
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
|
||||
// redisに追加されるのを待つ
|
||||
await setTimeout(100);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
|
||||
// #12956
|
||||
@@ -70,15 +69,14 @@ describe('Renote Mute', () => {
|
||||
const carolNote = await post(carol, { text: 'hi' });
|
||||
const bobRenote = await post(bob, { renoteId: carolNote.id });
|
||||
|
||||
// redisに追加されるのを待つ
|
||||
await setTimeout(100);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
const res = await api('notes/local-timeline', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
|
||||
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {
|
||||
|
||||
@@ -8,9 +8,9 @@ process.env.NODE_ENV = 'test';
|
||||
import * as assert from 'assert';
|
||||
import { describe, beforeAll, test } from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { MiFollowing } from '@/models/Following.js';
|
||||
import { api, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js';
|
||||
import { api, connectStream, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import { MiFollowing } from '@/models/Following.js';
|
||||
|
||||
describe('Streaming', () => {
|
||||
let Followings: any;
|
||||
@@ -510,7 +510,7 @@ describe('Streaming', () => {
|
||||
test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => {
|
||||
const erinNote = await post(erin, { text: 'hi', visibility: 'followers' });
|
||||
const fired = await waitFire(
|
||||
erin, 'homeTimeline', // erin:home
|
||||
erin, 'hybridTimeline', // erin:Hybrid
|
||||
() => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post
|
||||
msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano
|
||||
);
|
||||
@@ -521,7 +521,7 @@ describe('Streaming', () => {
|
||||
test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => {
|
||||
const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' });
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
ayano, 'hybridTimeline', // ayano:Hybrid
|
||||
() => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post
|
||||
msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin
|
||||
);
|
||||
@@ -530,9 +530,12 @@ describe('Streaming', () => {
|
||||
});
|
||||
|
||||
test('withReplies: true のフォローしていない人のfollowersノートに対するリプライが流れない', async () => {
|
||||
// ayano は kyoko をフォローしているため kyoko の followers 投稿にリプライできるが、
|
||||
// erin は kyoko をフォローしていないため、そのリプライは erin の Hybrid Timeline には流れないはず
|
||||
const kyokoFollowersNote = await post(kyoko, { text: 'hi', visibility: 'followers' });
|
||||
const fired = await waitFire(
|
||||
erin, 'homeTimeline', // erin:home
|
||||
() => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post
|
||||
erin, 'hybridTimeline', // erin:Hybrid
|
||||
() => api('notes/create', { text: 'hello', replyId: kyokoFollowersNote.id }, ayano), // ayano reply to kyoko's followers post
|
||||
msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano
|
||||
);
|
||||
|
||||
@@ -680,7 +683,7 @@ describe('Streaming', () => {
|
||||
const fired = await waitFire(
|
||||
chitose, 'userList',
|
||||
() => api('notes/create', { text: 'foo' }, takumi),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
msg => msg.type === 'note' && msg.body.userId === takumi.id,
|
||||
{ listId: list.id },
|
||||
);
|
||||
|
||||
@@ -744,164 +747,83 @@ describe('Streaming', () => {
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
|
||||
/*
|
||||
describe('Hashtag Timeline', () => {
|
||||
test('指定したハッシュタグの投稿が流れる', () => new Promise<void>(async done => {
|
||||
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
|
||||
if (type === 'note') {
|
||||
assert.deepStrictEqual(body.text, '#foo');
|
||||
ws.close();
|
||||
done();
|
||||
}
|
||||
}, {
|
||||
q: [
|
||||
['foo'],
|
||||
],
|
||||
});
|
||||
test('指定したハッシュタグの投稿が流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
chitose, 'hashtag',
|
||||
() => api('notes/create', { text: '#foo' }, chitose),
|
||||
msg => msg.type === 'note' && msg.body.text === '#foo',
|
||||
{ q: [['foo']] },
|
||||
);
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo',
|
||||
});
|
||||
}));
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
|
||||
let fooCount = 0;
|
||||
let barCount = 0;
|
||||
let fooBarCount = 0;
|
||||
test('指定したハッシュタグの投稿が流れる (AND)', async () => {
|
||||
const received: string[] = [];
|
||||
const ws = await connectStream(chitose, 'hashtag', (msg) => {
|
||||
if (msg.type === 'note') received.push(msg.body.text);
|
||||
}, { q: [['foo', 'bar']] });
|
||||
|
||||
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
|
||||
if (type === 'note') {
|
||||
if (body.text === '#foo') fooCount++;
|
||||
if (body.text === '#bar') barCount++;
|
||||
if (body.text === '#foo #bar') fooBarCount++;
|
||||
}
|
||||
}, {
|
||||
q: [
|
||||
['foo', 'bar'],
|
||||
],
|
||||
});
|
||||
await Promise.all([
|
||||
await api('notes/create', { text: '#foo' }, chitose),
|
||||
await api('notes/create', { text: '#bar' }, chitose),
|
||||
await api('notes/create', { text: '#foo #bar' }, chitose),
|
||||
]);
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo',
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.close();
|
||||
|
||||
post(chitose, {
|
||||
text: '#bar',
|
||||
});
|
||||
assert.strictEqual(received.includes('#foo'), false);
|
||||
assert.strictEqual(received.includes('#bar'), false);
|
||||
assert.strictEqual(received.includes('#foo #bar'), true);
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo #bar',
|
||||
});
|
||||
test('指定したハッシュタグの投稿が流れる (OR)', async () => {
|
||||
const received: string[] = [];
|
||||
const ws = await connectStream(chitose, 'hashtag', (msg) => {
|
||||
if (msg.type === 'note') received.push(msg.body.text);
|
||||
}, { q: [['foo'], ['bar']] });
|
||||
|
||||
setTimeout(() => {
|
||||
assert.strictEqual(fooCount, 0);
|
||||
assert.strictEqual(barCount, 0);
|
||||
assert.strictEqual(fooBarCount, 1);
|
||||
ws.close();
|
||||
done();
|
||||
}, 3000);
|
||||
}));
|
||||
await Promise.all([
|
||||
await api('notes/create', { text: '#foo' }, chitose),
|
||||
await api('notes/create', { text: '#bar' }, chitose),
|
||||
await api('notes/create', { text: '#foo #bar' }, chitose),
|
||||
await api('notes/create', { text: '#piyo' }, chitose),
|
||||
]);
|
||||
|
||||
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
|
||||
let fooCount = 0;
|
||||
let barCount = 0;
|
||||
let fooBarCount = 0;
|
||||
let piyoCount = 0;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.close();
|
||||
|
||||
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
|
||||
if (type === 'note') {
|
||||
if (body.text === '#foo') fooCount++;
|
||||
if (body.text === '#bar') barCount++;
|
||||
if (body.text === '#foo #bar') fooBarCount++;
|
||||
if (body.text === '#piyo') piyoCount++;
|
||||
}
|
||||
}, {
|
||||
q: [
|
||||
['foo'],
|
||||
['bar'],
|
||||
],
|
||||
});
|
||||
assert.strictEqual(received.includes('#foo'), true);
|
||||
assert.strictEqual(received.includes('#bar'), true);
|
||||
assert.strictEqual(received.includes('#foo #bar'), true);
|
||||
assert.strictEqual(received.includes('#piyo'), false);
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo',
|
||||
});
|
||||
test('指定したハッシュタグの投稿が流れる (AND + OR)', async () => {
|
||||
const received: string[] = [];
|
||||
const ws = await connectStream(chitose, 'hashtag', (msg) => {
|
||||
if (msg.type === 'note') received.push(msg.body.text);
|
||||
}, { q: [['foo', 'bar'], ['piyo']] });
|
||||
|
||||
post(chitose, {
|
||||
text: '#bar',
|
||||
});
|
||||
await Promise.all([
|
||||
api('notes/create', { text: '#foo' }, chitose),
|
||||
api('notes/create', { text: '#bar' }, chitose),
|
||||
api('notes/create', { text: '#foo #bar' }, chitose),
|
||||
api('notes/create', { text: '#piyo' }, chitose),
|
||||
api('notes/create', { text: '#waaa' }, chitose),
|
||||
]);
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo #bar',
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.close();
|
||||
|
||||
post(chitose, {
|
||||
text: '#piyo',
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
assert.strictEqual(fooCount, 1);
|
||||
assert.strictEqual(barCount, 1);
|
||||
assert.strictEqual(fooBarCount, 1);
|
||||
assert.strictEqual(piyoCount, 0);
|
||||
ws.close();
|
||||
done();
|
||||
}, 3000);
|
||||
}));
|
||||
|
||||
test('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise<void>(async done => {
|
||||
let fooCount = 0;
|
||||
let barCount = 0;
|
||||
let fooBarCount = 0;
|
||||
let piyoCount = 0;
|
||||
let waaaCount = 0;
|
||||
|
||||
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
|
||||
if (type === 'note') {
|
||||
if (body.text === '#foo') fooCount++;
|
||||
if (body.text === '#bar') barCount++;
|
||||
if (body.text === '#foo #bar') fooBarCount++;
|
||||
if (body.text === '#piyo') piyoCount++;
|
||||
if (body.text === '#waaa') waaaCount++;
|
||||
}
|
||||
}, {
|
||||
q: [
|
||||
['foo', 'bar'],
|
||||
['piyo'],
|
||||
],
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo',
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#bar',
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#foo #bar',
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#piyo',
|
||||
});
|
||||
|
||||
post(chitose, {
|
||||
text: '#waaa',
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
assert.strictEqual(fooCount, 0);
|
||||
assert.strictEqual(barCount, 0);
|
||||
assert.strictEqual(fooBarCount, 1);
|
||||
assert.strictEqual(piyoCount, 1);
|
||||
assert.strictEqual(waaaCount, 0);
|
||||
ws.close();
|
||||
done();
|
||||
}, 3000);
|
||||
}));
|
||||
assert.strictEqual(received.includes('#foo'), false);
|
||||
assert.strictEqual(received.includes('#bar'), false);
|
||||
assert.strictEqual(received.includes('#foo #bar'), true);
|
||||
assert.strictEqual(received.includes('#piyo'), true);
|
||||
assert.strictEqual(received.includes('#waaa'), false);
|
||||
});
|
||||
});
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,12 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { beforeAll, describe, test } from 'vitest';
|
||||
import { beforeAll, describe, test, vi } from 'vitest';
|
||||
import { api, post, signup, uploadUrl } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
const waitForPushToTlOptions = { timeout: 3000, interval: 25 };
|
||||
|
||||
describe('users/notes', () => {
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
let jpgNote: misskey.entities.Note;
|
||||
@@ -32,16 +34,18 @@ describe('users/notes', () => {
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
test('withFiles', async () => {
|
||||
const res = await api('users/notes', {
|
||||
userId: alice.id,
|
||||
withFiles: true,
|
||||
}, alice);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await api('users/notes', {
|
||||
userId: alice.id,
|
||||
withFiles: true,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.length, 3);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.length, 3);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === jpgNote.id), true);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === pngNote.id), true);
|
||||
assert.strictEqual(res.body.some((note: any) => note.id === jpgPngNote.id), true);
|
||||
}, waitForPushToTlOptions);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import { beforeAll } from 'vitest';
|
||||
import { initTestDb, sendEnvResetRequest } from './utils.js';
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTestDb(false);
|
||||
// 前ファイルのNestJSアプリをdispose(env-reset)した後にスキーマをdrop & 再作成する。
|
||||
// 逆順だと、前ファイルの最後のテストが投げっぱなしにした非同期処理(cacheServiceのrefresh等)が
|
||||
// dispose前のdrop中に発火し、Unhandled Rejection (relation does not exist) でクラッシュしうる。
|
||||
await sendEnvResetRequest();
|
||||
await initTestDb(false);
|
||||
});
|
||||
|
||||
@@ -9,10 +9,10 @@ import { basename, isAbsolute } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { inspect } from 'node:util';
|
||||
import WebSocket, { ClientOptions } from 'ws';
|
||||
import fetch, { RequestInit, type Headers } from 'node-fetch';
|
||||
import fetch, { Blob, FormData } from 'node-fetch';
|
||||
import type { RequestInit, Headers, Response } from 'node-fetch';
|
||||
import * as htmlParser from 'node-html-parser';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { type Response } from 'node-fetch';
|
||||
import Fastify from 'fastify';
|
||||
import { entities } from '@/postgres.js';
|
||||
import { loadConfig } from '@/config.js';
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.9",
|
||||
"@types/node": "24.13.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"rollup": "4.61.1"
|
||||
"@types/node": "26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"rollup": "4.62.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"magic-string": "0.30.21",
|
||||
"oxc-walker": "1.0.0",
|
||||
"rolldown": "1.1.0",
|
||||
"vite": "8.0.16"
|
||||
"rolldown": "1.1.2",
|
||||
"vite": "8.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"noEmit": true,
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
|
||||
@@ -8,5 +8,5 @@ export function assertNever(x: never): never {
|
||||
throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`);
|
||||
}
|
||||
|
||||
export function assertType<T>(_node: unknown): asserts node is T {
|
||||
export function assertType<T>(_node: unknown): asserts _node is T {
|
||||
}
|
||||
|
||||
2
packages/frontend-embed/@types/theme.d.ts
vendored
2
packages/frontend-embed/@types/theme.d.ts
vendored
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
declare module '@@/themes/*.json5' {
|
||||
import { Theme } from '@/theme.js';
|
||||
import { Theme } from '@@/js/theme.js';
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
"mfm-js": "0.26.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.61.1",
|
||||
"rollup": "4.62.2",
|
||||
"shiki": "4.2.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"uuid": "14.0.0",
|
||||
"vue": "3.5.35"
|
||||
"vue": "3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
@@ -34,31 +34,31 @@
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.9",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.13.1",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vue/runtime-core": "3.5.35",
|
||||
"acorn": "8.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vue/runtime-core": "3.5.38",
|
||||
"acorn": "8.17.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"intersection-observer": "0.12.2",
|
||||
"lightningcss": "1.32.0",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.14.6",
|
||||
"prettier": "3.8.4",
|
||||
"sass-embedded": "1.100.0",
|
||||
"start-server-and-test": "3.0.9",
|
||||
"start-server-and-test": "3.0.11",
|
||||
"tsx": "4.22.4",
|
||||
"vite": "8.0.16",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.3.4",
|
||||
"vue-component-type-helpers": "3.3.5",
|
||||
"vue-eslint-parser": "10.4.1",
|
||||
"vue-tsc": "3.3.4"
|
||||
"vue-tsc": "3.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ const isRenote = Misskey.note.isPureRenote(note.value);
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const renoteTime = shallowRef<HTMLElement>();
|
||||
const appearNote = computed(() => getAppearNote(note.value));
|
||||
const appearNote = computed(() => getAppearNote(note.value) ?? note.value);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
const isLong = shouldCollapsed(appearNote.value, []);
|
||||
|
||||
@@ -244,7 +244,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
if (i === 10) item._shouldInsertAd_ = true;
|
||||
}
|
||||
|
||||
const reverseConcat = _res => {
|
||||
const reverseConcat = (_res: MisskeyEntity[]) => {
|
||||
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
|
||||
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ const initialReactions = new Set(Object.keys(props.note.reactions));
|
||||
const reactions = ref<[string, number][]>([]);
|
||||
const hasMoreReactions = ref(false);
|
||||
|
||||
if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
|
||||
reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
|
||||
if (props.note.myReaction != null && !(props.note.myReaction in props.note.reactions)) {
|
||||
reactions.value.push([props.note.myReaction, props.note.reactions[props.note.myReaction]]);
|
||||
}
|
||||
|
||||
function onMockToggleReaction(emoji: string, count: number) {
|
||||
|
||||
@@ -46,6 +46,9 @@ const parsed = computed(() => {
|
||||
});
|
||||
|
||||
const render = () => {
|
||||
return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
|
||||
// slotsの型はTの条件型で決まり文字列インデックスアクセスができないため、
|
||||
// frontend側の同名コンポーネント (packages/frontend/src/components/global/I18n.vue) と同じくanyでキャストする
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : (slots as any)[x.arg]()));
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -45,11 +45,11 @@ export async function fetchCustomEmojis(force = false) {
|
||||
set('lastEmojisFetchedAt', now);
|
||||
}
|
||||
|
||||
let cachedTags;
|
||||
let cachedTags: string[] | null = null;
|
||||
export function getCustomEmojiTags() {
|
||||
if (cachedTags) return cachedTags;
|
||||
|
||||
const tags = new Set();
|
||||
const tags = new Set<string>();
|
||||
for (const emoji of customEmojis.value) {
|
||||
for (const tag of emoji.aliases) {
|
||||
tags.add(tag);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"removeComments": false,
|
||||
"skipLibCheck": true,
|
||||
"noLib": false,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.1",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"vue-eslint-parser": "10.4.1"
|
||||
},
|
||||
@@ -25,6 +25,6 @@
|
||||
"misskey-js": "workspace:*",
|
||||
"shiki": "4.2.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"vue": "3.5.35"
|
||||
"vue": "3.5.38"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sourceMap": false,
|
||||
"outDir": "./js-built",
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
|
||||
2
packages/frontend/@types/theme.d.ts
vendored
2
packages/frontend/@types/theme.d.ts
vendored
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
declare module '@@/themes/*.json5' {
|
||||
import { Theme } from '@/theme.js';
|
||||
import { Theme } from '@@/js/theme.js';
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@mcaptcha/core-glue": "0.1.0-alpha-5",
|
||||
"@misskey-dev/browser-image-resizer": "2024.1.0",
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@sentry/vue": "10.57.0",
|
||||
"@sentry/vue": "10.59.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@syuilo/aiscript": "1.2.1",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"chartjs-chart-matrix": "3.0.4",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"chromatic": "16.10.1",
|
||||
"chromatic": "17.5.0",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.46.0",
|
||||
"mediabunny": "1.49.0",
|
||||
"mfm-js": "0.26.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
@@ -60,14 +60,14 @@
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"sanitize-html": "2.17.4",
|
||||
"sanitize-html": "2.17.5",
|
||||
"shiki": "4.2.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.184.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vue": "3.5.35",
|
||||
"vue": "3.5.38",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,7 +77,7 @@
|
||||
"@rollup/pluginutils": "5.4.0",
|
||||
"@storybook/addon-essentials": "8.6.18",
|
||||
"@storybook/addon-interactions": "8.6.18",
|
||||
"@storybook/addon-links": "10.4.3",
|
||||
"@storybook/addon-links": "10.4.6",
|
||||
"@storybook/addon-mdx-gfm": "8.6.18",
|
||||
"@storybook/addon-storysource": "8.6.18",
|
||||
"@storybook/blocks": "8.6.18",
|
||||
@@ -85,13 +85,11 @@
|
||||
"@storybook/core-events": "8.6.18",
|
||||
"@storybook/manager-api": "8.6.18",
|
||||
"@storybook/preview-api": "8.6.18",
|
||||
"@storybook/react": "10.4.3",
|
||||
"@storybook/react-vite": "10.4.3",
|
||||
"@storybook/test": "8.6.18",
|
||||
"@storybook/theming": "8.6.18",
|
||||
"@storybook/types": "8.6.18",
|
||||
"@storybook/vue3": "10.4.3",
|
||||
"@storybook/vue3-vite": "10.4.3",
|
||||
"@storybook/vue3": "10.4.6",
|
||||
"@storybook/vue3-vite": "10.4.6",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
@@ -99,24 +97,21 @@
|
||||
"@types/insert-text-at-cursor": "0.3.2",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.13.1",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/textarea-caret": "3.0.4",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vue/compiler-core": "3.5.35",
|
||||
"acorn": "8.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vue/compiler-core": "3.5.38",
|
||||
"astring": "1.9.0",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.17.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"intersection-observer": "0.12.2",
|
||||
"lightningcss": "1.32.0",
|
||||
"micromatch": "4.0.8",
|
||||
@@ -128,21 +123,20 @@
|
||||
"prettier": "3.8.4",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"rolldown": "1.1.0",
|
||||
"rolldown": "1.1.2",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"sass-embedded": "1.100.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "3.0.9",
|
||||
"storybook": "10.4.3",
|
||||
"storybook": "10.4.6",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.22.4",
|
||||
"vite": "8.0.16",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-glsl": "1.6.0",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "4.1.8",
|
||||
"vitest": "4.1.9",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.3.4",
|
||||
"vue-component-type-helpers": "3.3.5",
|
||||
"vue-eslint-parser": "10.4.1",
|
||||
"vue-tsc": "3.3.4"
|
||||
"vue-tsc": "3.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { watch, version as vueVersion } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
|
||||
import { version, lang, isSafeMode } from '@@/js/config.js';
|
||||
import defaultLightTheme from '@@/themes/l-light.json5';
|
||||
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
|
||||
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
|
||||
@@ -30,6 +30,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { launchPlugins } from '@/plugin.js';
|
||||
import { initTelemetry } from '@/telemetry.js';
|
||||
|
||||
export async function common(createVue: () => Promise<App<Element>>) {
|
||||
console.info(`Misskey v${version}`);
|
||||
@@ -286,40 +287,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
return root;
|
||||
})();
|
||||
|
||||
if (instance.sentryForFrontend) {
|
||||
const Sentry = await import('@sentry/vue');
|
||||
Sentry.init({
|
||||
app,
|
||||
integrations: [
|
||||
...(instance.sentryForFrontend.vueIntegration !== undefined ? [
|
||||
Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined),
|
||||
] : []),
|
||||
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [
|
||||
Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined),
|
||||
] : []),
|
||||
...(instance.sentryForFrontend.replayIntegration !== undefined ? [
|
||||
Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined),
|
||||
] : []),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
// Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled
|
||||
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? {
|
||||
tracePropagationTargets: [apiUrl],
|
||||
} : {}),
|
||||
|
||||
// Capture Replay for 10% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
...(instance.sentryForFrontend.replayIntegration !== undefined ? {
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
} : {}),
|
||||
|
||||
...instance.sentryForFrontend.options,
|
||||
});
|
||||
}
|
||||
await initTelemetry(instance, app);
|
||||
|
||||
try {
|
||||
await launchPlugins();
|
||||
|
||||
@@ -33,7 +33,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us
|
||||
const url = `/${canonical}`;
|
||||
|
||||
const isMe = $i && (
|
||||
`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
|
||||
`@${props.username}@${toUnicode(props.host)}`.toLowerCase() === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
|
||||
);
|
||||
|
||||
const avatarUrl = computed(() => prefer.s.disableShowingAnimatedImages || prefer.s.dataSaver.avatar
|
||||
|
||||
@@ -144,7 +144,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<button v-else :class="$style.footerButton" class="_button" disabled>
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="handleToggleReact()">
|
||||
<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>
|
||||
@@ -192,58 +192,38 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
|
||||
so MkNote create empty div instead of no elements
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { inject, ref, useTemplateRef, provide, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { useNote } from '@/composables/use-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { noteEvents } from '@/composables/use-note-capture.js';
|
||||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { focusPrev, focusNext } from '@/utility/focus.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { DI } from '@/di.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
|
||||
// コンポーネント外部の依存関係
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as os from '@/os.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import { reactionPicker } from '@/utility/reaction-picker.js';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { getNoteSummary } from '@/utility/get-note-summary.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { focusPrev, focusNext } from '@/utility/focus.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
@@ -254,48 +234,21 @@ const props = withDefaults(defineProps<{
|
||||
mock: false,
|
||||
});
|
||||
|
||||
provide(DI.mock, props.mock);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'reaction', emoji: string): void;
|
||||
(ev: 'removeReaction', emoji: string): void;
|
||||
}>();
|
||||
|
||||
provide(DI.mock, props.mock);
|
||||
|
||||
// 周辺コンテキストのインジェクト
|
||||
const inTimeline = inject<boolean>('inTimeline', false);
|
||||
const tl_withSensitive = inject<Ref<boolean>>('tl_withSensitive', ref(true));
|
||||
const inChannel = inject(DI.inChannel, null);
|
||||
const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null);
|
||||
const currentAntenna = inject<Ref<Misskey.entities.Antenna | null> | null>('currentAntenna', null);
|
||||
|
||||
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) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
} else {
|
||||
note = result as Misskey.entities.Note;
|
||||
}
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note) ?? note;
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
mock: props.mock,
|
||||
});
|
||||
|
||||
// Template Refsの定義
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
const renoteButton = useTemplateRef('renoteButton');
|
||||
@@ -303,64 +256,80 @@ const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
|
||||
const isLong = shouldCollapsed(appearNote, urls.value ?? []);
|
||||
const collapsed = ref(appearNote.cw == null && isLong);
|
||||
const muted = ref(checkMute(appearNote, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords, true));
|
||||
|
||||
// コンポーサブルの呼び出し
|
||||
const {
|
||||
note,
|
||||
appearNote,
|
||||
$appearNote,
|
||||
hideByPlugin,
|
||||
isRenote,
|
||||
showContent,
|
||||
translating,
|
||||
translation,
|
||||
muted,
|
||||
hardMuted,
|
||||
collapsed,
|
||||
renoteCollapsed,
|
||||
parsed,
|
||||
urls,
|
||||
isLong,
|
||||
showTicker,
|
||||
canRenote,
|
||||
|
||||
renote,
|
||||
reply,
|
||||
react,
|
||||
reactViaMfmEmoji,
|
||||
toggleReact,
|
||||
onContextmenu,
|
||||
showMenu,
|
||||
clip,
|
||||
showRenoteMenu,
|
||||
blur,
|
||||
} = useNote(props, {
|
||||
rootEl,
|
||||
menuButton,
|
||||
renoteButton,
|
||||
renoteTime,
|
||||
reactButton,
|
||||
clipButton,
|
||||
}, {
|
||||
inTimeline,
|
||||
tl_withSensitive,
|
||||
inChannel,
|
||||
currentClip,
|
||||
currentAntenna,
|
||||
});
|
||||
|
||||
// provide
|
||||
provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
|
||||
|
||||
// MkNote固有
|
||||
const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
|
||||
const renoteCollapsed = ref(
|
||||
prefer.s.collapseRenotes && isRenote && (
|
||||
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
|
||||
($appearNote.myReaction != null)
|
||||
),
|
||||
);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
/** checkOnlyでは純粋なワードミュート結果をbooleanで返却する */
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly?: false): Array<string | string[]> | false | 'sensitiveMute';
|
||||
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | boolean | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) {
|
||||
return checkOnly ? (result.length > 0) : result;
|
||||
function handleToggleReact() {
|
||||
toggleReact((reaction) => {
|
||||
if ($appearNote.myReaction === reaction) {
|
||||
emit('removeReaction', reaction);
|
||||
} else {
|
||||
emit('reaction', reaction);
|
||||
$appearNote.reactions[reaction] = 1;
|
||||
$appearNote.reactionCount++;
|
||||
$appearNote.myReaction = reaction;
|
||||
}
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) {
|
||||
return checkOnly ? (replyResult.length > 0) : replyResult;
|
||||
}
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) {
|
||||
return checkOnly ? (renoteResult.length > 0) : renoteResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
/* eslint-enable no-redeclare */
|
||||
|
||||
function emitUpdReaction(emoji: string, delta: number) {
|
||||
if (delta < 0) {
|
||||
emit('removeReaction', emoji);
|
||||
} else if (delta > 0) {
|
||||
emit('reaction', emoji);
|
||||
}
|
||||
}
|
||||
|
||||
// キーボードショートカットマップ
|
||||
const keymap = {
|
||||
'r': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
@@ -392,7 +361,7 @@ const keymap = {
|
||||
renoteCollapsed.value = false;
|
||||
} else if (appearNote.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
} else if (isLong) {
|
||||
} else if (isLong.value) {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
},
|
||||
@@ -402,321 +371,13 @@ const keymap = {
|
||||
},
|
||||
'up|k|shift+tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusBefore(),
|
||||
callback: () => focusPrev(rootEl.value),
|
||||
},
|
||||
'down|j|tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusAfter(),
|
||||
callback: () => focusNext(rootEl.value),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!props.mock) {
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
const users = renotes.map(x => x.user);
|
||||
|
||||
if (users.length < 1 || renoteButton.value == null) return;
|
||||
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
anchorElement: renoteButton.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: $appearNote.reactionCount,
|
||||
anchorElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
});
|
||||
const el = reactButton.value;
|
||||
if (el && prefer.s.animation) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
|
||||
});
|
||||
|
||||
if (confirm.canceled) return;
|
||||
}
|
||||
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
if (props.mock) {
|
||||
emit('reaction', reaction);
|
||||
$appearNote.reactions[reaction] = 1;
|
||||
$appearNote.reactionCount++;
|
||||
$appearNote.myReaction = reaction;
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function undoReact(): void {
|
||||
const oldReaction = $appearNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
|
||||
if (props.mock) {
|
||||
emit('removeReaction', oldReaction);
|
||||
return;
|
||||
}
|
||||
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: appearNote.id,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact() {
|
||||
if ($appearNote.myReaction == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact();
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
if (prefer.s.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, currentClip: currentClip?.value, currentAntenna: currentAntenna?.value ?? undefined });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
function getUnrenote(): MenuItem {
|
||||
return {
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const renoteDetailsMenu: MenuItem[] = [{
|
||||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(note),
|
||||
}];
|
||||
|
||||
if (
|
||||
props.note.channelId != null &&
|
||||
(inChannel == null || props.note.channelId !== inChannel.value)
|
||||
) {
|
||||
renoteDetailsMenu.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isMyRenote) {
|
||||
os.popupMenu([
|
||||
...renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getUnrenote(),
|
||||
], renoteTime.value);
|
||||
} else {
|
||||
os.popupMenu([
|
||||
...renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote),
|
||||
...(($i?.isModerator || $i?.isAdmin) ? [getUnrenote()] : []),
|
||||
], renoteTime.value);
|
||||
}
|
||||
}
|
||||
|
||||
function focus() {
|
||||
rootEl.value?.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
rootEl.value?.blur();
|
||||
}
|
||||
|
||||
function focusBefore() {
|
||||
focusPrev(rootEl.value);
|
||||
}
|
||||
|
||||
function focusAfter() {
|
||||
focusNext(rootEl.value);
|
||||
}
|
||||
|
||||
function readPromo() {
|
||||
misskeyApi('promo/read', {
|
||||
noteId: appearNote.id,
|
||||
});
|
||||
}
|
||||
|
||||
function emitUpdReaction(emoji: string, delta: number) {
|
||||
if (delta < 0) {
|
||||
emit('removeReaction', emoji);
|
||||
} else if (delta > 0) {
|
||||
emit('reaction', emoji);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -239,92 +239,45 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, markRaw, provide, ref, useTemplateRef } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { inject, provide, ref, useTemplateRef, markRaw, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import { useNote } from '@/composables/use-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { DI } from '@/di.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
// コンポーネント外部の依存関係
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkCwButton from '@/components/MkCwButton.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import number from '@/filters/number.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import { reactionPicker } from '@/utility/reaction-picker.js';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/utility/get-note-menu.js';
|
||||
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { isEnabledUrlPreview } from '@/utility/url-preview.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents, useGlobalEvent } from '@/events.js';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
initialTab?: string;
|
||||
initialTab?: 'replies' | 'renotes' | 'reactions';
|
||||
}>(), {
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
||||
// 周辺コンテキストのインジェクト
|
||||
const inChannel = inject(DI.inChannel, null);
|
||||
|
||||
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) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
} else {
|
||||
note = result as Misskey.entities.Note;
|
||||
}
|
||||
}
|
||||
|
||||
const isRenote = Misskey.note.isPureRenote(note);
|
||||
const appearNote = getAppearNote(note) ?? note;
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: note,
|
||||
});
|
||||
|
||||
// Template Refsの定義
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const menuButton = useTemplateRef('menuButton');
|
||||
const renoteButton = useTemplateRef('renoteButton');
|
||||
@@ -332,30 +285,96 @@ const renoteTime = useTemplateRef('renoteTime');
|
||||
const reactButton = useTemplateRef('reactButton');
|
||||
const clipButton = useTemplateRef('clipButton');
|
||||
const galleryEl = useTemplateRef('galleryEl');
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
|
||||
|
||||
useGlobalEvent('noteDeleted', (noteId) => {
|
||||
if (noteId === note.id || noteId === appearNote.id) {
|
||||
isDeleted.value = true;
|
||||
}
|
||||
// コンポーサブルの呼び出し
|
||||
const {
|
||||
note,
|
||||
appearNote,
|
||||
$appearNote,
|
||||
hideByPlugin,
|
||||
isRenote,
|
||||
showContent,
|
||||
isDeleted,
|
||||
translating,
|
||||
translation,
|
||||
muted,
|
||||
canRenote,
|
||||
isMyRenote,
|
||||
parsed,
|
||||
urls,
|
||||
showTicker,
|
||||
|
||||
// 関数群
|
||||
renote,
|
||||
reply,
|
||||
react,
|
||||
reactViaMfmEmoji,
|
||||
toggleReact,
|
||||
onContextmenu,
|
||||
showMenu,
|
||||
clip,
|
||||
showRenoteMenu,
|
||||
blur,
|
||||
} = useNote(props, {
|
||||
rootEl,
|
||||
menuButton,
|
||||
renoteButton,
|
||||
renoteTime,
|
||||
reactButton,
|
||||
clipButton,
|
||||
}, {
|
||||
inChannel,
|
||||
});
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
// provide
|
||||
provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
|
||||
|
||||
// MkNoteDetailed固有
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
|
||||
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
|
||||
limit: 10,
|
||||
params: {
|
||||
noteId: appearNote.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
|
||||
limit: 10,
|
||||
computedParams: computed(() => ({
|
||||
noteId: appearNote.id,
|
||||
type: reactionTabType.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
const repliesLoaded = ref(false);
|
||||
|
||||
function loadReplies() {
|
||||
repliesLoaded.value = true;
|
||||
misskeyApi('notes/children', {
|
||||
noteId: appearNote.id,
|
||||
limit: 30,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
});
|
||||
}
|
||||
|
||||
const conversation = ref<Misskey.entities.Note[]>([]);
|
||||
const conversationLoaded = ref(false);
|
||||
|
||||
function loadConversation() {
|
||||
conversationLoaded.value = true;
|
||||
if (appearNote.replyId == null) return;
|
||||
misskeyApi('notes/conversation', {
|
||||
noteId: appearNote.replyId,
|
||||
}).then(res => {
|
||||
conversation.value = res.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
// キーボードショートカットマップ
|
||||
const keymap = {
|
||||
'r': () => reply(),
|
||||
'e|a|plus': () => react(),
|
||||
@@ -378,281 +397,6 @@ const keymap = {
|
||||
callback: () => blur(),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
provide(DI.mfmEmojiReactCallback, (reaction) => {
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
|
||||
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
|
||||
limit: 10,
|
||||
params: {
|
||||
noteId: appearNote.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
|
||||
limit: 10,
|
||||
computedParams: computed(() => ({
|
||||
noteId: appearNote.id,
|
||||
type: reactionTabType.value,
|
||||
})),
|
||||
}));
|
||||
|
||||
useTooltip(renoteButton, async (showing) => {
|
||||
const anchorElement = renoteButton.value;
|
||||
if (anchorElement == null) return;
|
||||
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
|
||||
const users = renotes.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
anchorElement: anchorElement,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
useTooltip(reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
if (users.length < 1) return;
|
||||
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: $appearNote.reactionCount,
|
||||
anchorElement: reactButton.value!,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renote() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton });
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
|
||||
// リノート後は反応が来る可能性があるので手動で購読する
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
});
|
||||
const el = reactButton.value;
|
||||
if (el && prefer.s.animation) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value ?? null, note, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
|
||||
});
|
||||
|
||||
if (confirm.canceled) return;
|
||||
}
|
||||
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function undoReact(targetNote: Misskey.entities.Note): void {
|
||||
const oldReaction = targetNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
misskeyApi('notes/reactions/delete', {
|
||||
noteId: targetNote.id,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: oldReaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact() {
|
||||
if (appearNote.myReaction == null) {
|
||||
react();
|
||||
} else {
|
||||
undoReact(appearNote);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent): void {
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
if (prefer.s.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note, translating, translation });
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (!isMyRenote) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
if (isMyRenote) {
|
||||
menu.push({
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', {
|
||||
noteId: note.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('noteDeleted', note.id);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
props.note.channelId != null &&
|
||||
(inChannel == null || props.note.channelId !== inChannel.value)
|
||||
) {
|
||||
menu.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menu, renoteTime.value);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
rootEl.value?.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
rootEl.value?.blur();
|
||||
}
|
||||
|
||||
const repliesLoaded = ref(false);
|
||||
|
||||
function loadReplies() {
|
||||
repliesLoaded.value = true;
|
||||
misskeyApi('notes/children', {
|
||||
noteId: appearNote.id,
|
||||
limit: 30,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
});
|
||||
}
|
||||
|
||||
const conversationLoaded = ref(false);
|
||||
|
||||
function loadConversation() {
|
||||
conversationLoaded.value = true;
|
||||
if (appearNote.replyId == null) return;
|
||||
misskeyApi('notes/conversation', {
|
||||
noteId: appearNote.replyId,
|
||||
}).then(res => {
|
||||
conversation.value = res.reverse();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div v-if="show" ref="el" :class="[$style.root]">
|
||||
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
|
||||
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu">
|
||||
<div v-if="!thin_ && (narrow || deviceKind === 'smartphone') && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu">
|
||||
<MkAvatar :class="$style.avatar" :user="$i"/>
|
||||
</div>
|
||||
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttons"></div>
|
||||
@@ -62,6 +62,7 @@ import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'v
|
||||
import { scrollToTop } from '@@/js/scroll.js';
|
||||
import XTabs from './MkPageHeader.tabs.vue';
|
||||
import { getAccountMenu } from '@/accounts.js';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { DI } from '@/di.js';
|
||||
import * as os from '@/os.js';
|
||||
@@ -160,10 +161,12 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
height: var(--height);
|
||||
|
||||
.tabs:first-child {
|
||||
.tabs:first-child,
|
||||
&:not(.slim) > :not(.titleContainer) ~ .tabs {
|
||||
margin-left: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
473
packages/frontend/src/composables/use-note.ts
Normal file
473
packages/frontend/src/composables/use-note.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import { checkWordMute } from '@/utility/check-word-mute.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import * as sound from '@/utility/sound.js';
|
||||
import * as os from '@/os.js';
|
||||
import { reactionPicker } from '@/utility/reaction-picker.js';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu, getAbuseNoteMenu, getCopyNoteLinkMenu } from '@/utility/get-note-menu.js';
|
||||
import { noteEvents, useNoteCapture } from '@/composables/use-note-capture.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { useTooltip } from '@/composables/use-tooltip.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { showMovedDialog } from '@/utility/show-moved-dialog.js';
|
||||
import { getAppearNote } from '@/utility/get-appear-note.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getPluginHandlers } from '@/plugin.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { globalEvents, useGlobalEvent } from '@/events.js';
|
||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import type { DI as DIType } from '@/di.js';
|
||||
import type { ExtractInjectedType } from '@/types/misc.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
export interface UseNoteProps {
|
||||
note: Misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
mock?: boolean;
|
||||
withHardMute?: boolean;
|
||||
}
|
||||
|
||||
export interface UseNoteElements {
|
||||
rootEl?: Ref<HTMLElement | null>;
|
||||
menuButton?: Ref<HTMLElement | null>;
|
||||
renoteButton?: Ref<HTMLElement | null>;
|
||||
renoteTime?: Ref<HTMLElement | null>;
|
||||
reactButton?: Ref<HTMLElement | null>;
|
||||
clipButton?: Ref<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export interface UseNoteOptions {
|
||||
inTimeline?: boolean;
|
||||
tl_withSensitive?: Ref<boolean>;
|
||||
inChannel?: ExtractInjectedType<typeof DIType['inChannel']>;
|
||||
currentClip?: Ref<Misskey.entities.Clip | null> | null;
|
||||
currentAntenna?: Ref<Misskey.entities.Antenna | null> | null;
|
||||
}
|
||||
|
||||
/* eslint-disable no-redeclare */
|
||||
export function calculateMuteStatus(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
inTimeline: boolean,
|
||||
tl_withSensitive: boolean,
|
||||
checkOnly: true,
|
||||
): boolean;
|
||||
export function calculateMuteStatus(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
inTimeline: boolean,
|
||||
tl_withSensitive: boolean,
|
||||
checkOnly?: false,
|
||||
): Array<string | string[]> | false | 'sensitiveMute';
|
||||
|
||||
export function calculateMuteStatus(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
inTimeline: boolean,
|
||||
tl_withSensitive: boolean,
|
||||
checkOnly = false
|
||||
): Array<string | string[]> | boolean | 'sensitiveMute' {
|
||||
if (user?.mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, user, user.mutedWords);
|
||||
if (Array.isArray(result)) return checkOnly ? (result.length > 0) : result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, user, user.mutedWords);
|
||||
if (Array.isArray(replyResult)) return checkOnly ? (replyResult.length > 0) : replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, user, user.mutedWords);
|
||||
if (Array.isArray(renoteResult)) return checkOnly ? (renoteResult.length > 0) : renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
||||
if (inTimeline && tl_withSensitive === false && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
/* eslint-enable no-redeclare */
|
||||
|
||||
/** MkNote, MkNoteDetailedの共通ロジック */
|
||||
export function useNote(
|
||||
props: UseNoteProps,
|
||||
els: UseNoteElements = {},
|
||||
options: UseNoteOptions = {},
|
||||
) {
|
||||
const inTimeline = options.inTimeline ?? false;
|
||||
const tl_withSensitive = options.tl_withSensitive ?? ref(true);
|
||||
const inChannel = options.inChannel ?? null;
|
||||
const currentClip = options.currentClip ?? null;
|
||||
const currentAntenna = options.currentAntenna ?? null;
|
||||
|
||||
// プラグインの割り込み処理
|
||||
let rawNote = deepClone(props.note);
|
||||
const hideByPlugin = ref(false);
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
let result: Misskey.entities.Note | null = deepClone(rawNote);
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
} else {
|
||||
rawNote = result as Misskey.entities.Note;
|
||||
}
|
||||
}
|
||||
|
||||
// 基本状態
|
||||
const isRenote = Misskey.note.isPureRenote(rawNote);
|
||||
const appearNote = getAppearNote(rawNote) ?? rawNote;
|
||||
|
||||
// キャプチャ(ストリーム購読)
|
||||
const { $note: $appearNote, subscribe: subscribeManuallyToNoteCapture } = useNoteCapture({
|
||||
note: appearNote,
|
||||
parentNote: rawNote,
|
||||
mock: props.mock,
|
||||
});
|
||||
|
||||
// 各種フラグ状態
|
||||
const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
const translating = ref(false);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
|
||||
// ミュート判定
|
||||
const muted = ref($i ? (options.inTimeline ? calculateMuteStatus(appearNote, $i, inTimeline, tl_withSensitive.value) : checkWordMute(appearNote, $i, $i.mutedWords)) : false);
|
||||
const hardMuted = ref(props.withHardMute && $i && calculateMuteStatus(appearNote, $i, inTimeline, tl_withSensitive.value, true));
|
||||
|
||||
// 計算プロパティ (Computed)
|
||||
const isMyRenote = computed(() => $i && ($i.id === rawNote.userId));
|
||||
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
|
||||
const isLong = computed(() => shouldCollapsed(appearNote, urls.value ?? []));
|
||||
const collapsed = ref(appearNote.cw == null && isLong.value);
|
||||
const showTicker = computed(() => (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance));
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
|
||||
const renoteCollapsed = ref(prefer.s.collapseRenotes && isRenote && (($i && ($i.id === rawNote.userId || $i.id === appearNote.userId)) || ($appearNote.myReaction != null)));
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
|
||||
// グローバルイベントの監視
|
||||
useGlobalEvent('noteDeleted', (noteId) => {
|
||||
if (noteId === rawNote.id || noteId === appearNote.id) {
|
||||
isDeleted.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ツールチップのセットアップ (Mockでない場合のみ)
|
||||
if (!props.mock) {
|
||||
if (els.renoteButton != null) {
|
||||
useTooltip(els.renoteButton, async (showing) => {
|
||||
const renotes = await misskeyApi('notes/renotes', {
|
||||
noteId: appearNote.id,
|
||||
limit: 11,
|
||||
});
|
||||
const users = renotes.map(x => x.user);
|
||||
if (users.length < 1 || els.renoteButton!.value == null) return;
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.renoteCount,
|
||||
anchorElement: els.renoteButton!.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly' && els.reactButton != null) {
|
||||
useTooltip(els.reactButton, async (showing) => {
|
||||
const reactions = await misskeyApiGet('notes/reactions', {
|
||||
noteId: appearNote.id,
|
||||
limit: 10,
|
||||
_cacheKey_: $appearNote.reactionCount,
|
||||
});
|
||||
const users = reactions.map(x => x.user);
|
||||
if (users.length < 1 || els.reactButton!.value == null) return;
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: $appearNote.reactionCount,
|
||||
anchorElement: els.reactButton!.value,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 共通アクション関数群
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
if (els.renoteButton == null) return;
|
||||
const { menu } = getRenoteMenu({
|
||||
note: rawNote,
|
||||
renoteButton: els.renoteButton,
|
||||
mock: props.mock,
|
||||
});
|
||||
os.popupMenu(menu, els.renoteButton.value);
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function react(customCallback?: (reaction: string) => void) {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
if (props.mock) return;
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: '❤️',
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, { userId: $i!.id, reaction: '❤️' });
|
||||
});
|
||||
if (els.reactButton != null && els.reactButton.value != null && prefer.s.animation) {
|
||||
const rect = els.reactButton.value.getBoundingClientRect();
|
||||
const { dispose } = os.popup(MkRippleEffect, {
|
||||
x: rect.left + (els.reactButton.value.offsetWidth / 2),
|
||||
y: rect.top + (els.reactButton.value.offsetHeight / 2),
|
||||
}, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
reactionPicker.show(els.reactButton?.value ?? null, rawNote, async (reaction) => {
|
||||
if (prefer.s.confirmOnReact) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.reactAreYouSure({ emoji: reaction.replace('@.', '') }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
}
|
||||
sound.playMisskeySfx('reaction');
|
||||
if (props.mock) {
|
||||
if (customCallback) customCallback(reaction);
|
||||
return;
|
||||
}
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, { userId: $i!.id, reaction: reaction });
|
||||
});
|
||||
if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
|
||||
claimAchievement('reactWithoutRead');
|
||||
}
|
||||
}, () => { focus(); });
|
||||
}
|
||||
}
|
||||
|
||||
async function reactViaMfmEmoji(reaction: string) {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
}).then(() => {
|
||||
noteEvents.emit(`reacted:${appearNote.id}`, {
|
||||
userId: $i!.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function undoReact(): void {
|
||||
const oldReaction = $appearNote.myReaction;
|
||||
if (!oldReaction) return;
|
||||
if (props.mock) return;
|
||||
misskeyApi('notes/reactions/delete', { noteId: appearNote.id }).then(() => {
|
||||
noteEvents.emit(`unreacted:${appearNote.id}`, { userId: $i!.id, reaction: oldReaction });
|
||||
});
|
||||
}
|
||||
|
||||
function toggleReact(customMockCallback?: (reaction: string) => void) {
|
||||
if ($appearNote.myReaction == null) {
|
||||
react(customMockCallback);
|
||||
} else {
|
||||
if (props.mock && customMockCallback) {
|
||||
customMockCallback($appearNote.myReaction);
|
||||
} else {
|
||||
undoReact();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: PointerEvent): void {
|
||||
if (props.mock) return;
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
if (prefer.s.useReactionPickerForContextMenu) {
|
||||
ev.preventDefault();
|
||||
react();
|
||||
} else {
|
||||
const { menu, cleanup } = getNoteMenu({
|
||||
note: rawNote,
|
||||
translating,
|
||||
translation,
|
||||
currentClip: currentClip?.value,
|
||||
currentAntenna: currentAntenna?.value ?? undefined,
|
||||
});
|
||||
os.contextMenu(menu, ev).then(focus).finally(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
if (props.mock || els.menuButton == null) return;
|
||||
const { menu, cleanup } = getNoteMenu({
|
||||
note: rawNote,
|
||||
translating,
|
||||
translation,
|
||||
currentClip: currentClip?.value,
|
||||
currentAntenna: currentAntenna?.value ?? undefined,
|
||||
});
|
||||
os.popupMenu(menu, els.menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip(): Promise<void> {
|
||||
if (props.mock) return;
|
||||
os.popupMenu(await getNoteClipMenu({
|
||||
note: rawNote,
|
||||
currentClip: currentClip?.value,
|
||||
}), els.clipButton?.value).then(focus);
|
||||
}
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const getUnrenote = () => ({
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => {
|
||||
misskeyApi('notes/delete', { noteId: rawNote.id }).then(() => { globalEvents.emit('noteDeleted', rawNote.id); });
|
||||
},
|
||||
});
|
||||
|
||||
const menuItems: MenuItem[] = [{
|
||||
type: 'link',
|
||||
text: i18n.ts.renoteDetails,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: notePage(rawNote),
|
||||
}];
|
||||
|
||||
if (props.note.channelId != null && (inChannel == null || props.note.channelId !== inChannel.value)) {
|
||||
menuItems.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.viewRenotedChannel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: `/channels/${props.note.channelId}`,
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push(getCopyNoteLinkMenu(rawNote, i18n.ts.copyLinkRenote));
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
if (isMyRenote.value) {
|
||||
menuItems.push(getUnrenote());
|
||||
os.popupMenu(menuItems, els.renoteTime?.value);
|
||||
} else {
|
||||
menuItems.push(getAbuseNoteMenu(rawNote, i18n.ts.reportAbuseRenote));
|
||||
if ($i?.isModerator || $i?.isAdmin) {
|
||||
menuItems.push(getUnrenote());
|
||||
}
|
||||
|
||||
os.popupMenu(menuItems, els.renoteTime?.value);
|
||||
}
|
||||
}
|
||||
|
||||
// フォーカス制御
|
||||
function focus() { els.rootEl?.value?.focus(); }
|
||||
|
||||
function blur() { els.rootEl?.value?.blur(); }
|
||||
|
||||
return {
|
||||
// 状態・データ
|
||||
note: rawNote,
|
||||
appearNote,
|
||||
$appearNote,
|
||||
hideByPlugin,
|
||||
isRenote,
|
||||
showContent,
|
||||
isDeleted,
|
||||
translating,
|
||||
translation,
|
||||
muted,
|
||||
hardMuted,
|
||||
collapsed,
|
||||
renoteCollapsed,
|
||||
|
||||
// 計算プロパティ
|
||||
isMyRenote,
|
||||
parsed,
|
||||
urls,
|
||||
isLong,
|
||||
showTicker,
|
||||
canRenote,
|
||||
|
||||
// アクション関数
|
||||
renote,
|
||||
reply,
|
||||
react,
|
||||
reactViaMfmEmoji,
|
||||
toggleReact,
|
||||
onContextmenu,
|
||||
showMenu,
|
||||
clip,
|
||||
showRenoteMenu,
|
||||
focus,
|
||||
blur,
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
<SearchMarker>
|
||||
<MkInput v-model="smtpPass" type="password">
|
||||
<MkInput v-model="smtpPass" type="password" autocomplete="new-password">
|
||||
<template #label><SearchLabel>{{ i18n.ts.smtpPass }}</SearchLabel></template>
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
@@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker>
|
||||
<MkInput v-model="objectStorageSecretKey" type="password">
|
||||
<MkInput v-model="objectStorageSecretKey" type="password" autocomplete="new-password">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label><SearchLabel>Secret key</SearchLabel></template>
|
||||
</MkInput>
|
||||
|
||||
@@ -46,7 +46,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['api', 'key', 'token', 'sensitive']">
|
||||
<MkInput v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionApiKey" type="password">
|
||||
<MkInput v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionApiKey" type="password" autocomplete="new-password">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.apiKey }}</SearchLabel></template>
|
||||
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.apiKeyDescription }}</SearchText></template>
|
||||
</MkInput>
|
||||
|
||||
@@ -240,6 +240,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchMarker :keywords="['deny', 'list']">
|
||||
<MkTextarea v-model="urlPreviewForm.state.urlPreviewSensitiveList" tall>
|
||||
<template #label><SearchLabel>{{ i18n.ts.urlPreviewSensitiveList }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewSensitiveList" class="_modified">{{ i18n.ts.modified }}</span></template>
|
||||
<template #caption>{{ i18n.ts.urlPreviewSensitiveListDescription }}</template>
|
||||
</MkTextarea>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
</div>
|
||||
</MkFolder>
|
||||
@@ -465,6 +472,7 @@ const urlPreviewForm = useForm({
|
||||
urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength,
|
||||
urlPreviewUserAgent: meta.urlPreviewUserAgent ?? '',
|
||||
urlPreviewSummaryProxyUrl: meta.urlPreviewSummaryProxyUrl ?? '',
|
||||
urlPreviewSensitiveList: meta.urlPreviewSensitiveList.join('\n'),
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
urlPreviewEnabled: state.urlPreviewEnabled,
|
||||
@@ -474,6 +482,7 @@ const urlPreviewForm = useForm({
|
||||
urlPreviewRequireContentLength: state.urlPreviewRequireContentLength,
|
||||
urlPreviewUserAgent: state.urlPreviewUserAgent,
|
||||
urlPreviewSummaryProxyUrl: state.urlPreviewSummaryProxyUrl,
|
||||
urlPreviewSensitiveList: state.urlPreviewSensitiveList.split('\n'),
|
||||
});
|
||||
fetchInstance(true);
|
||||
});
|
||||
|
||||
@@ -154,6 +154,7 @@ function onDrop(ev: DragEvent): void {
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
|
||||
if (ev.key === 'Enter') {
|
||||
if (prefer.s['chat.sendOnEnter']) {
|
||||
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
|
||||
|
||||
@@ -76,6 +76,12 @@ const note = ref<null | Misskey.entities.Note>(CTX_NOTE);
|
||||
const clips = ref<Misskey.entities.Clip[]>();
|
||||
const showPrev = ref<'user' | 'channel' | false>(false);
|
||||
const showNext = ref<'user' | 'channel' | false>(false);
|
||||
const initialTab = computed<'reactions' | 'replies' | 'renotes' | undefined>(() => {
|
||||
if (['reactions', 'replies', 'renotes'].includes(props.initialTab ?? '')) {
|
||||
return props.initialTab as 'reactions' | 'replies' | 'renotes';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const error = ref();
|
||||
|
||||
const prevUserPaginator = markRaw(new Paginator('users/notes', {
|
||||
|
||||
45
packages/frontend/src/telemetry.ts
Normal file
45
packages/frontend/src/telemetry.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import type { App } from 'vue';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
|
||||
export async function initTelemetry(instance: Misskey.entities.MetaDetailed, app: App): Promise<void> {
|
||||
if (!instance.sentryForFrontend) return;
|
||||
|
||||
const Sentry = await import('@sentry/vue');
|
||||
Sentry.init({
|
||||
app,
|
||||
integrations: [
|
||||
...(instance.sentryForFrontend.vueIntegration !== undefined ? [
|
||||
Sentry.vueIntegration(instance.sentryForFrontend.vueIntegration ?? undefined),
|
||||
] : []),
|
||||
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? [
|
||||
Sentry.browserTracingIntegration(instance.sentryForFrontend.browserTracingIntegration ?? undefined),
|
||||
] : []),
|
||||
...(instance.sentryForFrontend.replayIntegration !== undefined ? [
|
||||
Sentry.replayIntegration(instance.sentryForFrontend.replayIntegration ?? undefined),
|
||||
] : []),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
// Set `tracePropagationTargets` to control for which URLs distributed tracing should be enabled
|
||||
...(instance.sentryForFrontend.browserTracingIntegration !== undefined ? {
|
||||
tracePropagationTargets: [apiUrl],
|
||||
} : {}),
|
||||
|
||||
// Capture Replay for 10% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
...(instance.sentryForFrontend.replayIntegration !== undefined ? {
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
} : {}),
|
||||
|
||||
...instance.sentryForFrontend.options,
|
||||
});
|
||||
}
|
||||
@@ -2,5 +2,8 @@
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
export type Awaitable <T> = T | Promise<T>;
|
||||
export type Awaitable<T> = T | Promise<T>;
|
||||
|
||||
export type ExtractInjectedType<T extends InjectionKey<any>> = T extends InjectionKey<infer U> ? U : never;
|
||||
|
||||
@@ -33,7 +33,7 @@ const isInBrowserTranslationAvailable = (
|
||||
|
||||
export async function getNoteClipMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
currentClip?: Misskey.entities.Clip;
|
||||
currentClip?: Misskey.entities.Clip | null;
|
||||
}) {
|
||||
function getClipName(clip: Misskey.entities.Clip) {
|
||||
if ($i && clip.userId === $i.id && clip.notesCount != null) {
|
||||
@@ -181,8 +181,8 @@ export function getNoteMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
|
||||
translating: Ref<boolean>;
|
||||
currentClip?: Misskey.entities.Clip;
|
||||
currentAntenna?: Misskey.entities.Antenna;
|
||||
currentClip?: Misskey.entities.Clip | null;
|
||||
currentAntenna?: Misskey.entities.Antenna | null;
|
||||
}) {
|
||||
const appearNote = getAppearNote(props.note) ?? props.note;
|
||||
const link = appearNote.url ?? appearNote.uri;
|
||||
|
||||
@@ -7,18 +7,46 @@ import { vi } from 'vitest';
|
||||
import createFetchMock from 'vitest-fetch-mock';
|
||||
import type { Ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
// Set i18n
|
||||
import locales from 'i18n';
|
||||
import { updateI18n } from '@/i18n.js';
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
fetchMocker.enableMocks();
|
||||
|
||||
updateI18n(locales['en-US']);
|
||||
|
||||
// XXX: misskey-js panics if WebSocket is not defined
|
||||
vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; });
|
||||
|
||||
// XXX: localStorageがない場合がある
|
||||
const localStorageMock = (() => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
vi.stubGlobal('localStorage', localStorageMock);
|
||||
|
||||
// 中でlocalStorageを使うので上と順番を変えてはいけない
|
||||
const { default: locales } = await import('i18n');
|
||||
|
||||
fetchMocker.mockIf(/^\/assets\/locales\/.*\.json$/, async () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify(locales['en-US']),
|
||||
};
|
||||
});
|
||||
|
||||
const { updateI18n } = await import('@/i18n.js');
|
||||
updateI18n(locales['en-US']);
|
||||
|
||||
export const preferState: Record<string, unknown> = {
|
||||
|
||||
// なんかtestがうまいこと動かないのでここに書く
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user