1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-06-23 08:54:49 +02:00

Compare commits

...

21 Commits

Author SHA1 Message Date
syuilo
a1791ae189 Merge branch 'develop' into sp-reaction 2026-06-23 15:19:10 +09:00
syuilo
2c814ecd83 chore(dev): tweak frontend-js-size.mjs 2026-06-23 15:18:09 +09:00
syuilo
2c93ecacf6 Merge branch 'develop' into sp-reaction 2026-06-23 15:01:35 +09:00
syuilo
05e00e4c2b refactor(dev): refactor frontend-js-size.mjs 2026-06-23 15:01:19 +09:00
syuilo
3405db42ea Merge branch 'develop' into sp-reaction 2026-06-23 14:53:51 +09:00
syuilo
4bacb1bfbe chore(dev): fix typo 2026-06-23 14:53:38 +09:00
syuilo
4f2ab4e6e2 Merge branch 'develop' into sp-reaction 2026-06-23 14:49:06 +09:00
syuilo
8186742c0f refactor(dev): unify frontend-bundle-visualizer-report.mjs and frontend-js-size.mjs 2026-06-23 14:48:25 +09:00
syuilo
544c4227f7 chore(dev): refactor 2026-06-23 13:12:57 +09:00
syuilo
67071de164 Merge branch 'develop' into sp-reaction 2026-06-23 12:19:44 +09:00
syuilo
9d091a5245 Merge branch 'develop' into sp-reaction 2026-06-23 11:54:52 +09:00
syuilo
1beaee367c Merge branch 'develop' into sp-reaction 2026-06-23 11:30:00 +09:00
syuilo
9838686437 Merge branch 'develop' into sp-reaction 2026-06-23 11:21:39 +09:00
syuilo
a567bef08a Merge branch 'develop' into sp-reaction 2026-06-23 11:04:29 +09:00
syuilo
464256c6b5 Merge branch 'develop' into sp-reaction 2026-06-23 10:12:13 +09:00
syuilo
f08d6e46df Update ReactionService.ts 2026-01-29 17:42:40 +09:00
syuilo
d53b2f532a Create 1769664628306-sp-reactions.js 2026-01-29 14:31:16 +09:00
syuilo
40f6acd720 wip 2026-01-29 14:23:09 +09:00
syuilo
9dc4762fe0 wip 2026-01-29 13:50:37 +09:00
syuilo
f3d26722bb wip 2026-01-29 11:33:05 +09:00
syuilo
5eb873ff91 wip 2026-01-29 11:21:12 +09:00
28 changed files with 710 additions and 409 deletions

View File

@@ -1,276 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises';
const [beforeFile, afterFile, outputFile] = process.argv.slice(2);
if (beforeFile == null || afterFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <before-stats.json> <after-stats.json> <report.md>');
process.exit(1);
}
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatPercent(value) {
return `${Math.round(value)}%`;
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return formatPercent((value / total) * 100);
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiff(before, after, formatter) {
const diff = after - before;
if (diff === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff);
}
function formatDiffPercent(before, after) {
if (before === 0 && after === 0) return '0%';
if (before === 0) return '-';
const diff = after - before;
if (diff === 0) return '0%';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff);
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function collectReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function renderSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<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>${formatDiff(before.summary[key], after.summary[key], formatNumber)}</td>`),
...metrics.map((key) => `<td>${formatDiff(before.metrics[key], after.metrics[key], formatBytes)}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
const beforeData = JSON.parse(await readFile(beforeFile, 'utf8'));
const afterData = JSON.parse(await readFile(afterFile, 'utf8'));
const before = collectReport(beforeData);
const after = collectReport(afterData);
const lines = [
'## Frontend Bundle Report',
'',
...renderSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
await writeFile(outputFile, `${lines.join('\n')}\n`);

View File

@@ -1,8 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
@@ -33,47 +40,88 @@ async function* walk(dir) {
}
}
function formatBytes(size) {
if (size == null) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${stripTrailingZeros((size / 1024).toFixed(1))} KiB`;
return `${stripTrailingZeros((size / 1024 / 1024).toFixed(2))} MiB`;
function formatNumber(value) {
return numberFormatter.format(value);
}
function stripTrailingZeros(value) {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB'];
let unitIndex = 0;
let size = value;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatMathText(text) {
function escapeLatex(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\\\%');
.replaceAll('%', '\\%');
}
function formatDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
function formatColoredDiff(text, diff) {
if (diff === 0) return text;
const color = diff > 0 ? 'orange' : 'green';
const sign = diff > 0 ? '+' : '-';
const text = `${sign}${formatBytes(Math.abs(diff))}`;
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text)}}}$`;
}
function formatDiffPercent(beforeSize, afterSize) {
if (beforeSize == null || beforeSize === 0 || afterSize == null || afterSize === 0) return '-';
const diff = afterSize - beforeSize;
function formatNumberDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
return formatColoredDiff(formatNumber(Math.abs(diff)), diff);
}
function formatBytesDiff(before, after) {
if (before == null || after == null) return '-';
const diff = after - before;
if (diff === 0) return '0 B';
return formatColoredDiff(formatBytes(Math.abs(diff)), diff);
}
function formatDiffPercent(before, after) {
if (before == null || before === 0 || after == null || after === 0) return '-';
const diff = after - before;
if (diff === 0) return `0%`;
const percent = Math.round(diff / beforeSize * 100);
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(percent.toString() + '%')}}}$`;
const percent = Math.round(diff / before * 100);
return formatColoredDiff(`${percent}%`, diff);
}
function sharePercent(value, total) {
if (total === 0) return '0%';
return Math.round((value / total) * 100) + '%';
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
function code(value) {
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
const backtickRuns = sanitized.match(/`+/g) ?? [];
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
const fence = '`'.repeat(fenceLength);
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
return `${fence}${padding}${sanitized}${padding}${fence}`;
}
function tableCode(value) {
return tableCell(code(value));
}
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName || entry.file;
@@ -174,26 +222,148 @@ async function collectReport(repoDir) {
};
}
function commonKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] != null);
function collectVisualizerReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
function addedKeys(before, after) {
return Object.keys(after.chunks)
.filter((key) => before.chunks[key] == null);
}
function renderVisualizerSummaryTable(before, after) {
const summary = [
'bundles',
'modules',
'entries',
//'externals',
'staticImports',
'dynamicImports',
];
function removedKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] == null);
}
const metrics = [
'renderedLength',
'gzipLength',
'brotliLength',
];
function rowChangeType(beforeEntry, afterEntry, beforeSize, afterSize) {
if (beforeEntry == null) return 'added';
if (afterEntry == null) return 'removed';
if (beforeSize !== afterSize) return 'updated';
return 'unchanged';
return [
`<table>`,
`<thead>`,
`<tr>`,
`<th rowspan="2"></th>`,
`<th rowspan="2">Bundles</th>`,
`<th rowspan="2">Modules</th>`,
`<th rowspan="2">Entries</th>`,
`<th colspan="2">Imports</th>`,
`<th colspan="3">Size</th>`,
`</tr>`,
`<tr>`,
`<th>Static</th>`,
`<th>Dynamic</th>`,
`<th>Rendered</th>`,
`<th>Gzip</th>`,
`<th>Brotli</th>`,
`</tr>`,
`</thead>`,
`<tbody>`,
`<tr>`,
`<th><b>Before</b></th>`,
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>After</b></th>`,
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
`</tr>`,
`<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>${formatNumberDiff(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytesDiff(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`<tr>`,
`<th><b>Δ (%)</b></th>`,
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
`</tr>`,
`</tbody>`,
`</table>`,
];
}
function getChunkComparisonRows(keys, before, after) {
@@ -208,13 +378,13 @@ function getChunkComparisonRows(keys, before, after) {
chunkFile: beforeEntry?.file ?? afterEntry?.file,
beforeSize,
afterSize,
changeType: rowChangeType(beforeEntry, afterEntry, beforeSize, afterSize),
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
sortSize: Math.max(beforeSize, afterSize),
};
});
}
function summarizeChanges(rows) {
function summarizeChunkChanges(rows) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
@@ -222,18 +392,18 @@ function summarizeChanges(rows) {
};
}
function formatChangeSummary(label, summary) {
function formatChunkChangeSummary(label, summary) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
function compareComparisonRows(a, b) {
function compareChunkComparisonRows(a, b) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
function markdownTable(rows, total) {
function chunkMarkdownTable(rows, total) {
if (rows.length === 0) return '_No data_';
const lines = [
@@ -241,91 +411,140 @@ function markdownTable(rows, total) {
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatBytesDiff(total.beforeSize, total.afterSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | $\\color{orange}{\\text{(+)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{orange}{\\text{(+)}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | $\\color{green}{\\text{(-)}}$ |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | $\\color{green}{\\text{(-)}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatBytesDiff(row.beforeSize, row.afterSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize).replaceAll('\\%', '\\\\%')} |`);
}
}
return lines.join('\n');
}
const beforeDir = process.argv[2];
const afterDir = process.argv[3];
const outFile = process.argv[4];
const beforeSha = process.env.BASE_SHA;
const afterSha = process.env.HEAD_SHA;
function renderFrontendChunkReport(before, after) {
const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null);
const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null);
const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null);
const allChunkKeys = [
...commonChunkKeys,
...addedChunkKeys,
...removedChunkKeys,
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows.sort(compareChunkComparisonRows);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
return [
'<details open>',
`<summary>${formatChunkChangeSummary('Diffs', diffSummary)}</summary>`,
'',
chunkMarkdownTable(diffRows, diffTotal),
'',
'</details>',
'',
'<details>',
`<summary>${formatChunkChangeSummary('Startup', startupSummary)}</summary>`,
'',
chunkMarkdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
].join('\n');
}
function renderFrontendBundleReport(before, after) {
const lines = [
...renderVisualizerSummaryTable(before, after),
'',
'<details>',
'<summary>Top 10</summary>',
'',
];
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
'',
'</details>',
);
lines.push(
'',
'<details>',
'<summary>Hot Modules (Self Size)</summary>',
'',
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
lines.push(
'',
'</details>',
);
return lines.join('\n');
}
const args = process.argv.slice(2);
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const commonChunkKeys = commonKeys(before, after);
const allChunkKeys = [
...commonChunkKeys,
...addedKeys(before, after),
...removedKeys(before, after),
];
//const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChanges(changedRows);
const diffTotal = {
beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
const diffRows = changedRows.sort(compareComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
const startupRows = startupComparisonRows
.sort(compareComparisonRows);
const startupSummary = summarizeChanges(startupComparisonRows);
const startupTotal = {
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
};
//const largeRows = comparisonRows
// .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
// .slice(0, 30);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8'));
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8'));
const body = [
marker,
'',
`## Frontend Chunk Report`,
'',
'<details open>',
`<summary>${formatChangeSummary('Diffs', diffSummary)}</summary>`,
renderFrontendChunkReport(before, after),
'',
markdownTable(diffRows, diffTotal),
'## Frontend Bundle Report',
'',
'</details>',
'',
'<details>',
`<summary>${formatChangeSummary('Startup', startupSummary)}</summary>`,
'',
markdownTable(startupRows, startupTotal),
'',
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
//'<details>',
//`<summary>Largest</summary>`,
//'',
//markdownTable(largeRows),
//'',
//'</details>',
//'',
renderFrontendBundleReport(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
].join('\n');
await fs.writeFile(outFile, body);

View File

@@ -26,7 +26,6 @@ on:
- pnpm-workspace.yaml
- .node-version
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@@ -185,7 +184,6 @@ jobs:
const reportMarkers = [jsSizeMarker, visualizerMarker];
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
const visualizerReportPath = path.join(reportDir, 'frontend-bundle-visualizer-report.md');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
const headShaPath = path.join(reportDir, 'head-sha.txt');
const workflowRun = context.payload.workflow_run;
@@ -197,10 +195,6 @@ jobs:
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
return;
}
if (!fs.existsSync(visualizerReportPath)) {
core.setFailed('The frontend bundle report artifact does not contain frontend-bundle-visualizer-report.md.');
return;
}
const artifactHeadSha = fs.existsSync(headShaPath)
? fs.readFileSync(headShaPath, 'utf8').trim()
@@ -274,11 +268,7 @@ jobs:
core.setFailed('The frontend JS size report is missing the expected marker.');
return;
}
const visualizerReport = fs.readFileSync(visualizerReportPath, 'utf8').trim();
let body = [
jsSizeReport,
visualizerReport,
].join('\n\n') + '\n';
let body = `${jsSizeReport}\n`;
const maxCommentLength = 65_000;
if (body.length > maxCommentLength) {

View File

@@ -21,7 +21,6 @@ on:
- pnpm-workspace.yaml
- .node-version
- .github/scripts/frontend-js-size.mjs
- .github/scripts/frontend-bundle-visualizer-report.mjs
- .github/workflows/frontend-bundle-report.yml
- .github/workflows/frontend-bundle-report-comment.yml
@@ -134,8 +133,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/frontend-js-size-report.md"
node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-visualizer-report.md"
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
@@ -148,10 +146,7 @@ jobs:
test -s "$REPORT_DIR/before-stats.json"
test -s "$REPORT_DIR/after-stats.json"
test -s "$REPORT_DIR/frontend-js-size-report.md"
test -s "$REPORT_DIR/frontend-bundle-visualizer-report.md"
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
printf '\n\n' >> "$GITHUB_STEP_SUMMARY"
cat "$REPORT_DIR/frontend-bundle-visualizer-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload bundle report
if: steps.check-base-visualizer.outputs.supported == 'true'

View File

@@ -1790,6 +1790,12 @@ _serverSettings:
entrancePageStyle: "エントランスページのスタイル"
showTimelineForVisitor: "タイムラインを表示する"
showActivitiesForVisitor: "アクティビティを表示する"
features: "機能"
_spReactions:
enable: "スペシャルリアクションを有効にする"
description1: "通常のリアクションより目立つ「スペシャルリアクション」をノートに送れる機能です。"
description2: "有効にする場合、ロールポリシーで、毎月送ることのできる最大数を設定してください。"
_userGeneratedContentsVisibilityForVisitor:
all: "全て公開"
@@ -2904,6 +2910,7 @@ _notification:
renote: "リノート"
quote: "引用"
reaction: "リアクション"
spReaction: "スペシャルリアクション"
pollEnded: "アンケートが終了"
scheduledNotePosted: "予約投稿が成功した"
scheduledNotePostFailed: "予約投稿が失敗した"

View File

@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SpReactions1769664628306 {
name = 'SpReactions1769664628306'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "note_sp_reaction" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "reaction" character varying(260) NOT NULL, "noteUserId" character varying(32) NOT NULL, CONSTRAINT "PK_11fd5ecdd9bb91517edfcf890d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "note_sp_reaction"."noteUserId" IS '[Denormalized]'`);
await queryRunner.query(`CREATE INDEX "IDX_3463a48b09fa41e1826ebd9f58" ON "note_sp_reaction" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_bfe4caa46cc0526bc2932d6dbe" ON "note_sp_reaction" ("noteId") `);
await queryRunner.query(`CREATE INDEX "IDX_8be6eb3f4edc9940a3f8142669" ON "note_sp_reaction" ("noteUserId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b5f210b20bd987fe8584c85d33" ON "note_sp_reaction" ("userId", "noteId") `);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableSpReaction" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "note_sp_reaction" ADD CONSTRAINT "FK_3463a48b09fa41e1826ebd9f585" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_sp_reaction" ADD CONSTRAINT "FK_bfe4caa46cc0526bc2932d6dbed" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_sp_reaction" DROP CONSTRAINT "FK_bfe4caa46cc0526bc2932d6dbed"`);
await queryRunner.query(`ALTER TABLE "note_sp_reaction" DROP CONSTRAINT "FK_3463a48b09fa41e1826ebd9f585"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableSpReaction"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b5f210b20bd987fe8584c85d33"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8be6eb3f4edc9940a3f8142669"`);
await queryRunner.query(`DROP INDEX "public"."IDX_bfe4caa46cc0526bc2932d6dbe"`);
await queryRunner.query(`DROP INDEX "public"."IDX_3463a48b09fa41e1826ebd9f58"`);
await queryRunner.query(`DROP TABLE "note_sp_reaction"`);
}
}

View File

@@ -4,8 +4,9 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta, MiNoteSpReaction, NoteSpReactionsRepository } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
@@ -73,6 +74,9 @@ export class ReactionService {
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -82,6 +86,9 @@ export class ReactionService {
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.noteSpReactionsRepository)
private noteSpReactionsRepository: NoteSpReactionsRepository,
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
@@ -337,6 +344,118 @@ export class ReactionService {
//#endregion
}
@bindThis
public async createSp(user: { id: MiUser['id']; isBot: MiUser['isBot'] }, note: MiNote, reaction: string) {
if (!this.meta.enableSpReaction) {
throw new IdentifiableError('52c432ea-b166-491c-a73b-5dd703221b20');
}
if (note.userId === user.id) {
throw new IdentifiableError('afa694bf-6661-4d72-b8f7-bfb86a7545a1');
}
if (note.userHost !== null) {
throw new IdentifiableError('b59abda2-0d81-49e3-8148-80cf35ac4402');
}
if (note.reactionAcceptance === 'likeOnly') {
throw new IdentifiableError('1b168811-a1aa-470a-9b61-7fcf807cf9c1');
}
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
throw new IdentifiableError('3ce0e3bc-7d48-4e87-a902-578c6ffd369e', 'Note not accessible for you.');
}
// monthly limit
const policies = await this.roleService.getUserPolicies(user.id);
if (policies.spReactionsMonthlyLimit === 0) {
throw new IdentifiableError('e371be02-9478-4133-90ef-8401ee38e474');
}
const month = new Date().getUTCMonth() + 1;
const monthlySpReactionsCountMapKey = `monthlySpReactionsCountMap:${user.id}:${month}`;
const count = await this.redisClient.get(monthlySpReactionsCountMapKey);
if (count != null && parseInt(count, 10) >= policies.spReactionsMonthlyLimit) {
throw new IdentifiableError('82e1a10c-52a8-4ccb-8ff7-3678bff68444');
}
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
if (blocked) {
throw new IdentifiableError('388ee683-8720-4aea-9ac8-b8c92d260815');
}
const custom = reaction.match(isCustomEmojiRegexp);
if (custom) {
const name = custom[1];
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
if (emoji == null) {
throw new IdentifiableError('47c098e2-d0b6-4197-8d00-5a68bbb156be');
}
// センシティブ
if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) {
throw new IdentifiableError('7fc2efbd-2652-4a60-975b-6eb65f60c7b3');
}
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 && !(await this.roleService.getUserRoles(user.id)).some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))) {
// リアクションとして使う権限がない
throw new IdentifiableError('63288e20-4251-4c62-a9d5-9da4e0bdd41e');
}
reaction = `:${name}:`;
} else {
reaction = this.normalize(reaction);
}
const record: MiNoteSpReaction = {
id: this.idService.gen(),
noteId: note.id,
userId: user.id,
reaction,
noteUserId: note.userId,
};
try {
await this.noteSpReactionsRepository.insert(record);
} catch (e) {
if (isDuplicateKeyValueError(e)) {
throw new IdentifiableError('c9e8b0d0-d532-4453-8cc1-5cf8e95ba764');
} else {
throw e;
}
}
// increment monthly reactions count
const redisPipeline = this.redisClient.pipeline();
redisPipeline.incr(monthlySpReactionsCountMapKey);
redisPipeline.expireat(monthlySpReactionsCountMapKey,
(Date.now() / 1000) + (60 * 60 * 24 * 40), // TTLは最低でも一か月存続しさえすれば厳密でなくていい
'NX',
);
redisPipeline.exec();
// 3日以内に投稿されたートの場合ハイライト用ランキング更新
if (
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
) {
if (note.channelId != null) {
if (note.replyId == null) {
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
}
} else {
if (note.visibility === 'public' && note.replyId == null) {
this.featuredService.updateGlobalNotesRanking(note.id, 1);
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
}
}
}
this.notificationService.createNotification(note.userId, 'spReaction', {
noteId: note.id,
reaction: reaction,
}, user.id);
}
/**
* - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する
* - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果)

View File

@@ -73,6 +73,7 @@ export type RolePolicies = {
noteDraftLimit: number;
scheduledNoteLimit: number;
watermarkAvailable: boolean;
spReactionsMonthlyLimit: number;
};
export const DEFAULT_POLICIES: RolePolicies = {
@@ -121,6 +122,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
noteDraftLimit: 10,
scheduledNoteLimit: 1,
watermarkAvailable: true,
spReactionsMonthlyLimit: 0,
};
@Injectable()

View File

@@ -29,6 +29,7 @@ const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set([
'renote:grouped',
'quote',
'reaction',
'spReaction',
'reaction:grouped',
'pollEnded',
'scheduledNotePosted',

View File

@@ -24,6 +24,7 @@ export const DI = {
noteFavoritesRepository: Symbol('noteFavoritesRepository'),
noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
noteReactionsRepository: Symbol('noteReactionsRepository'),
noteSpReactionsRepository: Symbol('noteSpReactionsRepository'),
pollsRepository: Symbol('pollsRepository'),
pollVotesRepository: Symbol('pollVotesRepository'),
userProfilesRepository: Symbol('userProfilesRepository'),

View File

@@ -722,6 +722,11 @@ export class MiMeta {
})
public showRoleBadgesOfRemoteUsers: boolean;
@Column('boolean', {
default: false,
})
public enableSpReaction: boolean;
@Column('jsonb', {
default: { },
})

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiNote } from './Note.js';
@Entity('note_sp_reaction')
@Index(['userId', 'noteId'], { unique: true })
export class MiNoteSpReaction {
@PrimaryColumn(id())
public id: string;
@Index()
@Column(id())
public userId: MiUser['id'];
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user?: MiUser | null;
@Index()
@Column(id())
public noteId: MiNote['id'];
@ManyToOne(() => MiNote, {
onDelete: 'CASCADE',
})
@JoinColumn()
public note?: MiNote | null;
@Column('varchar', {
length: 260,
})
public reaction: string;
//#region Denormalized fields
@Index()
@Column({
...id(),
comment: '[Denormalized]',
})
public noteUserId: MiUser['id'];
//#endregion
}

View File

@@ -55,6 +55,13 @@ export type MiNotification = {
notifierId: MiUser['id'];
noteId: MiNote['id'];
reaction: string;
} | {
type: 'spReaction';
id: string;
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
reaction: string;
} | {
type: 'pollEnded';
id: string;

View File

@@ -42,6 +42,7 @@ import {
MiNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteSpReaction,
MiNoteThreadMuting,
MiNoteDraft,
MiPage,
@@ -142,6 +143,12 @@ const $noteReactionsRepository: Provider = {
inject: [DI.db],
};
const $noteSpReactionsRepository: Provider = {
provide: DI.noteSpReactionsRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteSpReaction).extend(miRepository as MiRepository<MiNoteSpReaction>),
inject: [DI.db],
};
const $noteDraftsRepository: Provider = {
provide: DI.noteDraftsRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteDraft).extend(miRepository as MiRepository<MiNoteDraft>),
@@ -556,6 +563,7 @@ const $reversiGamesRepository: Provider = {
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteSpReactionsRepository,
$noteDraftsRepository,
$pollsRepository,
$pollVotesRepository,
@@ -634,6 +642,7 @@ const $reversiGamesRepository: Provider = {
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteSpReactionsRepository,
$noteDraftsRepository,
$pollsRepository,
$pollVotesRepository,

View File

@@ -23,7 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiChannel } from '@/models/Channel.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChannelMuting } from "@/models/ChannelMuting.js";
import { MiChannelMuting } from '@/models/ChannelMuting.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
@@ -50,6 +50,7 @@ import { MiNote } from '@/models/Note.js';
import { MiNoteDraft } from '@/models/NoteDraft.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteSpReaction } from '@/models/NoteSpReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
import { MiPage } from '@/models/Page.js';
import { MiPageLike } from '@/models/PageLike.js';
@@ -131,6 +132,7 @@ export {
MiNoteDraft,
MiNoteFavorite,
MiNoteReaction,
MiNoteSpReaction,
MiNoteThreadMuting,
MiPage,
MiPageLike,
@@ -211,6 +213,7 @@ export type NotesRepository = Repository<MiNote> & MiRepository<MiNote>;
export type NoteDraftsRepository = Repository<MiNoteDraft> & MiRepository<MiNoteDraft>;
export type NoteFavoritesRepository = Repository<MiNoteFavorite> & MiRepository<MiNoteFavorite>;
export type NoteReactionsRepository = Repository<MiNoteReaction> & MiRepository<MiNoteReaction>;
export type NoteSpReactionsRepository = Repository<MiNoteSpReaction> & MiRepository<MiNoteSpReaction>;
export type NoteThreadMutingsRepository = Repository<MiNoteThreadMuting> & MiRepository<MiNoteThreadMuting>;
export type PagesRepository = Repository<MiPage> & MiRepository<MiPage>;
export type PageLikesRepository = Repository<MiPageLike> & MiRepository<MiPageLike>;

View File

@@ -182,6 +182,35 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['spReaction'],
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
note: {
type: 'object',
ref: 'Note',
optional: false, nullable: false,
},
reaction: {
type: 'string',
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {

View File

@@ -608,6 +608,7 @@ export const packedMeDetailedOnlySchema = {
renote: { optional: true, ...notificationRecieveConfig },
quote: { optional: true, ...notificationRecieveConfig },
reaction: { optional: true, ...notificationRecieveConfig },
spReaction: { optional: true, ...notificationRecieveConfig },
pollEnded: { optional: true, ...notificationRecieveConfig },
scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },

View File

@@ -44,6 +44,7 @@ import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteSpReaction } from '@/models/NoteSpReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
import { MiNoteDraft } from '@/models/NoteDraft.js';
import { MiPage } from '@/models/Page.js';
@@ -204,6 +205,7 @@ export const entities = [
MiNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteSpReaction,
MiNoteThreadMuting,
MiNoteDraft,
MiPage,

View File

@@ -596,6 +596,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableSpReaction: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
} as const;
@@ -752,6 +756,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
remoteNotesCleaningExpiryDaysForEachNotes: instance.remoteNotesCleaningExpiryDaysForEachNotes,
remoteNotesCleaningMaxProcessingDurationInMinutes: instance.remoteNotesCleaningMaxProcessingDurationInMinutes,
showRoleBadgesOfRemoteUsers: instance.showRoleBadgesOfRemoteUsers,
enableSpReaction: instance.enableSpReaction,
};
});
}

View File

@@ -218,6 +218,7 @@ export const paramDef = {
remoteNotesCleaningExpiryDaysForEachNotes: { type: 'number' },
remoteNotesCleaningMaxProcessingDurationInMinutes: { type: 'number' },
showRoleBadgesOfRemoteUsers: { type: 'boolean' },
enableSpReaction: { type: 'boolean' },
},
required: [],
} as const;
@@ -762,6 +763,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.showRoleBadgesOfRemoteUsers = ps.showRoleBadgesOfRemoteUsers;
}
if (ps.enableSpReaction !== undefined) {
set.enableSpReaction = ps.enableSpReaction;
}
const before = await this.metaService.fetch(true);
await this.metaService.update(set);

View File

@@ -208,6 +208,7 @@ export const paramDef = {
renote: notificationRecieveConfig,
quote: notificationRecieveConfig,
reaction: notificationRecieveConfig,
spReaction: notificationRecieveConfig,
pollEnded: notificationRecieveConfig,
scheduledNotePosted: notificationRecieveConfig,
scheduledNotePostFailed: notificationRecieveConfig,

View File

@@ -11,6 +11,7 @@
* renote - 投稿がRenoteされた
* quote - 投稿が引用Renoteされた
* reaction - 投稿にリアクションされた
* spReaction - スペシャルリアクションされた
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
* scheduledNotePosted - 予約したノートが投稿された
* scheduledNotePostFailed - 予約したノートの投稿に失敗した
@@ -33,6 +34,7 @@ export const notificationTypes = [
'renote',
'quote',
'reaction',
'spReaction',
'pollEnded',
'scheduledNotePosted',
'scheduledNotePostFailed',

View File

@@ -41,6 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'spReaction'" class="ti ti-octahedron-plus"></i>
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i>
<i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
@@ -74,7 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'spReaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
@@ -82,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
</header>
<div>
<MkA v-if="notification.type === 'reaction' || notification.type === 'reaction:grouped'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<MkA v-if="notification.type === 'reaction' || notification.type === 'reaction:grouped' || notification.type === 'spReaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="ti ti-quote" :class="$style.quote"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>

View File

@@ -96,6 +96,28 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['features']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-puzzle"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.features }}</SearchLabel></template>
<template v-if="featuresForm.modified.value" #footer>
<MkFormFooter :form="featuresForm"/>
</template>
<div class="_gaps">
<SearchMarker>
<MkSwitch v-model="featuresForm.state.enableSpReaction">
<template #label><SearchLabel>{{ i18n.ts._serverSettings._spReactions.enable }}</SearchLabel><span v-if="featuresForm.modifiedStates.enableSpReaction" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<SearchText>{{ i18n.ts._serverSettings._spReactions.description1 }}</SearchText>
<div>{{ i18n.ts._serverSettings._spReactions.description2 }}</div>
</template>
</MkSwitch>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['pinned', 'users']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-user-star"></i></SearchIcon></template>
@@ -426,6 +448,15 @@ const infoForm = useForm({
fetchInstance(true);
});
const featuresForm = useForm({
enableSpReaction: meta.enableSpReaction,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableSpReaction: state.enableSpReaction,
});
fetchInstance(true);
});
const pinnedUsersForm = useForm({
pinnedUsers: meta.pinnedUsers.join('\n'),
}, async (state) => {

View File

@@ -7020,6 +7020,24 @@ export interface Locale extends ILocale {
* アクティビティを表示する
*/
"showActivitiesForVisitor": string;
/**
* 機能
*/
"features": string;
"_spReactions": {
/**
* スペシャルリアクションを有効にする
*/
"enable": string;
/**
* 通常のリアクションより目立つ「スペシャルリアクション」をノートに送れる機能です。
*/
"description1": string;
/**
* 有効にする場合、ロールポリシーで、毎月送ることのできる最大数を設定してください。
*/
"description2": string;
};
"_userGeneratedContentsVisibilityForVisitor": {
/**
* 全て公開
@@ -10985,6 +11003,10 @@ export interface Locale extends ILocale {
* リアクション
*/
"reaction": string;
/**
* スペシャルリアクション
*/
"spReaction": string;
/**
* アンケートが終了
*/

View File

@@ -3286,7 +3286,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
// @public (undocumented)
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "spReaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
// @public (undocumented)
export function nyaize(text: string): string;
@@ -3501,7 +3501,7 @@ type RoleLite = components['schemas']['RoleLite'];
type RolePolicies = components['schemas']['RolePolicies'];
// @public (undocumented)
export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "canCreateChannel", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "scheduledNoteLimit", "watermarkAvailable"];
export const rolePolicies: readonly ["gtlAvailable", "ltlAvailable", "canPublicNote", "mentionLimit", "canInvite", "inviteLimit", "inviteLimitCycle", "inviteExpirationTime", "canManageCustomEmojis", "canManageAvatarDecorations", "canSearchNotes", "canSearchUsers", "canUseTranslator", "canHideAds", "canCreateChannel", "driveCapacityMb", "maxFileSizeMb", "alwaysMarkNsfw", "canUpdateBioMedia", "pinLimit", "antennaLimit", "wordMuteLimit", "webhookLimit", "clipLimit", "noteEachClipsLimit", "userListLimit", "userEachUserListsLimit", "rateLimitFactor", "avatarDecorationLimit", "canImportAntennas", "canImportBlocking", "canImportFollowing", "canImportMuting", "canImportUserLists", "chatAvailability", "uploadableFileTypes", "noteDraftLimit", "scheduledNoteLimit", "watermarkAvailable", "spReactionsMonthlyLimit"];
// @public (undocumented)
type RolesListResponse = operations['roles___list']['responses']['200']['content']['application/json'];

View File

@@ -4222,6 +4222,15 @@ export type components = {
/** Format: misskey:id */
userListId: string;
};
spReaction?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
pollEnded?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
@@ -4658,6 +4667,18 @@ export type components = {
userId: string;
note: components['schemas']['Note'];
reaction: string;
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'spReaction';
user: components['schemas']['UserLite'];
/** Format: id */
userId: string;
note: components['schemas']['Note'];
reaction: string;
} | {
/** Format: id */
id: string;
@@ -9561,6 +9582,7 @@ export interface operations {
remoteNotesCleaningExpiryDaysForEachNotes: number;
remoteNotesCleaningMaxProcessingDurationInMinutes: number;
showRoleBadgesOfRemoteUsers: boolean;
enableSpReaction: boolean;
};
};
};
@@ -13013,6 +13035,7 @@ export interface operations {
remoteNotesCleaningExpiryDaysForEachNotes?: number;
remoteNotesCleaningMaxProcessingDurationInMinutes?: number;
showRoleBadgesOfRemoteUsers?: boolean;
enableSpReaction?: boolean;
};
};
};
@@ -26525,8 +26548,8 @@ export interface operations {
untilDate?: number;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'spReaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'spReaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -26610,8 +26633,8 @@ export interface operations {
untilDate?: number;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'spReaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'spReaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -27876,6 +27899,15 @@ export interface operations {
/** Format: misskey:id */
userListId: string;
};
spReaction?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
pollEnded?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';

View File

@@ -24,6 +24,7 @@ export const notificationTypes = [
'renote',
'quote',
'reaction',
'spReaction',
'pollEnded',
'scheduledNotePosted',
'scheduledNotePostFailed',
@@ -230,6 +231,7 @@ export const rolePolicies = [
'noteDraftLimit',
'scheduledNoteLimit',
'watermarkAvailable',
'spReactionsMonthlyLimit',
] as const;
export const queueTypes = [