1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-04 14:24:55 +02:00

Compare commits

...

33 Commits

Author SHA1 Message Date
かっこかり
97082efd99 Update CHANGELOG.md [ci skip] 2026-07-04 20:46:03 +09:00
Johann150
187610d516 fix(frontend): correctly detect isMe in mention (#17664) 2026-07-04 20:43:42 +09:00
かっこかり
00b2cb2076 fix(backend/test): streaming.ts e2e の検証不備を修正 (#17663)
fix(backend/test): streaming.ts e2e の検証不備を修正 (tiramiss-community/endolphin#113)

Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com>
2026-07-04 18:53:10 +09:00
おさむのひと
426f6748e5 fix: frontend-builderのutils.tsが型エラーを起こしていたのを修正 (#17649) 2026-07-04 17:44:42 +09:00
かっこかり
c423bf92a8 Update CHANGELOG.md 2026-07-04 16:47:31 +09:00
かっこかり
f59bf35615 feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (#17635)
* URLプレビューのサムネイルを隠す機能を追加 (MisskeyIO#214)

* Update Changelog

* fix

* Update Changelog

* fix

* fix: もうセンシティブと判定されている場合はそれをそのまま通すように

* fix: unnecessary import

* fix lint

---------

Co-authored-by: CyberRex <hspwinx86@gmail.com>
2026-07-04 16:26:22 +09:00
おさむのひと
0f056c4955 refactor: Sentry呼び出し個所の抽象化 (#17661) 2026-07-04 16:06:24 +09:00
かっこかり
eb2c7ff6c6 fix(backend/test): follow-up of #17654 (waitForを他のe2eテストにも展開) (#17659)
test(backend): block.ts等のfanout timeline反映待ちレースを修正する (tiramiss-community/endolphin#112)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-04 11:21:23 +09:00
おさむのひと
9f614517c0 fix: tsconfig.jsonにskipLibCheck: trueを追加する (#17651)
* fix: tsconfig.jsonにskipLibCheck: trueを追加する

* skipLibCheckで抜けたチェックを自前でやる
2026-07-03 19:22:23 +09:00
github-actions[bot]
5432984af8 Bump version to 2026.7.0-alpha.0 2026-07-03 10:15:24 +00:00
かっこかり
c29a3d902b refactor(frontend): MkNote/MkNoteDetailedのロジックを統合 (#17636)
* refactor(frontend): MkNote/MkNoteDetailedのロジックを統合

* refactor

* fix

* fix: 差分を解消

* fix lint

* fix types

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-03 19:14:48 +09:00
かっこかり
721b1b06a0 fix(backend/test): backend e2e test の beforeAll でdispose前にschema dropが走るのを修正 (#17653)
fix(backend): setup.e2e.ts の beforeAll でdispose前にschema dropが走る順序バグを修正 (Phase 6) (tiramiss-community/endolphin#52)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 15:57:39 +09:00
かっこかり
97e54a1ee8 fix(frontend): APIキーを指定するいくつかのフォームでautocompleteが発動する問題を修正 (#17655) 2026-07-03 14:58:58 +09:00
かっこかり
96d6a09ebc fix(backend/test): e2e testの固定待ち時間をポーリングに変更 (#17654)
* test(backend): timelines.ts の固定250ms待ちをポーリング化する (tiramiss-community/endolphin#47)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>

* test(backend): move.ts のflaky/低速sleepをポーリング化する (tiramiss-community/endolphin#49)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>

---------

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-07-03 10:46:04 +09:00
かっこかり
1eedf04d9a fix(frontend): デバイスサイズをスマートフォンに固定している場合はページヘッダーのアイコンを常に表示するように (#17590)
* fix(frontend): デバイスサイズをスマートフォンに固定している場合はページヘッダーのアイコンを常に表示するように

* Update Changelog

* Update Changelog

* fix

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-07-02 22:36:57 +09:00
ihoronir
2d67380f2a fix(backend): 同名の新規ハッシュタグが複数同時に作成されたとき、DBで発生した競合を適切にハンドリング (#17640)
* Hashtag Timeline のテストを復活させる

* HashtagService.ts を修正

* fix indent

* refactor

* Update CHANGELOG.md

* fix review
2026-07-02 09:48:26 +09:00
おさむのひと
174fb434cc fix: frontend-embedで見つけた型エラーの修正 (#17650) 2026-07-02 09:24:50 +09:00
greymoth
7e29f04287 fix(chat): don't send message while the IME is composing (#17646)
Co-authored-by: greymoth <246701683+greymoth-jp@users.noreply.github.com>
2026-07-01 10:06:16 +09:00
renovate[bot]
bf88122140 chore(deps): update [github actions] update dependencies [ci skip] (#17645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 13:11:23 +09:00
かっこかり
4daa1ffe05 chore(deps): 未使用依存パッケージを削除 (#17641)
chore(deps): 未使用依存パッケージを削除 (tiramiss-community/endolphin#20)

Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
2026-06-29 23:08:09 +09:00
syuilo
62f8589c05 Update CONTRIBUTING.md 2026-06-28 17:52:08 +09:00
かっこかり
6193c35f9f fix: stats API の型に reactionCount が定義されていない問題を修正 (#17634)
* fix: stats API の型に reactionCount が定義されていない問題を修正

* Update Changelog
2026-06-28 01:49:00 +09:00
かっこかり
1220f05903 Update CHANGELOG.md 2026-06-28 01:02:03 +09:00
かっこかり
7544ebf7a3 Update CHANGELOG.md 2026-06-28 00:54:52 +09:00
かっこかり
ffe65caf10 Update CHANGELOG.md 2026-06-28 00:25:42 +09:00
かっこかり
4f993cef1b enhance: Set default Node.js version to v26 (#17623)
* enhance: Set default Node.js version to v26

* fix

* Update Changelog

* fix types

* drop node v22

* update changelog

* fix test

* update

* fix test

* fix test

* Revert "drop node v22"

This reverts commit bb4e011628.

* fix changelog

* attempt to fix test

* attempt to fix test

* fix: update re2 that supports node 26

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* restore 2fa e2e

* refactor

* attempt to fix test

* attempt to fix test

* run pnpm dedupe

* attempt to fix test
2026-06-28 00:06:49 +09:00
かっこかり
ba3fb4aa14 Merge branch 'master' into develop 2026-06-27 23:40:06 +09:00
かっこかり
0137b1c406 fix: update compatible node version on master (#17632)
Update package.json
2026-06-27 23:39:29 +09:00
かっこかり
797dec7d0e Update CHANGELOG.md 2026-06-27 23:36:35 +09:00
github-actions[bot]
42ff280163 Bump version to 2026.6.1-alpha.1 2026-06-27 13:08:35 +00:00
かっこかり
554339aaa1 deps: update dependencies (#17631)
* deps: update deps

* Update Changelog

* update dependencies (major)

* fix: broken lockfile on changelog-checker

* update vite

* update minimum working node version to 22.22.0

* update minimum working node version to 22.22.2

* fix types

* refactor: avoid using default import

* Revert "refactor: avoid using default import"

This reverts commit 5784df2ff0.

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2026-06-27 22:04:08 +09:00
syuilo
529c4d4d0e chore(dev): tweak heap snapshot table 2026-06-27 20:21:17 +09:00
syuilo
cec23e5756 enhance(dev): improve heap snapshot analysis (#17628)
* Update measure-memory.mts

* Update utility.mts

* Update backend-memory-report.mts

* tweak
2026-06-27 18:59:46 +09:00
121 changed files with 4486 additions and 5248 deletions

View File

@@ -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"

View File

@@ -1 +1 @@
22.15.0
22.22.2

View File

@@ -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);

View File

@@ -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');

View File

@@ -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)} |`);
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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:

View File

@@ -18,7 +18,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@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

View File

@@ -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

View File

@@ -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

View File

@@ -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!"

View File

@@ -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

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -1 +1 @@
22.18.0
26.4.0

View File

@@ -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

View File

@@ -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 という単語は使わない
広告ブロッカーで誤ってブロックされる

View File

@@ -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

View File

@@ -1419,6 +1419,8 @@ addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
append: "末尾に追加"
prepend: "先頭に追加"
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
_imageEditing:
_vars:

View File

@@ -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"
}
}

View File

@@ -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"`);
}
}

View File

@@ -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"
}
}

View File

@@ -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',
);

View File

@@ -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 };
}

View File

@@ -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}]`,

View File

@@ -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();

View File

@@ -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 付きで自動設定させるため、手動設定はしない。

View File

@@ -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
],
})

View File

@@ -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;

View File

@@ -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

View File

@@ -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',

View 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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>;
}

View 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()));
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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');

View File

@@ -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',

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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 () => {

View File

@@ -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);
});
});

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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);
});

View File

@@ -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';

View File

@@ -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"
}
}

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"noEmit": true,
"removeComments": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,

View File

@@ -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 {
}

View File

@@ -4,7 +4,7 @@
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@/theme.js';
import { Theme } from '@@/js/theme.js';
const theme: Theme;

View File

@@ -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"
}
}

View File

@@ -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, []);

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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);

View File

@@ -13,6 +13,7 @@
"module": "ES2022",
"moduleResolution": "Bundler",
"removeComments": false,
"skipLibCheck": true,
"noLib": false,
"strict": true,
"strictNullChecks": true,

View File

@@ -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"
}
}

View File

@@ -9,6 +9,7 @@
"sourceMap": false,
"outDir": "./js-built",
"removeComments": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,

View File

@@ -4,7 +4,7 @@
*/
declare module '@@/themes/*.json5' {
import { Theme } from '@/theme.js';
import { Theme } from '@@/js/theme.js';
const theme: Theme;

View File

@@ -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"
}
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View 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,
};
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
});

View File

@@ -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)) {

View File

@@ -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', {

View 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,
});
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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