Merge branch 'develop' into deps-update-node

This commit is contained in:
syuilo 2026-01-22 19:10:15 +09:00 committed by GitHub
commit 09b719e371
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 307 additions and 155 deletions

View File

@ -54,55 +54,110 @@ jobs:
BASE_MEMORY=$(cat ./artifacts/memory-base.json)
HEAD_MEMORY=$(cat ./artifacts/memory-head.json)
BASE_RSS=$(echo "$BASE_MEMORY" | jq -r '.memory.rss // 0')
HEAD_RSS=$(echo "$HEAD_MEMORY" | jq -r '.memory.rss // 0')
variation() {
calc() {
BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0")
HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0")
# Calculate difference
if [ "$BASE_RSS" -gt 0 ] && [ "$HEAD_RSS" -gt 0 ]; then
DIFF=$((HEAD_RSS - BASE_RSS))
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE_RSS" | bc)
DIFF=$((HEAD - BASE))
if [ "$BASE" -gt 0 ]; then
DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc)
else
DIFF_PERCENT=0
fi
# Convert to MB for readability
BASE_MB=$(echo "scale=2; $BASE_RSS / 1048576" | bc)
HEAD_MB=$(echo "scale=2; $HEAD_RSS / 1048576" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1048576" | bc)
# Convert KB to MB for readability
BASE_MB=$(echo "scale=2; $BASE / 1024" | bc)
HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc)
DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc)
echo "base_mb=$BASE_MB" >> "$GITHUB_OUTPUT"
echo "head_mb=$HEAD_MB" >> "$GITHUB_OUTPUT"
echo "diff_mb=$DIFF_MB" >> "$GITHUB_OUTPUT"
echo "diff_percent=$DIFF_PERCENT" >> "$GITHUB_OUTPUT"
echo "has_data=true" >> "$GITHUB_OUTPUT"
JSON=$(jq -c -n \
--argjson base "$BASE_MB" \
--argjson head "$HEAD_MB" \
--argjson diff "$DIFF_MB" \
--argjson diff_percent "$DIFF_PERCENT" \
'{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}')
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$DIFF_PERCENT > 5" | bc)" -eq 1 ]; then
echo "significant_increase=true" >> "$GITHUB_OUTPUT"
else
echo "significant_increase=false" >> "$GITHUB_OUTPUT"
fi
else
echo "has_data=false" >> "$GITHUB_OUTPUT"
fi
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson VmRSS "$(calc $1 VmRSS)" \
--argjson VmHWM "$(calc $1 VmHWM)" \
--argjson VmSize "$(calc $1 VmSize)" \
--argjson VmData "$(calc $1 VmData)" \
'{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}')
echo "$JSON"
}
JSON=$(jq -c -n \
--argjson beforeGc "$(variation beforeGc)" \
--argjson afterGc "$(variation afterGc)" \
--argjson afterRequest "$(variation afterRequest)" \
'{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}')
echo "res=$JSON" >> "$GITHUB_OUTPUT"
- id: build-comment
name: Build memory comment
env:
RES: ${{ steps.compare.outputs.res }}
run: |
HEADER="## Backend Memory Usage Comparison"
HEADER="## Backend memory usage comparison"
FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})"
echo "$HEADER" > ./output.md
echo >> ./output.md
if [ "${{ steps.compare.outputs.has_data }}" == "true" ]; then
echo "| Metric | base | head | Diff |" >> ./output.md
echo "|--------|------|------|------|" >> ./output.md
echo "| RSS | ${{ steps.compare.outputs.base_mb }} MB | ${{ steps.compare.outputs.head_mb }} MB | ${{ steps.compare.outputs.diff_mb }} MB (${{ steps.compare.outputs.diff_percent }}%) |" >> ./output.md
echo >> ./output.md
table() {
echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md
echo "|--------|------:|------:|------:|------:|" >> ./output.md
if [ "${{ steps.compare.outputs.significant_increase }}" == "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi
else
echo "Could not retrieve memory usage data." >> ./output.md
line() {
METRIC=$2
BASE=$(echo "$RES" | jq -r ".${1}.${2}.base")
HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head")
DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff")
DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent")
if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then
DIFF="+$DIFF"
DIFF_PERCENT="+$DIFF_PERCENT"
fi
# highlight VmRSS
if [ "$2" = "VmRSS" ]; then
METRIC="**${METRIC}**"
BASE="**${BASE}**"
HEAD="**${HEAD}**"
DIFF="**${DIFF}**"
DIFF_PERCENT="**${DIFF_PERCENT}**"
fi
echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md
}
line $1 VmRSS
line $1 VmHWM
line $1 VmSize
line $1 VmData
}
echo "### Before GC" >> ./output.md
table beforeGc
echo >> ./output.md
echo "### After GC" >> ./output.md
table afterGc
echo >> ./output.md
echo "### After Request" >> ./output.md
table afterRequest
echo >> ./output.md
# Determine if this is a significant change (more than 5% increase)
if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then
echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md
echo >> ./output.md
fi

View File

@ -96,7 +96,6 @@
"@swc/cli": "0.7.9",
"@swc/core": "1.15.7",
"@twemoji/parser": "16.0.0",
"@types/redis-info": "3.0.3",
"accepts": "1.3.8",
"ajv": "8.17.1",
"archiver": "7.0.1",
@ -154,7 +153,6 @@
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.23.0",
"redis-info": "3.1.0",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",

View File

@ -14,16 +14,46 @@ import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import * as http from 'node:http';
import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SAMPLE_COUNT = 3; // Number of samples to measure
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
async function measureMemory() {
const startTime = Date.now();
const keys = {
VmPeak: 0,
VmSize: 0,
VmHWM: 0,
VmRSS: 0,
VmData: 0,
VmStk: 0,
VmExe: 0,
VmLib: 0,
VmPTE: 0,
VmSwap: 0,
};
async function getMemoryUsage(pid) {
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const result = {};
for (const key of Object.keys(keys)) {
const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
if (match) {
result[key] = parseInt(match[1], 10);
} else {
throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
}
}
return result;
}
async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], {
cwd: join(__dirname, '..'),
@ -31,9 +61,9 @@ async function measureMemory() {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_FORCE_GC: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
execArgv: [...process.execArgv, '--expose-gc'],
});
let serverReady = false;
@ -59,6 +89,40 @@ async function measureMemory() {
process.stderr.write(`[server error] ${err}\n`);
});
async function triggerGc() {
const ok = new Promise((resolve) => {
serverProcess.once('message', (message) => {
if (message === 'gc ok') resolve();
});
});
serverProcess.send('gc');
await ok;
await setTimeout(1000);
}
function createRequest() {
return new Promise((resolve, reject) => {
const req = http.request({
host: 'localhost',
port: 61812,
path: '/api/meta',
method: 'POST',
}, (res) => {
res.on('data', () => { });
res.on('end', () => {
resolve();
});
});
req.on('error', (err) => {
reject(err);
});
req.end();
});
}
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
@ -75,46 +139,23 @@ async function measureMemory() {
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
// Get memory usage from the server process via /proc
const pid = serverProcess.pid;
let memoryInfo;
try {
const fs = await import('node:fs/promises');
const beforeGc = await getMemoryUsage(pid);
// Read /proc/[pid]/status for detailed memory info
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
await triggerGc();
memoryInfo = {
rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
};
} catch (err) {
// Fallback: use ps command
process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
const afterGc = await getMemoryUsage(pid);
const { execSync } = await import('node:child_process');
try {
const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
const rssKb = parseInt(ps.trim(), 10);
memoryInfo = {
rss: rssKb * 1024,
heapUsed: null,
vmSize: null,
};
} catch {
memoryInfo = {
rss: null,
heapUsed: null,
vmSize: null,
error: 'Could not measure memory',
};
}
}
// create some http requests to simulate load
const REQUEST_COUNT = 10;
await Promise.all(
Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
);
await triggerGc();
const afterRequest = await getMemoryUsage(pid);
// Stop the server
serverProcess.kill('SIGTERM');
@ -137,15 +178,51 @@ async function measureMemory() {
const result = {
timestamp: new Date().toISOString(),
startupTimeMs: startupTime,
memory: memoryInfo,
beforeGc,
afterGc,
afterRequest,
};
return result;
}
async function main() {
// 直列の方が時間的に分散されて正確そうだから直列でやる
const results = [];
for (let i = 0; i < SAMPLE_COUNT; i++) {
const res = await measureMemory();
results.push(res);
}
// Calculate averages
const beforeGc = structuredClone(keys);
const afterGc = structuredClone(keys);
const afterRequest = structuredClone(keys);
for (const res of results) {
for (const key of Object.keys(keys)) {
beforeGc[key] += res.beforeGc[key];
afterGc[key] += res.afterGc[key];
afterRequest[key] += res.afterRequest[key];
}
}
for (const key of Object.keys(keys)) {
beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
}
const result = {
timestamp: new Date().toISOString(),
beforeGc,
afterGc,
afterRequest,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
measureMemory().catch((err) => {
main().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),

View File

@ -86,9 +86,17 @@ if (!envOption.disableClustering) {
ev.mount();
}
if (envOption.forceGc && global.gc != null) {
global.gc();
}
process.on('message', msg => {
if (msg === 'gc') {
if (global.gc != null) {
logger.info('Manual GC triggered');
global.gc();
if (process.send != null) process.send('gc ok');
} else {
logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.');
}
}
});
readyRef.value = true;

View File

@ -6,7 +6,6 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { MetricsTime, type JobType } from 'bullmq';
import { parse as parseRedisInfo } from 'redis-info';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
@ -86,6 +85,19 @@ const REPEATABLE_SYSTEM_JOB_DEF = [{
pattern: '0 4 * * *',
}];
function parseRedisInfo(infoText: string): Record<string, string> {
const fields = infoText
.split('\n')
.filter(line => line.length > 0 && !line.startsWith('#'))
.map(line => line.trim().split(':'));
const result: Record<string, string> = {};
for (const [key, value] of fields) {
result[key] = value;
}
return result;
}
@Injectable()
export class QueueService {
constructor(
@ -890,7 +902,7 @@ export class QueueService {
},
db: {
version: db.redis_version,
mode: db.redis_mode,
mode: db.redis_mode as 'cluster' | 'standalone' | 'sentinel',
runId: db.run_id,
processId: db.process_id,
port: parseInt(db.tcp_port),

View File

@ -11,7 +11,6 @@ const envOption = {
verbose: false,
withLogTime: false,
quiet: false,
forceGc: false,
};
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {

View File

@ -144,7 +144,15 @@ export default [
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/html-self-closing': ['error', {
html: {
void: 'any',
normal: 'never',
component: 'any',
},
svg: 'any',
math: 'any',
}],
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', {
autofix: true,

View File

@ -9,16 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
</div>
</div>
<div :class="$style.earRight">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''">
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"></canvas>
<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
</div>
</template>

View File

@ -147,7 +147,15 @@ export default [
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/html-self-closing': ['error', {
html: {
void: 'any',
normal: 'never',
component: 'any',
},
svg: 'any',
math: 'any',
}],
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', {
autofix: true,

View File

@ -25,8 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/>
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"></span>
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"></span>
</template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect>

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
borderWidth ? { borderWidth: borderWidth } : {},
borderColor ? { borderColor: borderColor } : {},
]"
/>
></div>
</template>
<script setup lang="ts">

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
/>
></canvas>
<img
v-show="!hide"
key="img"

View File

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@input="onInput"
>
<datalist v-if="datalist" :id="id">
<option v-for="data in datalist" :key="data" :value="data"/>
<option v-for="data in datalist" :key="data" :value="data"></option>
</datalist>
<div ref="suffixEl" :class="$style.suffix"><slot name="suffix"></slot></div>
</div>

View File

@ -143,8 +143,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-else-if="notification.type === 'receiveFollowRequest'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}</span>
<div v-if="full && !followRequestDone" :class="$style.followRequestCommands">
<MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
<MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"></i> {{ i18n.ts.accept }}</MkButton>
<MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"></i> {{ i18n.ts.reject }}</MkButton>
</div>
</template>
<span v-else-if="notification.type === 'test'" :class="$style.text">{{ i18n.ts._notification.notificationWillBeDisplayedLikeThis }}</span>

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.buttons">
<div v-if="prevDotVisible" :class="$style.headTailButtons">
<MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton>
<span class="ti ti-dots"/>
<span class="ti ti-dots"></span>
</div>
<MkButton
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton>
<div v-if="nextDotVisible" :class="$style.headTailButtons">
<span class="ti ti-dots"/>
<span class="ti ti-dots"></span>
<MkButton @click="onToTailButtonClicked">{{ max }}</MkButton>
</div>
</div>

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/>
<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"></div>
</template>
<script lang="ts" setup>

View File

@ -77,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<div v-if="targetChannel" :class="$style.colorBar" :style="{ background: targetChannel.color }"></div>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"></textarea>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
@ -108,7 +108,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</footer>
<datalist id="hashtags">
<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"></option>
</datalist>
</div>
</template>

View File

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<slot/>
<slot></slot>
</div>
</template>

View File

@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:myReaction="props.myReaction"
@reactionToggled="onMockToggleReaction"
/>
<slot v-if="hasMoreReactions" name="more"/>
<slot v-if="hasMoreReactions" name="more"></slot>
</component>
</template>

View File

@ -11,16 +11,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.isCat" :class="[$style.ears]">
<div :class="$style.earLeft">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
</div>
</div>
<div :class="$style.earRight">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"></div>
</div>
</div>
</div>

View File

@ -37,8 +37,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
</MkSelect>
<MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked">
<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
<span v-else class="ti ti-settings" style="line-height: normal"/>
<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"></span>
<span v-else class="ti ti-settings" style="line-height: normal"></span>
</MkButton>
</div>
</div>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root" class="_panel _gaps_s">
<div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div>
<div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"></span> {{ methodName }}</div>
<div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div>
<div :class="$style.rightDivider" style="flex: 1">
<div v-if="method === 'email' && user">
@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.recipientButtons" style="margin-left: auto">
<button :class="$style.recipientButton" @click="onEditButtonClicked()">
<span class="ti ti-settings"/>
<span class="ti ti-settings"></span>
</button>
<button :class="$style.recipientButton" @click="onDeleteButtonClicked()">
<span class="ti ti-trash"/>
<span class="ti ti-trash"></span>
</button>
</div>
</div>

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root" class="_gaps_m">
<div :class="$style.addButton">
<MkButton primary @click="onAddButtonClicked">
<span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
<span class="ti ti-plus"></span> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
</MkButton>
</div>
<div :class="$style.subMenus" class="_gaps_s">

View File

@ -21,8 +21,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ item, index, dragStart }">
<div :class="$style.item">
<div :class="$style.itemHeader">
<div :class="$style.itemNumber" v-text="String(index + 1)"/>
<span :class="$style.itemHandle" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"/></span>
<div :class="$style.itemNumber">{{ index + 1 }}</div>
<span :class="$style.itemHandle" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"></i></span>
<button class="_button" :class="$style.itemRemove" @click="remove(item.id)"><i class="ti ti-x"></i></button>
</div>
<MkInput :modelValue="item.text" @update:modelValue="serverRules[index].text = $event"/>

View File

@ -8,14 +8,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ entity.name || entity.url }}</template>
<template v-if="entity.name != null && entity.name != ''" #caption>{{ entity.url }}</template>
<template #icon>
<i v-if="!entity.isActive" class="ti ti-player-pause"/>
<i v-else-if="entity.latestStatus === null" class="ti ti-circle"/>
<i v-if="!entity.isActive" class="ti ti-player-pause"></i>
<i v-else-if="entity.latestStatus === null" class="ti ti-circle"></i>
<i
v-else-if="[200, 201, 204].includes(entity.latestStatus)"
class="ti ti-check"
:style="{ color: 'var(--MI_THEME-success)' }"
/>
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"/>
></i>
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"></i>
</template>
<template #suffix>
<MkTime v-if="entity.latestSentAt" :time="entity.latestSentAt" style="margin-right: 8px"/>

View File

@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div ref="containerEl" :class="[$style.gameContainer, { [$style.gameOver]: isGameOver && !replaying }]" @contextmenu.stop.prevent @click.stop.prevent="onClick" @touchmove.stop.prevent="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove">
<img v-if="store.s.darkMode" src="/client-assets/drop-and-fusion/frame-dark.svg" :class="$style.mainFrameImg"/>
<img v-else src="/client-assets/drop-and-fusion/frame-light.svg" :class="$style.mainFrameImg"/>
<canvas ref="canvasEl" :class="$style.canvas"/>
<canvas ref="canvasEl" :class="$style.canvas"></canvas>
<Transition
:enterActiveClass="$style.transition_combo_enterActive"
:leaveActiveClass="$style.transition_combo_leaveActive"
@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</Transition>
<template v-if="dropReady && currentPick">
<img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentMonoArrow"/>
<div :class="$style.dropGuide"/>
<div :class="$style.dropGuide"></div>
</template>
</div>
<div v-if="isGameOver && !replaying" :class="$style.gameOverLabel">

View File

@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<p class="acct">@{{ acct(displayUser(req)) }}</p>
</div>
<div v-if="tab === 'list'" class="commands">
<MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
<MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"></i> {{ i18n.ts.accept }}</MkButton>
<MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"></i> {{ i18n.ts.reject }}</MkButton>
</div>
<div v-else class="commands">
<MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.cancel }}</MkButton>
<MkButton class="command" rounded danger @click="cancel(displayUser(req))"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
</div>
</div>
</div>

View File

@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #suffix><i v-if="$i.twoFactorEnabled" class="ti ti-check" style="color: var(--MI_THEME-success)"></i></template>
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
<div>{{ i18n.ts._2fa.alreadyRegistered }}</div>
<template v-if="$i.securityKeysList!.length > 0">
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>

View File

@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="user.isLocked"><i class="ti ti-lock"></i></span>
<span v-if="user.isBot"><i class="ti ti-robot"></i></span>
<button v-if="$i && !isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea">
<i class="ti ti-edit"/> {{ i18n.ts.addMemo }}
<i class="ti ti-edit"></i> {{ i18n.ts.addMemo }}
</button>
</div>
</div>
@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}">
<div class="heading" v-text="i18n.ts.memo"/>
<div class="heading">{{ i18n.ts.memo }}</div>
<textarea
ref="memoTextareaEl"
v-model="memoDraft"
@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@focus="isEditingMemo = true"
@blur="updateMemo"
@input="adjustMemoTextarea"
/>
></textarea>
</div>
<div class="description">
<MkOmit>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="async () => { await timeline?.reloadTimeline() }">
<template #header>
<i v-if="column.tl != null" :class="basicTimelineIconClass(column.tl)"/>
<i v-if="column.tl != null" :class="basicTimelineIconClass(column.tl)"></i>
<span style="margin-left: 8px;">{{ column.name || (column.tl ? i18n.ts._timelines[column.tl] : null) || i18n.ts._deck._columns.tl }}</span>
</template>

View File

@ -165,9 +165,6 @@ importers:
'@twemoji/parser':
specifier: 16.0.0
version: 16.0.0
'@types/redis-info':
specifier: 3.0.3
version: 3.0.3
accepts:
specifier: 1.3.8
version: 1.3.8
@ -339,9 +336,6 @@ importers:
re2:
specifier: 1.23.0
version: 1.23.0
redis-info:
specifier: 3.1.0
version: 3.1.0
reflect-metadata:
specifier: 0.2.2
version: 0.2.2
@ -4684,9 +4678,6 @@ packages:
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/redis-info@3.0.3':
resolution: {integrity: sha512-VIkNy6JbYI/RLdbPHdm9JQvv6RVld2uE2/6Hdid38Qdq+zvDli2FTpImI8pC5zwp8xS8qVqfzlfyAub8xZEd5g==}
'@types/rename@1.0.7':
resolution: {integrity: sha512-E9qapfghUGfBMi3jNhsmCKPIp3f2zvNKpaX1BDGLGJNjzpgsZ/RTx7NaNksFjGoJ+r9NvWF1NSM5vVecnNjVmw==}
@ -9522,9 +9513,6 @@ packages:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-info@3.1.0:
resolution: {integrity: sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
@ -10947,6 +10935,9 @@ packages:
vue-component-type-helpers@3.2.1:
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
vue-component-type-helpers@3.2.2:
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@ -14941,7 +14932,7 @@ snapshots:
storybook: 10.1.10(@testing-library/dom@10.4.0)(bufferutil@4.1.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@6.0.6)
type-fest: 2.19.0
vue: 3.5.26(typescript@5.9.3)
vue-component-type-helpers: 3.2.1
vue-component-type-helpers: 3.2.2
'@stylistic/eslint-plugin@5.5.0(eslint@9.39.2)':
dependencies:
@ -15478,8 +15469,6 @@ snapshots:
dependencies:
'@types/node': 24.10.4
'@types/redis-info@3.0.3': {}
'@types/rename@1.0.7': {}
'@types/resolve@1.20.6': {}
@ -21352,10 +21341,6 @@ snapshots:
redis-errors@1.2.0: {}
redis-info@3.1.0:
dependencies:
lodash: 4.17.21
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
@ -22855,6 +22840,8 @@ snapshots:
vue-component-type-helpers@3.2.1: {}
vue-component-type-helpers@3.2.2: {}
vue-demi@0.14.10(vue@3.5.26(typescript@5.9.3)):
dependencies:
vue: 3.5.26(typescript@5.9.3)