diff options
Diffstat (limited to 'packages')
578 files changed, 8731 insertions, 4868 deletions
diff --git a/packages/backend/assets/misc/bios.js b/packages/backend/assets/misc/bios.js index 9ff5dca72a..f9716d8f00 100644 --- a/packages/backend/assets/misc/bios.js +++ b/packages/backend/assets/misc/bios.js @@ -9,7 +9,7 @@ window.onload = async () => { const account = JSON.parse(localStorage.getItem('account')); const i = account.token; - const api = (endpoint, data = {}) => { + const _api = (endpoint, data = {}) => { const promise = new Promise((resolve, reject) => { // Append a credential if (i) data.i = i; diff --git a/packages/backend/build.js b/packages/backend/build.js new file mode 100644 index 0000000000..52ca09b7a8 --- /dev/null +++ b/packages/backend/build.js @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { build } from 'esbuild'; +import { swcPlugin } from 'esbuild-plugin-swc'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); +const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8')); + +const resolveTsPathsPlugin = { + name: 'resolve-ts-paths', + setup(build) { + build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => { + if (args.importer) { + const absPath = join(args.resolveDir, args.path); + const tsPath = absPath.slice(0, -3) + '.ts'; + if (fs.existsSync(tsPath)) return { path: tsPath }; + const tsxPath = absPath.slice(0, -3) + '.tsx'; + if (fs.existsSync(tsxPath)) return { path: tsxPath }; + } + }); + }, +}; + +const externalIpaddrPlugin = { + name: 'external-ipaddr', + setup(build) { + build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => { + return { path: args.path, external: true }; + }); + }, +}; + +/** @type {import('esbuild').BuildOptions} */ +const options = { + entryPoints: ['./src/boot/entry.ts'], + minify: true, + keepNames: true, + bundle: true, + outdir: './built/boot', + target: 'node22', + platform: 'node', + format: 'esm', + sourcemap: 'linked', + packages: 'external', + banner: { + js: 'import { createRequire as topLevelCreateRequire } from "module";' + + 'import ___url___ from "url";' + + 'const require = topLevelCreateRequire(import.meta.url);' + + 'const __filename = ___url___.fileURLToPath(import.meta.url);' + + 'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));', + }, + plugins: [ + externalIpaddrPlugin, + resolveTsPathsPlugin, + swcPlugin({ + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + dynamicImport: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: true, + }, + experimental: { + keepImportAssertions: true, + }, + baseUrl: join(_dirname, 'src'), + paths: { + '@/*': ['*'], + }, + target: 'esnext', + keepClassNames: true, + }, + }), + externalIpaddrPlugin, + ], + // external: [ + // 'slacc-*', + // 'class-transformer', + // 'class-validator', + // '@sentry/*', + // '@nestjs/websockets/socket-module', + // '@nestjs/microservices/microservices-module', + // '@nestjs/microservices', + // '@napi-rs/canvas-win32-x64-msvc', + // 'mock-aws-s3', + // 'aws-sdk', + // 'nock', + // 'sharp', + // 'jsdom', + // 're2', + // '@napi-rs/canvas', + // ], +}; + +const args = process.argv.slice(2).map(arg => arg.toLowerCase()); + +if (!args.includes('--no-clean')) { + fs.rmSync('./built', { recursive: true, force: true }); +} + +await buildSrc(); + +async function buildSrc() { + console.log(`[${_package.name}] start building...`); + + await build(options) + .then(() => { + console.log(`[${_package.name}] build succeeded.`); + }) + .catch((err) => { + process.stderr.write(err.stderr || err.message || err); + process.exit(1); + }); + + console.log(`[${_package.name}] finish building.`); +} diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index ba7c705def..d15a703ba2 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -25,7 +25,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', 'import/order': ['warn', { groups: [ 'builtin', diff --git a/packages/backend/migration/1767169026317-birthday-index.js b/packages/backend/migration/1767169026317-birthday-index.js new file mode 100644 index 0000000000..972fc08c9b --- /dev/null +++ b/packages/backend/migration/1767169026317-birthday-index.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class BirthdayIndex1767169026317 { + name = 'BirthdayIndex1767169026317' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`); + await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`); + await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`); + } + + async down(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`); + await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`); + await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`); + } +} diff --git a/packages/backend/ormconfig.js b/packages/backend/ormconfig.js index dabc0893f4..1a8c146451 100644 --- a/packages/backend/ormconfig.js +++ b/packages/backend/ormconfig.js @@ -1,6 +1,6 @@ import { DataSource } from 'typeorm'; -import { loadConfig } from './built/config.js'; -import { entities } from './built/postgres.js'; +import { loadConfig } from './src-js/config.js'; +import { entities } from './src-js/postgres.js'; const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; diff --git a/packages/backend/package.json b/packages/backend/package.json index c7a8a6c223..f5270ea554 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,17 +12,17 @@ "start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js", "migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js", "revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js", - "cli": "pnpm compile-config && node ./built/boot/cli.js", + "cli": "pnpm compile-config && node ./src-js/boot/cli.js", "check:connect": "pnpm compile-config && node ./scripts/check_connect.js", "compile-config": "node ./scripts/compile_config.js", - "build": "swc src -d built -D --strip-leading-paths", + "build": "swc src -d src-js -D --strip-leading-paths && node ./build.js", "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths", "watch:swc": "swc src -d built -D -w --strip-leading-paths", - "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", + "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "pnpm compile-config && node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", "dev": "pnpm compile-config && node ./scripts/dev.mjs", - "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", + "typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs", @@ -41,20 +41,20 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.15.3", - "@swc/core-darwin-x64": "1.15.3", + "@swc/core-darwin-arm64": "1.15.11", + "@swc/core-darwin-x64": "1.15.11", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.15.3", - "@swc/core-linux-arm64-gnu": "1.15.3", - "@swc/core-linux-arm64-musl": "1.15.3", - "@swc/core-linux-x64-gnu": "1.15.3", - "@swc/core-linux-x64-musl": "1.15.3", - "@swc/core-win32-arm64-msvc": "1.15.3", - "@swc/core-win32-ia32-msvc": "1.15.3", - "@swc/core-win32-x64-msvc": "1.15.3", + "@swc/core-linux-arm-gnueabihf": "1.15.11", + "@swc/core-linux-arm64-gnu": "1.15.11", + "@swc/core-linux-arm64-musl": "1.15.11", + "@swc/core-linux-x64-gnu": "1.15.11", + "@swc/core-linux-x64-musl": "1.15.11", + "@swc/core-win32-arm64-msvc": "1.15.11", + "@swc/core-win32-ia32-msvc": "1.15.11", + "@swc/core-win32-x64-msvc": "1.15.11", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10", @@ -68,43 +68,42 @@ "slacc-linux-x64-musl": "0.0.10", "slacc-win32-arm64-msvc": "0.0.10", "slacc-win32-x64-msvc": "0.0.10", - "utf-8-validate": "6.0.5" + "utf-8-validate": "6.0.6" }, "dependencies": { - "@aws-sdk/client-s3": "3.948.0", - "@aws-sdk/lib-storage": "3.948.0", + "@aws-sdk/client-s3": "3.995.0", + "@aws-sdk/lib-storage": "3.995.0", "@discordapp/twemoji": "16.0.1", "@fastify/accepts": "5.0.4", "@fastify/cors": "11.2.0", - "@fastify/express": "4.0.2", + "@fastify/express": "4.0.4", "@fastify/http-proxy": "11.4.1", - "@fastify/multipart": "9.3.0", - "@fastify/static": "8.3.0", - "@kitajs/html": "4.2.11", + "@fastify/multipart": "9.4.0", + "@fastify/static": "9.0.0", + "@kitajs/html": "4.2.13", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.2.5", - "@napi-rs/canvas": "0.1.84", - "@nestjs/common": "11.1.9", - "@nestjs/core": "11.1.9", - "@nestjs/testing": "11.1.9", + "@napi-rs/canvas": "0.1.94", + "@nestjs/common": "11.1.14", + "@nestjs/core": "11.1.14", + "@nestjs/testing": "11.1.14", "@peertube/http-signature": "1.7.0", - "@sentry/node": "10.29.0", - "@sentry/profiling-node": "10.29.0", + "@sentry/node": "10.39.0", + "@sentry/profiling-node": "10.39.0", "@simplewebauthn/server": "13.2.2", - "@sinonjs/fake-timers": "15.0.0", - "@smithy/node-http-handler": "4.4.5", - "@swc/cli": "0.7.9", - "@swc/core": "1.15.3", + "@sinonjs/fake-timers": "15.1.0", + "@smithy/node-http-handler": "4.4.10", + "@swc/cli": "0.8.0", + "@swc/core": "1.15.11", "@twemoji/parser": "16.0.0", - "@types/redis-info": "3.0.3", "accepts": "1.3.8", - "ajv": "8.17.1", + "ajv": "8.18.0", "archiver": "7.0.1", "async-mutex": "0.5.0", "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "body-parser": "2.2.1", - "bullmq": "5.65.1", + "body-parser": "2.2.2", + "bullmq": "5.69.4", "cacheable-lookup": "7.0.0", "chalk": "5.6.2", "chalk-template": "1.1.2", @@ -113,24 +112,24 @@ "content-disposition": "1.0.1", "date-fns": "4.1.0", "deep-email-validator": "0.1.21", - "fastify": "5.6.2", + "fastify": "5.7.4", "fastify-raw-body": "5.0.0", - "feed": "5.1.0", - "file-type": "21.1.1", + "feed": "5.2.0", + "file-type": "21.3.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.5", - "got": "14.6.5", + "got": "14.6.6", "hpagent": "1.2.0", "http-link-header": "1.1.3", "i18n": "workspace:*", - "ioredis": "5.8.2", + "ioredis": "5.9.3", "ip-cidr": "4.0.2", "ipaddr.js": "2.3.0", "is-svg": "6.1.0", "json5": "2.2.3", "jsonld": "9.0.0", - "juice": "11.0.3", - "meilisearch": "0.54.0", + "juice": "11.1.1", + "meilisearch": "0.55.0", "mfm-js": "0.25.0", "mime-types": "3.0.2", "misskey-js": "workspace:*", @@ -139,50 +138,48 @@ "nanoid": "5.1.6", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "node-html-parser": "7.0.1", - "nodemailer": "7.0.11", + "node-html-parser": "7.0.2", + "nodemailer": "8.0.1", "nsfwjs": "4.2.0", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.4.1", - "pg": "8.16.3", - "pkce-challenge": "5.0.1", + "otpauth": "9.5.0", + "pg": "8.18.0", + "pkce-challenge": "6.0.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.22.3", - "redis-info": "3.1.0", + "re2": "1.23.3", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.2", - "sanitize-html": "2.17.0", + "sanitize-html": "2.17.1", "secure-json-parse": "4.1.0", - "semver": "7.7.3", + "semver": "7.7.4", "sharp": "0.33.5", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.27.14", + "systeminformation": "5.31.1", "tinycolor2": "1.6.0", "tmp": "0.2.5", "tsc-alias": "1.8.16", "typeorm": "0.3.28", - "typescript": "5.9.3", "ulid": "3.0.2", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.18.3", + "ws": "8.19.0", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.7.0", - "@kitajs/ts-html-plugin": "4.1.3", - "@nestjs/platform-express": "11.1.9", - "@sentry/vue": "10.29.0", + "@kitajs/ts-html-plugin": "4.1.4", + "@nestjs/platform-express": "11.1.14", + "@sentry/vue": "10.39.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.39", "@types/accepts": "1.3.7", @@ -196,11 +193,11 @@ "@types/jsonld": "1.5.15", "@types/mime-types": "3.0.1", "@types/ms": "2.1.0", - "@types/node": "24.10.2", - "@types/nodemailer": "7.0.4", + "@types/node": "24.10.13", + "@types/nodemailer": "7.0.11", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.15.6", + "@types/pg": "8.16.0", "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", @@ -215,21 +212,22 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", "aws-sdk-client-mock": "4.1.0", "cbor": "10.0.11", "cross-env": "10.1.0", + "esbuild-plugin-swc": "1.0.1", "eslint-plugin-import": "2.32.0", "execa": "9.6.1", - "fkill": "10.0.1", + "fkill": "10.0.3", "jest": "29.7.0", "jest-mock": "29.7.0", "js-yaml": "4.1.1", - "nodemon": "3.1.11", - "pid-port": "2.0.0", + "nodemon": "3.1.14", + "pid-port": "2.0.1", "simple-oauth2": "5.1.0", - "supertest": "7.1.4", - "vite": "7.2.7" + "supertest": "7.2.2", + "vite": "7.3.1" } } diff --git a/packages/backend/scripts/check_connect.js b/packages/backend/scripts/check_connect.js index 96c4549ccb..a1cb839303 100644 --- a/packages/backend/scripts/check_connect.js +++ b/packages/backend/scripts/check_connect.js @@ -4,8 +4,8 @@ */ import Redis from 'ioredis'; -import { loadConfig } from '../built/config.js'; -import { createPostgresDataSource } from '../built/postgres.js'; +import { loadConfig } from '../src-js/config.js'; +import { createPostgresDataSource } from '../src-js/postgres.js'; const config = loadConfig(); @@ -16,26 +16,22 @@ async function connectToPostgres() { } async function connectToRedis(redisOptions) { - return await new Promise(async (resolve, reject) => { - const redis = new Redis({ + let redis; + try { + redis = new Redis({ ...redisOptions, lazyConnect: true, reconnectOnError: false, showFriendlyErrorStack: true, }); - redis.on('error', e => reject(e)); - try { - await redis.connect(); - resolve(); - - } catch (e) { - reject(e); - - } finally { - redis.disconnect(false); - } - }); + await Promise.race([ + new Promise((_, reject) => redis.on('error', e => reject(e))), + redis.connect(), + ]); + } finally { + redis.disconnect(false); + } } // If not all of these are defined, the default one gets reused. @@ -50,7 +46,7 @@ const promises = Array ])) .map(connectToRedis) .concat([ - connectToPostgres() + connectToPostgres(), ]); await Promise.all(promises); diff --git a/packages/backend/scripts/generate_api_json.js b/packages/backend/scripts/generate_api_json.js index 798e243004..237f63a4d3 100644 --- a/packages/backend/scripts/generate_api_json.js +++ b/packages/backend/scripts/generate_api_json.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { writeFileSync, existsSync } from 'node:fs'; import { execa } from 'execa'; -import { writeFileSync, existsSync } from "node:fs"; async function main() { if (!process.argv.includes('--no-build')) { @@ -19,10 +19,10 @@ async function main() { } /** @type {import('../src/config.js')} */ - const { loadConfig } = await import('../built/config.js'); + const { loadConfig } = await import('../src-js/config.js'); /** @type {import('../src/server/api/openapi/gen-spec.js')} */ - const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js'); + const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js'); const config = loadConfig(); const spec = genOpenapiSpec(config, true); diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs index 017252d7ec..3f30e24fb4 100644 --- a/packages/backend/scripts/measure-memory.mjs +++ b/packages/backend/scripts/measure-memory.mjs @@ -14,24 +14,56 @@ 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'), [], { + const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], { cwd: join(__dirname, '..'), env: { ...process.env, - NODE_ENV: 'test', + NODE_ENV: 'production', + MK_DISABLE_CLUSTERING: '1', }, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + execArgv: [...process.execArgv, '--expose-gc'], }); let serverReady = false; @@ -57,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) { @@ -73,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'); @@ -135,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(), diff --git a/packages/backend/scripts/watch.mjs b/packages/backend/scripts/watch.mjs index a0ccea3b16..9d608b233c 100644 --- a/packages/backend/scripts/watch.mjs +++ b/packages/backend/scripts/watch.mjs @@ -21,7 +21,7 @@ import { execa } from 'execa'; }); }, 3000); - execa('tsc', ['-w', '-p', 'tsconfig.json'], { + execa('tsgo', ['-w', '-p', 'tsconfig.json'], { stdout: process.stdout, stderr: process.stderr, }); diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index da585ad68d..3a33d198a5 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -86,6 +86,18 @@ if (!envOption.disableClustering) { ev.mount(); } +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; // ユニットテスト時にMisskeyが子プロセスで起動された時のため diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 4776d0d412..041f58e509 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -4,8 +4,6 @@ */ import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; import * as os from 'node:os'; import cluster from 'node:cluster'; import chalk from 'chalk'; @@ -17,20 +15,15 @@ import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; import { jobQueue, server } from './common.js'; -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8')); - const logger = new Logger('core', 'cyan'); const bootLogger = logger.createSubLogger('boot', 'magenta'); const themeColor = chalk.hex('#86b300'); -function greet() { +function greet(props: { version: string }) { if (!envOption.quiet) { //#region Misskey logo - const v = `v${meta.version}`; + const v = `v${props.version}`; console.log(themeColor(' _____ _ _ ')); console.log(themeColor(' | |_|___ ___| |_ ___ _ _ ')); console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |')); @@ -46,7 +39,7 @@ function greet() { } bootLogger.info('Welcome to Misskey!'); - bootLogger.info(`Misskey v${meta.version}`, null, true); + bootLogger.info(`Misskey v${props.version}`, null, true); } /** @@ -57,15 +50,15 @@ export async function masterMain() { // initialize app try { - greet(); + config = loadConfigBoot(); + greet({ version: config.version }); showEnvironment(); await showMachineInfo(bootLogger); showNodejsVersion(); - config = loadConfigBoot(); //await connectDb(); if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString()); } catch (e) { - bootLogger.error('Fatal error occurred during initialization', null, true); + bootLogger.error('Fatal error occurred during initialization: ' + e, null, true); process.exit(1); } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 657d7869fa..4cd82bed87 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -219,24 +219,42 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json'); +/** Path of repository root directory */ +let rootDir = _dirname; +// 見つかるまで上に遡る +while (!fs.existsSync(resolve(rootDir, 'packages'))) { + const parentDir = dirname(rootDir); + if (parentDir === rootDir) { + throw new Error('Cannot find root directory'); + } + rootDir = parentDir; +} + +/** Path of configuration directory */ +const configDir = resolve(rootDir, '.config'); +/** Path of built directory */ +const projectBuiltDir = resolve(rootDir, 'built'); + +const compiledConfigFilePathForTest = resolve(projectBuiltDir, '._config_.json'); -export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json'); +export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) + ? compiledConfigFilePathForTest + : resolve(projectBuiltDir, '.config.json'); export function loadConfig(): Config { if (!fs.existsSync(compiledConfigFilePath)) { throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.'); } - const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); + const meta = JSON.parse(fs.readFileSync(resolve(projectBuiltDir, 'meta.json'), 'utf-8')); - const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); - const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); + const frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json')); + const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json')); const frontendManifest = frontendManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) + JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'), 'utf-8')) : { 'src/_boot_.ts': { file: null } }; const frontendEmbedManifest = frontendEmbedManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) + JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'), 'utf-8')) : { 'src/boot.ts': { file: null } }; const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source; @@ -334,7 +352,7 @@ export function loadConfig(): Config { function tryCreateUrl(url: string) { try { return new URL(url); - } catch (e) { + } catch (_) { throw new Error(`url="${url}" is not a valid URL.`); } } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index f8e3eaf01f..5d668bc582 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -75,7 +75,7 @@ export class AccountMoveService { */ @bindThis public async moveFromLocal(src: MiLocalUser, dst: MiLocalUser | MiRemoteUser): Promise<unknown> { - const srcUri = this.userEntityService.getUserUri(src); + const _srcUri = this.userEntityService.getUserUri(src); const dstUri = this.userEntityService.getUserUri(dst); // add movedToUri to indicate that the user has moved diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index a9f6731977..f750ca212a 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -205,7 +205,7 @@ export class AnnouncementService { announcementId: announcementId, userId: user.id, }); - } catch (e) { + } catch (_) { return; } diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 4efd6122b1..70a50a0175 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -39,7 +39,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; + const { type, body: _ } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'avatarDecorationCreated': case 'avatarDecorationUpdated': diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 87575ca59a..f075671d93 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -141,7 +141,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js'; import { ApMfmService } from './activitypub/ApMfmService.js'; import { ApRendererService } from './activitypub/ApRendererService.js'; import { ApRequestService } from './activitypub/ApRequestService.js'; -import { ApResolverService } from './activitypub/ApResolverService.js'; +import { ApResolverService, Resolver } from './activitypub/ApResolverService.js'; import { JsonLdService } from './activitypub/JsonLdService.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; import { RemoteUserResolveService } from './RemoteUserResolveService.js'; @@ -447,6 +447,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, + Resolver, JsonLdService, RemoteLoggerService, RemoteUserResolveService, @@ -745,6 +746,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, + Resolver, JsonLdService, RemoteLoggerService, RemoteUserResolveService, diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index c7be0f7843..384704b252 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -366,7 +366,7 @@ export class EmailService { valid: true, reason: null, }; - } catch (error) { + } catch (_) { return { valid: false, reason: 'network', diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index af4d0b8c6b..c7c9f8037d 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -484,25 +484,13 @@ export class FileInfoService { * Calculate blurhash string of image */ @bindThis - private getBlurhash(path: string, type: string): Promise<string> { - return new Promise(async (resolve, reject) => { - (await sharpBmp(path, type)) - .raw() - .ensureAlpha() - .resize(64, 64, { fit: 'inside' }) - .toBuffer((err, buffer, info) => { - if (err) return reject(err); - - let hash; - - try { - hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); - } catch (e) { - return reject(e); - } - - resolve(hash); - }); - }); + private async getBlurhash(path: string, type: string): Promise<string> { + const sharp = await sharpBmp(path, type); + const { data: buffer, info } = await sharp + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer({ resolveWithObject: true }); + return blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); } } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index f4c747b139..da5982abf6 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -38,11 +38,7 @@ export interface BroadcastTypes { emojis: Packed<'EmojiDetailed'>[]; }; emojiDeleted: { - emojis: { - id?: string; - name: string; - [other: string]: any; - }[]; + emojis: Packed<'EmojiDetailed'>[]; }; announcementCreated: { announcement: Packed<'Announcement'>; diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index b9f1c62d9d..274966d921 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -308,7 +308,7 @@ export class MfmService { try { const date = new Date(parseInt(text, 10) * 1000); return `<time datetime="${escapeHtml(date.toISOString())}">${escapeHtml(date.toISOString())}</time>`; - } catch (err) { + } catch (_) { return fnDefault(node); } } @@ -376,7 +376,7 @@ export class MfmService { try { const url = new URL(node.props.url); return `<a href="${escapeHtml(url.href)}">${toHtml(node.children)}</a>`; - } catch (err) { + } catch (_) { return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`; } }, @@ -390,7 +390,7 @@ export class MfmService { try { const url = new URL(href); return `<a href="${escapeHtml(url.href)}" class="u-url mention">${escapeHtml(acct)}</a>`; - } catch (err) { + } catch (_) { return escapeHtml(acct); } }, @@ -419,7 +419,7 @@ export class MfmService { try { const url = new URL(node.props.url); return `<a href="${escapeHtml(url.href)}">${escapeHtml(node.props.url)}</a>`; - } catch (err) { + } catch (_) { return escapeHtml(node.props.url); } }, diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts index a346ff7618..e144138c2c 100644 --- a/packages/backend/src/core/NoteDraftService.ts +++ b/packages/backend/src/core/NoteDraftService.ts @@ -187,9 +187,9 @@ export class NoteDraftService { } //#region visibleUsers - let visibleUsers: MiUser[] = []; + let _visibleUsers: MiUser[] = []; if (data.visibleUserIds != null && data.visibleUserIds.length > 0) { - visibleUsers = await this.usersRepository.findBy({ + _visibleUsers = await this.usersRepository.findBy({ id: In(data.visibleUserIds), }); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 42782167bb..f90ae80731 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -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), diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index f2f7480dfa..2ffee69c21 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -314,7 +314,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { default: return false; } - } catch (err) { + } catch (_) { // TODO: log error return false; } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 71dc718916..87097ada93 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -190,8 +190,7 @@ export class SearchService { return this.searchNoteByMeiliSearch(q, me, opts, pagination); } default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const typeCheck: never = this.provider; + const _: never = this.provider; return []; } } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e36..3ecb912a64 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -49,8 +49,8 @@ export class UserSuspendService { }); (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); + await this.postSuspend(user).catch(_ => {}); + await this.unFollowAll(user).catch(_ => {}); })(); } @@ -67,7 +67,7 @@ export class UserSuspendService { }); (async () => { - await this.postUnsuspend(user).catch(e => {}); + await this.postUnsuspend(user).catch(_ => {}); })(); } diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 21ea9b9983..e3ceebccae 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -98,7 +98,7 @@ export class UtilityService { try { // TODO: RE2インスタンスをキャッシュ return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 81637580e3..ff47ca930d 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -95,7 +95,7 @@ export class ApInboxService { if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); if (items.length >= resolver.getRecursionLimit()) { @@ -221,7 +221,7 @@ export class ApInboxService { this.logger.info(`Accept: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); @@ -284,7 +284,7 @@ export class ApInboxService { this.logger.info(`Announce: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); @@ -406,7 +406,7 @@ export class ApInboxService { } // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -575,7 +575,7 @@ export class ApInboxService { this.logger.info(`Reject: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -642,7 +642,7 @@ export class ApInboxService { this.logger.info(`Undo: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -774,7 +774,7 @@ export class ApInboxService { this.logger.debug('Update'); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 4570977c5d..8c461b6031 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -515,7 +515,7 @@ export class ApRendererService { const restPart = maybeUrl.slice(match[0].length); return `<a href="${urlPartParsed.href}" rel="me nofollow noopener" target="_blank">${urlPart}</a>${restPart}`; - } catch (e) { + } catch (_) { return maybeUrl; } }; diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 49298a1d22..d14b82dc92 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -226,7 +226,7 @@ export class ApRequestService { return await this.signedGet(href, user, allowSoftfail, false); } } - } catch (e) { + } catch (_) { // something went wrong parsing the HTML, ignore the whole thing } } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 646150455b..0f51b1ce8d 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -3,10 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; +import type { + FollowRequestsRepository, + MiMeta, + NoteReactionsRepository, + NotesRepository, + PollsRepository, + UsersRepository +} from '@/models/_.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; @@ -16,26 +23,43 @@ import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { ICollection, IObject, IOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; import { FetchAllowSoftFailMask } from './misc/check-against-url.js'; -import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import { ModuleRef } from '@nestjs/core'; +@Injectable({ scope: Scope.TRANSIENT }) export class Resolver { private history: Set<string>; private user?: MiLocalUser; private logger: Logger; + private recursionLimit = 256; constructor( + @Inject(DI.config) private config: Config, + + @Inject(DI.meta) private meta: MiMeta, + + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + + @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, + private utilityService: UtilityService, private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, @@ -43,7 +67,6 @@ export class Resolver { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, - private recursionLimit = 256, ) { this.history = new Set(); this.logger = this.loggerService.getLogger('ap-resolve'); @@ -180,54 +203,12 @@ export class Resolver { @Injectable() export class ApResolverService { constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.meta) - private meta: MiMeta, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private utilityService: UtilityService, - private systemAccountService: SystemAccountService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private loggerService: LoggerService, + private moduleRef: ModuleRef, ) { } @bindThis - public createResolver(): Resolver { - return new Resolver( - this.config, - this.meta, - this.usersRepository, - this.notesRepository, - this.pollsRepository, - this.noteReactionsRepository, - this.followRequestsRepository, - this.utilityService, - this.systemAccountService, - this.apRequestService, - this.httpRequestService, - this.apRendererService, - this.apDbResolverService, - this.loggerService, - ); + public async createResolver(): Promise<Resolver> { + return await this.moduleRef.create(Resolver); } } diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index e7ece87b01..0496774c19 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -46,7 +46,7 @@ export class ApImageService { throw new Error('actor has been suspended'); } - const image = await this.apResolverService.createResolver().resolve(value); + const image = await (await this.apResolverService.createResolver()).resolve(value); if (!isDocument(image)) return null; diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 214d32f67f..1fc5728c98 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -128,7 +128,7 @@ export class ApNoteService { @bindThis public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> { // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(value); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index e52078ed0f..ebe8e9c964 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit { } // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(uri); if (object.id == null) throw new Error('invalid object.id: ' + object.id); @@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit { //#endregion // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = hint ?? await resolver.resolve(uri); @@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit { // リモートサーバーからフェッチしてきて登録 // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); return await this.createPerson(uri, resolver); } @@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Updating the featured: ${user.uri}`); - const _resolver = resolver ?? this.apResolverService.createResolver(); + const _resolver = resolver ?? await this.apResolverService.createResolver(); // Resolve to (Ordered)Collection Object const collection = await _resolver.resolveCollection(user.featured); diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index a2cdaf02ca..8ac2f21e26 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -45,7 +45,7 @@ export class ApQuestionService { @bindThis public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> { // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const question = await resolver.resolve(source); if (!isQuestion(question)) throw new Error('invalid type'); @@ -91,7 +91,7 @@ export class ApQuestionService { // resolve new Question object // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const question = await resolver.resolve(value); this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts index cfa983e766..f69a484398 100644 --- a/packages/backend/src/core/entities/ChatEntityService.ts +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -138,7 +138,7 @@ export class ChatEntityService { const reactions: { reaction: string; }[] = []; for (const record of message.reactions) { - const [userId, reaction] = record.split('/'); + const [, reaction] = record.split('/'); reactions.push({ reaction, }); diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index a6f7f369a6..1865d494c4 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -17,6 +17,7 @@ import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { IdService } from '@/core/IdService.js'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; import { UserEntityService } from './UserEntityService.js'; @@ -226,6 +227,7 @@ export class DriveFileEntityService { options?: PackOptions, hint?: { packedUser?: Packed<'UserLite'> + packedFolder?: Packed<'DriveFolder'> }, ): Promise<Packed<'DriveFile'> | null> { const opts = Object.assign({ @@ -250,9 +252,9 @@ export class DriveFileEntityService { thumbnailUrl: this.getThumbnailUrl(file), comment: file.comment, folderId: file.folderId, - folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { + folder: opts.detail && file.folderId ? (hint?.packedFolder ?? this.driveFolderEntityService.pack(file.folderId, { detail: true, - }) : null, + })) : null, userId: file.userId, user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); @@ -263,10 +265,41 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise<Packed<'DriveFile'>[]> { - const _user = files.map(({ user, userId }) => user ?? userId).filter(x => x != null); - const _userMap = await this.userEntityService.packMany(_user) - .then(users => new Map(users.map(user => [user.id, user]))); - const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); + // -- ユーザ情報の事前取得 -- + + let userMap: Map<string, Packed<'UserLite'>> | null = null; + if (options?.withUser) { + const users = files + .map(({ user, userId }) => user ?? userId) + .filter(x => x != null); + + const uniqueUsers = uniqueByKey(users, (user) => typeof user === 'string' ? user : user.id); + const packedUsers = await this.userEntityService.packMany(uniqueUsers); + userMap = new Map(packedUsers.map(user => [user.id, user])); + } + + // -- フォルダ情報の事前取得 -- + + let folderMap: Map<string, Packed<'DriveFolder'>> | null = null; + if (options?.detail) { + const folders = files + .map(({ folder, folderId }) => folder ?? folderId) + .filter(x => x != null); + + const uniqueFolders = uniqueByKey(folders, (folder) => typeof folder === 'string' ? folder : folder.id); + const packedFolders = await this.driveFolderEntityService.packMany(uniqueFolders, { detail: true }); + folderMap = new Map(packedFolders.map(folder => [folder.id, folder])); + } + + const items = await Promise.all(files.map(f => this.packNullable( + f, + options, + { + packedUser: f.userId ? userMap?.get(f.userId) : undefined, + packedFolder: f.folderId ? folderMap?.get(f.folderId) : undefined, + }, + ))); + return items.filter(x => x != null); } diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 299f23ad38..326421e149 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -12,6 +12,9 @@ import type { } from '@/models/Blocking.js'; import type { MiDriveFolder } from '@/models/DriveFolder.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { In } from 'typeorm'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; +import { splitIdAndObjects } from '@/misc/split-id-and-objects.js'; @Injectable() export class DriveFolderEntityService { @@ -32,12 +35,20 @@ export class DriveFolderEntityService { options?: { detail: boolean }, + hint?: { + folderMap?: Map<string, MiDriveFolder>; + foldersCountMap?: Map<string, number> | null; + filesCountMap?: Map<string, number> | null; + parentPacker?: (id: string) => Promise<Packed<'DriveFolder'>>; + }, ): Promise<Packed<'DriveFolder'>> { const opts = Object.assign({ detail: false, }, options); - const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); + const folder = typeof src === 'object' + ? src + : hint?.folderMap?.get(src) ?? await this.driveFoldersRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: folder.id, @@ -46,20 +57,141 @@ export class DriveFolderEntityService { parentId: folder.parentId, ...(opts.detail ? { - foldersCount: this.driveFoldersRepository.countBy({ - parentId: folder.id, - }), - filesCount: this.driveFilesRepository.countBy({ - folderId: folder.id, - }), + foldersCount: hint?.foldersCountMap?.get(folder.id) + ?? this.driveFoldersRepository.countBy({ + parentId: folder.id, + }), + filesCount: hint?.filesCountMap?.get(folder.id) + ?? this.driveFilesRepository.countBy({ + folderId: folder.id, + }), ...(folder.parentId ? { - parent: this.pack(folder.parentId, { - detail: true, - }), + parent: hint?.parentPacker + ? hint.parentPacker(folder.parentId) + : this.pack(folder.parentId, { detail: true }, hint), } : {}), } : {}), }); } -} + public async packMany( + src: Array<MiDriveFolder['id'] | MiDriveFolder>, + options?: { + detail: boolean + }, + ): Promise<Array<Packed<'DriveFolder'>>> { + /** + * 重複を除去しつつ、必要なDriveFolderオブジェクトをすべて取得する + */ + const collectUniqueObjects = async (src: Array<MiDriveFolder['id'] | MiDriveFolder>) => { + const uniqueSrc = uniqueByKey( + src, + (s) => typeof s === 'string' ? s : s.id, + ); + const { ids, objects } = splitIdAndObjects(uniqueSrc); + + const uniqueObjects = new Map<string, MiDriveFolder>(objects.map(s => [s.id, s])); + const needsFetchIds = ids.filter(id => !uniqueObjects.has(id)); + + if (needsFetchIds.length > 0) { + const fetchedObjects = await this.driveFoldersRepository.find({ + where: { + id: In(needsFetchIds), + }, + }); + for (const obj of fetchedObjects) { + uniqueObjects.set(obj.id, obj); + } + } + + return uniqueObjects; + }; + + /** + * 親フォルダーを再帰的に収集する + */ + const collectAncestors = async (folderMap: Map<string, MiDriveFolder>) => { + for (;;) { + const parentIds = new Set<string>(); + for (const folder of folderMap.values()) { + if (folder.parentId != null && !folderMap.has(folder.parentId)) { + parentIds.add(folder.parentId); + } + } + + if (parentIds.size === 0) break; + + const fetchedParents = await this.driveFoldersRepository.find({ + where: { + id: In([...parentIds]), + }, + }); + + if (fetchedParents.length === 0) break; + + for (const parent of fetchedParents) { + folderMap.set(parent.id, parent); + } + } + }; + + const opts = Object.assign({ + detail: false, + }, options); + + const folderMap = await collectUniqueObjects(src); + + let foldersCountMap: Map<string, number> | null = null; + let filesCountMap: Map<string, number> | null = null; + if (opts.detail) { + await collectAncestors(folderMap); + + const ids = [...folderMap.keys()]; + if (ids.length > 0) { + const folderCounts = await this.driveFoldersRepository.createQueryBuilder('folder') + .select('folder.parentId', 'parentId') + .addSelect('COUNT(*)', 'count') + .where('folder.parentId IN (:...ids)', { ids }) + .groupBy('folder.parentId') + .getRawMany<{ parentId: string; count: string }>(); + + const fileCounts = await this.driveFilesRepository.createQueryBuilder('file') + .select('file.folderId', 'folderId') + .addSelect('COUNT(*)', 'count') + .where('file.folderId IN (:...ids)', { ids }) + .groupBy('file.folderId') + .getRawMany<{ folderId: string; count: string }>(); + + foldersCountMap = new Map(folderCounts.map(row => [row.parentId, Number(row.count)])); + filesCountMap = new Map(fileCounts.map(row => [row.folderId, Number(row.count)])); + } else { + foldersCountMap = new Map(); + filesCountMap = new Map(); + } + } + + const packedMap = new Map<string, Promise<Packed<'DriveFolder'>>>(); + const packFromId = (id: string): Promise<Packed<'DriveFolder'>> => { + const cached = packedMap.get(id); + if (cached) return cached; + + const folder = folderMap.get(id); + if (!folder) { + throw new Error(`DriveFolder not found: ${id}`); + } + + const packedPromise = this.pack(folder, options, { + folderMap, + foldersCountMap, + filesCountMap, + parentPacker: packFromId, + }); + packedMap.set(id, packedPromise); + + return packedPromise; + }; + + return Promise.all(src.map(s => packFromId(typeof s === 'string' ? s : s.id))); + } +} diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 490d3f2511..309de3b08f 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -41,7 +41,7 @@ export class EmojiEntityService { @bindThis public packSimpleMany( - emojis: any[], + emojis: (MiEmoji['id'] | MiEmoji)[], ) { return Promise.all(emojis.map(x => this.packSimple(x))); } @@ -69,7 +69,7 @@ export class EmojiEntityService { @bindThis public packDetailedMany( - emojis: any[], + emojis: (MiEmoji['id'] | MiEmoji)[], ): Promise<Packed<'EmojiDetailed'>[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 2da614a120..8e56ddbc02 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -55,13 +55,13 @@ export class MetaEntityService { if (instance.defaultLightTheme) { try { defaultLightTheme = JSON.stringify(JSON5.parse(instance.defaultLightTheme)); - } catch (e) { + } catch (_) { } } if (instance.defaultDarkTheme) { try { defaultDarkTheme = JSON.stringify(JSON5.parse(instance.defaultDarkTheme)); - } catch (e) { + } catch (_) { } } diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 54ce4d472a..fe4926bfe3 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -54,7 +54,7 @@ export class NoteReactionEntityService implements OnModuleInit { packedUser?: Packed<'UserLite'> }, ): Promise<Packed<'NoteReaction'>> { - const opts = Object.assign({ + const _opts = Object.assign({ }, options); const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); @@ -90,7 +90,7 @@ export class NoteReactionEntityService implements OnModuleInit { packedUser?: Packed<'UserLite'> }, ): Promise<Packed<'NoteReactionWithNote'>> { - const opts = Object.assign({ + const _opts = Object.assign({ }, options); const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index df042e75c1..21099bad3e 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -14,6 +14,10 @@ import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { UserEntityService } from './UserEntityService.js'; +function assertBw(bw: string): bw is Packed<'ReversiGameDetailed'>['bw'] { + return ['random', '1', '2'].includes(bw); +} + @Injectable() export class ReversiGameEntityService { constructor( @@ -58,7 +62,7 @@ export class ReversiGameEntityService { surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, - bw: game.bw, + bw: assertBw(game.bw) ? game.bw : 'random', isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, @@ -116,7 +120,7 @@ export class ReversiGameEntityService { surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, - bw: game.bw, + bw: assertBw(game.bw) ? game.bw : 'random', isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ac5b855096..0f4051e7b8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit { me, { ...options, - userProfile: profilesMap.get(u.id), + userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes, diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index c50f2b723c..0d1c7ee46e 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -56,7 +56,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi try { return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } diff --git a/packages/backend/src/misc/get-ip-hash.ts b/packages/backend/src/misc/get-ip-hash.ts index e132fa8f31..571996973b 100644 --- a/packages/backend/src/misc/get-ip-hash.ts +++ b/packages/backend/src/misc/get-ip-hash.ts @@ -12,7 +12,7 @@ export function getIpHash(ip: string): string { // (this means for IPv4 the entire address is used) const prefix = IPCIDR.createAddress(ip).mask(64); return 'ip-' + BigInt('0b' + prefix).toString(36); - } catch (e) { + } catch (_) { const prefix = IPCIDR.createAddress(ip.replace(/:[0-9]+$/, '')).mask(64); return 'ip-' + BigInt('0b' + prefix).toString(36); } diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts index 6cbbdef74c..40067cacf5 100644 --- a/packages/backend/src/misc/i18n.ts +++ b/packages/backend/src/misc/i18n.ts @@ -26,7 +26,7 @@ export class I18n<T extends Record<string, any>> { } } return str; - } catch (e) { + } catch (_) { console.warn(`missing localization '${key}'`); return key; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ed7d5bfc3a..cf233defd9 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -64,6 +64,7 @@ import { packedMetaDetailedOnlySchema, packedMetaDetailedSchema, packedMetaLiteSchema, + packedMetaClientOptionsSchema, } from '@/models/json-schema/meta.js'; import { packedUserWebhookSchema } from '@/models/json-schema/user-webhook.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; @@ -135,6 +136,7 @@ export const refs = { MetaLite: packedMetaLiteSchema, MetaDetailedOnly: packedMetaDetailedOnlySchema, MetaDetailed: packedMetaDetailedSchema, + MetaClientOptions: packedMetaClientOptionsSchema, UserWebhook: packedUserWebhookSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, @@ -262,8 +264,6 @@ type ObjectSchemaTypeDef<p extends Schema> = never : any; -type ObjectSchemaType<p extends Schema> = NullOrUndefined<p, ObjectSchemaTypeDef<p>>; - export type SchemaTypeDef<p extends Schema> = p['type'] extends 'null' ? null : p['type'] extends 'integer' ? number : diff --git a/packages/backend/src/misc/split-id-and-objects.ts b/packages/backend/src/misc/split-id-and-objects.ts new file mode 100644 index 0000000000..d23bb93695 --- /dev/null +++ b/packages/backend/src/misc/split-id-and-objects.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * idとオブジェクトを分離する + * @param input idまたはオブジェクトの配列 + * @returns idの配列とオブジェクトの配列 + */ +export function splitIdAndObjects<T extends { id: string }>(input: (T | string)[]): { ids: string[]; objects: T[] } { + const ids: string[] = []; + const objects : T[] = []; + + for (const item of input) { + if (typeof item === 'string') { + ids.push(item); + } else { + objects.push(item); + } + } + + return { + ids, + objects, + }; +} diff --git a/packages/backend/src/misc/unique-by-key.ts b/packages/backend/src/misc/unique-by-key.ts new file mode 100644 index 0000000000..4308e29d21 --- /dev/null +++ b/packages/backend/src/misc/unique-by-key.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * itemsの中でkey関数が返す値が重複しないようにした配列を返す + * @param items 重複を除去したい配列 + * @param key 重複判定に使うキーを返す関数 + * @returns 重複を除去した配列 + */ +export function uniqueByKey<TItem, TKey = string>(items: Iterable<TItem>, key: (item: TItem) => TKey): TItem[] { + const map = new Map<TKey, TItem>(); + for (const item of items) { + const k = key(item); + if (!map.has(k)) { + map.set(k, item); + } + } + return [...map.values()]; +} diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts index 17ec6abed5..daed81c174 100644 --- a/packages/backend/src/models/AbuseReportNotificationRecipient.ts +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -67,7 +67,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザ. */ - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'userId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId1' }) @@ -76,7 +76,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザプロフィール. */ - @ManyToOne(type => MiUserProfile, { + @ManyToOne(() => MiUserProfile, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) @@ -96,7 +96,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のシステムWebhook. */ - @ManyToOne(type => MiSystemWebhook, { + @ManyToOne(() => MiSystemWebhook, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'systemWebhookId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId' }) diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index d43ebf9342..cd49fcddfe 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -18,7 +18,7 @@ export class MiAbuseUserReport { @Column(id()) public targetUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiAbuseUserReport { @Column(id()) public reporterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class MiAbuseUserReport { }) public assigneeId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts index 6f98c14ec1..a853dcc6cb 100644 --- a/packages/backend/src/models/AccessToken.ts +++ b/packages/backend/src/models/AccessToken.ts @@ -41,7 +41,7 @@ export class MiAccessToken { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -53,7 +53,7 @@ export class MiAccessToken { }) public appId: MiApp['id'] | null; - @ManyToOne(type => MiApp, { + @ManyToOne(() => MiApp, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index d0c59fff50..f664c75262 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -79,7 +79,7 @@ export class MiAnnouncement { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/AnnouncementRead.ts b/packages/backend/src/models/AnnouncementRead.ts index 47de8dd180..2133cff140 100644 --- a/packages/backend/src/models/AnnouncementRead.ts +++ b/packages/backend/src/models/AnnouncementRead.ts @@ -18,7 +18,7 @@ export class MiAnnouncementRead { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiAnnouncementRead { @Column(id()) public announcementId: MiAnnouncement['id']; - @ManyToOne(type => MiAnnouncement, { + @ManyToOne(() => MiAnnouncement, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index ccc8823703..3433cf20af 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -24,7 +24,7 @@ export class MiAntenna { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -45,7 +45,7 @@ export class MiAntenna { }) public userListId: MiUserList['id'] | null; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/App.ts b/packages/backend/src/models/App.ts index 0185e2995c..bbb80b99ef 100644 --- a/packages/backend/src/models/App.ts +++ b/packages/backend/src/models/App.ts @@ -20,7 +20,7 @@ export class MiApp { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', nullable: true, }) diff --git a/packages/backend/src/models/AuthSession.ts b/packages/backend/src/models/AuthSession.ts index 03050ba955..a7273e63bf 100644 --- a/packages/backend/src/models/AuthSession.ts +++ b/packages/backend/src/models/AuthSession.ts @@ -25,7 +25,7 @@ export class MiAuthSession { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', nullable: true, }) @@ -35,7 +35,7 @@ export class MiAuthSession { @Column(id()) public appId: MiApp['id']; - @ManyToOne(type => MiApp, { + @ManyToOne(() => MiApp, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Blocking.ts b/packages/backend/src/models/Blocking.ts index 34a6efe5a6..49b584f509 100644 --- a/packages/backend/src/models/Blocking.ts +++ b/packages/backend/src/models/Blocking.ts @@ -20,7 +20,7 @@ export class MiBlocking { }) public blockeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiBlocking { }) public blockerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/BubbleGameRecord.ts b/packages/backend/src/models/BubbleGameRecord.ts index 686e39c118..5dd7009fc6 100644 --- a/packages/backend/src/models/BubbleGameRecord.ts +++ b/packages/backend/src/models/BubbleGameRecord.ts @@ -18,7 +18,7 @@ export class MiBubbleGameRecord { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index f5e9b17e3e..5a5b914eb1 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -27,7 +27,7 @@ export class MiChannel { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() @@ -52,7 +52,7 @@ export class MiChannel { }) public bannerId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelFavorite.ts b/packages/backend/src/models/ChannelFavorite.ts index 167f41cf16..4f49468598 100644 --- a/packages/backend/src/models/ChannelFavorite.ts +++ b/packages/backend/src/models/ChannelFavorite.ts @@ -20,7 +20,7 @@ export class MiChannelFavorite { }) public channelId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChannelFavorite { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelFollowing.ts b/packages/backend/src/models/ChannelFollowing.ts index c7afdd05b0..7597e704a8 100644 --- a/packages/backend/src/models/ChannelFollowing.ts +++ b/packages/backend/src/models/ChannelFollowing.ts @@ -21,7 +21,7 @@ export class MiChannelFollowing { }) public followeeId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiChannelFollowing { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelMuting.ts b/packages/backend/src/models/ChannelMuting.ts index 11ac7e5cef..b7054c9c5f 100644 --- a/packages/backend/src/models/ChannelMuting.ts +++ b/packages/backend/src/models/ChannelMuting.ts @@ -20,7 +20,7 @@ export class MiChannelMuting { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChannelMuting { }) public channelId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts index 55c9f07e9a..bd2509b67f 100644 --- a/packages/backend/src/models/ChatApproval.ts +++ b/packages/backend/src/models/ChatApproval.ts @@ -19,7 +19,7 @@ export class MiChatApproval { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -31,7 +31,7 @@ export class MiChatApproval { }) public otherId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts index 3d2b64268e..530ef9b842 100644 --- a/packages/backend/src/models/ChatMessage.ts +++ b/packages/backend/src/models/ChatMessage.ts @@ -20,7 +20,7 @@ export class MiChatMessage { }) public fromUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatMessage { }) public toUserId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -44,7 +44,7 @@ export class MiChatMessage { }) public toRoomId: MiChatRoom['id'] | null; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() @@ -72,7 +72,7 @@ export class MiChatMessage { }) public fileId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts index ad2a910b78..c148b16af8 100644 --- a/packages/backend/src/models/ChatRoom.ts +++ b/packages/backend/src/models/ChatRoom.ts @@ -23,7 +23,7 @@ export class MiChatRoom { }) public ownerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts index 36ce12bc92..5827d0401d 100644 --- a/packages/backend/src/models/ChatRoomInvitation.ts +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -20,7 +20,7 @@ export class MiChatRoomInvitation { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatRoomInvitation { }) public roomId: MiChatRoom['id']; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts index 3cb5524859..d59b4426df 100644 --- a/packages/backend/src/models/ChatRoomMembership.ts +++ b/packages/backend/src/models/ChatRoomMembership.ts @@ -20,7 +20,7 @@ export class MiChatRoomMembership { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatRoomMembership { }) public roomId: MiChatRoom['id']; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Clip.ts b/packages/backend/src/models/Clip.ts index 6295a329fb..ddd0298f44 100644 --- a/packages/backend/src/models/Clip.ts +++ b/packages/backend/src/models/Clip.ts @@ -25,7 +25,7 @@ export class MiClip { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ClipFavorite.ts b/packages/backend/src/models/ClipFavorite.ts index 40bdb9f4aa..2d46fd0f0e 100644 --- a/packages/backend/src/models/ClipFavorite.ts +++ b/packages/backend/src/models/ClipFavorite.ts @@ -18,7 +18,7 @@ export class MiClipFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiClipFavorite { @Column(id()) public clipId: MiClip['id']; - @ManyToOne(type => MiClip, { + @ManyToOne(() => MiClip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ClipNote.ts b/packages/backend/src/models/ClipNote.ts index 6e1d2bec4c..23df66c4e0 100644 --- a/packages/backend/src/models/ClipNote.ts +++ b/packages/backend/src/models/ClipNote.ts @@ -21,7 +21,7 @@ export class MiClipNote { }) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiClipNote { }) public clipId: MiClip['id']; - @ManyToOne(type => MiClip, { + @ManyToOne(() => MiClip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts index 7b03e3e494..79189b10eb 100644 --- a/packages/backend/src/models/DriveFile.ts +++ b/packages/backend/src/models/DriveFile.ts @@ -22,7 +22,7 @@ export class MiDriveFile { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() @@ -142,7 +142,7 @@ export class MiDriveFile { }) public folderId: MiDriveFolder['id'] | null; - @ManyToOne(type => MiDriveFolder, { + @ManyToOne(() => MiDriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/DriveFolder.ts b/packages/backend/src/models/DriveFolder.ts index 07046d6e11..7e34c07f46 100644 --- a/packages/backend/src/models/DriveFolder.ts +++ b/packages/backend/src/models/DriveFolder.ts @@ -26,7 +26,7 @@ export class MiDriveFolder { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class MiDriveFolder { }) public parentId: MiDriveFolder['id'] | null; - @ManyToOne(type => MiDriveFolder, { + @ManyToOne(() => MiDriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts index 5db7dca992..ed677a9de3 100644 --- a/packages/backend/src/models/Flash.ts +++ b/packages/backend/src/models/Flash.ts @@ -38,7 +38,7 @@ export class MiFlash { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/FlashLike.ts b/packages/backend/src/models/FlashLike.ts index a9fb48123e..0d99c2a9ae 100644 --- a/packages/backend/src/models/FlashLike.ts +++ b/packages/backend/src/models/FlashLike.ts @@ -18,7 +18,7 @@ export class MiFlashLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiFlashLike { @Column(id()) public flashId: MiFlash['id']; - @ManyToOne(type => MiFlash, { + @ManyToOne(() => MiFlash, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/FollowRequest.ts b/packages/backend/src/models/FollowRequest.ts index 3ff5e7a478..468829b7e8 100644 --- a/packages/backend/src/models/FollowRequest.ts +++ b/packages/backend/src/models/FollowRequest.ts @@ -20,7 +20,7 @@ export class MiFollowRequest { }) public followeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiFollowRequest { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..fe62166287 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -21,7 +21,7 @@ export class MiFollowing { }) public followeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiFollowing { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/GalleryLike.ts b/packages/backend/src/models/GalleryLike.ts index ed0963122d..787b38e46d 100644 --- a/packages/backend/src/models/GalleryLike.ts +++ b/packages/backend/src/models/GalleryLike.ts @@ -18,7 +18,7 @@ export class MiGalleryLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiGalleryLike { @Column(id()) public postId: MiGalleryPost['id']; - @ManyToOne(type => MiGalleryPost, { + @ManyToOne(() => MiGalleryPost, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/GalleryPost.ts b/packages/backend/src/models/GalleryPost.ts index 04d8823e37..f66956628b 100644 --- a/packages/backend/src/models/GalleryPost.ts +++ b/packages/backend/src/models/GalleryPost.ts @@ -36,7 +36,7 @@ export class MiGalleryPost { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 205c9eeb89..620853450c 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -21,7 +21,7 @@ export class MiMeta { }) public rootUserId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', nullable: true, }) @@ -725,7 +725,11 @@ export class MiMeta { @Column('jsonb', { default: { }, }) - public clientOptions: Record<string, any>; + public clientOptions: { + entrancePageStyle: 'classic' | 'simple'; + showTimelineForVisitor: boolean; + showActivitiesForVisitor: boolean; + }; } export type SoftwareSuspension = { diff --git a/packages/backend/src/models/ModerationLog.ts b/packages/backend/src/models/ModerationLog.ts index edde315fdf..c22114a36d 100644 --- a/packages/backend/src/models/ModerationLog.ts +++ b/packages/backend/src/models/ModerationLog.ts @@ -16,7 +16,7 @@ export class MiModerationLog { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Muting.ts b/packages/backend/src/models/Muting.ts index e1240b9c4e..9406b97a62 100644 --- a/packages/backend/src/models/Muting.ts +++ b/packages/backend/src/models/Muting.ts @@ -26,7 +26,7 @@ export class MiMuting { }) public muteeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -39,7 +39,7 @@ export class MiMuting { }) public muterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 23e5960b60..089fe8f188 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -35,7 +35,7 @@ export class MiNote { }) public replyId: MiNote['id'] | null; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -49,7 +49,7 @@ export class MiNote { }) public renoteId: MiNote['id'] | null; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -83,7 +83,7 @@ export class MiNote { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -208,7 +208,7 @@ export class MiNote { }) public channelId: MiChannel['id'] | null; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts index f078e8c21b..5bfd9699fe 100644 --- a/packages/backend/src/models/NoteDraft.ts +++ b/packages/backend/src/models/NoteDraft.ts @@ -27,7 +27,7 @@ export class MiNoteDraft { public replyId: MiNote['id'] | null; // There is a possibility that replyId is not null but reply is null when the reply note is deleted. - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -42,7 +42,7 @@ export class MiNoteDraft { public renoteId: MiNote['id'] | null; // There is a possibility that renoteId is not null but renote is null when the renote note is deleted. - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -66,7 +66,7 @@ export class MiNoteDraft { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -120,7 +120,7 @@ export class MiNoteDraft { // There is a possibility that channelId is not null but channel is null when the channel is deleted. // (deleting channel is not implemented so it's not happening now but may happen in the future) - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { createForeignKeyConstraints: false, }) @JoinColumn() diff --git a/packages/backend/src/models/NoteFavorite.ts b/packages/backend/src/models/NoteFavorite.ts index cf76c767b0..0e498eb70d 100644 --- a/packages/backend/src/models/NoteFavorite.ts +++ b/packages/backend/src/models/NoteFavorite.ts @@ -18,7 +18,7 @@ export class MiNoteFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiNoteFavorite { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteReaction.ts b/packages/backend/src/models/NoteReaction.ts index 42dfcaa9ad..98263081ab 100644 --- a/packages/backend/src/models/NoteReaction.ts +++ b/packages/backend/src/models/NoteReaction.ts @@ -18,7 +18,7 @@ export class MiNoteReaction { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiNoteReaction { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteThreadMuting.ts b/packages/backend/src/models/NoteThreadMuting.ts index e7bd39f348..32bb829c0b 100644 --- a/packages/backend/src/models/NoteThreadMuting.ts +++ b/packages/backend/src/models/NoteThreadMuting.ts @@ -19,7 +19,7 @@ export class MiNoteThreadMuting { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index d46f6e9d16..8811200801 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -56,7 +56,7 @@ export class MiPage { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -68,7 +68,7 @@ export class MiPage { }) public eyeCatchingImageId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/PageLike.ts b/packages/backend/src/models/PageLike.ts index 05ca22cf2c..cf3025ae1c 100644 --- a/packages/backend/src/models/PageLike.ts +++ b/packages/backend/src/models/PageLike.ts @@ -18,7 +18,7 @@ export class MiPageLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiPageLike { @Column(id()) public pageId: MiPage['id']; - @ManyToOne(type => MiPage, { + @ManyToOne(() => MiPage, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PasswordResetRequest.ts b/packages/backend/src/models/PasswordResetRequest.ts index fdaf21056b..3379b540ee 100644 --- a/packages/backend/src/models/PasswordResetRequest.ts +++ b/packages/backend/src/models/PasswordResetRequest.ts @@ -24,7 +24,7 @@ export class MiPasswordResetRequest { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts index ca985c8b24..d82e29fb85 100644 --- a/packages/backend/src/models/Poll.ts +++ b/packages/backend/src/models/Poll.ts @@ -15,7 +15,7 @@ export class MiPoll { @PrimaryColumn(id()) public noteId: MiNote['id']; - @OneToOne(type => MiNote, { + @OneToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PollVote.ts b/packages/backend/src/models/PollVote.ts index b5c780293c..600ca8ea41 100644 --- a/packages/backend/src/models/PollVote.ts +++ b/packages/backend/src/models/PollVote.ts @@ -18,7 +18,7 @@ export class MiPollVote { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiPollVote { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PromoNote.ts b/packages/backend/src/models/PromoNote.ts index ae27adec9e..871f7471fc 100644 --- a/packages/backend/src/models/PromoNote.ts +++ b/packages/backend/src/models/PromoNote.ts @@ -13,7 +13,7 @@ export class MiPromoNote { @PrimaryColumn(id()) public noteId: MiNote['id']; - @OneToOne(type => MiNote, { + @OneToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PromoRead.ts b/packages/backend/src/models/PromoRead.ts index b2a698cc7b..15a3573ef3 100644 --- a/packages/backend/src/models/PromoRead.ts +++ b/packages/backend/src/models/PromoRead.ts @@ -18,7 +18,7 @@ export class MiPromoRead { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiPromoRead { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RegistrationTicket.ts b/packages/backend/src/models/RegistrationTicket.ts index 0a4e4b9189..07216599d3 100644 --- a/packages/backend/src/models/RegistrationTicket.ts +++ b/packages/backend/src/models/RegistrationTicket.ts @@ -23,7 +23,7 @@ export class MiRegistrationTicket { }) public expiresAt: Date | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -36,7 +36,7 @@ export class MiRegistrationTicket { }) public createdById: MiUser['id'] | null; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RegistryItem.ts b/packages/backend/src/models/RegistryItem.ts index 335e8b9eab..869980bbff 100644 --- a/packages/backend/src/models/RegistryItem.ts +++ b/packages/backend/src/models/RegistryItem.ts @@ -25,7 +25,7 @@ export class MiRegistryItem { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RenoteMuting.ts b/packages/backend/src/models/RenoteMuting.ts index 448a0b7663..b760a09c53 100644 --- a/packages/backend/src/models/RenoteMuting.ts +++ b/packages/backend/src/models/RenoteMuting.ts @@ -20,7 +20,7 @@ export class MiRenoteMuting { }) public muteeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiRenoteMuting { }) public muterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index 6b29a0ce8c..fbbf24792f 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -27,7 +27,7 @@ export class MiReversiGame { @Column(id()) public user1Id: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -36,7 +36,7 @@ export class MiReversiGame { @Column(id()) public user2Id: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RoleAssignment.ts b/packages/backend/src/models/RoleAssignment.ts index 37755d631b..cb96377f66 100644 --- a/packages/backend/src/models/RoleAssignment.ts +++ b/packages/backend/src/models/RoleAssignment.ts @@ -21,7 +21,7 @@ export class MiRoleAssignment { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiRoleAssignment { }) public roleId: MiRole['id']; - @ManyToOne(type => MiRole, { + @ManyToOne(() => MiRole, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Signin.ts b/packages/backend/src/models/Signin.ts index f8ff9c57d7..59cbad735d 100644 --- a/packages/backend/src/models/Signin.ts +++ b/packages/backend/src/models/Signin.ts @@ -16,7 +16,7 @@ export class MiSignin { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/SwSubscription.ts b/packages/backend/src/models/SwSubscription.ts index 0c531132b3..a95aede44f 100644 --- a/packages/backend/src/models/SwSubscription.ts +++ b/packages/backend/src/models/SwSubscription.ts @@ -16,7 +16,7 @@ export class MiSwSubscription { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/SystemAccount.ts b/packages/backend/src/models/SystemAccount.ts index f32880b81d..2a48e62ed1 100644 --- a/packages/backend/src/models/SystemAccount.ts +++ b/packages/backend/src/models/SystemAccount.ts @@ -18,7 +18,7 @@ export class MiSystemAccount { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index a6e9edcf5f..084dd35485 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -99,7 +99,7 @@ export class MiUser { }) public avatarId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() @@ -112,7 +112,7 @@ export class MiUser { }) public bannerId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/UserKeypair.ts b/packages/backend/src/models/UserKeypair.ts index f5252d126c..894739c84c 100644 --- a/packages/backend/src/models/UserKeypair.ts +++ b/packages/backend/src/models/UserKeypair.ts @@ -12,7 +12,7 @@ export class MiUserKeypair { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserList.ts b/packages/backend/src/models/UserList.ts index 5fb991a87d..05fd833b6f 100644 --- a/packages/backend/src/models/UserList.ts +++ b/packages/backend/src/models/UserList.ts @@ -25,7 +25,7 @@ export class MiUserList { }) public isPublic: boolean; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserListFavorite.ts b/packages/backend/src/models/UserListFavorite.ts index 80b2d61eb7..67ab92d98c 100644 --- a/packages/backend/src/models/UserListFavorite.ts +++ b/packages/backend/src/models/UserListFavorite.ts @@ -18,7 +18,7 @@ export class MiUserListFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiUserListFavorite { @Column(id()) public userListId: MiUserList['id']; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserListMembership.ts b/packages/backend/src/models/UserListMembership.ts index af659d071d..1a2b3fffc1 100644 --- a/packages/backend/src/models/UserListMembership.ts +++ b/packages/backend/src/models/UserListMembership.ts @@ -21,7 +21,7 @@ export class MiUserListMembership { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiUserListMembership { }) public userListId: MiUserList['id']; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserMemo.ts b/packages/backend/src/models/UserMemo.ts index 29e28d290a..facc8c6b1c 100644 --- a/packages/backend/src/models/UserMemo.ts +++ b/packages/backend/src/models/UserMemo.ts @@ -20,7 +20,7 @@ export class MiUserMemo { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiUserMemo { }) public targetUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserNotePining.ts b/packages/backend/src/models/UserNotePining.ts index 92c5cd55d0..950da2ad22 100644 --- a/packages/backend/src/models/UserNotePining.ts +++ b/packages/backend/src/models/UserNotePining.ts @@ -18,7 +18,7 @@ export class MiUserNotePining { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiUserNotePining { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 501b539210..b05bf14ef9 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -17,7 +17,7 @@ export class MiUserProfile { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -215,7 +215,7 @@ export class MiUserProfile { }) public pinnedPageId: MiPage['id'] | null; - @OneToOne(type => MiPage, { + @OneToOne(() => MiPage, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/UserPublickey.ts b/packages/backend/src/models/UserPublickey.ts index 6bcd785304..8c23d368e9 100644 --- a/packages/backend/src/models/UserPublickey.ts +++ b/packages/backend/src/models/UserPublickey.ts @@ -12,7 +12,7 @@ export class MiUserPublickey { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserSecurityKey.ts b/packages/backend/src/models/UserSecurityKey.ts index 0babbe1abe..577ec359e4 100644 --- a/packages/backend/src/models/UserSecurityKey.ts +++ b/packages/backend/src/models/UserSecurityKey.ts @@ -18,7 +18,7 @@ export class MiUserSecurityKey { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts index b4cab4edc8..5f833115cc 100644 --- a/packages/backend/src/models/Webhook.ts +++ b/packages/backend/src/models/Webhook.ts @@ -22,7 +22,7 @@ export class MiWebhook { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index a0e7d490b3..0c3ec141bc 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -72,8 +72,7 @@ export const packedMetaLiteSchema = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, disableRegistration: { type: 'boolean', @@ -397,3 +396,23 @@ export const packedMetaDetailedSchema = { }, ], } as const; + +export const packedMetaClientOptionsSchema = { + type: 'object', + optional: false, nullable: false, + properties: { + entrancePageStyle: { + type: 'string', + enum: ['classic', 'simple'], + optional: false, nullable: false, + }, + showTimelineForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + showActivitiesForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts index cb37200384..378ae41cb5 100644 --- a/packages/backend/src/models/json-schema/reversi-game.ts +++ b/packages/backend/src/models/json-schema/reversi-game.ts @@ -81,6 +81,7 @@ export const packedReversiGameLiteSchema = { bw: { type: 'string', optional: false, nullable: false, + enum: ['random', '1', '2'], }, noIrregularRules: { type: 'boolean', @@ -199,6 +200,7 @@ export const packedReversiGameDetailedSchema = { bw: { type: 'string', optional: false, nullable: false, + enum: ['random', '1', '2'], }, noIrregularRules: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index b5fd38a7d7..f71ec1d023 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -618,6 +618,9 @@ export const packedMeDetailedOnlySchema = { achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, + login: { optional: true, ...notificationRecieveConfig }, + createToken: { optional: true, ...notificationRecieveConfig }, + exportCompleted: { optional: true, ...notificationRecieveConfig }, }, }, emailNotificationTypes: { diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index e237cd4975..53ecd2d180 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -123,8 +123,8 @@ export class ExportCustomEmojisProcessorService { metaStream.end(); // Create archive - await new Promise<void>(async (resolve) => { - const [archivePath, archiveCleanup] = await createTemp(); + const [archivePath, archiveCleanup] = await createTemp(); + await new Promise<void>((resolve) => { const archiveStream = fs.createWriteStream(archivePath); const archive = archiver('zip', { zlib: { level: 0 }, diff --git a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts index d0eaeee090..719a09980c 100644 --- a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts +++ b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts @@ -63,7 +63,7 @@ export class PostScheduledNoteProcessorService { this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', { noteId: note.id, }); - } catch (err) { + } catch (_) { this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', { noteDraftId: draft.id, }); diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index a5fb5b82e3..54ffeecc6b 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -116,7 +116,7 @@ export class ActivityPubServerService { try { signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); - } catch (e) { + } catch (_) { reply.code(401); return; } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 772c37094c..f5034d0733 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -7,27 +7,22 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import rename from 'rename'; -import sharp from 'sharp'; -import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { InternalStorageService } from '@/core/InternalStorageService.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; import { FileInfoService } from '@/core/FileInfoService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { FileServerDriveHandler } from './file/FileServerDriveHandler.js'; +import { FileServerFileResolver } from './file/FileServerFileResolver.js'; +import { FileServerProxyHandler } from './file/FileServerProxyHandler.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); @@ -38,6 +33,9 @@ const assets = `${_dirname}/../../server/file/assets/`; @Injectable() export class FileServerService { private logger: Logger; + private driveHandler: FileServerDriveHandler; + private proxyHandler: FileServerProxyHandler; + private fileResolver: FileServerFileResolver; constructor( @Inject(DI.config) @@ -54,6 +52,24 @@ export class FileServerService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); + this.fileResolver = new FileServerFileResolver( + this.driveFilesRepository, + this.fileInfoService, + this.downloadService, + this.internalStorageService, + ); + this.driveHandler = new FileServerDriveHandler( + this.config, + this.fileResolver, + assets, + this.videoProcessingService, + ); + this.proxyHandler = new FileServerProxyHandler( + this.config, + this.fileResolver, + assets, + this.imageProcessingService, + ); //this.createServer = this.createServer.bind(this); } @@ -78,7 +94,7 @@ export class FileServerService { }); fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { - return await this.sendDriveFile(request, reply) + return await this.driveHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { @@ -91,7 +107,7 @@ export class FileServerService { Params: { url: string; }; Querystring: { url?: string; }; }>('/proxy/:url*', async (request, reply) => { - return await this.proxyHandler(request, reply) + return await this.proxyHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); @@ -116,462 +132,4 @@ export class FileServerService { reply.code(500); return; } - - @bindThis - private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { - const key = request.params.key; - const file = await this.getFileFromKey(key).then(); - - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - if (file.state === 'remote') { - let image: IImageStreamable | null = null; - - if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } else if (file.mime.startsWith('video/')) { - const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); - if (externalThumbnail) { - file.cleanup(); - return await reply.redirect(externalThumbnail, 301); - } - - image = await this.videoProcessingService.generateVideoThumbnail(file.path); - } - } - - if (file.fileRole === 'webpublic') { - if (['image/svg+xml'].includes(file.mime)) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/svg.webp`); - url.searchParams.set('url', file.url); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } - } - - if (!image) { - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } - - if (file.fileRole !== 'original') { - const filename = rename(file.filename, { - suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : '.unknown', - }).toString(); - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } else { - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; - - if (typeof url !== 'string') { - reply.code(400); - return; - } - - // アバタークロップなど、どうしてもオリジンである必要がある場合 - const mustOrigin = 'origin' in request.query; - - if (this.config.externalMediaProxyEnabled && !mustOrigin) { - // 外部のメディアプロキシが有効なら、そちらにリダイレクト - - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - url.searchParams.append(key, value); - } - - return await reply.redirect( - url.toString(), - 301, - ); - } - - if (!request.headers['user-agent']) { - throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); - } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); - } - - // Create temp file - const file = await this.getStreamAndTypeFromUrl(url); - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); - - if ( - 'emoji' in request.query || - 'avatar' in request.query || - 'static' in request.query || - 'preview' in request.query || - 'badge' in request.query - ) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - } - - let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } else { - const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) - .resize({ - height: 'emoji' in request.query ? 128 : 320, - withoutEnlargement: true, - }) - .webp(webpDefault); - - image = { - data, - ext: 'webp', - type: 'image/webp', - }; - } - } else if ('static' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { - const mask = (await sharpBmp(file.path, file.mime)) - .resize(96, 96, { - fit: 'contain', - position: 'centre', - withoutEnlargement: false, - }) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .toColorspace('b-w'); - - const stats = await mask.clone().stats(); - - if (stats.entropy < 0.1) { - // エントロピーがあまりない場合は404にする - throw new StatusError('Skip to provide badge', 404); - } - - const data = sharp({ - create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(await mask.png().toBuffer(), 'eor'); - - image = { - data: await data.png().toBuffer(), - ext: 'png', - type: 'image/png', - }; - } else if (file.mime === 'image/svg+xml') { - image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); - } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } - - if (!image) { - if (request.headers.range && file.file && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('cleanup' in file) { - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - } - - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - if (url.startsWith(`${this.config.url}/files/`)) { - const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); - if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); - - return await this.getFileFromKey(key); - } - - return await this.downloadAndDetectTypeFromUrl(url); - } - - @bindThis - private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - > { - const [path, cleanup] = await createTemp(); - try { - const { filename } = await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - return { - state: 'remote', - mime, ext, - path, cleanup, - filename, - }; - } catch (e) { - cleanup(); - throw e; - } - } - - @bindThis - private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - // Fetch drive file - const file = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.accessKey = :accessKey', { accessKey: key }) - .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) - .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) - .getOne(); - - if (file == null) return '404'; - - const isThumbnail = file.thumbnailAccessKey === key; - const isWebpublic = file.webpublicAccessKey === key; - - if (!file.storedInternal) { - if (!(file.isLink && file.uri)) return '204'; - const result = await this.downloadAndDetectTypeFromUrl(file.uri); - file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので - return { - ...result, - url: file.uri, - fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', - file, - filename: file.name, - }; - } - - const path = this.internalStorageService.resolvePath(key); - - if (isThumbnail || isWebpublic) { - const { mime, ext } = await this.fileInfoService.detectType(path); - return { - state: 'stored_internal', - fileRole: isThumbnail ? 'thumbnail' : 'webpublic', - file, - filename: file.name, - mime, ext, - path, - }; - } - - return { - state: 'stored_internal', - fileRole: 'original', - file, - filename: file.name, - // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる - mime: this.fileInfoService.fixMime(file.type), - ext: null, - path, - }; - } } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 239ef82dec..93c36f5365 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -48,8 +48,6 @@ export class NodeinfoServerService { @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { const nodeinfo2 = async (version: number) => { - const now = Date.now(); - const notesChart = await this.notesChart.getChart('hour', 1, null); const localPosts = notesChart.local.total[0]; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 111421472d..8259a2a9e4 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -13,7 +13,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ServerService } from './ServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { GetterService } from './api/GetterService.js'; -import { ChannelsService } from './api/stream/ChannelsService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { ApiLoggerService } from './api/ApiLoggerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -31,24 +30,25 @@ import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; -import { MainChannelService } from './api/stream/channels/main.js'; -import { AdminChannelService } from './api/stream/channels/admin.js'; -import { AntennaChannelService } from './api/stream/channels/antenna.js'; -import { ChannelChannelService } from './api/stream/channels/channel.js'; -import { DriveChannelService } from './api/stream/channels/drive.js'; -import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; -import { HashtagChannelService } from './api/stream/channels/hashtag.js'; -import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; -import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; -import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; -import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; -import { UserListChannelService } from './api/stream/channels/user-list.js'; -import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; -import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; -import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; -import { ReversiChannelService } from './api/stream/channels/reversi.js'; -import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import MainStreamConnection from '@/server/api/stream/Connection.js'; +import { MainChannel } from './api/stream/channels/main.js'; +import { AdminChannel } from './api/stream/channels/admin.js'; +import { AntennaChannel } from './api/stream/channels/antenna.js'; +import { ChannelChannel } from './api/stream/channels/channel.js'; +import { DriveChannel } from './api/stream/channels/drive.js'; +import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js'; +import { HashtagChannel } from './api/stream/channels/hashtag.js'; +import { HomeTimelineChannel } from './api/stream/channels/home-timeline.js'; +import { HybridTimelineChannel } from './api/stream/channels/hybrid-timeline.js'; +import { LocalTimelineChannel } from './api/stream/channels/local-timeline.js'; +import { QueueStatsChannel } from './api/stream/channels/queue-stats.js'; +import { ServerStatsChannel } from './api/stream/channels/server-stats.js'; +import { UserListChannel } from './api/stream/channels/user-list.js'; +import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js'; +import { ChatUserChannel } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannel } from './api/stream/channels/chat-room.js'; +import { ReversiChannel } from './api/stream/channels/reversi.js'; +import { ReversiGameChannel } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ @@ -69,7 +69,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ServerService, WellKnownServerService, GetterService, - ChannelsService, + MainStreamConnection, ApiCallService, ApiLoggerService, ApiServerService, @@ -80,24 +80,24 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j SigninService, SignupApiService, StreamingApiServerService, - MainChannelService, - AdminChannelService, - AntennaChannelService, - ChannelChannelService, - DriveChannelService, - GlobalTimelineChannelService, - HashtagChannelService, - RoleTimelineChannelService, - ChatUserChannelService, - ChatRoomChannelService, - ReversiChannelService, - ReversiGameChannelService, - HomeTimelineChannelService, - HybridTimelineChannelService, - LocalTimelineChannelService, - QueueStatsChannelService, - ServerStatsChannelService, - UserListChannelService, + MainChannel, + AdminChannel, + AntennaChannel, + ChannelChannel, + DriveChannel, + GlobalTimelineChannel, + HashtagChannel, + RoleTimelineChannel, + ChatUserChannel, + ChatRoomChannel, + ReversiChannel, + ReversiGameChannel, + HomeTimelineChannel, + HybridTimelineChannel, + LocalTimelineChannel, + QueueStatsChannel, + ServerStatsChannel, + UserListChannel, OpenApiServerService, OAuth2ProviderService, ], diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 8bae46d9fb..0ccb3df631 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -426,7 +426,7 @@ export class ApiCallService implements OnApplicationShutdown { if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { try { data[k] = JSON.parse(data[k]); - } catch (e) { + } catch (_) { throw new ApiError({ message: 'Invalid param.', code: 'INVALID_PARAM', diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 00e8828242..5c9d16a95a 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -231,7 +231,7 @@ export class SigninApiService { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 920f9d0b3a..6feb4c3afa 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -93,7 +93,7 @@ export class SigninWithPasskeyApiService { // Not more than 1 API call per 250ms and not more than 100 attempts per 30min // NOTE: 1 Sign-in require 2 API calls await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); - } catch (err) { + } catch (_) { reply.code(429); return { error: { diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 53336a087d..b419c51ef1 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -255,7 +255,7 @@ export class SignupApiService { throw new FastifyReplyError(400, 'EXPIRED'); } - const { account, secret } = await this.signupService.signup({ + const { account } = await this.signupService.signup({ username: pendingUser.username, passwordHash: pendingUser.password, }); diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 21f2f0b7e2..8a317bdc4e 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -8,18 +8,14 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NotificationService } from '@/core/NotificationService.js'; +import type { MiAccessToken } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; -import MainStreamConnection from './stream/Connection.js'; -import { ChannelsService } from './stream/ChannelsService.js'; +import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js'; import type * as http from 'node:http'; +import { ContextIdFactory, ModuleRef } from '@nestjs/core'; @Injectable() export class StreamingApiServerService { @@ -31,16 +27,9 @@ export class StreamingApiServerService { @Inject(DI.redisForSub) private redisForSub: Redis.Redis, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private cacheService: CacheService, + private moduleRef: ModuleRef, private authenticateService: AuthenticateService, - private channelsService: ChannelsService, - private notificationService: NotificationService, private usersService: UserService, - private channelFollowingService: ChannelFollowingService, - private channelMutingService: ChannelMutingService, ) { } @@ -94,14 +83,12 @@ export class StreamingApiServerService { return; } - const stream = new MainStreamConnection( - this.channelsService, - this.notificationService, - this.cacheService, - this.channelFollowingService, - this.channelMutingService, - user, app, - ); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId<ConnectionRequest>({ + user, + token: app, + }, contextId); + const stream = await this.moduleRef.create(MainStreamConnection, contextId); await stream.init(); @@ -124,7 +111,7 @@ export class StreamingApiServerService { user: MiLocalUser | null; app: MiAccessToken | null }) => { - const { stream, user, app } = ctx; + const { stream, user } = ctx; const ev = new EventEmitter(); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 9aecc0f0fd..6679005c3c 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; export * as 'users/followers' from './endpoints/users/followers.js'; export * as 'users/following' from './endpoints/users/following.js'; +export * as 'users/get-following-users-by-birthday' from './endpoints/users/get-following-users-by-birthday.js'; export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js'; export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js'; export * as 'users/lists/create' from './endpoints/users/lists/create.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index b8bfda73a4..74462b302a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const { raw, packed } = await this.announcementService.create({ + const { packed } = await this.announcementService.create({ updatedAt: null, title: ps.title, text: ps.text, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 804bd5d9b9..aeebceed5a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -51,11 +51,13 @@ export const meta = { }, icon: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, + enum: ['info', 'warning', 'error', 'success'], }, display: { type: 'string', optional: false, nullable: false, + enum: ['normal', 'banner', 'dialog'], }, isActive: { type: 'boolean', diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index cf03859ce5..d4305e7d7c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -76,7 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { // Create file driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); - } catch (e) { + } catch (_) { // TODO: need to return Drive Error throw new ApiError(); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index 660aa55bf8..b9448b4bc2 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -24,39 +24,7 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - }, + ref: 'EmojiDetailed', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 34d200455e..658367409c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -24,39 +24,7 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - }, + ref: 'EmojiDetailed', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 7bde10af46..e20bc21f6b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -117,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); } // 網羅性チェック - const mustBeNever: never = error; + const _mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts index b7781b8c99..bdd0ee6cac 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'read:admin:user-ips', res: { type: 'array', diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2c7f793584..5beed3a7e8 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -428,8 +428,7 @@ export const meta = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, description: { type: 'string', diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index b3c2cecc67..372fe3a25f 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import type { MiMeta } from '@/models/Meta.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -67,7 +68,14 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - clientOptions: { type: 'object', nullable: false }, + clientOptions: { + type: 'object', nullable: false, + properties: { + entrancePageStyle: { type: 'string', nullable: false, enum: ['classic', 'simple'] }, + showTimelineForVisitor: { type: 'boolean', nullable: false }, + showActivitiesForVisitor: { type: 'boolean', nullable: false }, + }, + }, cacheRemoteFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -217,6 +225,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private metaService: MetaService, private moderationLogService: ModerationLogService, ) { @@ -329,7 +340,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } if (ps.clientOptions !== undefined) { - set.clientOptions = ps.clientOptions; + set.clientOptions = { + ...this.serverSettings.clientOptions, + ...ps.clientOptions, + }; } if (ps.cacheRemoteFiles !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 14286bc23e..ff03fce72b 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private apResolverService: ApResolverService, ) { super(meta, paramDef, async (ps, me) => { - const resolver = this.apResolverService.createResolver(); + const resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(ps.uri); return object; }); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index fe48e7497a..47da6b4fbd 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -148,7 +148,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (this.utilityService.isSelfHost(host)) return null; // リモートから一旦オブジェクトフェッチ - const resolver = this.apResolverService.createResolver(); + const resolver = await this.apResolverService.createResolver(); // allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob) const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => { if (err instanceof IdentifiableError) { @@ -215,7 +215,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- type: 'Note', object, }; - } catch (e) { + } catch (_) { return null; } } diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index 30f0c1b0c8..7b2c137bd4 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -32,6 +32,7 @@ export const paramDef = { properties: { tag: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, @@ -74,7 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; } - const users = await query.limit(ps.limit).getMany(); + const users = await query + .limit(ps.limit) + .offset(ps.offset) + .getMany(); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 65eece5b97..8dc5cafb56 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 9391aee5e0..050dbaf49e 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -212,7 +212,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a54c598213..b6c837eda7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index c350136eae..6e5d9943de 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index b5a53cc889..23b577dc18 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -57,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index bb78d47149..19ea187ee8 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -45,7 +45,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index bfa0b4605d..42324c7778 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index f933eaab00..4fe39bb8e8 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -71,7 +71,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - const EXTRA_LIMIT = 100; const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index da1faee30d..c2f4281f36 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 9971a1ea4d..5207d9f2b0 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -323,7 +323,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { new RE2(regexp[1], regexp[2]); - } catch (err) { + } catch (_) { throw new ApiError(meta.errors.invalidRegexp); } } @@ -587,7 +587,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }) .execute(); } - } catch (err) { + } catch (_) { // なにもしない } } diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 29c6aa7434..7c0dddb827 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -59,7 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw err; }); - const mutedNotes = await this.notesRepository.find({ + const _mutedNotes = await this.notesRepository.find({ where: [{ id: note.threadId ?? note.id, }, { diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 047f9a053b..4defcc9dcf 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -86,7 +86,7 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - birthday: { ...birthdaySchema, nullable: true }, + birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-users-by-birthday instead.' }, }, }, ], @@ -146,15 +146,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .andWhere('following.followerId = :userId', { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); + // @deprecated use get-following-users-by-birthday instead. if (ps.birthday) { - try { - const birthday = ps.birthday.substring(5, 10); - const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); - birthdayUserQuery.select('user_profile.userId') - .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); + query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); - query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`); - } catch (err) { + try { + const birthday = ps.birthday.split('-'); + birthday.shift(); // 年の部分を削除 + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) }); + } catch (_) { throw new ApiError(meta.errors.birthdayInvalid); } } diff --git a/packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts b/packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts new file mode 100644 index 0000000000..947c19d81e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + FollowingsRepository, + UserProfilesRepository, +} from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { Packed } from '@/misc/json-schema.js'; + +export const meta = { + tags: ['users'], + + requireCredential: true, + kind: 'read:account', + + description: 'Retrieve users who have a birthday on the specified range.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + birthday: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + birthday: { + oneOf: [{ + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, { + type: 'object', + properties: { + begin: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + end: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + }, + required: ['begin', 'end'], + }], + }, + }, + required: ['birthday'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.followingsRepository + .createQueryBuilder('following') + .andWhere('following.followerId = :userId', { userId: me.id }) + .innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); + + if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) { + const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; }; + + // 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換 + const begin = range.begin.month * 100 + range.begin.day; + const end = range.end.month * 100 + range.end.day; + + if (begin <= end) { + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end }); + } else { + // 12/31 から 1/1 の範囲を取得するために OR で対応 + query.andWhere(new Brackets(qb => { + qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin }); + qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end }); + })); + } + } else { + const { month, day } = ps.birthday as { month: number; day: number }; + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day }); + } + + query.select('following.followeeId', 'user_id'); + query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date'); + query.orderBy('birthday_date', 'ASC'); + + const birthdayUsers = await query + .offset(ps.offset).limit(ps.limit) + .getRawMany<{ birthday_date: number; user_id: string }>(); + + const users = new Map<string, Packed<'UserLite'>>(( + await this.userEntityService.packMany( + birthdayUsers.map(u => u.user_id), + me, + { schema: 'UserLite' }, + ) + ).map(u => [u.id, u])); + + return birthdayUsers + .map(item => { + const birthday = new Date(); + birthday.setHours(0, 0, 0, 0); + // item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定 + birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100); + + if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) { + birthday.setFullYear(new Date().getFullYear() + 1); + } + + const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`; + return { + id: item.user_id, + birthday: birthdayStr, + user: users.get(item.user_id), + }; + }) + .filter(item => item.user != null) + .map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> }); + }); + } +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 1cdcbebd1a..0714f61294 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -9,9 +9,8 @@ import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 'res', includeSelfRef: boolean): any { // optional, nullable, refはスキーマ定義に含まれないので分離しておく - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { optional, nullable, ref, selfRef, ..._res }: any = schema; - const res = deepClone(_res); + const { optional, nullable, ref, selfRef, ...res1 }: any = schema; + const res = deepClone(res1); if (schema.type === 'object' && schema.properties) { if (type === 'res') { diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index c0ef589dea..63ad9281b2 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -4,72 +4,54 @@ */ import { Injectable } from '@nestjs/common'; +import { HybridTimelineChannel } from './channels/hybrid-timeline.js'; +import { LocalTimelineChannel } from './channels/local-timeline.js'; +import { HomeTimelineChannel } from './channels/home-timeline.js'; +import { GlobalTimelineChannel } from './channels/global-timeline.js'; +import { MainChannel } from './channels/main.js'; +import { ChannelChannel } from './channels/channel.js'; +import { AdminChannel } from './channels/admin.js'; +import { ServerStatsChannel } from './channels/server-stats.js'; +import { QueueStatsChannel } from './channels/queue-stats.js'; +import { UserListChannel } from './channels/user-list.js'; +import { AntennaChannel } from './channels/antenna.js'; +import { DriveChannel } from './channels/drive.js'; +import { HashtagChannel } from './channels/hashtag.js'; +import { RoleTimelineChannel } from './channels/role-timeline.js'; +import { ChatUserChannel } from './channels/chat-user.js'; +import { ChatRoomChannel } from './channels/chat-room.js'; +import { ReversiChannel } from './channels/reversi.js'; +import { ReversiGameChannel } from './channels/reversi-game.js'; +import type { ChannelConstructor } from './channel.js'; import { bindThis } from '@/decorators.js'; -import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './channels/local-timeline.js'; -import { HomeTimelineChannelService } from './channels/home-timeline.js'; -import { GlobalTimelineChannelService } from './channels/global-timeline.js'; -import { MainChannelService } from './channels/main.js'; -import { ChannelChannelService } from './channels/channel.js'; -import { AdminChannelService } from './channels/admin.js'; -import { ServerStatsChannelService } from './channels/server-stats.js'; -import { QueueStatsChannelService } from './channels/queue-stats.js'; -import { UserListChannelService } from './channels/user-list.js'; -import { AntennaChannelService } from './channels/antenna.js'; -import { DriveChannelService } from './channels/drive.js'; -import { HashtagChannelService } from './channels/hashtag.js'; -import { RoleTimelineChannelService } from './channels/role-timeline.js'; -import { ChatUserChannelService } from './channels/chat-user.js'; -import { ChatRoomChannelService } from './channels/chat-room.js'; -import { ReversiChannelService } from './channels/reversi.js'; -import { ReversiGameChannelService } from './channels/reversi-game.js'; -import { type MiChannelService } from './channel.js'; @Injectable() export class ChannelsService { constructor( - private mainChannelService: MainChannelService, - private homeTimelineChannelService: HomeTimelineChannelService, - private localTimelineChannelService: LocalTimelineChannelService, - private hybridTimelineChannelService: HybridTimelineChannelService, - private globalTimelineChannelService: GlobalTimelineChannelService, - private userListChannelService: UserListChannelService, - private hashtagChannelService: HashtagChannelService, - private roleTimelineChannelService: RoleTimelineChannelService, - private antennaChannelService: AntennaChannelService, - private channelChannelService: ChannelChannelService, - private driveChannelService: DriveChannelService, - private serverStatsChannelService: ServerStatsChannelService, - private queueStatsChannelService: QueueStatsChannelService, - private adminChannelService: AdminChannelService, - private chatUserChannelService: ChatUserChannelService, - private chatRoomChannelService: ChatRoomChannelService, - private reversiChannelService: ReversiChannelService, - private reversiGameChannelService: ReversiGameChannelService, ) { } @bindThis - public getChannelService(name: string): MiChannelService<boolean> { + public getChannelConstructor(name: string): ChannelConstructor<boolean> { switch (name) { - case 'main': return this.mainChannelService; - case 'homeTimeline': return this.homeTimelineChannelService; - case 'localTimeline': return this.localTimelineChannelService; - case 'hybridTimeline': return this.hybridTimelineChannelService; - case 'globalTimeline': return this.globalTimelineChannelService; - case 'userList': return this.userListChannelService; - case 'hashtag': return this.hashtagChannelService; - case 'roleTimeline': return this.roleTimelineChannelService; - case 'antenna': return this.antennaChannelService; - case 'channel': return this.channelChannelService; - case 'drive': return this.driveChannelService; - case 'serverStats': return this.serverStatsChannelService; - case 'queueStats': return this.queueStatsChannelService; - case 'admin': return this.adminChannelService; - case 'chatUser': return this.chatUserChannelService; - case 'chatRoom': return this.chatRoomChannelService; - case 'reversi': return this.reversiChannelService; - case 'reversiGame': return this.reversiGameChannelService; + case 'main': return MainChannel; + case 'homeTimeline': return HomeTimelineChannel; + case 'localTimeline': return LocalTimelineChannel; + case 'hybridTimeline': return HybridTimelineChannel; + case 'globalTimeline': return GlobalTimelineChannel; + case 'userList': return UserListChannel; + case 'hashtag': return HashtagChannel; + case 'roleTimeline': return RoleTimelineChannel; + case 'antenna': return AntennaChannel; + case 'channel': return ChannelChannel; + case 'drive': return DriveChannel; + case 'serverStats': return ServerStatsChannel; + case 'queueStats': return QueueStatsChannel; + case 'admin': return AdminChannel; + case 'chatUser': return ChatUserChannel; + case 'chatRoom': return ChatRoomChannel; + case 'reversi': return ReversiChannel; + case 'reversiGame': return ReversiGameChannel; default: throw new Error(`no such channel: ${name}`); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 222086c960..5989409997 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -6,19 +6,39 @@ import * as WebSocket from 'ws'; import type { MiUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { NotificationService } from '@/core/NotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; -import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import type { ChannelsService } from './ChannelsService.js'; +import { isJsonObject } from '@/misc/json-value.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; +import type { ChannelConstructor } from './channel.js'; +import type { ChannelRequest } from './channel.js'; +import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core'; +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { MainChannel } from '@/server/api/stream/channels/main.js'; +import { HomeTimelineChannel } from '@/server/api/stream/channels/home-timeline.js'; +import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timeline.js'; +import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js'; +import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js'; +import { UserListChannel } from '@/server/api/stream/channels/user-list.js'; +import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js'; +import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js'; +import { AntennaChannel } from '@/server/api/stream/channels/antenna.js'; +import { ChannelChannel } from '@/server/api/stream/channels/channel.js'; +import { DriveChannel } from '@/server/api/stream/channels/drive.js'; +import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js'; +import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js'; +import { AdminChannel } from '@/server/api/stream/channels/admin.js'; +import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js'; +import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js'; +import { ReversiChannel } from '@/server/api/stream/channels/reversi.js'; +import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js'; const MAX_CHANNELS_PER_CONNECTION = 32; @@ -26,6 +46,7 @@ const MAX_CHANNELS_PER_CONNECTION = 32; * Main stream connection */ // eslint-disable-next-line import/no-default-export +@Injectable({ scope: Scope.TRANSIENT }) export default class Connection { public user?: MiUser; public token?: MiAccessToken; @@ -44,16 +65,16 @@ export default class Connection { private fetchIntervalId: NodeJS.Timeout | null = null; constructor( - private channelsService: ChannelsService, + private moduleRef: ModuleRef, private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, private channelMutingService: ChannelMutingService, - user: MiUser | null | undefined, - token: MiAccessToken | null | undefined, + @Inject(REQUEST) + request: ConnectionRequest, ) { - if (user) this.user = user; - if (token) this.token = token; + if (request.user) this.user = request.user; + if (request.token) this.token = request.token; } @bindThis @@ -118,7 +139,7 @@ export default class Connection { try { obj = JSON.parse(data.toString()); - } catch (e) { + } catch (_) { return; } @@ -232,28 +253,34 @@ export default class Connection { * チャンネルに接続 */ @bindThis - public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { + public async connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) { return; } - const channelService = this.channelsService.getChannelService(channel); + const channelConstructor = this.getChannelConstructor(channel); - if (channelService.requireCredential && this.user == null) { + if (channelConstructor.requireCredential && this.user == null) { return; } - if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind)) - || (!channelService.kind && channelService.requireCredential))) { + if (this.token && ((channelConstructor.kind && !this.token.permission.some(p => p === channelConstructor.kind)) + || (!channelConstructor.kind && channelConstructor.requireCredential))) { return; } // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 - if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) { + if (channelConstructor.shouldShare && this.channels.some(c => c.chName === channel)) { return; } - const ch: Channel = channelService.create(id, this); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId<ChannelRequest>({ + id: id, + connection: this, + }, contextId); + const ch: Channel = await this.moduleRef.create<Channel>(channelConstructor, contextId); + this.channels.push(ch); ch.init(params ?? {}); @@ -264,6 +291,33 @@ export default class Connection { } } + @bindThis + public getChannelConstructor(name: string): ChannelConstructor<boolean> { + switch (name) { + case 'main': return MainChannel; + case 'homeTimeline': return HomeTimelineChannel; + case 'localTimeline': return LocalTimelineChannel; + case 'hybridTimeline': return HybridTimelineChannel; + case 'globalTimeline': return GlobalTimelineChannel; + case 'userList': return UserListChannel; + case 'hashtag': return HashtagChannel; + case 'roleTimeline': return RoleTimelineChannel; + case 'antenna': return AntennaChannel; + case 'channel': return ChannelChannel; + case 'drive': return DriveChannel; + case 'serverStats': return ServerStatsChannel; + case 'queueStats': return QueueStatsChannel; + case 'admin': return AdminChannel; + case 'chatUser': return ChatUserChannel; + case 'chatRoom': return ChatRoomChannel; + case 'reversi': return ReversiChannel; + case 'reversiGame': return ReversiGameChannel; + + default: + throw new Error(`no such channel: ${name}`); + } + } + /** * チャンネルから切断 * @param id チャンネルコネクションID @@ -306,3 +360,8 @@ export default class Connection { } } } + +export interface ConnectionRequest { + user: MiUser | null | undefined, + token: MiAccessToken | null | undefined, +} diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 465ed4238c..86b073414d 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -22,7 +22,7 @@ export default abstract class Channel { public abstract readonly chName: string; public static readonly shouldShare: boolean; public static readonly requireCredential: boolean; - public static readonly kind?: string | null; + public static readonly kind: string | null; protected get user() { return this.connection.user; @@ -85,9 +85,9 @@ export default abstract class Channel { return false; } - constructor(id: string, connection: Connection) { - this.id = id; - this.connection = connection; + constructor(request: ChannelRequest) { + this.id = request.id; + this.connection = request.connection; } public send(payload: { type: string, body: JsonValue }): void; @@ -111,9 +111,14 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; } -export type MiChannelService<T extends boolean> = { +export interface ChannelRequest { + id: string, + connection: Connection, +} + +export interface ChannelConstructor<T extends boolean> { + new(...args: any[]): Channel; shouldShare: boolean; requireCredential: T; kind: T extends true ? string : string | null | undefined; - create: (id: string, connection: Connection) => Channel; -}; +} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 355d5dba21..821888cca0 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class AdminChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class AdminChannel extends Channel { public readonly chName = 'admin'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:admin:stream'; + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); + } + @bindThis public async init(params: JsonObject) { // Subscribe admin stream @@ -22,22 +31,3 @@ class AdminChannel extends Channel { }); } } - -@Injectable() -export class AdminChannelService implements MiChannelService<true> { - public readonly shouldShare = AdminChannel.shouldShare; - public readonly requireCredential = AdminChannel.requireCredential; - public readonly kind = AdminChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AdminChannel { - return new AdminChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index e08562fdf9..ece9d2c8b1 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class AntennaChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class AntennaChannel extends Channel { public readonly chName = 'antenna'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class AntennaChannel extends Channel { private antennaId: string; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onEvent = this.onEvent.bind(this); } @@ -55,24 +57,3 @@ class AntennaChannel extends Channel { this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); } } - -@Injectable() -export class AntennaChannelService implements MiChannelService<true> { - public readonly shouldShare = AntennaChannel.shouldShare; - public readonly requireCredential = AntennaChannel.requireCredential; - public readonly kind = AntennaChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AntennaChannel { - return new AntennaChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index c07eaac98d..1706b17526 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -11,20 +11,23 @@ import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChannelChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChannelChannel extends Channel { public readonly chName = 'channel'; public static shouldShare = false; public static requireCredential = false as const; private channelId: string; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private noteEntityService: NoteEntityService, - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -92,24 +95,3 @@ class ChannelChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class ChannelChannelService implements MiChannelService<false> { - public readonly shouldShare = ChannelChannel.shouldShare; - public readonly requireCredential = ChannelChannel.requireCredential; - public readonly kind = ChannelChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChannelChannel { - return new ChannelChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts index eda333dd30..7f949032e2 100644 --- a/packages/backend/src/server/api/stream/channels/chat-room.ts +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChatRoomChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChatRoomChannel extends Channel { public readonly chName = 'chatRoom'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class ChatRoomChannel extends Channel { private roomId: string; constructor( - private chatService: ChatService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private chatService: ChatService, ) { - super(id, connection); + super(request); } @bindThis @@ -55,24 +57,3 @@ class ChatRoomChannel extends Channel { this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent); } } - -@Injectable() -export class ChatRoomChannelService implements MiChannelService<true> { - public readonly shouldShare = ChatRoomChannel.shouldShare; - public readonly requireCredential = ChatRoomChannel.requireCredential; - public readonly kind = ChatRoomChannel.kind; - - constructor( - private chatService: ChatService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChatRoomChannel { - return new ChatRoomChannel( - this.chatService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts index 5323484ed7..36f3f67b28 100644 --- a/packages/backend/src/server/api/stream/channels/chat-user.ts +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChatUserChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChatUserChannel extends Channel { public readonly chName = 'chatUser'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class ChatUserChannel extends Channel { private otherId: string; constructor( - private chatService: ChatService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private chatService: ChatService, ) { - super(id, connection); + super(request); } @bindThis @@ -55,24 +57,3 @@ class ChatUserChannel extends Channel { this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); } } - -@Injectable() -export class ChatUserChannelService implements MiChannelService<true> { - public readonly shouldShare = ChatUserChannel.shouldShare; - public readonly requireCredential = ChatUserChannel.requireCredential; - public readonly kind = ChatUserChannel.kind; - - constructor( - private chatService: ChatService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChatUserChannel { - return new ChatUserChannel( - this.chatService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 03768f3d23..6f2eb2c8f9 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class DriveChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class DriveChannel extends Channel { public readonly chName = 'drive'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); + } + @bindThis public async init(params: JsonObject) { // Subscribe drive stream @@ -22,22 +31,3 @@ class DriveChannel extends Channel { }); } } - -@Injectable() -export class DriveChannelService implements MiChannelService<true> { - public readonly shouldShare = DriveChannel.shouldShare; - public readonly requireCredential = DriveChannel.requireCredential; - public readonly kind = DriveChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): DriveChannel { - return new DriveChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index d7c781ad12..be6be1b1e7 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class GlobalTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = false; public static requireCredential = false as const; @@ -21,14 +23,14 @@ class GlobalTimelineChannel extends Channel { private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -74,28 +76,3 @@ class GlobalTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class GlobalTimelineChannelService implements MiChannelService<false> { - public readonly shouldShare = GlobalTimelineChannel.shouldShare; - public readonly requireCredential = GlobalTimelineChannel.requireCredential; - public readonly kind = GlobalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): GlobalTimelineChannel { - return new GlobalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index c911d63642..1456b4f262 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -3,28 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HashtagChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HashtagChannel extends Channel { public readonly chName = 'hashtag'; public static shouldShare = false; public static requireCredential = false as const; private q: string[][]; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -62,24 +64,3 @@ class HashtagChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HashtagChannelService implements MiChannelService<false> { - public readonly shouldShare = HashtagChannel.shouldShare; - public readonly requireCredential = HashtagChannel.requireCredential; - public readonly kind = HashtagChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HashtagChannel { - return new HashtagChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index eb5b4a8c6c..665c11b692 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -3,15 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HomeTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = false; public static requireCredential = true as const; @@ -20,12 +22,12 @@ class HomeTimelineChannel extends Channel { private withFiles: boolean; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -98,24 +100,3 @@ class HomeTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HomeTimelineChannelService implements MiChannelService<true> { - public readonly shouldShare = HomeTimelineChannel.shouldShare; - public readonly requireCredential = HomeTimelineChannel.requireCredential; - public readonly kind = HomeTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HomeTimelineChannel { - return new HomeTimelineChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 2155e02012..54250d2a90 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HybridTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = false; public static requireCredential = true as const; @@ -23,14 +25,14 @@ class HybridTimelineChannel extends Channel { private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -118,28 +120,3 @@ class HybridTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HybridTimelineChannelService implements MiChannelService<true> { - public readonly shouldShare = HybridTimelineChannel.shouldShare; - public readonly requireCredential = HybridTimelineChannel.requireCredential; - public readonly kind = HybridTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HybridTimelineChannel { - return new HybridTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 3d7ed6acdb..b394e9663f 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,25 +11,27 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class LocalTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; - public static shouldShare = false; + public static shouldShare = false as const; public static requireCredential = false as const; private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -84,28 +86,3 @@ class LocalTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class LocalTimelineChannelService implements MiChannelService<false> { - public readonly shouldShare = LocalTimelineChannel.shouldShare; - public readonly requireCredential = LocalTimelineChannel.requireCredential; - public readonly kind = LocalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): LocalTimelineChannel { - return new LocalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 525f24c105..2ce53ac288 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -3,26 +3,28 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class MainChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class MainChannel extends Channel { public readonly chName = 'main'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); } @bindThis @@ -61,24 +63,3 @@ class MainChannel extends Channel { }); } } - -@Injectable() -export class MainChannelService implements MiChannelService<true> { - public readonly shouldShare = MainChannel.shouldShare; - public readonly requireCredential = MainChannel.requireCredential; - public readonly kind = MainChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): MainChannel { - return new MainChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index 91b62255b4..a87863f26c 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -4,21 +4,26 @@ */ import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; const ev = new Xev(); -class QueueStatsChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class QueueStatsChannel extends Channel { public readonly chName = 'queueStats'; public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -56,22 +61,3 @@ class QueueStatsChannel extends Channel { ev.removeListener('queueStats', this.onStats); } } - -@Injectable() -export class QueueStatsChannelService implements MiChannelService<false> { - public readonly shouldShare = QueueStatsChannel.shouldShare; - public readonly requireCredential = QueueStatsChannel.requireCredential; - public readonly kind = QueueStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): QueueStatsChannel { - return new QueueStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 7597a1cfa3..58fc16e98c 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -3,31 +3,32 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { MiReversiGame } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { ReversiService } from '@/core/ReversiService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; import { reversiUpdateKeys } from 'misskey-js'; +import { REQUEST } from '@nestjs/core'; -class ReversiGameChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ReversiGameChannel extends Channel { public readonly chName = 'reversiGame'; public static shouldShare = false; public static requireCredential = false as const; private gameId: MiReversiGame['id'] | null = null; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private reversiService: ReversiService, private reversiGameEntityService: ReversiGameEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); } @bindThis @@ -107,25 +108,3 @@ class ReversiGameChannel extends Channel { } } -@Injectable() -export class ReversiGameChannelService implements MiChannelService<false> { - public readonly shouldShare = ReversiGameChannel.shouldShare; - public readonly requireCredential = ReversiGameChannel.requireCredential; - public readonly kind = ReversiGameChannel.kind; - - constructor( - private reversiService: ReversiService, - private reversiGameEntityService: ReversiGameEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ReversiGameChannel { - return new ReversiGameChannel( - this.reversiService, - this.reversiGameEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts index 6e88939724..5eff73eeef 100644 --- a/packages/backend/src/server/api/stream/channels/reversi.ts +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -3,22 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ReversiChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ReversiChannel extends Channel { public readonly chName = 'reversi'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; constructor( - id: string, - connection: Channel['connection'], + @Inject(REQUEST) + request: ChannelRequest, ) { - super(id, connection); + super(request); } @bindThis @@ -32,22 +34,3 @@ class ReversiChannel extends Channel { this.subscriber.off(`reversiStream:${this.user!.id}`, this.send); } } - -@Injectable() -export class ReversiChannelService implements MiChannelService<true> { - public readonly shouldShare = ReversiChannel.shouldShare; - public readonly requireCredential = ReversiChannel.requireCredential; - public readonly kind = ReversiChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ReversiChannel { - return new ReversiChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index fcfa26c38b..99e0b69023 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -3,28 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class RoleTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; public static shouldShare = false; public static requireCredential = false as const; private roleId: string; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private noteEntityService: NoteEntityService, private roleservice: RoleService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -60,26 +62,3 @@ class RoleTimelineChannel extends Channel { this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); } } - -@Injectable() -export class RoleTimelineChannelService implements MiChannelService<false> { - public readonly shouldShare = RoleTimelineChannel.shouldShare; - public readonly requireCredential = RoleTimelineChannel.requireCredential; - public readonly kind = RoleTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - private roleservice: RoleService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): RoleTimelineChannel { - return new RoleTimelineChannel( - this.noteEntityService, - this.roleservice, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index ec5352d12d..aece5435b0 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -4,21 +4,26 @@ */ import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; const ev = new Xev(); -class ServerStatsChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ServerStatsChannel extends Channel { public readonly chName = 'serverStats'; public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -54,22 +59,3 @@ class ServerStatsChannel extends Channel { ev.removeListener('serverStats', this.onStats); } } - -@Injectable() -export class ServerStatsChannelService implements MiChannelService<false> { - public readonly shouldShare = ServerStatsChannel.shouldShare; - public readonly requireCredential = ServerStatsChannel.requireCredential; - public readonly kind = ServerStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ServerStatsChannel { - return new ServerStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 5bfd8fa68c..2f7345e150 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class UserListChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; public static requireCredential = false as const; @@ -24,14 +26,18 @@ class UserListChannel extends Channel { private withRenotes: boolean; constructor( + @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + + @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, - private noteEntityService: NoteEntityService, - id: string, - connection: Channel['connection'], + @Inject(REQUEST) + request: ChannelRequest, + + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.updateListUsers = this.updateListUsers.bind(this); //this.onNote = this.onNote.bind(this); } @@ -130,32 +136,3 @@ class UserListChannel extends Channel { clearInterval(this.listUsersClock); } } - -@Injectable() -export class UserListChannelService implements MiChannelService<false> { - public readonly shouldShare = UserListChannel.shouldShare; - public readonly requireCredential = UserListChannel.requireCredential; - public readonly kind = UserListChannel.kind; - - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): UserListChannel { - return new UserListChannel( - this.userListsRepository, - this.userListMembershipsRepository, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/file/FileServerDriveHandler.ts b/packages/backend/src/server/file/FileServerDriveHandler.ts new file mode 100644 index 0000000000..51b527b146 --- /dev/null +++ b/packages/backend/src/server/file/FileServerDriveHandler.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import rename from 'rename'; +import type { Config } from '@/config.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, needsCleanup } from './FileServerUtils.js'; +import type { FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export class FileServerDriveHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private videoProcessingService: VideoProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) { + const key = request.params.key; + const file = await this.fileResolver.resolveFileByAccessKey(key); + + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + if (file.kind === 'remote') { + let image: IImageStreamable | null = null; + + if (file.fileRole === 'thumbnail') { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/static.webp`); + url.searchParams.set('url', file.url); + url.searchParams.set('static', '1'); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } else if (file.mime.startsWith('video/')) { + const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); + if (externalThumbnail) { + file.cleanup(); + return await reply.redirect(externalThumbnail, 301); + } + + image = await this.videoProcessingService.generateVideoThumbnail(file.path); + } + } + + if (file.fileRole === 'webpublic') { + if (['image/svg+xml'].includes(file.mime)) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/svg.webp`); + url.searchParams.set('url', file.url); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } + } + + image ??= { + data: handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path), + ext: file.ext, + type: file.mime, + }; + + attachStreamCleanup(image.data, file.cleanup); + + reply.header('Content-Type', getSafeContentType(image.type)); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } + + if (file.fileRole !== 'original') { + const filename = rename(file.filename, { + suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', + extname: file.ext ? `.${file.ext}` : '.unknown', + }).toString(); + + setFileResponseHeaders(reply, { mime: file.mime, filename }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } else { + setFileResponseHeaders(reply, { mime: file.file.type, filename: file.filename, size: file.file.size }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } + } catch (e) { + if (file.kind === 'remote') file.cleanup(); + throw e; + } + } +} diff --git a/packages/backend/src/server/file/FileServerFileResolver.ts b/packages/backend/src/server/file/FileServerFileResolver.ts new file mode 100644 index 0000000000..687d486efd --- /dev/null +++ b/packages/backend/src/server/file/FileServerFileResolver.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { DownloadService } from '@/core/DownloadService.js'; +import type { FileInfoService } from '@/core/FileInfoService.js'; +import type { InternalStorageService } from '@/core/InternalStorageService.js'; + +export type DownloadedFileResult = { + kind: 'downloaded'; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + filename: string; +}; + +export type FileResolveResult = + | { kind: 'not-found' } + | { kind: 'unavailable' } + | { + kind: 'stored'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + mime: string; + ext: string | null; + path: string; + } + | { + kind: 'remote'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + url: string; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + }; + +export class FileServerFileResolver { + constructor( + private driveFilesRepository: DriveFilesRepository, + private fileInfoService: FileInfoService, + private downloadService: DownloadService, + private internalStorageService: InternalStorageService, + ) {} + + public async downloadAndDetectTypeFromUrl(url: string): Promise<DownloadedFileResult> { + const [path, cleanup] = await createTemp(); + try { + const { filename } = await this.downloadService.downloadUrl(url, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + + return { + kind: 'downloaded', + mime, ext, + path, cleanup, + filename, + }; + } catch (e) { + cleanup(); + throw e; + } + } + + public async resolveFileByAccessKey(key: string): Promise<FileResolveResult> { + // Fetch drive file + const file = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) return { kind: 'not-found' }; + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (!(file.isLink && file.uri)) return { kind: 'unavailable' }; + const result = await this.downloadAndDetectTypeFromUrl(file.uri); + const { kind: _kind, ...downloaded } = result; + file.size = (await fs.promises.stat(downloaded.path)).size; // DB file.sizeは正確とは限らないので + return { + kind: 'remote', + ...downloaded, + url: file.uri, + fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', + file, + filename: file.name, + }; + } + + const path = this.internalStorageService.resolvePath(key); + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await this.fileInfoService.detectType(path); + return { + kind: 'stored', + fileRole: isThumbnail ? 'thumbnail' : 'webpublic', + file, + filename: file.name, + mime, ext, + path, + }; + } + + return { + kind: 'stored', + fileRole: 'original', + file, + filename: file.name, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), + ext: null, + path, + }; + } +} diff --git a/packages/backend/src/server/file/FileServerProxyHandler.ts b/packages/backend/src/server/file/FileServerProxyHandler.ts new file mode 100644 index 0000000000..41e8e47ba5 --- /dev/null +++ b/packages/backend/src/server/file/FileServerProxyHandler.ts @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import sharp from 'sharp'; +import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; +import type { Config } from '@/config.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { StatusError } from '@/misc/status-error.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; +import { createRangeStream, attachStreamCleanup, needsCleanup } from './FileServerUtils.js'; +import type { DownloadedFileResult, FileResolveResult, FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +type ProxySource = DownloadedFileResult | FileResolveResult; +type CleanupableFile = ProxySource & { cleanup: () => void }; +type AvailableFile = Exclude<ProxySource, { kind: 'not-found' | 'unavailable' }>; +type ProxyQuery = { + emoji?: string; + avatar?: string; + static?: string; + preview?: string; + badge?: string; + origin?: string; + url?: string; +}; + +export class FileServerProxyHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private imageProcessingService: ImageProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, reply: FastifyReply) { + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + + if (typeof url !== 'string') { + reply.code(400); + return; + } + + // アバタークロップなど、どうしてもオリジンである必要がある場合 + const mustOrigin = 'origin' in request.query; + + if (this.config.externalMediaProxyEnabled && !mustOrigin) { + return await this.redirectToExternalProxy(request, reply); + } + + this.validateUserAgent(request); + + // Create temp file + const file = await this.getStreamAndTypeFromUrl(url); + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + const image = await this.processImage(file, request, reply); + + if (needsCleanup(file)) { + attachStreamCleanup(image.data, file.cleanup); + } + + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } catch (e) { + if (needsCleanup(file)) file.cleanup(); + throw e; + } + } + + /** + * 外部メディアプロキシにリダイレクトする + */ + private async redirectToExternalProxy( + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ) { + reply.header('Cache-Control', 'public, max-age=259200'); // 3 days + + const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); + + for (const [key, value] of Object.entries(request.query)) { + url.searchParams.append(key, value); + } + + return reply.redirect(url.toString(), 301); + } + + /** + * User-Agent を検証する + */ + private validateUserAgent(request: FastifyRequest): void { + if (!request.headers['user-agent']) { + throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); + } + if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + } + } + + /** + * 画像を処理してストリーム可能な形式に変換する + */ + private async processImage( + file: AvailableFile, + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ): Promise<IImageStreamable> { + const query = request.query; + + const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query; + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + if (requiresImageConversion && !isConvertibleImage) { + throw new StatusError('Unexpected mime', 404); + } + + if ('emoji' in query || 'avatar' in query) { + return this.processEmojiOrAvatar(file, query); + } + + if ('static' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); + } + + if ('preview' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); + } + + if ('badge' in query) { + return this.processBadge(file); + } + + if (file.mime === 'image/svg+xml') { + return this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); + } + + if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } + + return this.createDefaultStream(file, request, reply); + } + + /** + * 絵文字またはアバター用の画像を処理する + */ + private async processEmojiOrAvatar( + file: AvailableFile, + query: Pick<ProxyQuery, 'emoji' | 'avatar' | 'static'>, + ): Promise<IImageStreamable> { + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); + if (!isAnimationConvertibleImage && !('static' in query)) { + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in query) })) + .resize({ + height: 'emoji' in query ? 128 : 320, + withoutEnlargement: true, + }) + .webp(webpDefault); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } + + /** + * バッジ用の画像を処理する + */ + private async processBadge(file: AvailableFile): Promise<IImageStreamable> { + const mask = (await sharpBmp(file.path, file.mime)) + .resize(96, 96, { + fit: 'contain', + position: 'centre', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + return { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } + + /** + * デフォルトのストリームを作成する(Range リクエスト対応) + */ + private createDefaultStream( + file: AvailableFile, + request: FastifyRequest, + reply: FastifyReply, + ): IImageStreamable { + if (request.headers.range && 'file' in file && file.file.size > 0) { + const { stream, start, end, chunksize } = createRangeStream(request.headers.range as string, file.file.size, file.path); + + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + + return { + data: stream, + ext: file.ext, + type: file.mime, + }; + } + + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + private async getStreamAndTypeFromUrl(url: string): Promise<ProxySource> { + if (url.startsWith(`${this.config.url}/files/`)) { + const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); + if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); + + return await this.fileResolver.resolveFileByAccessKey(key); + } + + return await this.fileResolver.downloadAndDetectTypeFromUrl(url); + } +} diff --git a/packages/backend/src/server/file/FileServerUtils.ts b/packages/backend/src/server/file/FileServerUtils.ts new file mode 100644 index 0000000000..c5995a2cca --- /dev/null +++ b/packages/backend/src/server/file/FileServerUtils.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import type { FastifyReply } from 'fastify'; + +export type RangeStream = { + stream: fs.ReadStream; + start: number; + end: number; + chunksize: number; +}; + +/** + * Range リクエストに対応したストリームを作成する + */ +export function createRangeStream(rangeHeader: string, size: number, path: string): RangeStream { + const parts = rangeHeader.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : size - 1; + if (end > size) { + end = size - 1; + } + const chunksize = end - start + 1; + + return { + stream: fs.createReadStream(path, { start, end }), + start, + end, + chunksize, + }; +} + +/** + * ストリームにcleanupハンドラを設定する + * ストリームでない場合は即座にcleanupを実行する + */ +export function attachStreamCleanup(data: IImageStreamable['data'], cleanup: () => void): void { + if ('pipe' in data && typeof data.pipe === 'function') { + data.on('end', cleanup); + data.on('close', cleanup); + } else { + cleanup(); + } +} + +/** + * MIME タイプがブラウザセーフかどうかに応じて Content-Type を返す + */ +export function getSafeContentType(mime: string): string { + return FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'; +} + +/** + * Range リクエストを処理してストリームを返す + * Range ヘッダーがない場合は通常のストリームを返す + */ +export function handleRangeRequest( + reply: FastifyReply, + rangeHeader: string | undefined, + size: number, + path: string, +): fs.ReadStream { + if (rangeHeader && size > 0) { + const { stream, start, end, chunksize } = createRangeStream(rangeHeader, size, path); + reply.header('Content-Range', `bytes ${start}-${end}/${size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return stream; + } + return fs.createReadStream(path); +} + +export type FileResponseOptions = { + mime: string; + filename: string; + size?: number; + cacheControl?: string; +}; + +/** + * ファイルレスポンス用の共通ヘッダーを設定する + */ +export function setFileResponseHeaders( + reply: FastifyReply, + options: FileResponseOptions, +): void { + reply.header('Content-Type', getSafeContentType(options.mime)); + reply.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', options.filename)); + if (options.size !== undefined) { + reply.header('Content-Length', options.size); + } +} + +/** + * cleanup が必要なファイルかどうかを判定する型ガード + */ +export function needsCleanup<T extends { kind?: string; cleanup?: () => void }>(file: T): file is T & { cleanup: () => void } { + return 'cleanup' in file && typeof file.cleanup === 'function'; +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d2391c43ab..840c34b806 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -123,41 +123,86 @@ function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: str return { name, logo }; } -// https://indieauth.spec.indieweb.org/#client-information-discovery -// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, -// and if there is an [h-app] with a url property matching the client_id URL, -// then it should use the name and icon and display them on the authorization prompt." -// (But we don't display any icon for now) -// https://indieauth.spec.indieweb.org/#redirect-url -// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute -// of redirect_uri at the client_id URL. -// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST -// look for an exact match of the given redirect_uri in the request against the list of -// redirect_uris discovered after resolving any relative URLs." async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> { try { const res = await httpRequestService.send(id); + const redirectUris: string[] = []; + let name = id; + let logo: string | null = null; + // https://indieauth.spec.indieweb.org/#redirect-url + // "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute + // of redirect_uri at the client_id URL. + // Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST + // look for an exact match of the given redirect_uri in the request against the list of + // redirect_uris discovered after resolving any relative URLs." const linkHeader = res.headers.get('link'); if (linkHeader) { redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); } - const text = await res.text(); - const doc = htmlParser.parse(`<div>${text}</div>`); + const contentType = res.headers.get('content-type'); + const mediaType = contentType ? contentType.split(';')[0].trim() : null; + if (mediaType === 'application/json') { + // Client discovery via JSON document (11 July 2024 spec) + // https://indieauth.spec.indieweb.org/#client-metadata + // "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing + // client metadata defined in [RFC7591], the minimum properties for an IndieAuth + // client defined below." - redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + const json = await res.json() as { + client_id: string; + client_name?: string; + client_uri: string; + logo_uri?: string; + redirect_uris?: string[]; + }; - let name = id; - let logo: string | null = null; - if (text) { - const microformats = parseMicroformats(doc, res.url, id); - if (typeof microformats.name === 'string') { - name = microformats.name; + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The authorization server MUST verify that the client_id in the document matches the + // client_id of the URL where the document was retrieved." + if (json.client_id !== id) { + throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request'); + } + + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The client_uri MUST be a prefix of the client_id." + if (!json.client_uri || !id.startsWith(json.client_uri)) { + throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request'); + } + + if (typeof json.client_name === 'string') { + name = json.client_name; } - if (typeof microformats.logo === 'string') { - logo = microformats.logo; + + if (typeof json.logo_uri === 'string') { + // Since uri can be relative, resolve it against the document URL + logo = new URL(json.logo_uri, res.url).toString(); + } + + if (Array.isArray(json.redirect_uris)) { + redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string')); + } + } else { + // Client discovery via HTML microformats (12 February 2022 spec) + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + // "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, + // and if there is an [h-app] with a url property matching the client_id URL, + // then it should use the name and icon and display them on the authorization prompt." + const text = await res.text(); + const doc = htmlParser.parse(`<div>${text}</div>`); + + redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + + if (text) { + const microformats = parseMicroformats(doc, res.url, id); + if (typeof microformats.name === 'string') { + name = microformats.name; + } + if (typeof microformats.logo === 'string') { + logo = microformats.logo; + } } } @@ -172,6 +217,8 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt logger.error('Error while fetching client information', { err }); if (err instanceof StatusError) { throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); + } else if (err instanceof AuthorizationError) { + throw err; } else { throw new AuthorizationError('Failed to parse client information', 'server_error'); } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index bcea935409..24bc619e79 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -4,8 +4,9 @@ */ import { randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import sharp from 'sharp'; @@ -69,13 +70,28 @@ import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const staticAssets = `${_dirname}/../../../assets/`; -const clientAssets = `${_dirname}/../../../../frontend/assets/`; -const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; -const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; -const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; -const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; -const tarball = `${_dirname}/../../../../../built/tarball/`; +let rootDir = _dirname; +// 見つかるまで上に遡る +while (!fs.existsSync(resolve(rootDir, 'packages'))) { + const parentDir = dirname(rootDir); + if (parentDir === rootDir) { + throw new Error('Cannot find root directory'); + } + rootDir = parentDir; +} + +const backendRootDir = resolve(rootDir, 'packages/backend'); +const frontendRootDir = resolve(rootDir, 'packages/frontend'); + +const staticAssets = resolve(backendRootDir, 'assets'); +const clientAssets = resolve(frontendRootDir, 'assets'); +const assets = resolve(rootDir, 'built/_frontend_dist_'); +const swAssets = resolve(rootDir, 'built/_sw_dist_'); +const fluentEmojisDir = resolve(rootDir, 'fluent-emojis/dist'); +const twemojiDir = resolve(backendRootDir, 'node_modules/@discordapp/twemoji/dist/svg'); +const frontendViteOut = resolve(rootDir, 'built/_frontend_vite_'); +const frontendEmbedViteOut = resolve(rootDir, 'built/_frontend_embed_vite_'); +const tarball = resolve(rootDir, 'built/tarball'); @Injectable() export class ClientServerService { @@ -207,6 +223,7 @@ export class ClientServerService { //#region vite assets if (this.config.frontendEmbedManifestExists) { + console.log(`[ClientServerService] Using built frontend vite assets. ${frontendViteOut}`); fastify.register((fastify, options, done) => { fastify.register(fastifyStatic, { root: frontendViteOut, @@ -226,6 +243,7 @@ export class ClientServerService { done(); }); } else { + console.log('[ClientServerService] Proxying to Vite dev server.'); const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, ''); const port = (process.env.VITE_PORT ?? '5173'); @@ -297,7 +315,7 @@ export class ClientServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - return await reply.sendFile(path, `${_dirname}/../../../../../fluent-emojis/dist/`, { + return reply.sendFile(path, fluentEmojisDir, { maxAge: ms('30 days'), }); }); @@ -312,7 +330,7 @@ export class ClientServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, { + return reply.sendFile(path, twemojiDir, { maxAge: ms('30 days'), }); }); @@ -326,7 +344,7 @@ export class ClientServerService { } const mask = await sharp( - `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, + `${twemojiDir}/${path.replace('.png', '')}.svg`, { density: 1000 }, ) .resize(488, 488) @@ -854,9 +872,6 @@ export class ClientServerService { })); }); - const override = (source: string, target: string, depth = 0) => - [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); - fastify.get('/flush', async (request, reply) => { let sendHeader = true; diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index 1404345e2a..ac93b24b87 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -35,6 +35,10 @@ services: target: /misskey/packages/backend/built read_only: true - type: bind + source: ../src-js + target: /misskey/packages/backend/src-js + read_only: true + - type: bind source: ../migration target: /misskey/packages/backend/migration read_only: true diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index 25475a89ab..4d1b4b0d60 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -143,7 +143,7 @@ services: bash -c " npm install -g pnpm pnpm -F backend i --frozen-lockfile - pnpm exec tsc -p ./packages/backend/test-federation + pnpm exec tsgo -p ./packages/backend/test-federation node ./packages/backend/test-federation/built/daemon.js " diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts index 056a16ba15..6f09f13f17 100644 --- a/packages/backend/test-federation/test/utils.ts +++ b/packages/backend/test-federation/test/utils.ts @@ -234,30 +234,26 @@ export async function isFired<C extends keyof Misskey.Channels, T extends keyof cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean, params?: Misskey.Channels[C]['params'], ): Promise<boolean> { - return new Promise<boolean>(async (resolve, reject) => { - const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + try { const connection = stream.useChannel(channel, params); - connection.on(type as any, ((msg: any) => { - if (cond(msg)) { - stream.close(); - clearTimeout(timer); - resolve(true); - } - }) as any); - - let timer: NodeJS.Timeout | undefined; - await trigger().then(() => { - timer = setTimeout(() => { - stream.close(); - resolve(false); - }, 500); - }).catch(err => { - stream.close(); - clearTimeout(timer); - reject(err); + const receivePromise = new Promise<boolean>((resolve) => { + connection.on(type as never, ((msg: any) => { + if (cond(msg)) { + resolve(true); + } + }) as any); }); - }); + + await trigger(); + return await Promise.race([ + receivePromise, + sleep(500).then(() => false), + ]); + } finally { + stream.close(); + } }; export async function isNoteUpdatedEventFired( @@ -267,30 +263,27 @@ export async function isNoteUpdatedEventFired( trigger: () => Promise<unknown>, cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean, ): Promise<boolean> { - return new Promise<boolean>(async (resolve, reject) => { - const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + try { stream.send('s', { id: noteId }); - stream.on('noteUpdated', msg => { - if (cond(msg)) { - stream.close(); - clearTimeout(timer); - resolve(true); - } + + const receivePromise = new Promise<boolean>((resolve) => { + stream.on('noteUpdated', msg => { + if (cond(msg)) { + resolve(true); + } + }); }); - let timer: NodeJS.Timeout | undefined; + await trigger(); - await trigger().then(() => { - timer = setTimeout(() => { - stream.close(); - resolve(false); - }, 500); - }).catch(err => { - stream.close(); - clearTimeout(timer); - reject(err); - }); - }); + return await Promise.race([ + receivePromise, + sleep(500).then(() => false), + ]); + } finally { + stream.close(); + } }; export async function assertNotificationReceived( diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc index eeac7eabc6..3859603da3 100644 --- a/packages/backend/test-server/.swcrc +++ b/packages/backend/test-server/.swcrc @@ -13,7 +13,7 @@ "experimental": { "keepImportAssertions": true }, - "baseUrl": "../built", + "baseUrl": "../src-js", "paths": { "@/*": ["*"] }, diff --git a/packages/backend/test/compose.yml b/packages/backend/test/compose.yml index fe96616fc0..4f1dba6428 100644 --- a/packages/backend/test/compose.yml +++ b/packages/backend/test/compose.yml @@ -11,3 +11,11 @@ services: environment: POSTGRES_DB: "test-misskey" POSTGRES_HOST_AUTH_METHOD: trust + + meilisearchtest: + image: getmeili/meilisearch:v1.3.4 + ports: + - "127.0.0.1:57712:7700" + environment: + - MEILI_NO_ANALYTICS=true + - MEILI_ENV=development diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 96a6311a5a..67a9026eb5 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; +const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`; const basicAuthParams: AuthorizationParamsExtended = { redirect_uri, @@ -807,45 +808,193 @@ describe('OAuth', () => { }); }); - // https://indieauth.spec.indieweb.org/#client-information-discovery describe('Client Information Discovery', () => { - describe('Redirection', () => { - const tests: Record<string, (reply: FastifyReply) => void> = { - 'Read HTTP header': reply => { - reply.header('Link', '</redirect>; rel="redirect_uri"'); - reply.send(` - <!DOCTYPE html> - <div class="h-app"><a href="/" class="u-url p-name">Misklient - `); - }, - 'Mixed links': reply => { - reply.header('Link', '</redirect>; rel="redirect_uri"'); - reply.send(` - <!DOCTYPE html> - <link rel="redirect_uri" href="/redirect2" /> - <div class="h-app"><a href="/" class="u-url p-name">Misklient - `); - }, - 'Multiple items in Link header': reply => { - reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"'); - reply.send(` - <!DOCTYPE html> - <div class="h-app"><a href="/" class="u-url p-name">Misklient - `); - }, - 'Multiple items in HTML': reply => { - reply.send(` - <!DOCTYPE html> - <link rel="redirect_uri" href="/redirect2" /> - <link rel="redirect_uri" href="/redirect" /> - <div class="h-app"><a href="/" class="u-url p-name">Misklient - `); - }, - }; + // https://indieauth.spec.indieweb.org/#client-information-discovery + describe('JSON client metadata (11 July 2024)', () => { + test('Read JSON document', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + logo_uri: '/logo.png', + redirect_uris: ['/redirect'], + }); + }; - for (const [title, replyFunc] of Object.entries(tests)) { - test(title, async () => { - sender = replyFunc; + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient JSON'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); + + test('Merge Link header redirect_uri with JSON redirect_uris', async () => { + sender = (reply): void => { + reply.header('Link', '</redirect2>; rel="redirect_uri"'); + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + + const ok1 = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(ok1.status, 200); + + const ok2 = await fetch(client.authorizeURL({ + redirect_uri: redirect_uri2, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(ok2.status, 200); + }); + + test('Reject when client_id does not match retrieved URL', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/mismatch`, + client_uri: `http://127.0.0.1:${clientPort}/`, + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + + test('Reject when client_uri is not a prefix of client_id', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`, + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + + test('Reject when JSON metadata has no redirect_uris and no Link header', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + }); + + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + describe('HTML link client metadata (12 Feb 2022)', () => { + describe('Redirection', () => { + const tests: Record<string, (reply: FastifyReply) => void> = { + 'Read HTTP header': reply => { + reply.header('Link', '</redirect>; rel="redirect_uri"'); + reply.send(` + <!DOCTYPE html> + <div class="h-app"><a href="/" class="u-url p-name">Misklient + `); + }, + 'Mixed links': reply => { + reply.header('Link', '</redirect>; rel="redirect_uri"'); + reply.send(` + <!DOCTYPE html> + <link rel="redirect_uri" href="/redirect2" /> + <div class="h-app"><a href="/" class="u-url p-name">Misklient + `); + }, + 'Multiple items in Link header': reply => { + reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"'); + reply.send(` + <!DOCTYPE html> + <div class="h-app"><a href="/" class="u-url p-name">Misklient + `); + }, + 'Multiple items in HTML': reply => { + reply.send(` + <!DOCTYPE html> + <link rel="redirect_uri" href="/redirect2" /> + <link rel="redirect_uri" href="/redirect" /> + <div class="h-app"><a href="/" class="u-url p-name">Misklient + `); + }, + }; + + for (const [title, replyFunc] of Object.entries(tests)) { + test(title, async () => { + sender = replyFunc; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + }); + } + + test('No item', async () => { + sender = (reply): void => { + reply.send(` + <!DOCTYPE html> + <div class="h-app"><a href="/" class="u-url p-name">Misklient + `); + }; const client = new AuthorizationCode(clientConfig); @@ -856,20 +1005,17 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); + + // direct error because there's no redirect URI to ping + await assertDirectError(response, 400, 'invalid_request'); }); - } + }); - test('No item', async () => { - sender = (reply): void => { - reply.send(` - <!DOCTYPE html> - <div class="h-app"><a href="/" class="u-url p-name">Misklient - `); - }; - const client = new AuthorizationCode(clientConfig); + test('Disallow loopback', async () => { + await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); + const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ redirect_uri, scope: 'write:notes', @@ -877,119 +1023,103 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); - - // direct error because there's no redirect URI to ping await assertDirectError(response, 400, 'invalid_request'); }); - }); - test('Disallow loopback', async () => { - await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); - - const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); - - test('Missing name', async () => { - sender = (reply): void => { - reply.header('Link', '</redirect>; rel="redirect_uri"'); - reply.send(); - }; + test('Missing name', async () => { + sender = (reply): void => { + reply.header('Link', '</redirect>; rel="redirect_uri"'); + reply.send(); + }; - const client = new AuthorizationCode(clientConfig); + const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); - }); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); - test('With Logo', async () => { - sender = (reply): void => { - reply.header('Link', '</redirect>; rel="redirect_uri"'); - reply.send(` - <!DOCTYPE html> - <div class="h-app"> - <a href="/" class="u-url p-name">Misklient</a> - <img src="/logo.png" class="u-logo" /> - </div> - `); - reply.send(); - }; + test('With Logo', async () => { + sender = (reply): void => { + reply.header('Link', '</redirect>; rel="redirect_uri"'); + reply.send(` + <!DOCTYPE html> + <div class="h-app"> + <a href="/" class="u-url p-name">Misklient</a> + <img src="/logo.png" class="u-logo" /> + </div> + `); + reply.send(); + }; - const client = new AuthorizationCode(clientConfig); + const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - const meta = getMeta(await response.text()); - assert.strictEqual(meta.clientName, 'Misklient'); - assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); - }); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); - test('Missing Logo', async () => { - sender = (reply): void => { - reply.header('Link', '</redirect>; rel="redirect_uri"'); - reply.send(` - <!DOCTYPE html> - <div class="h-app"><a href="/" class="u-url p-name">Misklient - `); - reply.send(); - }; + test('Missing Logo', async () => { + sender = (reply): void => { + reply.header('Link', '</redirect>; rel="redirect_uri"'); + reply.send(` + <!DOCTYPE html> + <div class="h-app"><a href="/" class="u-url p-name">Misklient + `); + reply.send(); + }; - const client = new AuthorizationCode(clientConfig); + const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - const meta = getMeta(await response.text()); - assert.strictEqual(meta.clientName, 'Misklient'); - assert.strictEqual(meta.clientLogo, undefined); - }); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, undefined); + }); - test('Mismatching URL in h-app', async () => { - sender = (reply): void => { - reply.header('Link', '</redirect>; rel="redirect_uri"'); - reply.send(` - <!DOCTYPE html> - <div class="h-app"><a href="/foo" class="u-url p-name">Misklient - `); - reply.send(); - }; + test('Mismatching URL in h-app', async () => { + sender = (reply): void => { + reply.header('Link', '</redirect>; rel="redirect_uri"'); + reply.send(` + <!DOCTYPE html> + <div class="h-app"><a href="/foo" class="u-url p-name">Misklient + `); + reply.send(); + }; - const client = new AuthorizationCode(clientConfig); + const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); }); }); diff --git a/packages/backend/test/resources/dummy-for-file-server-service.png b/packages/backend/test/resources/dummy-for-file-server-service.png Binary files differnew file mode 100644 index 0000000000..39332b0c1b --- /dev/null +++ b/packages/backend/test/resources/dummy-for-file-server-service.png diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index c6754c4802..a2a86c696e 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -25,7 +25,6 @@ "isolatedModules": true, "jsx": "react-jsx", "jsxImportSource": "@kitajs/html", - "baseUrl": "./", "paths": { "@/*": ["../src/*"] }, diff --git a/packages/backend/test/unit/SearchService.ts b/packages/backend/test/unit/SearchService.ts new file mode 100644 index 0000000000..6e17bef1c3 --- /dev/null +++ b/packages/backend/test/unit/SearchService.ts @@ -0,0 +1,483 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Index, MeiliSearch } from 'meilisearch'; +import { type Config, loadConfig } from '@/config.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { SearchService } from '@/core/SearchService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { + type BlockingsRepository, + type ChannelsRepository, + type FollowingsRepository, + type MutingsRepository, + type NotesRepository, + type UserProfilesRepository, + type UsersRepository, + type MiChannel, + type MiNote, + type MiUser, +} from '@/models/_.js'; + +describe('SearchService', () => { + type TestContext = { + app: TestingModule; + service: SearchService; + cacheService: CacheService; + idService: IdService; + mutingsRepository: MutingsRepository; + blockingsRepository: BlockingsRepository; + usersRepository: UsersRepository; + userProfilesRepository: UserProfilesRepository; + notesRepository: NotesRepository; + channelsRepository: ChannelsRepository; + followingsRepository: FollowingsRepository; + indexer?: (note: MiNote) => Promise<void>; + }; + + const meilisearchSettings = { + searchableAttributes: [ + 'text', + 'cw', + ], + sortableAttributes: [ + 'createdAt', + ], + filterableAttributes: [ + 'createdAt', + 'userId', + 'userHost', + 'channelId', + 'tags', + ], + typoTolerance: { + enabled: false, + }, + pagination: { + maxTotalHits: 10000, + }, + }; + + async function buildContext(configOverride?: Config): Promise<TestContext> { + const builder = Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + }); + + if (configOverride) { + builder.overrideProvider(DI.config).useValue(configOverride); + } + + const app = await builder.compile(); + + app.enableShutdownHooks(); + + return { + app, + service: app.get(SearchService), + cacheService: app.get(CacheService), + idService: app.get(IdService), + mutingsRepository: app.get(DI.mutingsRepository), + blockingsRepository: app.get(DI.blockingsRepository), + usersRepository: app.get(DI.usersRepository), + userProfilesRepository: app.get(DI.userProfilesRepository), + notesRepository: app.get(DI.notesRepository), + channelsRepository: app.get(DI.channelsRepository), + followingsRepository: app.get(DI.followingsRepository), + }; + } + + async function cleanupContext(ctx: TestContext) { + await ctx.notesRepository.createQueryBuilder().delete().execute(); + await ctx.mutingsRepository.createQueryBuilder().delete().execute(); + await ctx.blockingsRepository.createQueryBuilder().delete().execute(); + await ctx.followingsRepository.createQueryBuilder().delete().execute(); + await ctx.channelsRepository.createQueryBuilder().delete().execute(); + await ctx.userProfilesRepository.createQueryBuilder().delete().execute(); + await ctx.usersRepository.createQueryBuilder().delete().execute(); + } + + async function createUser(ctx: TestContext, data: Partial<MiUser> = {}) { + const id = ctx.idService.gen(); + const username = data.username ?? `user_${id}`; + const usernameLower = data.usernameLower ?? username.toLowerCase(); + + const user = await ctx.usersRepository + .insert({ + id, + username, + usernameLower, + ...data, + }) + .then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0])); + + await ctx.userProfilesRepository.insert({ + userId: id, + }); + + return user; + } + + async function createChannel(ctx: TestContext, user: MiUser, data: Partial<MiChannel> = {}) { + const id = ctx.idService.gen(); + const channel = await ctx.channelsRepository + .insert({ + id, + userId: user.id, + name: data.name ?? `channel_${id}`, + ...data, + }) + .then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0])); + + return channel; + } + + async function createNote(ctx: TestContext, user: MiUser, data: Partial<MiNote> = {}, time?: number) { + const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time); + const note = await ctx.notesRepository + .insert({ + id, + text: 'hello', + userId: user.id, + userHost: user.host, + visibility: 'public', + tags: [], + ...data, + }) + .then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0])); + + if (ctx.indexer) { + await ctx.indexer(note); + } + + return note; + } + + async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) { + await ctx.followingsRepository.insert({ + id: ctx.idService.gen(), + followerId: follower.id, + followeeId: followee.id, + followerHost: follower.host, + followeeHost: followee.host, + }); + } + + function clearUserCaches(ctx: TestContext, userId: MiUser['id']) { + ctx.cacheService.userMutingsCache.delete(userId); + ctx.cacheService.userBlockedCache.delete(userId); + ctx.cacheService.userBlockingCache.delete(userId); + } + + async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) { + await ctx.mutingsRepository.insert({ + id: ctx.idService.gen(), + muterId: muter.id, + muteeId: mutee.id, + }); + clearUserCaches(ctx, muter.id); + } + + async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) { + await ctx.blockingsRepository.insert({ + id: ctx.idService.gen(), + blockerId: blocker.id, + blockeeId: blockee.id, + }); + clearUserCaches(ctx, blocker.id); + clearUserCaches(ctx, blockee.id); + } + + function defineSearchNoteTests( + getCtx: () => TestContext, + { + supportsFollowersVisibility, + sinceIdOrder, + }: { + supportsFollowersVisibility: boolean; + sinceIdOrder: 'asc' | 'desc'; + }, + ) { + describe('searchNote', () => { + test('filters notes by visibility (followers only visible to followers)', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' }); + const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' }); + + const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]); + + await createFollowing(ctx, me, author); + + const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + const expectedIds = supportsFollowersVisibility + ? [followersNote.id, publicNote.id] + : [publicNote.id]; + expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort()); + }); + + test('filters out suspended users via base note filtering', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null }); + const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true }); + + const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' }); + await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([activeNote.id]); + }); + + test('filters by userId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null }); + const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null }); + + const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' }); + await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([aliceNote.id]); + }); + + test('filters by channelId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + const channelA = await createChannel(ctx, author, { name: 'channel-a' }); + const channelB = await createChannel(ctx, author, { name: 'channel-b' }); + + const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' }); + await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([channelNote.id]); + }); + + test('filters by host', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null }); + const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' }); + + const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' }); + const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' }); + + const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 }); + expect(localResult.map(note => note.id)).toEqual([localNote.id]); + + const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 }); + expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]); + }); + + describe('muting and blocking', () => { + test('filters out muted users', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createMuting(ctx, me, muted); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + + expect(result.map(note => note.id)).toEqual([otherNote.id]); + }); + + test('filters out users who block me', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createBlocking(ctx, blocker, me); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + + expect(result.map(note => note.id)).toEqual([otherNote.id]); + }); + + test('filters no out users I block', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createBlocking(ctx, me, blocked); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort()); + }); + }); + + describe('pagination', () => { + test('paginates with sinceId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id }); + + const expected = sinceIdOrder === 'asc' + ? [note2.id, note3.id] + : [note3.id, note2.id]; + expect(result.map(note => note.id)).toEqual(expected); + }); + + test('paginates with untilId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id }); + + expect(result.map(note => note.id)).toEqual([note2.id, note1.id]); + }); + + test('paginates with sinceId and untilId together', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 4000; + const t2 = Date.now() - 3000; + const t3 = Date.now() - 2000; + const t4 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + const note4 = await createNote(ctx, author, { text: 'hello' }, t4); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id }); + + expect(result.map(note => note.id)).toEqual([note3.id, note2.id]); + }); + }); + }); + } + + describe('sqlLike', () => { + let ctx: TestContext; + + beforeAll(async () => { + ctx = await buildContext(); + }); + + afterAll(async () => { + await ctx.app.close(); + }); + + afterEach(async () => { + await cleanupContext(ctx); + }); + + defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' }); + }); + + describe('meilisearch', () => { + let ctx: TestContext; + let meilisearch: MeiliSearch; + let meilisearchIndex: Index; + let meiliConfig: Config; + + beforeAll(async () => { + const baseConfig = loadConfig(); + meiliConfig = { + ...baseConfig, + fulltextSearch: { + provider: 'meilisearch', + }, + meilisearch: { + host: '127.0.0.1', + port: '57712', + apiKey: '', + index: 'test-search-service', + scope: 'global', + ssl: false, + }, + }; + + ctx = await buildContext(meiliConfig); + meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch; + meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`); + + const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings); + await meilisearch.tasks.waitForTask(settingsTask.taskUid); + + const clearTask = await meilisearchIndex.deleteAllDocuments(); + await meilisearch.tasks.waitForTask(clearTask.taskUid); + + ctx.indexer = async (note: MiNote) => { + if (note.text == null && note.cw == null) return; + if (!['home', 'public'].includes(note.visibility)) return; + if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return; + + const task = await meilisearchIndex.addDocuments([{ + id: note.id, + createdAt: ctx.idService.parse(note.id).date.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + tags: note.tags, + }], { + primaryKey: 'id', + }); + await meilisearch.tasks.waitForTask(task.taskUid); + }; + }); + + afterAll(async () => { + await ctx.app.close(); + }); + + afterEach(async () => { + await cleanupContext(ctx); + const clearTask = await meilisearchIndex.deleteAllDocuments(); + await meilisearch.tasks.waitForTask(clearTask.taskUid); + }); + + defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' }); + }); +}); diff --git a/packages/backend/test/unit/entities/DriveFileEntityService.ts b/packages/backend/test/unit/entities/DriveFileEntityService.ts new file mode 100644 index 0000000000..2e416326ee --- /dev/null +++ b/packages/backend/test/unit/entities/DriveFileEntityService.ts @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DriveFilesRepository, DriveFoldersRepository, UsersRepository } from '@/models/_.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip; + +describe('DriveFileEntityService', () => { + let app: TestingModule; + let service: DriveFileEntityService; + let driveFolderEntityService: DriveFolderEntityService; + let driveFilesRepository: DriveFilesRepository; + let driveFoldersRepository: DriveFoldersRepository; + let usersRepository: UsersRepository; + let idCounter = 0; + + const userEntityServiceMock = { + packMany: jest.fn(async (users: Array<string | { id: string }>) => { + return users.map(u => ({ + id: typeof u === 'string' ? u : u.id, + username: 'user', + })); + }), + pack: jest.fn(async (user: string | { id: string }) => { + return { + id: typeof user === 'string' ? user : user.id, + username: 'user', + }; + }), + }; + + const nextId = () => genAidx(Date.now() + (idCounter++)); + + const createUser = async () => { + const un = secureRndstr(16); + const id = nextId(); + await usersRepository.insert({ + id, + username: un, + usernameLower: un.toLowerCase(), + }); + return usersRepository.findOneByOrFail({ id }); + }; + + const createFolder = async (name: string, parentId: string | null) => { + const id = nextId(); + await driveFoldersRepository.insert({ + id, + name, + userId: null, + parentId, + }); + return driveFoldersRepository.findOneByOrFail({ id }); + }; + + const createFile = async (folderId: string | null, userId: string | null) => { + const id = nextId(); + await driveFilesRepository.insert({ + id, + userId, + userHost: null, + md5: secureRndstr(32), + name: `file-${id}`, + type: 'text/plain', + size: 1, + comment: null, + blurhash: null, + properties: {}, + storedInternal: true, + url: `https://example.com/${id}`, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }); + return driveFilesRepository.findOneByOrFail({ id }); + }; + + beforeAll(async () => { + const moduleBuilder = Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }); + moduleBuilder.overrideProvider(UserEntityService).useValue(userEntityServiceMock as any); + + app = await moduleBuilder.compile(); + await app.init(); + app.enableShutdownHooks(); + + service = app.get<DriveFileEntityService>(DriveFileEntityService); + driveFolderEntityService = app.get<DriveFolderEntityService>(DriveFolderEntityService); + driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository); + driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository); + usersRepository = app.get<UsersRepository>(DI.usersRepository); + }); + + beforeEach(() => { + userEntityServiceMock.packMany.mockClear(); + userEntityServiceMock.pack.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('pack', () => { + test('detail: false', async () => { + const user = await createUser(); + const folder = await createFolder('pack-root', null); + const file = await createFile(folder.id, user.id); + + const packed = await service.pack(file, { detail: false, self: true }) as any; + expect(packed.id).toBe(file.id); + expect(packed.folder).toBeNull(); + expect(packed.user).toBeNull(); + expect(packed.userId).toBeNull(); + }); + + test('detail: true', async () => { + const folder = await createFolder('pack-parent', null); + const child = await createFolder('pack-child', folder.id); + const file = await createFile(child.id, null); + + const packed = await service.pack(file, { detail: true, self: true }) as any; + expect(packed.folder?.id).toBe(child.id); + expect(packed.folder?.parent?.id).toBe(folder.id); + }); + }); + + describe('packNullable', () => { + test('returns null for missing', async () => { + const packed = await service.packNullable('non-existent' as any, { detail: false }); + expect(packed).toBeNull(); + }); + + test('uses packedUser hint when withUser', async () => { + const user = await createUser(); + const file = await createFile(null, user.id); + + const packed = await service.packNullable(file, { withUser: true, self: true }, { + packedUser: { id: user.id, username: 'hint' } as any, + }); + expect(packed?.user?.id).toBe(user.id); + expect(packed?.user?.username).toBe('hint'); + }); + }); + + describe('packMany', () => { + test('withUser: true uses deduped packMany', async () => { + const user = await createUser(); + const fileA = await createFile(null, user.id); + const fileB = await createFile(null, user.id); + + const packed = await service.packMany([fileA, fileB], { withUser: true, self: true }); + expect(packed.length).toBe(2); + expect(userEntityServiceMock.packMany).toHaveBeenCalledTimes(1); + expect(userEntityServiceMock.packMany.mock.calls[0]?.[0]?.length).toBe(1); + expect(packed[0]?.user?.id).toBe(user.id); + }); + + test('detail: true packs folder', async () => { + const folder = await createFolder('packmany-root', null); + const file = await createFile(folder.id, null); + + const packed = await service.packMany([file], { detail: true, self: true }); + expect(packed[0]?.folder?.id).toBe(folder.id); + expect(packed[0]?.folder?.parent).toBeUndefined(); + }); + + test('detail: true uses DriveFolderEntityService pack', async () => { + const folder = await createFolder('packmany-folder', null); + const file = await createFile(folder.id, null); + const packSpy = jest.spyOn(driveFolderEntityService, 'pack'); + + await service.packMany([file], { detail: true, self: true }); + expect(packSpy).toHaveBeenCalled(); + packSpy.mockRestore(); + }); + }); + + describeBenchmark('benchmark', () => { + test('packMany', async () => { + const user = await createUser(); + const folders = []; + for (let i = 0; i < 100; i++) { + folders.push(await createFolder(`bench-${i}`, null)); + } + const files = []; + for (const folder of folders) { + for (let j = 0; j < 20; j++) { + files.push(await createFile(folder.id, user.id)); + } + } + + const start = Date.now(); + await service.packMany(files, { detail: true, withUser: true, self: true }); + const elapsed = Date.now() - start; + + console.log(`DriveFileEntityService.packMany benchmark: ${elapsed}ms`); + }); + }); +}); diff --git a/packages/backend/test/unit/entities/DriveFolderEntityService.ts b/packages/backend/test/unit/entities/DriveFolderEntityService.ts new file mode 100644 index 0000000000..299ee5f42b --- /dev/null +++ b/packages/backend/test/unit/entities/DriveFolderEntityService.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip; + +describe('DriveFolderEntityService', () => { + let app: TestingModule; + let service: DriveFolderEntityService; + let driveFoldersRepository: DriveFoldersRepository; + let driveFilesRepository: DriveFilesRepository; + let idCounter = 0; + + const nextId = () => genAidx(Date.now() + (idCounter++)); + + const createFolder = async (name: string, parentId: string | null) => { + const id = nextId(); + await driveFoldersRepository.insert({ + id, + name, + userId: null, + parentId, + }); + return driveFoldersRepository.findOneByOrFail({ id }); + }; + + const createFile = async (folderId: string | null) => { + const id = nextId(); + await driveFilesRepository.insert({ + id, + userId: null, + userHost: null, + md5: secureRndstr(32), + name: `file-${id}`, + type: 'text/plain', + size: 1, + comment: null, + blurhash: null, + properties: {}, + storedInternal: true, + url: `https://example.com/${id}`, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }); + }; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + await app.init(); + app.enableShutdownHooks(); + + service = app.get<DriveFolderEntityService>(DriveFolderEntityService); + driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository); + driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('pack', () => { + test('detail: false', async () => { + const root = await createFolder('root', null); + const child = await createFolder('child', root.id); + + const packed = await service.pack(child, { detail: false }) as any; + expect(packed.id).toBe(child.id); + expect(packed.parentId).toBe(root.id); + expect(packed.parent).toBeUndefined(); + expect(packed.foldersCount).toBeUndefined(); + expect(packed.filesCount).toBeUndefined(); + }); + + test('detail: true', async () => { + const root = await createFolder('root-detail', null); + const child = await createFolder('child-detail', root.id); + await createFolder('grandchild-detail', child.id); + await createFile(child.id); + await createFile(child.id); + + const packed = await service.pack(child, { detail: true }) as any; + expect(packed.id).toBe(child.id); + expect(packed.foldersCount).toBe(1); + expect(packed.filesCount).toBe(2); + expect(packed.parent?.id).toBe(root.id); + expect(packed.parent?.parent).toBeUndefined(); + }); + + test('detail: true reaches root for deep hierarchy', async () => { + const root = await createFolder('root-deep', null); + const level1 = await createFolder('level-1', root.id); + const level2 = await createFolder('level-2', level1.id); + const level3 = await createFolder('level-3', level2.id); + const level4 = await createFolder('level-4', level3.id); + const level5 = await createFolder('level-5', level4.id); + + const packed = await service.pack(level5, { detail: true }) as any; + expect(packed.id).toBe(level5.id); + expect(packed.parent?.id).toBe(level4.id); + expect(packed.parent?.parent?.id).toBe(level3.id); + expect(packed.parent?.parent?.parent?.id).toBe(level2.id); + expect(packed.parent?.parent?.parent?.parent?.id).toBe(level1.id); + expect(packed.parent?.parent?.parent?.parent?.parent?.id).toBe(root.id); + expect(packed.parent?.parent?.parent?.parent?.parent?.parent).toBeUndefined(); + }); + }); + + describe('packMany', () => { + test('preserves order and packs parents', async () => { + const root = await createFolder('root-many', null); + const childA = await createFolder('child-a', root.id); + const childB = await createFolder('child-b', root.id); + await createFolder('child-a-sub', childA.id); + await createFile(childA.id); + + const packed = await service.packMany([childB, childA], { detail: true }) as any; + expect(packed[0].id).toBe(childB.id); + expect(packed[1].id).toBe(childA.id); + expect(packed[0].parent?.id).toBe(root.id); + expect(packed[1].parent?.id).toBe(root.id); + expect(packed[0].filesCount).toBe(0); + expect(packed[1].filesCount).toBe(1); + expect(packed[0].foldersCount).toBe(0); + expect(packed[1].foldersCount).toBe(1); + }); + }); + + describeBenchmark('benchmark', () => { + test('packMany', async () => { + const root = await createFolder('bench-root', null); + const folders = []; + for (let i = 0; i < 200; i++) { + folders.push(await createFolder(`bench-${i}`, root.id)); + } + + const start = Date.now(); + await service.packMany(folders, { detail: true }); + const elapsed = Date.now() - start; + console.log(`DriveFolderEntityService.packMany benchmark: ${elapsed}ms`); + }); + }); +}); diff --git a/packages/backend/test/unit/server/FileServerService.ts b/packages/backend/test/unit/server/FileServerService.ts new file mode 100644 index 0000000000..c88175c5c7 --- /dev/null +++ b/packages/backend/test/unit/server/FileServerService.ts @@ -0,0 +1,770 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import fastifyStatic from '@fastify/static'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, expect, test } from '@jest/globals'; +import sharp from 'sharp'; +import { DataSource, type Repository } from 'typeorm'; +import { initTestDb, randomString } from '../../utils.js'; +import type { AiService } from '@/core/AiService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { InternalStorageService } from '@/core/InternalStorageService.js'; +import { IdService } from '@/core/IdService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { loadConfig, type Config } from '@/config.js'; +import { MiDriveFile } from '@/models/DriveFile.js'; +import { FileServerService } from '@/server/FileServerService.js'; + +const dummyPath = path.resolve('test/resources/dummy-for-file-server-service.png'); +const dummySize = fs.statSync(dummyPath).size; +const dummyBuffer = fs.readFileSync(dummyPath); +const svgBuffer = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"></svg>', 'utf8'); +const textBuffer = Buffer.from('dummy text', 'utf8'); + +async function createRemoteFileServer() { + const flatPngBuffer = await sharp({ + create: { width: 8, height: 8, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }).png().toBuffer(); + const server = Fastify(); + + server.get('/dummy.png', async (_request, reply) => { + reply.header('Content-Type', 'image/png'); + reply.header('Content-Length', String(dummyBuffer.length)); + return reply.send(dummyBuffer); + }); + + server.get('/dummy.svg', async (_request, reply) => { + reply.header('Content-Type', 'image/svg+xml'); + reply.header('Content-Length', String(svgBuffer.length)); + return reply.send(svgBuffer); + }); + + server.get('/dummy.txt', async (_request, reply) => { + reply.header('Content-Type', 'text/plain'); + reply.header('Content-Length', String(textBuffer.length)); + return reply.send(textBuffer); + }); + + server.get('/flat.png', async (_request, reply) => { + reply.header('Content-Type', 'image/png'); + reply.header('Content-Length', String(flatPngBuffer.length)); + return reply.send(flatPngBuffer); + }); + + const baseUrl = await server.listen({ port: 0, host: '127.0.0.1' }); + + return { + server, + pngUrl: `${baseUrl}/dummy.png`, + svgUrl: `${baseUrl}/dummy.svg`, + textUrl: `${baseUrl}/dummy.txt`, + flatPngUrl: `${baseUrl}/flat.png`, + }; +} + +describe('FileServerService', () => { + let db: DataSource; + let fastify: FastifyInstance; + let externalFastify: FastifyInstance; + let driveFilesRepository: Repository<MiDriveFile>; + let internalStorageService: InternalStorageService; + let idService: IdService; + let config: Config; + let fileServerService: FileServerService; + let externalFileServerService: FileServerService; + let remoteServer: FastifyInstance; + let remotePngUrl: string; + let remoteSvgUrl: string; + let remoteTextUrl: string; + let remoteFlatPngUrl: string; + const storedPaths: string[] = []; + let createdFallbackAssets = false; + let fallbackAssetsDir = ''; + + function writeInternalFile(key: string) { + const dest = internalStorageService.resolvePath(key); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(dummyPath, dest); + storedPaths.push(dest); + } + + async function insertDriveFile(params: { + accessKey: string; + thumbnailAccessKey?: string | null; + webpublicAccessKey?: string | null; + storedInternal: boolean; + isLink: boolean; + uri?: string | null; + name?: string; + type?: string; + size?: number; + }) { + const accessKey = params.accessKey; + const url = params.uri ?? `${config.url}/files/${accessKey}`; + await driveFilesRepository.insert({ + id: idService.gen(), + userId: null, + userHost: null, + md5: '00000000000000000000000000000000', + name: params.name ?? 'dummy.png', + type: params.type ?? 'image/png', + size: params.size ?? dummySize, + comment: null, + blurhash: null, + properties: {}, + storedInternal: params.storedInternal, + url, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey, + thumbnailAccessKey: params.thumbnailAccessKey ?? null, + webpublicAccessKey: params.webpublicAccessKey ?? null, + uri: params.uri ?? null, + src: null, + folderId: null, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: params.isLink, + requestHeaders: {}, + requestIp: null, + }); + } + + beforeAll(async () => { + config = loadConfig(); + db = await initTestDb(false); + driveFilesRepository = db.getRepository(MiDriveFile); + + const loggerService = new LoggerService(); + const aiService = { + detectSensitive: async () => null, + } as unknown as AiService; + const fileInfoService = new FileInfoService(aiService, loggerService); + const httpRequestService = new HttpRequestService(config); + const downloadService = new DownloadService(config, httpRequestService, loggerService); + const imageProcessingService = new ImageProcessingService(); + const videoProcessingService = new VideoProcessingService(config, imageProcessingService); + internalStorageService = new InternalStorageService(config); + idService = new IdService(config); + fileServerService = new FileServerService( + config, + driveFilesRepository as any, + fileInfoService, + downloadService, + imageProcessingService, + videoProcessingService, + internalStorageService, + loggerService, + ); + + fastify = Fastify(); + await fastify.register(fastifyStatic, { + root: path.resolve('src/server/assets'), + serve: false, + }); + fileServerService.createServer(fastify, {}, () => {}); + await fastify.ready(); + + const externalConfig = { + ...config, + mediaProxy: 'https://media-proxy.test', + externalMediaProxyEnabled: true, + } as Config; + externalFileServerService = new FileServerService( + externalConfig, + driveFilesRepository as any, + fileInfoService, + downloadService, + imageProcessingService, + videoProcessingService, + internalStorageService, + loggerService, + ); + externalFastify = Fastify(); + await externalFastify.register(fastifyStatic, { + root: path.resolve('src/server/assets'), + serve: false, + }); + externalFileServerService.createServer(externalFastify, {}, () => {}); + await externalFastify.ready(); + + const remoteServerInfo = await createRemoteFileServer(); + remoteServer = remoteServerInfo.server; + remotePngUrl = remoteServerInfo.pngUrl; + remoteSvgUrl = remoteServerInfo.svgUrl; + remoteTextUrl = remoteServerInfo.textUrl; + remoteFlatPngUrl = remoteServerInfo.flatPngUrl; + + fallbackAssetsDir = path.resolve('src/server/file/assets'); + if (!fs.existsSync(fallbackAssetsDir)) { + fs.mkdirSync(fallbackAssetsDir, { recursive: true }); + fs.copyFileSync(dummyPath, path.join(fallbackAssetsDir, 'dummy.png')); + createdFallbackAssets = true; + } + }); + + afterEach(async () => { + await driveFilesRepository.createQueryBuilder().delete().execute(); + for (const filePath of storedPaths) { + try { + fs.unlinkSync(filePath); + } catch { + // NOP + } + } + storedPaths.length = 0; + }); + + afterAll(async () => { + await fastify.close(); + await externalFastify.close(); + await remoteServer.close(); + await db.destroy(); + if (createdFallbackAssets) { + fs.rmSync(fallbackAssetsDir, { recursive: true, force: true }); + } + }); + + describe('GET /files/app-default.jpg', () => { + test('GET /files/app-default.jpg ヘッダを検証する', async () => { + const prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + + try { + const res = await fastify.inject({ + method: 'GET', + url: '/files/app-default.jpg', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-type']).toBe('image/jpeg'); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + } + }); + + test('GET /files/app-default.jpg development で CORS を許可する', async () => { + const prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + try { + const res = await fastify.inject({ + method: 'GET', + url: '/files/app-default.jpg', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('*'); + } finally { + process.env.NODE_ENV = prevNodeEnv; + } + }); + + test('GET /files/app-default.jpg?x=1 クエリを除去してリダイレクトする', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/files/app-default.jpg?x=1', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/files/app-default.jpg'); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + }); + + describe('GET /files/:key', () => { + test('GET /files/:key 404 のときダミー画像を返す', async () => { + const accessKey = randomString(); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('max-age=86400'); + }); + + test('GET /files/:key 画像配信ヘッダを検証する', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['content-length']).toBe(String(dummySize)); + expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/); + }); + + test('GET /files/:key Range で部分配信する', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + headers: { + range: 'bytes=0-3', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe('4'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + }); + + test('GET /files/:key Range の終端を補正する', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + headers: { + range: 'bytes=0-999999', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-${dummySize - 1}/${dummySize}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe(String(dummySize)); + }); + + test('GET /files/:key thumbnail の Range で部分配信する', async () => { + const accessKey = randomString(); + const thumbnailKey = randomString(); + writeInternalFile(thumbnailKey); + await insertDriveFile({ + accessKey, + thumbnailAccessKey: thumbnailKey, + storedInternal: true, + isLink: false, + name: 'sample.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${thumbnailKey}`, + headers: { + range: 'bytes=0-3', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe('4'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + }); + + test('GET /files/:key thumbnail のファイル名を整形する', async () => { + const accessKey = randomString(); + const thumbnailKey = randomString(); + writeInternalFile(thumbnailKey); + await insertDriveFile({ + accessKey, + thumbnailAccessKey: thumbnailKey, + storedInternal: true, + isLink: false, + name: 'sample.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${thumbnailKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('sample-thumb.png'); + }); + + test('GET /files/:key webpublic のファイル名を整形する', async () => { + const accessKey = randomString(); + const webpublicKey = randomString(); + writeInternalFile(webpublicKey); + await insertDriveFile({ + accessKey, + webpublicAccessKey: webpublicKey, + storedInternal: true, + isLink: false, + name: 'sample.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${webpublicKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('sample-web.png'); + }); + + test('GET /files/:key browsersafe でない MIME は octet-stream になる', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + type: 'application/x-msdownload', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/octet-stream'); + }); + + test('GET /files/:key 204 のときキャッシュ制御を返す', async () => { + const accessKey = randomString(); + await insertDriveFile({ + accessKey, + storedInternal: false, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(204); + expect(res.headers['cache-control']).toBe('max-age=86400'); + }); + + test('GET /files/:key 外部リンクを取得して配信する', async () => { + const accessKey = randomString(); + await insertDriveFile({ + accessKey, + storedInternal: false, + isLink: true, + uri: remotePngUrl, + name: 'remote.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-length']).toBe(String(dummyBuffer.length)); + expect(res.headers['content-disposition'] ?? '').toContain('remote.png'); + }); + + test('GET /files/:key 外部リンクを Range で部分配信する', async () => { + const accessKey = randomString(); + await insertDriveFile({ + accessKey, + storedInternal: false, + isLink: true, + uri: remotePngUrl, + name: 'remote.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + headers: { + range: 'bytes=0-3', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-3/${dummyBuffer.length}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe(String(dummyBuffer.length)); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + }); + + test('GET /files/:key thumbnail は mediaProxy/static.webp にリダイレクトする', async () => { + const accessKey = randomString(); + const thumbnailKey = randomString(); + await insertDriveFile({ + accessKey, + thumbnailAccessKey: thumbnailKey, + storedInternal: false, + isLink: true, + uri: remotePngUrl, + name: 'remote.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${thumbnailKey}`, + }); + + expect(res.statusCode).toBe(301); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers.location).toContain(`${config.mediaProxy}/static.webp`); + expect(res.headers.location).toContain('static=1'); + }); + + test('GET /files/:key webpublic svg は mediaProxy/svg.webp にリダイレクトする', async () => { + const accessKey = randomString(); + const webpublicKey = randomString(); + await insertDriveFile({ + accessKey, + webpublicAccessKey: webpublicKey, + storedInternal: false, + isLink: true, + uri: remoteSvgUrl, + name: 'vector.svg', + type: 'image/svg+xml', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${webpublicKey}`, + }); + + expect(res.statusCode).toBe(301); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers.location).toContain(`${config.mediaProxy}/svg.webp`); + }); + }); + + describe('GET /files/:key/*', () => { + test('GET /files/:key/* 正規の /files/:key にリダイレクトする', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/files/testkey/extra/path', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe(`${config.url}/files/testkey`); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + }); + + describe('GET /proxy/:url*', () => { + test('GET /proxy/:url* 外部メディアプロキシへリダイレクトする', async () => { + const res = await externalFastify.inject({ + method: 'GET', + url: '/proxy/path-part?url=https%3A%2F%2Fexample.com%2Fimg.png&static=1', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers['cache-control']).toBe('public, max-age=259200'); + expect(res.headers.location).toContain('https://media-proxy.test/'); + expect(res.headers.location).toContain('url=https%3A%2F%2Fexample.com%2Fimg.png'); + expect(res.headers.location).toContain('static=1'); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + + test('GET /proxy/:url* misskey User-Agent を拒否する', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png', + headers: { + 'user-agent': 'misskey/1.0', + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* origin 指定時は User-Agent 必須を検証する', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png&origin=1', + headers: { + 'user-agent': '', + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.headers['cache-control']).toBe('max-age=300'); + expect(res.headers.location).toBeUndefined(); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + + test('GET /proxy/:url* emoji 指定で非画像は 404 を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}&emoji=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* 非画像は 403 を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* emoji static で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&emoji=1&static=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* avatar static で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&avatar=1&static=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* static で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&static=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* preview で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&preview=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* svg を webp に変換する', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteSvgUrl)}`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.svg.webp'); + }); + + test('GET /proxy/:url* badge で低エントロピー画像は 404 を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteFlatPngUrl)}&badge=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* 画像をそのまま返す', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(`${config.url}/files/${accessKey}`)}&origin=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png'); + }); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index ecca28b5af..f91fb7f9b1 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -404,37 +404,28 @@ export function connectStream<C extends keyof misskey.Channels>(user: UserToken, } export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => { - return new Promise<boolean>(async (res, rej) => { - let timer: NodeJS.Timeout | null = null; + let ws: WebSocket | undefined; - let ws: WebSocket; - try { - ws = await connectStream(user, channel, msg => { + try { + let callback: (msg: Record<string, unknown>) => void; + const receivedPromise = new Promise<boolean>((resolve) => { + callback = (msg: Record<string, unknown>) => { if (cond(msg)) { - ws.close(); - if (timer) clearTimeout(timer); - res(true); + resolve(true); } - }, params); - } catch (e) { - rej(e); - } - - if (!ws!) return; + }; + }); - timer = setTimeout(() => { - ws.close(); - res(false); - }, 3000); + ws = await connectStream(user, channel, callback!, params); + await trgr(); - try { - await trgr(); - } catch (e) { - ws.close(); - if (timer) clearTimeout(timer); - rej(e); - } - }); + return await Promise.race([ + receivedPromise, + new Promise<void>((r) => setTimeout(() => r(), 3000)).then(() => false), + ]); + } finally { + if (ws) ws.close(); + } }; /** diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 25584e475d..dac56f25de 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -26,7 +26,6 @@ "jsx": "react-jsx", "jsxImportSource": "@kitajs/html", "rootDir": "./src", - "baseUrl": "./", "paths": { "@/*": ["./src/*"] }, diff --git a/packages/frontend-builder/eslint.config.js b/packages/frontend-builder/eslint.config.js index a13490c97f..7337abe14c 100644 --- a/packages/frontend-builder/eslint.config.js +++ b/packages/frontend-builder/eslint.config.js @@ -34,7 +34,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index c1d9e316e6..622f4f40be 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "exports": { @@ -11,16 +11,15 @@ }, "devDependencies": { "@types/estree": "1.0.8", - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "rollup": "4.53.3", - "typescript": "5.9.3" + "@types/node": "24.10.13", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "rollup": "4.59.0" }, "dependencies": { "i18n": "workspace:*", "estree-walker": "3.0.3", "magic-string": "0.30.21", - "vite": "7.2.7" + "vite": "7.3.1" } } diff --git a/packages/frontend-builder/tsconfig.json b/packages/frontend-builder/tsconfig.json index 9250b2f3da..ab943fded4 100644 --- a/packages/frontend-builder/tsconfig.json +++ b/packages/frontend-builder/tsconfig.json @@ -17,7 +17,6 @@ "noImplicitReturns": true, "esModuleInterop": true, "verbatimModuleSyntax": true, - "baseUrl": ".", "typeRoots": [ "./@types", "./node_modules/@types" diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 46247e40d5..d1ca70617b 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -41,7 +41,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], @@ -145,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, diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 808559f44a..14a3f27601 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -11,27 +11,27 @@ }, "dependencies": { "@discordapp/twemoji": "16.0.1", - "i18n": "workspace:*", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", "@rollup/pluginutils": "5.3.0", "@twemoji/parser": "16.0.0", - "@vitejs/plugin-vue": "6.0.2", + "@vitejs/plugin-vue": "6.0.4", "buraha": "0.0.1", "estree-walker": "3.0.3", "frontend-shared": "workspace:*", + "i18n": "workspace:*", "icons-subsetter": "workspace:*", "json5": "2.2.3", "mfm-js": "0.25.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.53.3", - "sass": "1.95.1", - "shiki": "3.19.0", + "rollup": "4.59.0", + "sass": "1.97.3", + "shiki": "3.22.0", "tinycolor2": "1.6.0", "uuid": "13.0.0", - "vite": "7.2.7", - "vue": "3.5.25" + "vite": "7.3.1", + "vue": "3.5.28" }, "devDependencies": { "@misskey-dev/summaly": "5.2.5", @@ -39,30 +39,29 @@ "@testing-library/vue": "8.1.0", "@types/estree": "1.0.8", "@types/micromatch": "4.0.10", - "@types/node": "24.10.2", + "@types/node": "24.10.13", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@vitest/coverage-v8": "4.0.15", - "@vue/runtime-core": "3.5.25", - "acorn": "8.15.0", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@vitest/coverage-v8": "4.0.18", + "@vue/runtime-core": "3.5.28", + "acorn": "8.16.0", "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-vue": "10.6.2", - "happy-dom": "20.0.11", + "eslint-plugin-vue": "10.8.0", + "happy-dom": "20.7.0", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.12.4", - "nodemon": "3.1.11", - "prettier": "3.7.4", + "msw": "2.12.10", + "nodemon": "3.1.14", + "prettier": "3.8.1", "start-server-and-test": "2.1.3", "tsx": "4.21.0", - "typescript": "5.9.3", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "3.1.8", - "vue-eslint-parser": "10.2.0", - "vue-tsc": "3.1.8" + "vue-component-type-helpers": "3.2.4", + "vue-eslint-parser": "10.4.0", + "vue-tsc": "3.2.4" } } diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue index 58c35c8ef0..3f91e14403 100644 --- a/packages/frontend-embed/src/components/EmAvatar.vue +++ b/packages/frontend-embed/src/components/EmAvatar.vue @@ -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> diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue index 71f0ee9294..be18ce79d5 100644 --- a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -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> diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue index 94f0268da4..8cb90247fc 100644 --- a/packages/frontend-embed/src/components/EmMediaImage.vue +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -64,7 +64,7 @@ const url = computed(() => (props.raw) : props.image.thumbnailUrl, ); -async function onclick(ev: MouseEvent) { +async function onclick(ev: PointerEvent) { if (hide.value) { ev.stopPropagation(); hide.value = false; diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts index 1f9ce9d4f4..5b9a53bbc2 100644 --- a/packages/frontend-embed/src/components/EmMfm.ts +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -299,7 +299,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven ]); } case 'clickable': { - return h('span', { onClick(ev: MouseEvent): void { + return h('span', { onClick(ev: PointerEvent): void { ev.stopPropagation(); ev.preventDefault(); const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; diff --git a/packages/frontend-embed/src/pages/clip.vue b/packages/frontend-embed/src/pages/clip.vue index f4d4e8cf6f..a65f38aa7d 100644 --- a/packages/frontend-embed/src/pages/clip.vue +++ b/packages/frontend-embed/src/pages/clip.vue @@ -84,7 +84,7 @@ const pagination = computed(() => ({ const notesEl = useTemplateRef('notesEl'); -function top(ev: MouseEvent) { +function top(ev: PointerEvent) { const target = ev.target as HTMLElement | null; if (target && isLink(target)) return; diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue index 4b00ae7c2d..8c6cd32bab 100644 --- a/packages/frontend-embed/src/pages/tag.vue +++ b/packages/frontend-embed/src/pages/tag.vue @@ -40,15 +40,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> import { computed, inject, useTemplateRef } from 'vue'; import { scrollToTop } from '@@/js/scroll.js'; +import { url, instanceName } from '@@/js/config.js'; +import { isLink } from '@@/js/is-link.js'; +import { defaultEmbedParams } from '@@/js/embed-page.js'; import type { Paging } from '@/components/EmPagination.vue'; import EmNotes from '@/components/EmNotes.vue'; import XNotFound from '@/pages/not-found.vue'; import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; import { i18n } from '@/i18n.js'; -import { url, instanceName } from '@@/js/config.js'; -import { isLink } from '@@/js/is-link.js'; import { DI } from '@/di.js'; -import { defaultEmbedParams } from '@@/js/embed-page.js'; const props = defineProps<{ tag: string; @@ -67,7 +67,7 @@ const pagination = computed(() => ({ const notesEl = useTemplateRef('notesEl'); -function top(ev: MouseEvent) { +function top(ev: PointerEvent) { const target = ev.target as HTMLElement | null; if (target && isLink(target)) return; diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue index 348b1a7622..4b5daf54da 100644 --- a/packages/frontend-embed/src/pages/user-timeline.vue +++ b/packages/frontend-embed/src/pages/user-timeline.vue @@ -49,6 +49,8 @@ import { ref, computed, inject, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import { url, instanceName } from '@@/js/config.js'; import { defaultEmbedParams } from '@@/js/embed-page.js'; +import { scrollToTop } from '@@/js/scroll.js'; +import { isLink } from '@@/js/is-link.js'; import type { Paging } from '@/components/EmPagination.vue'; import EmNotes from '@/components/EmNotes.vue'; import EmAvatar from '@/components/EmAvatar.vue'; @@ -56,8 +58,6 @@ import EmUserName from '@/components/EmUserName.vue'; import I18n from '@/components/I18n.vue'; import XNotFound from '@/pages/not-found.vue'; import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; -import { scrollToTop } from '@@/js/scroll.js'; -import { isLink } from '@@/js/is-link.js'; import { misskeyApi } from '@/misskey-api.js'; import { i18n } from '@/i18n.js'; import { assertServerContext } from '@/server-context.js'; @@ -101,7 +101,7 @@ const pagination = computed(() => ({ const notesEl = useTemplateRef('notesEl'); -function top(ev: MouseEvent) { +function top(ev: PointerEvent) { const target = ev.target as HTMLElement | null; if (target && isLink(target)) return; diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json index 63e637c844..24fa71de19 100644 --- a/packages/frontend-embed/tsconfig.json +++ b/packages/frontend-embed/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "allowJs": true, "noEmitOnError": false, - "noImplicitAny": false, + "noImplicitAny": true, "noImplicitReturns": true, "noUnusedParameters": false, "noUnusedLocals": false, @@ -22,7 +22,6 @@ "isolatedModules": true, "useDefineForClassFields": true, "verbatimModuleSyntax": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@@/*": ["../frontend-shared/*"] diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js index 07e98ad182..1f98267468 100644 --- a/packages/frontend-shared/build.js +++ b/packages/frontend-shared/build.js @@ -60,7 +60,7 @@ async function buildSrc() { function buildDts() { return execa( - 'tsc', + 'tsgo', [ '--project', 'tsconfig.json', '--outDir', 'js-built', diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index b972cfdb27..6168637f22 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -46,7 +46,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend-shared/js/worker-multi-dispatch.ts b/packages/frontend-shared/js/worker-multi-dispatch.ts index 5d393ed1ed..909aa1e81e 100644 --- a/packages/frontend-shared/js/worker-multi-dispatch.ts +++ b/packages/frontend-shared/js/worker-multi-dispatch.ts @@ -34,7 +34,7 @@ export class WorkerMultiDispatch<POST = unknown, RETURN = unknown> { public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: WorkerNumberGetter = this.getUseWorkerNumber) { let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length); workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length; - if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber); + // if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber); this.prevWorkerNumber = workerNumber; // 不毛だがunionをoverloadに突っ込めない diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index 49cce0d707..abcdea9244 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -17,18 +17,17 @@ "build": "node ./build.js", "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"", "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "esbuild": "0.27.1", - "eslint-plugin-vue": "10.6.2", - "nodemon": "3.1.11", - "typescript": "5.9.3", - "vue-eslint-parser": "10.2.0" + "@types/node": "24.10.13", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "esbuild": "0.27.3", + "eslint-plugin-vue": "10.8.0", + "nodemon": "3.1.14", + "vue-eslint-parser": "10.4.0" }, "files": [ "js-built" @@ -36,6 +35,6 @@ "dependencies": { "i18n": "workspace:*", "misskey-js": "workspace:*", - "vue": "3.5.25" + "vue": "3.5.28" } } diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json index 12f00eb503..6b1804a0fc 100644 --- a/packages/frontend-shared/tsconfig.json +++ b/packages/frontend-shared/tsconfig.json @@ -17,7 +17,6 @@ "noImplicitReturns": true, "esModuleInterop": true, "verbatimModuleSyntax": true, - "baseUrl": ".", "paths": { "@/*": ["./*"], "@@/*": ["./*"] diff --git a/packages/frontend/.storybook/charts.ts b/packages/frontend/.storybook/charts.ts index 93e1287d69..7a92df8e86 100644 --- a/packages/frontend/.storybook/charts.ts +++ b/packages/frontend/.storybook/charts.ts @@ -33,7 +33,7 @@ export function getChartResolver(fields: string[], option?: { accumulate?: boole const res = {}; for (const field of fields) { const layers = field.split('.'); - let current = res; + let current = res as any; while (layers.length > 1) { const currentKey = layers.shift()!; if (current[currentKey] == null) current[currentKey] = {}; diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index a0fb80be92..adae471c0a 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -42,7 +42,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], @@ -148,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, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 730bf71789..b3244d0737 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -6,7 +6,7 @@ "watch": "vite", "build": "tsx build.ts", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", - "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", + "build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static", "chromatic": "chromatic", "test": "vitest --run --globals", @@ -19,20 +19,19 @@ "@analytics/google-analytics": "1.1.0", "@discordapp/twemoji": "16.0.1", "@github/webauthn-json": "2.1.1", - "@mcaptcha/vanilla-glue": "0.1.0-rc2", - "i18n": "workspace:*", + "@mcaptcha/core-glue": "0.1.0-alpha-5", "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", "@rollup/pluginutils": "5.3.0", - "@sentry/vue": "10.29.0", - "@syuilo/aiscript": "1.2.0", + "@sentry/vue": "10.39.0", + "@syuilo/aiscript": "1.2.1", "@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0", "@twemoji/parser": "16.0.0", - "@vitejs/plugin-vue": "6.0.2", + "@vitejs/plugin-vue": "6.0.4", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16", "analytics": "0.8.19", - "broadcast-channel": "7.2.0", + "broadcast-channel": "7.3.0", "buraha": "0.0.1", "canvas-confetti": "1.9.4", "chart.js": "4.5.1", @@ -40,14 +39,15 @@ "chartjs-chart-matrix": "3.0.0", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", - "chromatic": "13.3.4", + "chromatic": "15.1.1", "compare-versions": "6.1.1", "cropperjs": "2.1.0", "date-fns": "4.1.0", - "eventemitter3": "5.0.1", + "eventemitter3": "5.0.4", "execa": "9.6.1", - "exifreader": "4.33.1", + "exifreader": "4.36.2", "frontend-shared": "workspace:*", + "i18n": "workspace:*", "icons-subsetter": "workspace:*", "idb-keyval": "6.2.2", "insert-text-at-cursor": "0.3.0", @@ -55,7 +55,7 @@ "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.20.0", - "mediabunny": "1.25.8", + "mediabunny": "1.34.4", "mfm-js": "0.25.0", "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", @@ -64,85 +64,85 @@ "punycode.js": "2.3.1", "qr-code-styling": "1.9.2", "qr-scanner": "1.4.2", - "rollup": "4.53.3", - "sanitize-html": "2.17.0", - "sass": "1.95.1", - "shiki": "3.19.0", + "rollup": "4.59.0", + "sanitize-html": "2.17.1", + "sass": "1.97.3", + "shiki": "3.22.0", "textarea-caret": "3.1.0", - "three": "0.181.2", + "three": "0.183.1", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "v-code-diff": "1.13.1", - "vite": "7.2.7", - "vue": "3.5.25", - "vuedraggable": "next", + "vite": "7.3.1", + "vue": "3.5.28", "wanakana": "5.3.1" }, "devDependencies": { "@misskey-dev/summaly": "5.2.5", - "@storybook/addon-essentials": "8.6.14", - "@storybook/addon-interactions": "8.6.14", - "@storybook/addon-links": "10.1.5", - "@storybook/addon-mdx-gfm": "8.6.14", - "@storybook/addon-storysource": "8.6.14", - "@storybook/blocks": "8.6.14", - "@storybook/components": "8.6.14", - "@storybook/core-events": "8.6.14", - "@storybook/manager-api": "8.6.14", - "@storybook/preview-api": "8.6.14", - "@storybook/react": "10.1.5", - "@storybook/react-vite": "10.1.5", - "@storybook/test": "8.6.14", - "@storybook/theming": "8.6.14", - "@storybook/types": "8.6.14", - "@storybook/vue3": "10.1.5", - "@storybook/vue3-vite": "10.1.5", + "@storybook/addon-essentials": "8.6.17", + "@storybook/addon-interactions": "8.6.17", + "@storybook/addon-links": "10.2.10", + "@storybook/addon-mdx-gfm": "8.6.17", + "@storybook/addon-storysource": "8.6.17", + "@storybook/blocks": "8.6.17", + "@storybook/components": "8.6.17", + "@storybook/core-events": "8.6.17", + "@storybook/manager-api": "8.6.17", + "@storybook/preview-api": "8.6.17", + "@storybook/react": "10.2.10", + "@storybook/react-vite": "10.2.10", + "@storybook/test": "8.6.17", + "@storybook/theming": "8.6.17", + "@storybook/types": "8.6.17", + "@storybook/vue3": "10.2.10", + "@storybook/vue3-vite": "10.2.10", "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.8", + "@types/insert-text-at-cursor": "0.3.2", "@types/matter-js": "0.20.2", "@types/micromatch": "4.0.10", - "@types/node": "24.10.2", + "@types/node": "24.10.13", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.0", "@types/seedrandom": "3.0.8", + "@types/textarea-caret": "3.0.4", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@vitest/coverage-v8": "4.0.15", - "@vue/compiler-core": "3.5.25", - "acorn": "8.15.0", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@vitest/coverage-v8": "4.0.18", + "@vue/compiler-core": "3.5.28", + "acorn": "8.16.0", "astring": "1.9.0", "cross-env": "10.1.0", - "cypress": "15.7.1", + "cypress": "15.10.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-vue": "10.6.2", + "eslint-plugin-vue": "10.8.0", "estree-walker": "3.0.3", - "happy-dom": "20.0.11", + "happy-dom": "20.7.0", "intersection-observer": "0.12.2", "magic-string": "0.30.21", "micromatch": "4.0.8", - "minimatch": "10.1.1", - "msw": "2.12.4", + "minimatch": "10.2.4", + "msw": "2.12.10", "msw-storybook-addon": "2.0.6", - "nodemon": "3.1.11", - "prettier": "3.7.4", - "react": "19.2.1", - "react-dom": "19.2.1", + "nodemon": "3.1.14", + "prettier": "3.8.1", + "react": "19.2.4", + "react-dom": "19.2.4", "seedrandom": "3.0.5", "start-server-and-test": "2.1.3", - "storybook": "10.1.5", + "storybook": "10.2.10", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "tsx": "4.21.0", - "typescript": "5.9.3", "vite-plugin-glsl": "1.5.5", "vite-plugin-turbosnap": "1.0.3", - "vitest": "4.0.15", + "vitest": "4.0.18", "vitest-fetch-mock": "0.4.5", - "vue-component-type-helpers": "3.1.8", - "vue-eslint-parser": "10.2.0", - "vue-tsc": "3.1.8" + "vue-component-type-helpers": "3.2.4", + "vue-eslint-parser": "10.4.0", + "vue-tsc": "3.2.4" } } diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 79086c2b39..862ef4e113 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -126,10 +126,10 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { if (!$i) return; const token = $i.token; for (const key of Object.keys($i)) { - delete $i[key]; + delete $i[key as keyof typeof $i]; } for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; + ($i[key as keyof typeof accountData] as any) = value; } store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i }); $i.token = token; @@ -139,7 +139,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { export function updateCurrentAccountPartial(accountData: Partial<Misskey.entities.MeDetailed>) { if (!$i) return; for (const [key, value] of Object.entries(accountData)) { - $i[key] = value; + ($i[key as keyof typeof accountData] as any) = value; } store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i }); diff --git a/packages/frontend/src/aiscript/ui.ts b/packages/frontend/src/aiscript/ui.ts index 9c330da3c5..768425746d 100644 --- a/packages/frontend/src/aiscript/ui.ts +++ b/packages/frontend/src/aiscript/ui.ts @@ -531,7 +531,7 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) { type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>; - const instances = {}; + const instances = {} as Record<string, values.VObj>; function createComponentInstance<T extends AsUiComponent, C>( type: T['type'], @@ -555,7 +555,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R const updates = getOptions(def, call); for (const update of def.value.keys()) { if (!Object.hasOwn(updates, update)) continue; - component.value[update] = updates[update]; + component.value[update] = updates[update as keyof Options<T>]; } })], ])); diff --git a/packages/frontend/src/analytics.ts b/packages/frontend/src/analytics.ts index e07a4e9258..9889018ddb 100644 --- a/packages/frontend/src/analytics.ts +++ b/packages/frontend/src/analytics.ts @@ -90,6 +90,7 @@ export async function initAnalytics(instance: Misskey.entities.MetaDetailed) { // Google Analytics if (instance.googleAnalyticsMeasurementId) { + //@ts-expect-error Dynamic import const { default: googleAnalytics } = await import('@analytics/google-analytics'); plugins.push(googleAnalytics({ diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 10b1199bbb..3d4ecbf75b 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -29,6 +29,7 @@ import { prefer } from '@/preferences.js'; import { updateCurrentAccountPartial } from '@/accounts.js'; import { migrateOldSettings } from '@/pref-migrate.js'; import { unisonReload } from '@/utility/unison-reload.js'; +import { isBirthday } from '@/utility/is-birthday.js'; export async function mainBoot() { const { isClientUpdated, lastVersion } = await common(async () => { @@ -144,12 +145,8 @@ export async function mainBoot() { const m = now.getMonth() + 1; const d = now.getDate(); - if ($i.birthday) { - const bm = parseInt($i.birthday.split('-')[1]); - const bd = parseInt($i.birthday.split('-')[2]); - if (m === bm && d === bd) { - claimAchievement('loggedInOnBirthday'); - } + if (isBirthday($i, now)) { + claimAchievement('loggedInOnBirthday'); } if (m === 1 && d === 1) { diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index c7252e7c98..cbc5b27fca 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -115,7 +115,7 @@ watch(moderationNote, async () => { }); }); -function resolve(resolvedAs) { +function resolve(resolvedAs: 'accept' | 'reject' | null) { os.apiWithDialog('admin/resolve-abuse-user-report', { reportId: props.report.id, resolvedAs, @@ -132,7 +132,7 @@ function forward() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu([{ icon: 'ti ti-hash', text: 'Copy ID', diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index c786e9fe9f..fe6415eabb 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -23,13 +23,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.body"> <div :class="$style.header"> - <span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span> + <span :class="$style.title">{{ i18n.ts._achievements._types[`_${achievement.name}`].title }}</span> <span :class="$style.time"> <time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time> </span> </div> - <div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types['_' + achievement.name].description : '???' }}</div> - <div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor && withDescription" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div> + <div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types[`_${achievement.name}`].description : '???' }}</div> + <div v-if="'flavor' in i18n.ts._achievements._types[`_${achievement.name}`] && withDescription" :class="$style.flavor">{{ (i18n.ts._achievements._types[`_${achievement.name}`] as { flavor: string; }).flavor }}</div> </div> </div> <template v-if="withLocked"> @@ -54,7 +54,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { onMounted, ref, computed } from 'vue'; -import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js'; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 81c92bfb5c..da0f618e95 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="$emit('closed')" @click="onBgClick"> +<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="emit('closed')" @click="onBgClick"> <div ref="rootEl" :class="$style.root"> <div :class="$style.header"> <span :class="$style.icon"> @@ -44,6 +44,10 @@ const props = defineProps<{ announcement: Misskey.entities.Announcement; }>(); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const rootEl = useTemplateRef('rootEl'); const bottomEl = useTemplateRef('bottomEl'); const modal = useTemplateRef('modal'); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index a3b6112629..c66e9d176a 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -64,13 +64,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import type { Ref } from 'vue'; +import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import * as os from '@/os.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import { useMkSelect } from '@/composables/use-mkselect.js'; @@ -106,7 +106,7 @@ const containerStyle = computed(() => { const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle; const border = isBordered ? { - borderWidth: c.borderWidth ?? '1px', + borderWidth: `${c.borderWidth ?? 1}px`, borderColor: c.borderColor ?? 'var(--MI_THEME-divider)', borderStyle: c.borderStyle ?? 'solid', } : undefined; @@ -144,7 +144,7 @@ const { initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null, }); -function onSelectUpdate(v) { +function onSelectUpdate(v: string | null) { valueForSelect.value = v; if ('onChange' in c && c.onChange) { c.onChange(v as never); diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 8744b50926..b1a29660ad 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -183,7 +183,7 @@ async function init() { init(); -function clickAddAccount(ev: MouseEvent) { +function clickAddAccount(ev: PointerEvent) { selectedUser.value = null; os.popupMenu([{ diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index 15aab8daed..9104650752 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; import MkInput from './MkInput.vue'; +import type { StoryObj } from '@storybook/vue3'; import { tick } from '@/utility/test-utils.js'; const common = { render(args) { @@ -81,7 +80,7 @@ export const User = { ...common.args, type: 'user', }, - async play({ canvasElement }) { + async play({ canvasElement }: { canvasElement: HTMLElement }) { const canvas = within(canvasElement); const input = canvas.getByRole('combobox'); await waitFor(() => userEvent.hover(input)); @@ -114,7 +113,7 @@ export const Hashtag = { ...common.args, type: 'hashtag', }, - async play({ canvasElement }) { + async play({ canvasElement }: { canvasElement: HTMLElement }) { const canvas = within(canvasElement); const input = canvas.getByRole('combobox'); await waitFor(() => userEvent.hover(input)); diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index cf5d95e11b..bfe66cdf8f 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -45,12 +45,13 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import sanitizeHtml from 'sanitize-html'; import { emojilist, getEmojiName } from '@@/js/emojilist.js'; import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js'; import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; import type { EmojiDef } from '@/utility/search-emoji.js'; -import contains from '@/utility/contains.js'; +import { elementContains } from '@/utility/element-contains.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -63,7 +64,7 @@ import { prefer } from '@/preferences.js'; export type CompleteInfo = { user: { - payload: any; + payload: Misskey.entities.User; query: string | null; }, hashtag: { @@ -185,9 +186,9 @@ const suggests = ref<Element>(); const rootEl = useTemplateRef('rootEl'); const fetching = ref(true); -const users = ref<any[]>([]); -const hashtags = ref<any[]>([]); -const emojis = ref<(EmojiDef)[]>([]); +const users = ref<Misskey.entities.User[]>([]); +const hashtags = ref<string[]>([]); +const emojis = ref<EmojiDef[]>([]); const items = ref<Element[] | HTMLCollection>([]); const mfmTags = ref<string[]>([]); const mfmParams = ref<string[]>([]); @@ -204,8 +205,8 @@ function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T][ emit('closed'); if (type === 'emoji' || type === 'emojiComplete') { let recents = store.s.recentlyUsedEmojis; - recents = recents.filter((emoji: any) => emoji !== value); - recents.unshift(value); + recents = recents.filter((emoji) => emoji !== value); + recents.unshift(value as string); store.set('recentlyUsedEmojis', recents.splice(0, 32)); } } @@ -254,7 +255,7 @@ function exec() { limit: 10, detail: false, }).then(searchedUsers => { - users.value = searchedUsers as any[]; + users.value = searchedUsers; fetching.value = false; // キャッシュ sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers)); @@ -276,7 +277,7 @@ function exec() { query: props.q, limit: 30, }).then(searchedHashtags => { - hashtags.value = searchedHashtags as any[]; + hashtags.value = searchedHashtags; fetching.value = false; // キャッシュ sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags)); @@ -310,8 +311,8 @@ function exec() { } } -function onMousedown(event: Event) { - if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); +function onMousedown(event: MouseEvent) { + if (!elementContains(rootEl.value, event.target as Element) && (rootEl.value !== event.target)) props.close(); } function onKeydown(event: KeyboardEvent) { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index b729128a21..854ed31ed5 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -63,7 +63,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'click', payload: MouseEvent): void; + (ev: 'click', payload: PointerEvent): void; }>(); const el = useTemplateRef('el'); @@ -77,11 +77,11 @@ onMounted(() => { } }); -function distance(p, q): number { +function distance(p: { x: number; y: number }, q: { x: number; y: number }): number { return Math.hypot(p.x - q.x, p.y - q.y); } -function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number { +function calcCircleScale(boxW: number, boxH: number, circleCenterX: number, circleCenterY: number): number { const origin = { x: circleCenterX, y: circleCenterY }; const dist1 = distance({ x: 0, y: 0 }, origin); const dist2 = distance({ x: boxW, y: 0 }, origin); diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 30940a34a9..2fa1135398 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -7,8 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <span v-if="!available">Loading<MkEllipsis/></span> <div v-if="props.provider == 'mcaptcha'"> - <div id="mcaptcha__widget-container" class="m-captcha-style"></div> - <div ref="captchaEl"></div> + <iframe + v-if="mCaptchaIframeUrl != null" + ref="mCaptchaIframe" + :src="mCaptchaIframeUrl" + style="border: none; max-width: 320px; width: 100%; height: 100%; max-height: 80px;" + ></iframe> </div> <div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;"> <img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/> @@ -26,7 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted } from 'vue'; +import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, watch, onUnmounted, nextTick } from 'vue'; +import type Reciever_typeReferenceOnly from '@mcaptcha/core-glue'; import { store } from '@/store.js'; // APIs provided by Captcha services @@ -71,6 +76,19 @@ const available = ref(false); const captchaEl = useTemplateRef('captchaEl'); const captchaWidgetId = ref<string | undefined>(undefined); + +let mCaptchaReciever: Reciever_typeReferenceOnly | null = null; +const mCaptchaIframe = useTemplateRef('mCaptchaIframe'); +const mCaptchaRemoveState = ref(false); +const mCaptchaIframeUrl = computed(() => { + if (props.provider === 'mcaptcha' && !mCaptchaRemoveState.value && props.instanceUrl && props.sitekey) { + const url = new URL('/widget', props.instanceUrl); + url.searchParams.set('sitekey', props.sitekey); + return url.toString(); + } + return null; +}); + const testcaptchaInput = ref(''); const testcaptchaPassed = ref(false); @@ -84,7 +102,7 @@ const variable = computed(() => { } }); -const loaded = !!window[variable.value]; +const loaded = !!(window as any)[variable.value]; const src = computed(() => { switch (props.provider) { @@ -98,7 +116,7 @@ const src = computed(() => { const scriptId = computed(() => `script-${props.provider}`); -const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); +const captcha = computed<Captcha>(() => (window as any)[variable.value] ?? {} as unknown as Captcha); watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => { // 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない @@ -129,8 +147,14 @@ function reset() { if (_DEV_) console.warn(error); } } + testcaptchaPassed.value = false; testcaptchaInput.value = ''; + + if (mCaptchaReciever != null) { + mCaptchaReciever.destroy(); + mCaptchaReciever = null; + } } function remove() { @@ -143,6 +167,10 @@ function remove() { if (_DEV_) console.warn(error); } } + + if (props.provider === 'mcaptcha') { + mCaptchaRemoveState.value = true; + } } async function requestRender() { @@ -160,32 +188,29 @@ async function requestRender() { 'error-callback': () => callback(undefined), }); } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { - const { default: Widget } = await import('@mcaptcha/vanilla-glue'); - new Widget({ + const { default: Reciever } = await import('@mcaptcha/core-glue'); + mCaptchaReciever = new Reciever({ siteKey: { - instanceUrl: new URL(props.instanceUrl), key: props.sitekey, + instanceUrl: new URL(props.instanceUrl), }, + }, (token: string) => { + callback(token); }); + mCaptchaReciever.listen(); + mCaptchaRemoveState.value = false; } else { - window.setTimeout(requestRender, 1); + window.setTimeout(requestRender, 50); } } function clearWidget() { - if (props.provider === 'mcaptcha') { - const container = window.document.getElementById('mcaptcha__widget-container'); - if (container) { - container.innerHTML = ''; - } - } else { - reset(); - remove(); + reset(); + remove(); - if (captchaEl.value) { - // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止 - captchaEl.value.innerHTML = ''; - } + if (captchaEl.value) { + // レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止 + captchaEl.value.innerHTML = ''; } } diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue index 23bb32c6b9..af89ec8252 100644 --- a/packages/frontend/src/components/MkChannelList.vue +++ b/packages/frontend/src/components/MkChannelList.vue @@ -24,6 +24,6 @@ const props = withDefaults(defineProps<{ noGap?: boolean; extractor?: ExtractorFunction<P, Misskey.entities.Channel>; }>(), { - extractor: (item) => item, + extractor: (item: any) => item as Misskey.entities.Channel, }); </script> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index c54081ad42..e418e729ca 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -94,8 +94,8 @@ const props = withDefaults(defineProps<{ const legendEl = useTemplateRef('legendEl'); -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); +const sum = (...arr: number[][]) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = (arr: number[]) => arr.map((x) => -x); const colors = { blue: '#008FFB', @@ -108,7 +108,7 @@ const colors = { cyan: '#00e0e0', }; const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple]; -const getColor = (i) => { +const getColor = (i: number) => { return colorSets[i % colorSets.length]; }; @@ -142,7 +142,7 @@ const getDate = (ago: number) => { return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); }; -const format = (arr) => { +const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, @@ -371,7 +371,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => { }; }; -const fetchNotesChart = async (type: string): Promise<typeof chartData> => { +const fetchNotesChart = async (type: 'local' | 'remote' | 'combined'): Promise<typeof chartData> => { const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 775964af50..0c856c57eb 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { useInterval } from '@@/js/use-interval.js'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; -import { useInterval } from '@@/js/use-interval.js'; import * as game from '@/utility/clicker-game.js'; import number from '@/filters/number.js'; import { claimAchievement } from '@/utility/achievements.js'; @@ -32,7 +32,7 @@ const cookies = computed(() => saveData.value?.cookies); const cps = ref(0); const prevCookies = ref(0); -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { const x = ev.clientX; const y = ev.clientY; const { dispose } = os.popup(MkPlusOneEffect, { x, y }, { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index bdb2ba6a44..dda5a14716 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -40,7 +40,7 @@ import XCode from '@/components/MkCode.core.vue'; const props = withDefaults(defineProps<{ modelValue: string | null; - lang: string; + lang?: string; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -51,7 +51,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'change', _ev: InputEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter'): void; (ev: 'update:modelValue', value: string): void; @@ -63,15 +63,17 @@ const focused = ref(false); const changed = ref(false); const inputEl = useTemplateRef('inputEl'); -const focus = () => inputEl.value?.focus(); +function focus() { + inputEl.value?.focus(); +} -const onInput = (ev) => { - v.value = ev.target?.value ?? v.value; +function onInput(ev: InputEvent) { + v.value = (inputEl.value?.value) ?? ''; changed.value = true; emit('change', ev); -}; +} -const onKeydown = (ev: KeyboardEvent) => { +function onKeydown(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; emit('keydown', ev); @@ -102,12 +104,12 @@ const onKeydown = (ev: KeyboardEvent) => { }); ev.preventDefault(); } -}; +} -const updated = () => { +function updated() { changed.value = false; emit('update:modelValue', v.value); -}; +} const debouncedUpdated = debounce(1000, updated); diff --git a/packages/frontend/src/components/MkContextMenu.stories.impl.ts b/packages/frontend/src/components/MkContextMenu.stories.impl.ts index 7a5e36131b..fc9fd9bc49 100644 --- a/packages/frontend/src/components/MkContextMenu.stories.impl.ts +++ b/packages/frontend/src/components/MkContextMenu.stories.impl.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; import { userEvent, within } from '@storybook/test'; import MkContextMenu from './MkContextMenu.vue'; +import type { StoryObj } from '@storybook/vue3'; import * as os from '@/os.js'; export const Empty = { render(args) { @@ -25,7 +23,7 @@ export const Empty = { }, }, methods: { - onContextmenu(ev: MouseEvent) { + onContextmenu(ev: PointerEvent) { os.contextMenu(args.items, ev); }, }, diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 9c6397a72c..6678c8fb91 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue'; import MkMenu from './MkMenu.vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/utility/contains.js'; +import { elementContains } from '@/utility/element-contains.js'; import { prefer } from '@/preferences.js'; import * as os from '@/os.js'; const props = defineProps<{ items: MenuItem[]; - ev: MouseEvent; + ev: PointerEvent; }>(); const emit = defineEmits<{ @@ -75,8 +75,8 @@ onBeforeUnmount(() => { window.document.body.removeEventListener('mousedown', onMousedown); }); -function onMousedown(evt: Event) { - if (!contains(rootEl.value, evt.target) && (rootEl.value !== evt.target)) emit('closed'); +function onMousedown(evt: MouseEvent) { + if (!elementContains(rootEl.value, evt.target as Element) && (rootEl.value !== evt.target)) emit('closed'); } </script> diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 6c07eac47a..1fad936d16 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkModalWindow> </template> -<script lang="ts" setup> +<script lang="ts" setup generic="F extends File | Blob"> import { onMounted, useTemplateRef, ref, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; @@ -38,13 +38,13 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - imageFile: File | Blob; + imageFile: F; aspectRatio: number | null; uploadFolder?: string | null; }>(); const emit = defineEmits<{ - (ev: 'ok', cropped: File | Blob): void; + (ev: 'ok', cropped: F): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); @@ -74,8 +74,14 @@ async function ok() { }); const f = await promise; + let finalFile: F; + if (props.imageFile instanceof File) { + finalFile = new File([f], props.imageFile.name, { type: f.type }) as F; + } else { + finalFile = f as F; + } - emit('ok', f); + emit('ok', finalFile); if (dialogEl.value != null) dialogEl.value.close(); } diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 705301a6a6..fb8b38de6d 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -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> @@ -41,13 +41,19 @@ SPDX-License-Identifier: AGPL-3.0-only </MkModal> </template> +<script lang="ts"> +export type Result = string | number | true | null; +export type MkDialogReturnType<T = Result> = { canceled: true, result: undefined } | { canceled: false, result: T }; +</script> + <script lang="ts" setup> import { ref, useTemplateRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { OptionValue } from '@/types/option-value.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; @@ -65,8 +71,6 @@ type Select = { default: OptionValue | null; }; -type Result = string | number | true | null; - const props = withDefaults(defineProps<{ type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; title?: string; @@ -93,7 +97,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void; + (ev: 'done', v: MkDialogReturnType): void; (ev: 'closed'): void; }>(); @@ -131,7 +135,7 @@ function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare - emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result }); + emit('done', { canceled, result } as MkDialogReturnType); modal.value?.close(); } diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue index f72f091383..808a9ae2f8 100644 --- a/packages/frontend/src/components/MkDivider.vue +++ b/packages/frontend/src/components/MkDivider.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only borderWidth ? { borderWidth: borderWidth } : {}, borderColor ? { borderColor: borderColor } : {}, ]" -/> +></div> </template> <script setup lang="ts"> diff --git a/packages/frontend/src/components/MkDraggable.vue b/packages/frontend/src/components/MkDraggable.vue new file mode 100644 index 0000000000..6e2e038f87 --- /dev/null +++ b/packages/frontend/src/components/MkDraggable.vue @@ -0,0 +1,311 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<TransitionGroup + tag="div" + :enterActiveClass="$style.transition_items_enterActive" + :leaveActiveClass="$style.transition_items_leaveActive" + :enterFromClass="$style.transition_items_enterFrom" + :leaveToClass="$style.transition_items_leaveTo" + :moveClass="$style.transition_items_move" + :class="[$style.items, { [$style.dragging]: dragging, [$style.horizontal]: direction === 'horizontal', [$style.vertical]: direction === 'vertical', [$style.withGaps]: withGaps, [$style.canNest]: canNest }]" +> + <slot name="header"></slot> + <div + v-if="modelValue.length === 0" + :class="$style.emptyDropArea" + @dragover.prevent.stop="() => {}" + @dragleave="() => {}" + @drop.prevent.stop="onEmptyDrop($event)" + > + </div> + <div + v-for="(item, i) in modelValue" + :key="`MkDraggableRoot:${item.id}`" + :class="$style.item" + :draggable="!manualDragStart" + @dragstart.stop="onDragstart($event, item)" + > + <div + :class="[$style.forwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'forward' }]" + @dragover.prevent.stop="onDragover($event, item, false)" + @dragleave="onDragleave($event, item)" + @drop.prevent.stop="onDrop($event, item, false)" + ></div> + <div :key="`MkDraggableItem:${item.id}`" style="position: relative; z-index: 0;"> + <slot :item="item" :index="i" :dragStart="(ev) => onDragstart(ev, item)"></slot> + </div> + <div + :class="[$style.backwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'backward' }]" + @dragover.prevent.stop="onDragover($event, item, true)" + @dragleave="onDragleave($event, item)" + @drop.prevent.stop="onDrop($event, item, true)" + ></div> + </div> + <slot name="footer"></slot> +</TransitionGroup> +</template> + +<script lang="ts"> +import { ref } from 'vue'; + +// 別々のコンポーネントインスタンス間でD&Dを融通するためにグローバルに状態を持っておく必要がある +const dragging = ref(false); +let dropCallback: ((targetInstanceId: string) => void) | null = null; +</script> + +<script lang="ts" setup generic="T extends { id: string; }"> +import { nextTick } from 'vue'; +import { getDragData, setDragData } from '@/drag-and-drop.js'; +import { genId } from '@/utility/id.js'; + +const slots = defineSlots<{ + default(props: { item: T; index: number; dragStart: (ev: DragEvent) => void }): any; + header(): any; + footer(): any; +}>(); + +const props = withDefaults(defineProps<{ + modelValue: T[]; + direction: 'horizontal' | 'vertical'; + group?: string | null; + manualDragStart?: boolean; + withGaps?: boolean; + canNest?: boolean; +}>(), { + group: null, + manualDragStart: false, + withGaps: false, + canNest: false, +}); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: T[]): void; +}>(); + +const dropReadyArea = ref<[T['id'] | null, 'forward' | 'backward' | null]>([null, null]); +const instanceId = genId(); +const group = props.group ?? instanceId; + +function onDragstart(ev: DragEvent, item: T) { + if (ev.dataTransfer == null) return; + ev.dataTransfer.effectAllowed = 'move'; + setDragData(ev, 'MkDraggable', { item, instanceId, group }); + + const target = ev.target as HTMLElement; + target.addEventListener('dragend', (ev) => { + dragging.value = false; + dropReadyArea.value = [null, null]; + }, { once: true }); + + dropCallback = (targetInstanceId) => { + if (targetInstanceId === instanceId) return; + const newValue = props.modelValue.filter(x => x.id !== item.id); + emit('update:modelValue', newValue); + }; + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + // SEE: https://issues.chromium.org/issues/41150279 + window.setTimeout(() => { + dragging.value = true; + }, 10); +} + +function onDragover(ev: DragEvent, item: T, backward: boolean) { + nextTick(() => { + dropReadyArea.value = [item.id, backward ? 'backward' : 'forward']; + }); +} + +function onDragleave(ev: DragEvent, item: T) { + dropReadyArea.value = [null, null]; +} + +function onDrop(ev: DragEvent, item: T, backward: boolean) { + const dragged = getDragData(ev, 'MkDraggable'); + dropReadyArea.value = [null, null]; + if (dragged == null || dragged.group !== group || dragged.item.id === item.id) return; + dropCallback?.(instanceId); + + const fromIndex = props.modelValue.findIndex(x => x.id === dragged.item.id); + let toIndex = props.modelValue.findIndex(x => x.id === item.id); + + const newValue = [...props.modelValue]; + if (fromIndex > -1) newValue.splice(fromIndex, 1); + toIndex = newValue.findIndex(x => x.id === item.id); + if (backward) toIndex += 1; + newValue.splice(toIndex, 0, dragged.item as T); + + emit('update:modelValue', newValue); +} + +function onEmptyDrop(ev: DragEvent) { + const dragged = getDragData(ev, 'MkDraggable'); + if (dragged == null) return; + dropCallback?.(instanceId); + + emit('update:modelValue', [dragged.item as T]); +} +</script> + +<style lang="scss" module> +.transition_items_move, +.transition_items_enterActive, +.transition_items_leaveActive { + transition: all 0.15s ease; +} +.transition_items_enterFrom, +.transition_items_leaveTo { + opacity: 0; +} +.transition_items_leaveActive { + position: absolute; +} + +.items { + display: flex; + align-items: center; + justify-content: left; + flex-wrap: wrap; +} + +.items.horizontal { + flex-direction: row; +} +.items.vertical { + flex-direction: column; +} + +.item { + position: relative; +} + +.items.vertical .item { + width: 100%; +} + +.items.horizontal.withGaps { + row-gap: var(--MI-margin); +} + +.items.horizontal.withGaps .item { + padding-left: calc(var(--MI-margin) / 2); + padding-right: calc(var(--MI-margin) / 2); +} + +.items.vertical.withGaps .item { + padding-top: calc(var(--MI-margin) / 2); + padding-bottom: calc(var(--MI-margin) / 2); +} + +.forwardArea, .backwardArea { + position: absolute; + z-index: 1; + pointer-events: none; +} + +.items.dragging { + .forwardArea, .backwardArea { + pointer-events: auto; + } +} + +.items.horizontal { + .forwardArea { + top: 0; + left: 0; + width: 50%; + height: 100%; + } + + .backwardArea { + top: 0; + right: 0; + width: 50%; + height: 100%; + } +} + +.items.vertical { + .forwardArea { + top: 0; + left: 0; + width: 100%; + height: 50%; + } + + .backwardArea { + bottom: 0; + left: 0; + width: 100%; + height: 50%; + } +} + +.items.canNest.horizontal { + .forwardArea, .backwardArea { + width: 30px; + } +} + +.items.canNest.vertical { + .forwardArea, .backwardArea { + height: 30px; + } +} + +.dropReady::before { + content: ''; + position: absolute; + z-index: 99999; + background: var(--MI_THEME-accent); + border-radius: 999px; + pointer-events: none; +} + +.items.horizontal { + .forwardArea.dropReady::before { + top: 0; + left: -1px; + width: 2px; + height: 100%; + } + + .backwardArea.dropReady::before { + top: 0; + right: -1px; + width: 2px; + height: 100%; + } +} + +.items.vertical { + .forwardArea.dropReady::before { + top: -1px; + left: 0; + width: 100%; + height: 2px; + } + + .backwardArea.dropReady::before { + bottom: -1px; + left: 0; + width: 100%; + height: 2px; + } +} + +.items.horizontal .emptyDropArea { + width: 40px; + height: 40px; +} + +.items.vertical .emptyDropArea { + width: 100%; + height: 50px; +} +</style> diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 0eca85b3a6..e2858084c0 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -64,7 +64,7 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index d7dd12408c..6d93dfc0d4 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -57,7 +57,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void; - (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder); + (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder): void; (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -231,17 +231,17 @@ function rename() { } function move() { - selectDriveFolder(null).then(folder => { - if (folder[0] && folder[0].id === props.folder.id) return; + selectDriveFolder(null).then(({ canceled, folders }) => { + if (canceled || (folders[0] && folders[0].id === props.folder.id)) return; misskeyApi('drive/folders/update', { folderId: props.folder.id, - parentId: folder[0] ? folder[0].id : null, + parentId: folders[0] ? folders[0].id : null, }).then(() => { globalEvents.emit('driveFoldersUpdated', [{ ...props.folder, - parentId: folder[0] ? folder[0].id : null, - parent: folder[0] ?? null, + parentId: folders[0] ? folders[0].id : null, + parent: folders[0] ?? null, }]); }); }); @@ -277,7 +277,7 @@ function setAsUploadFolder() { prefer.commit('uploadFolder', props.folder.id); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { let menu: MenuItem[]; menu = [{ text: i18n.ts.openInWindow, diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index d8c949d8eb..2961bc5032 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -69,7 +69,6 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="(f, i) in foldersPaginator.items.value" :key="f.id" v-anim="i" - :class="$style.folder" :folder="f" :selectMode="select === 'folder'" :isSelected="selectedFolders.some(x => x.id === f.id)" @@ -102,7 +101,6 @@ SPDX-License-Identifier: AGPL-3.0-only > <XFile v-for="file in item.items" :key="file.id" - :class="$style.file" :file="file" :folder="folder" :isSelected="selectedFiles.some(x => x.id === file.id)" @@ -125,7 +123,6 @@ SPDX-License-Identifier: AGPL-3.0-only > <XFile v-for="file in filesPaginator.items.value" :key="file.id" - :class="$style.file" :file="file" :folder="folder" :isSelected="selectedFiles.some(x => x.id === file.id)" @@ -135,7 +132,16 @@ SPDX-License-Identifier: AGPL-3.0-only /> </TransitionGroup> - <MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> + <MkButton + v-show="canFetchFiles" + v-appear="shouldEnableInfiniteScroll ? fetchMoreFiles : null" + :class="$style.loadMore" + primary + rounded + @click="fetchMoreFiles" + > + {{ i18n.ts.loadMore }} + </MkButton> <div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty"> <div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div> @@ -182,10 +188,12 @@ const props = withDefaults(defineProps<{ type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; + forceDisableInfiniteScroll?: boolean; }>(), { initialFolder: null, multiple: false, select: null, + forceDisableInfiniteScroll: false, }); const emit = defineEmits<{ @@ -194,6 +202,10 @@ const emit = defineEmits<{ (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; }>(); +const shouldEnableInfiniteScroll = computed(() => { + return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll; +}); + const folder = ref<Misskey.entities.DriveFolder | null>(null); const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); @@ -228,10 +240,9 @@ const filesPaginator = markRaw(new Paginator('drive/files', { params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない folderId: folder.value ? folder.value.id : null, type: props.type, - sort: sortModeSelect.value, + sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value, }), })); - const foldersPaginator = markRaw(new Paginator('drive/folders', { limit: 30, canFetchDetection: 'limit', @@ -240,6 +251,16 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', { }), })); +const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value)); + +async function fetchMoreFiles() { + if (filesPaginator.order.value === 'oldest') { + filesPaginator.fetchNewer(); + } else { + filesPaginator.fetchOlder(); + } +} + const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month'); const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value)); @@ -250,10 +271,10 @@ watch(sortModeSelect, () => { async function initialize() { fetching.value = true; - await Promise.all([ - foldersPaginator.init(), - filesPaginator.init(), - ]); + await foldersPaginator.reload(); + filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older'; + filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest'; + await filesPaginator.reload(); fetching.value = false; } @@ -472,7 +493,7 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -function onFileClick(ev: MouseEvent, file: Misskey.entities.DriveFile) { +function onFileClick(ev: PointerEvent, file: Misskey.entities.DriveFile) { if (ev.shiftKey) { isEditMode.value = true; } @@ -544,7 +565,7 @@ function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder folder.value = folderToMove; hierarchyFolders.value = []; - const dive = folderToDive => { + const dive = (folderToDive: Misskey.entities.DriveFolder) => { hierarchyFolders.value.unshift(folderToDive); if (folderToDive.parent) dive(folderToDive.parent); }; @@ -558,17 +579,19 @@ function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder async function moveFilesBulk() { if (selectedFiles.value.length === 0) return; - const toFolder = await selectDriveFolder(folder.value ? folder.value.id : null); + const { canceled, folders } = await selectDriveFolder(folder.value ? folder.value.id : null); + + if (canceled) return; await os.apiWithDialog('drive/files/move-bulk', { fileIds: selectedFiles.value.map(f => f.id), - folderId: toFolder[0] ? toFolder[0].id : null, + folderId: folders[0] ? folders[0].id : null, }); globalEvents.emit('driveFilesUpdated', selectedFiles.value.map(x => ({ ...x, - folderId: toFolder[0] ? toFolder[0].id : null, - folder: toFolder[0] ?? null, + folderId: folders[0] ? folders[0].id : null, + folder: folders[0] ?? null, }))); } @@ -668,11 +691,11 @@ function getMenu() { return menu; } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getMenu(), ev); } diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 4f16149caa..9002669378 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only :enterFromClass="$style.transition_x_enterFrom" :leaveToClass="$style.transition_x_leaveTo" > - <div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot"> - <div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]"> - <MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/> + <MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading"> + <template #preview> <div :class="$style.embedCodeGenPreviewWrapper"> <div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div> <div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert> @@ -43,27 +42,29 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - </div> - <div :class="$style.embedCodeGenSettings" class="_gaps"> - <MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0"> - <template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template> - <template #suffix>px</template> - <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> - </MkInput> - <MkSelect v-model="colorMode" :items="colorModeDef"> - <template #label>{{ i18n.ts.theme }}</template> - </MkSelect> - <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> - <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> - <MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch> - <MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo> - <MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo> - <div class="_buttons"> - <MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton> - <MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton> + </template> + <template #controls> + <div class="_spacer _gaps"> + <MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0"> + <template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template> + <template #suffix>px</template> + <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> + </MkInput> + <MkSelect v-model="colorMode" :items="colorModeDef"> + <template #label>{{ i18n.ts.theme }}</template> + </MkSelect> + <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> + <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> + <MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch> + <MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo> + <MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo> + <div class="_buttons"> + <MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton> + <MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton> + </div> </div> - </div> - </div> + </template> + </MkPreviewWithControls> <div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot"> <div :class="$style.embedCodeGenResultWrapper" class="_gaps"> <div class="_gaps_s"> @@ -89,18 +90,17 @@ import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; -import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; -import { prefer } from '@/preferences.js'; const emit = defineEmits<{ (ev: 'ok'): void; @@ -302,29 +302,6 @@ onUnmounted(() => { height: 100%; } -.embedCodeGenInputRoot { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.embedCodeGenPreviewRoot { - position: relative; - cursor: not-allowed; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .embedCodeGenPreviewWrapper { display: flex; flex-direction: column; @@ -372,11 +349,6 @@ onUnmounted(() => { color-scheme: light dark; } -.embedCodeGenSettings { - padding: 24px; - overflow-y: scroll; -} - .embedCodeGenResultRoot { box-sizing: border-box; padding: 24px; @@ -417,11 +389,4 @@ onUnmounted(() => { .embedCodeGenResultButtons { margin: 0 auto; } - -@container (max-width: 800px) { - .embedCodeGenInputRoot { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index ef515e471f..3ee32710e5 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; -import type { Ref } from 'vue'; import { getEmojiName } from '@@/js/emojilist.js'; +import type { Ref } from 'vue'; import type { CustomEmojiFolderTree } from '@@/js/emojilist.js'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; @@ -78,7 +78,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'chosen', v: string, event: MouseEvent): void; + (ev: 'chosen', v: string, event: PointerEvent): void; }>(); const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props.emojis.value); @@ -86,13 +86,13 @@ const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props const shown = ref(!!props.initialShown); /** @see MkEmojiPicker.vue */ -function computeButtonTitle(ev: MouseEvent): void { +function computeButtonTitle(ev: PointerEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; elm.title = getEmojiName(emoji); } -function nestedChosen(emoji: string, ev: MouseEvent) { +function nestedChosen(emoji: string, ev: PointerEvent) { emit('chosen', emoji, ev); } </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 33e9137c2f..bf0f9d0130 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -412,13 +412,13 @@ function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeE } /** @see MkEmojiPicker.section.vue */ -function computeButtonTitle(ev: MouseEvent): void { +function computeButtonTitle(ev: PointerEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; elm.title = getEmojiName(emoji); } -function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { +function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: PointerEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index c9d18ee731..3f7eb9bccd 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #key>{{ i18n.ts.permission }}</template> <template #value> <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> - <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] ?? permission }}</li> </ul> <template v-else>{{ i18n.ts.none }}</template> </template> @@ -91,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> - <slot name="additionalInfo"/> + <slot name="additionalInfo"></slot> <div class="_buttonsCenter"> <MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> @@ -101,6 +101,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> +import * as Misskey from 'misskey-js'; + export type Extension = { type: 'plugin'; raw: string; @@ -109,7 +111,7 @@ export type Extension = { version: string; author: string; description?: string; - permissions?: string[]; + permissions?: (typeof Misskey.permissions)[number][]; config?: Record<string, unknown>; }; } | { @@ -125,7 +127,6 @@ export type Extension = { <script lang="ts" setup> import { computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue index a998c810f0..59dac46162 100644 --- a/packages/frontend/src/components/MkFileListForAdmin.vue +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> <MkPagination v-slot="{ items }" :paginator="paginator"> - <div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]"> + <div + :class="{ + [$style.grid]: viewMode === 'grid', + [$style.list]: viewMode === 'list', + '_gaps_s': viewMode === 'list', + }" + > <MkA v-for="file in items" :key="file.id" diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 94fdf6da36..864f53d09c 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -169,7 +169,7 @@ function afterLeave(el: Element) { let pageId = pageFolderTeleportCount.value; pageFolderTeleportCount.value += 1000; -async function toggle(ev: MouseEvent) { +async function toggle(ev: PointerEvent) { if (asPage && !opened.value) { pageId++; const { dispose } = await popup(MkFolderPage, { diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ba21fe82e4..72a24411c1 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -81,7 +81,13 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { } async function onClick() { - pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } }); + const isLoggedIn = await pleaseLogin({ + openOnRemote: { + type: 'web', + path: `/@${props.user.username}@${props.user.host ?? host}`, + }, + }); + if (!isLoggedIn) return; wait.value = true; diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkForm.file.vue index 182ff3ccf5..d233467e8b 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkForm.file.vue @@ -50,7 +50,7 @@ if (props.fileId) { }); } -function selectButton(ev: MouseEvent) { +function selectButton(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/components/MkForm.vue b/packages/frontend/src/components/MkForm.vue new file mode 100644 index 0000000000..f2360e8cdd --- /dev/null +++ b/packages/frontend/src/components/MkForm.vue @@ -0,0 +1,125 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div v-if="Object.values(form).filter(item => typeof item.hidden !== 'boolean' || item.hidden === true).length > 0" class="_gaps_m"> + <template v-for="v, k in form"> + <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> + <MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkInput> + <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkInput> + <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave" @savingStateChange="(changed, invalid) => onSavingStateChange(k, changed, invalid)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkTextarea> + <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]"> + <span v-text="v.label || k"></span> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkSwitch> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + </MkSelect> + <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]" :options="getRadioOptionsDef(v)"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + </MkRadios> + <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter"> + <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> + <template v-if="v.description" #caption>{{ v.description }}</template> + </MkRange> + <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> + <span v-text="v.content || k"></span> + </MkButton> + <XFile + v-else-if="v.type === 'drive-file'" + :fileId="v.defaultFileId" + :validate="async f => !v.validate || await v.validate(f)" + @update="f => values[k] = f" + /> + </template> +</div> +<MkResult v-else type="empty" :text="i18n.ts.nothingToConfigure"/> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import XFile from '@/components/MkForm.file.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkRange from '@/components/MkRange.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import { i18n } from '@/i18n.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { MkRadiosOption } from '@/components/MkRadios.vue'; +import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; + +const props = defineProps<{ + form: Form; +}>(); + +const emit = defineEmits<{ + (ev: 'canSaveStateChange', canSave: boolean): void; +}>(); + +// TODO: ジェネリックにしたい +const values = defineModel<Record<string, any>>({ required: true }); + +// 保存可能状態の管理 +const inputSavingStates = ref<Record<string, { changed: boolean; invalid: boolean }>>({}); + +function onSavingStateChange(key: string, changed: boolean, invalid: boolean) { + inputSavingStates.value[key] = { changed, invalid }; +} + +const canSave = computed(() => { + for (const key in inputSavingStates.value) { + const state = inputSavingStates.value[key]; + if ( + ('manualSave' in props.form[key] && props.form[key].manualSave && state.changed) || + state.invalid + ) { + return false; + } + if ('required' in props.form[key] && props.form[key].required) { + const val = values.value[key]; + if (val === null || val === undefined || val === '') { + return false; + } + } + } + return true; +}); + +watch(canSave, (newCanSave) => { + emit('canSaveStateChange', newCanSave); +}, { immediate: true }); + +function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { + return def.enum.map((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); +} + +function getRadioOptionsDef(def: RadioFormItem): MkRadiosOption[] { + return def.options.map<MkRadiosOption>((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); +} +</script> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 142ccb12a3..091721b40b 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only :width="450" :canClose="false" :withOkButton="true" - :okButtonDisabled="false" + :okButtonDisabled="!canSave" @click="cancel()" @ok="ok()" @close="cancel()" @@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;"> - <div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m"> - <template v-for="(v, k) in Object.fromEntries(Object.entries(form))"> - <template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template> - <MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkInput> - <MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkInput> - <MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkTextarea> - <MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]"> - <span v-text="v.label || k"></span> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkSwitch> - <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - </MkSelect> - <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option> - </MkRadios> - <MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter"> - <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <template v-if="v.description" #caption>{{ v.description }}</template> - </MkRange> - <MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)"> - <span v-text="v.content || k"></span> - </MkButton> - <XFile - v-else-if="v.type === 'drive-file'" - :fileId="v.defaultFileId" - :validate="async f => !v.validate || await v.validate(f)" - @update="f => values[k] = f" - /> - </template> - </div> - <MkResult v-else type="empty"/> + <MkForm v-model="values" :form="form" @canSaveStateChange="onCanSaveStateChanged"/> </div> </MkModalWindow> </template> <script lang="ts" setup> -import { reactive, useTemplateRef } from 'vue'; -import MkInput from './MkInput.vue'; -import MkTextarea from './MkTextarea.vue'; -import MkSwitch from './MkSwitch.vue'; -import MkSelect from './MkSelect.vue'; -import MkRange from './MkRange.vue'; -import MkButton from './MkButton.vue'; -import MkRadios from './MkRadios.vue'; -import XFile from './MkFormDialog.file.vue'; -import type { MkSelectItem } from '@/components/MkSelect.vue'; -import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; +import { ref, useTemplateRef } from 'vue'; +import type { Form } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { i18n } from '@/i18n.js'; +import MkForm from '@/components/MkForm.vue'; const props = defineProps<{ title: string; @@ -96,19 +46,30 @@ const emit = defineEmits<{ }>(); const dialog = useTemplateRef('dialog'); -const values = reactive({}); -for (const item in props.form) { - if ('default' in props.form[item]) { - values[item] = props.form[item].default ?? null; - } else { - values[item] = null; +const values = ref((() => { + const obj: Record<string, any> = {}; + for (const item in props.form) { + if ('default' in props.form[item]) { + obj[item] = props.form[item].default ?? null; + } else { + obj[item] = null; + } } + return obj; +})()); + +const canSave = ref(true); + +function onCanSaveStateChanged(newCanSave: boolean) { + canSave.value = newCanSave; } function ok() { + if (!canSave.value) return; + emit('done', { - result: values, + result: values.value, }); dialog.value?.close(); } @@ -119,18 +80,4 @@ function cancel() { }); dialog.value?.close(); } - -function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { - return def.enum.map((v) => { - if (typeof v === 'string') { - return { value: v, label: v }; - } else { - return { value: v.value, label: v.label }; - } - }); -} - -function getRadioKey(e: RadioFormItem['options'][number]) { - return typeof e.value === 'string' ? e.value : JSON.stringify(e.value); -} </script> diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index abbf86004b..03780bf3ba 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -125,8 +125,7 @@ async function renderChart() { data: format(values) as any, borderWidth: 0, borderRadius: 3, - backgroundColor(c) { - // @ts-expect-error TS(2339) + backgroundColor(c: any) { const value = c.dataset.data[c.dataIndex].v as number; let a = (value - min) / max; if (value !== 0) { // 0でない限りは完全に不可視にはしない @@ -195,7 +194,7 @@ async function renderChart() { font: { size: 9, }, - callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], + callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value as any], }, }, }, diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue index 3d7801f925..85e86e3a77 100644 --- a/packages/frontend/src/components/MkImageEffectorDialog.vue +++ b/packages/frontend/src/components/MkImageEffectorDialog.vue @@ -16,37 +16,36 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template> - <div :class="$style.root"> - <div :class="$style.container"> - <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> - <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas> - <div :class="$style.previewContainer"> - <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> - <div class="_acrylic" :class="$style.editControls"> - <button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button> - </div> - <div class="_acrylic" :class="$style.previewControls"> - <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> - <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> - </div> + <MkPreviewWithControls> + <template #preview> + <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div class="_acrylic" :class="$style.editControls"> + <button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button> + </div> + <div class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> + <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> </div> </div> - <div :class="$style.controls"> - <div class="_spacer _gaps"> - <XLayer - v-for="(layer, i) in layers" - :key="layer.id" - v-model:layer="layers[i]" - @del="onLayerDelete(layer)" - @swapUp="onLayerSwapUp(layer)" - @swapDown="onLayerSwapDown(layer)" - ></XLayer> + </template> - <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> - </div> + <template #controls> + <div class="_spacer _gaps"> + <XLayer + v-for="(layer, i) in layers" + :key="layer.id" + v-model:layer="layers[i]" + @del="onLayerDelete(layer)" + @swapUp="onLayerSwapUp(layer)" + @swapDown="onLayerSwapDown(layer)" + ></XLayer> + + <MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton> </div> - </div> - </div> + </template> + </MkPreviewWithControls> </MkModalWindow> </template> @@ -56,15 +55,12 @@ import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector. import { i18n } from '@/i18n.js'; import { ImageEffector } from '@/utility/image-effector/ImageEffector.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import MkSelect from '@/components/MkSelect.vue'; +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; import XLayer from '@/components/MkImageEffectorDialog.Layer.vue'; import * as os from '@/os.js'; -import { deepClone } from '@/utility/clone.js'; import { FXS } from '@/utility/image-effector/fxs.js'; import { genId } from '@/utility/id.js'; -import { prefer } from '@/preferences.js'; const props = defineProps<{ image: File; @@ -99,7 +95,7 @@ watch(layers, async () => { } }, { deep: true }); -function addEffect(ev: MouseEvent) { +function addEffect(ev: PointerEvent) { os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({ text: fx.uiDefinition.name, action: () => { @@ -223,7 +219,7 @@ watch(enabled, () => { const penMode = ref<'fill' | 'blur' | 'pixelate' | null>(null); -function showPenMenu(ev: MouseEvent) { +function showPenMenu(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts._imageEffector._fxs.fill, action: () => { @@ -299,7 +295,7 @@ function onImagePointerdown(ev: PointerEvent) { scaleX: 0.1, scaleY: 0.1, angle: 0, - radius: 3, + radius: 10, ellipse: false, }, }); @@ -367,33 +363,6 @@ function onImagePointerdown(ev: PointerEvent) { </script> <style module> -.root { - container-type: inline-size; - height: 100%; -} - -.container { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.preview { - position: relative; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .previewContainer { display: flex; flex-direction: column; @@ -442,16 +411,6 @@ function onImagePointerdown(ev: PointerEvent) { } } -.previewSpinner { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - user-select: none; - -webkit-user-drag: none; -} - .previewCanvas { position: absolute; top: 0; @@ -467,15 +426,4 @@ function onImagePointerdown(ev: PointerEvent) { object-fit: contain; touch-action: none; } - -.controls { - overflow-y: scroll; -} - -@container (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkImageEffectorFxForm.vue b/packages/frontend/src/components/MkImageEffectorFxForm.vue index e581b1f743..6bbec6c868 100644 --- a/packages/frontend/src/components/MkImageEffectorFxForm.vue +++ b/packages/frontend/src/components/MkImageEffectorFxForm.vue @@ -28,13 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ v.label ?? k }}</template> <template v-if="v.caption != null" #caption>{{ v.caption }}</template> </MkRange> - <MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]"> + <MkRadios v-else-if="v.type === 'number:enum'" v-model="params[k]" :options="v.enum"> <template #label>{{ v.label ?? k }}</template> <template v-if="v.caption != null" #caption>{{ v.caption }}</template> - <option v-for="item in v.enum" :value="item.value"> - <i v-if="item.icon" :class="item.icon"></i> - <template v-else>{{ item.label }}</template> - </option> </MkRadios> <div v-else-if="v.type === 'seed'"> <MkRange v-model="params[k]" continuousUpdate type="number" :min="0" :max="10000" :step="1"> @@ -48,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </div> <div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure"> - {{ i18n.ts._imageEffector.nothingToConfigure }} + {{ i18n.ts.nothingToConfigure }} </div> </div> </template> @@ -68,7 +64,7 @@ defineProps<{ const params = defineModel<Record<string, any>>({ required: true }); function getHex(c: ImageEffectorRGB) { - return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; + return `#${c.map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('')}`; } function getRgb(hex: string | number): ImageEffectorRGB | null { diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue index 2a91c85952..1a37a32a96 100644 --- a/packages/frontend/src/components/MkImageFrameEditorDialog.vue +++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue @@ -16,140 +16,139 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template> - <div :class="$style.root"> - <div :class="$style.container"> - <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> - <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> - <div :class="$style.previewContainer"> - <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> - <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> - <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> - </div> + <MkPreviewWithControls> + <template #preview> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> </div> </div> - <div :class="$style.controls"> - <div class="_spacer _gaps"> - <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template> - </MkRange> + </template> - <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }"> - <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template> - </MkInput> + <template #controls> + <div class="_spacer _gaps"> + <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template> + </MkRange> - <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }"> - <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template> - </MkInput> + <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }"> + <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template> + </MkInput> - <MkSelect - v-model="params.font" :items="[ - { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' }, - { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' }, - ]" - > - <template #label>{{ i18n.ts._imageFrameEditor.font }}</template> - </MkSelect> + <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }"> + <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template> + </MkInput> - <MkFolder :defaultOpen="params.labelTop.enabled"> - <template #label>{{ i18n.ts._imageFrameEditor.header }}</template> + <MkSelect + v-model="params.font" :items="[ + { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' }, + { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' }, + ]" + > + <template #label>{{ i18n.ts._imageFrameEditor.font }}</template> + </MkSelect> - <div class="_gaps"> - <MkSwitch v-model="params.labelTop.enabled"> - <template #label>{{ i18n.ts.show }}</template> - </MkSwitch> + <MkFolder :defaultOpen="params.labelTop.enabled"> + <template #label>{{ i18n.ts._imageFrameEditor.header }}</template> - <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> - </MkRange> + <div class="_gaps"> + <MkSwitch v-model="params.labelTop.enabled"> + <template #label>{{ i18n.ts.show }}</template> + </MkSwitch> - <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> - </MkRange> + <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> + </MkRange> - <MkSwitch v-model="params.labelTop.centered"> - <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> - </MkSwitch> + <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> + </MkRange> - <MkInput v-model="params.labelTop.textBig"> - <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> - </MkInput> + <MkSwitch v-model="params.labelTop.centered"> + <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> + </MkSwitch> - <MkTextarea v-model="params.labelTop.textSmall"> - <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> - </MkTextarea> + <MkInput v-model="params.labelTop.textBig"> + <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> + </MkInput> - <MkSwitch v-model="params.labelTop.withQrCode"> - <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> - </MkSwitch> - </div> - </MkFolder> + <MkTextarea v-model="params.labelTop.textSmall"> + <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> + </MkTextarea> - <MkFolder :defaultOpen="params.labelBottom.enabled"> - <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template> + <MkSwitch v-model="params.labelTop.withQrCode"> + <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> + </MkSwitch> + </div> + </MkFolder> - <div class="_gaps"> - <MkSwitch v-model="params.labelBottom.enabled"> - <template #label>{{ i18n.ts.show }}</template> - </MkSwitch> + <MkFolder :defaultOpen="params.labelBottom.enabled"> + <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template> - <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> - </MkRange> + <div class="_gaps"> + <MkSwitch v-model="params.labelBottom.enabled"> + <template #label>{{ i18n.ts.show }}</template> + </MkSwitch> - <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> - <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> - </MkRange> + <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template> + </MkRange> - <MkSwitch v-model="params.labelBottom.centered"> - <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> - </MkSwitch> + <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true"> + <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template> + </MkRange> - <MkInput v-model="params.labelBottom.textBig"> - <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> - </MkInput> + <MkSwitch v-model="params.labelBottom.centered"> + <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template> + </MkSwitch> - <MkTextarea v-model="params.labelBottom.textSmall"> - <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> - </MkTextarea> + <MkInput v-model="params.labelBottom.textBig"> + <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template> + </MkInput> - <MkSwitch v-model="params.labelBottom.withQrCode"> - <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> - </MkSwitch> - </div> - </MkFolder> + <MkTextarea v-model="params.labelBottom.textSmall"> + <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template> + </MkTextarea> - <MkInfo> - <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div> - <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div> - <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div> - <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div> - <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div> - <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div> - <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div> - <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div> - <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div> - <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div> - <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div> - <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div> - <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div> - <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div> - <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div> - <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div> - <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div> - <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div> - <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div> - <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div> - </MkInfo> - </div> + <MkSwitch v-model="params.labelBottom.withQrCode"> + <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkInfo> + <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div> + <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div> + <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div> + <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div> + <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div> + <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div> + <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div> + <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div> + <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div> + <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div> + <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div> + <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div> + <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div> + <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div> + <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div> + <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div> + <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div> + <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div> + <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div> + <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div> + </MkInfo> </div> - </div> - </div> + </template> + </MkPreviewWithControls> </MkModalWindow> </template> @@ -157,12 +156,12 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; import ExifReader from 'exifreader'; import { throttle } from 'throttle-debounce'; +import MkPreviewWithControls from './MkPreviewWithControls.vue'; import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkSelect from '@/components/MkSelect.vue'; -import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRange from '@/components/MkRange.vue'; @@ -173,8 +172,6 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; -import { useMkSelect } from '@/composables/use-mkselect.js'; -import { prefer } from '@/preferences.js'; const $i = ensureSignin(); @@ -393,7 +390,7 @@ async function save() { } function getHex(c: [number, number, number]) { - return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`; + return `#${c.map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('')}`; } function getRgb(hex: string | number): [number, number, number] | null { @@ -412,33 +409,6 @@ function getRgb(hex: string | number): [number, number, number] | null { </script> <style module> -.root { - container-type: inline-size; - height: 100%; -} - -.container { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.preview { - position: relative; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .previewContainer { display: flex; flex-direction: column; @@ -495,15 +465,4 @@ function getRgb(hex: string | number): [number, number, number] | null { box-sizing: border-box; object-fit: contain; } - -.controls { - overflow-y: scroll; -} - -@container (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue index e17a1651cf..530b6c45db 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.vue +++ b/packages/frontend/src/components/MkImgPreviewDialog.vue @@ -11,10 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only @close="close" @esc="close" @click="close" + @closed="emit('closed')" > <template #header>{{ file.name }}</template> <div :class="$style.container"> - <img :src="file.url" :alt="file.comment ?? file.name" :class="$style.img"/> + <img :src="file.url" :alt="file.comment || file.name" :class="$style.img"/> </div> </MkModalWindow> </template> @@ -27,6 +28,10 @@ defineProps<{ file: Misskey.entities.DriveFile; }>(); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const modal = ref<typeof MkModalWindow | null>(null); function close() { diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 983a0932c3..a61836e101 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -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" diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 7f052dff94..aebeefe165 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -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> @@ -88,10 +88,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'change', _ev: InputEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter', _ev: KeyboardEvent): void; (ev: 'update:modelValue', value: ModelValueType<T>): void; + (ev: 'savingStateChange', saved: boolean, invalid: boolean): void; }>(); const { modelValue } = toRefs(props); @@ -111,10 +112,9 @@ const height = let autocompleteWorker: Autocomplete | null = null; const focus = () => inputEl.value?.focus(); -const onInput = (event: Event) => { - const ev = event as KeyboardEvent; +const onInput = (event: InputEvent) => { changed.value = true; - emit('change', ev); + emit('change', event); }; const onKeydown = (ev: KeyboardEvent) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; @@ -153,6 +153,10 @@ watch(v, () => { invalid.value = inputEl.value?.validity.badInput ?? true; }); +watch([changed, invalid], ([newChanged, newInvalid]) => { + emit('savingStateChange', newChanged, newInvalid); +}, { immediate: true }); + // このコンポーネントが作成された時、非表示状態である場合がある // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する useInterval(() => { diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index 7902151921..130a0e9986 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -33,7 +33,7 @@ misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, spa chartValues.value = res.requests.received; }); -function getInstanceIcon(instance): string { +function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; } </script> diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 13048a2e1b..368fa5be27 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -57,10 +57,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import MkSelect from '@/components/MkSelect.vue'; import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue'; -import MkChart from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkChart from '@/components/MkChart.vue'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { $i } from '@/i.js'; import * as os from '@/os.js'; @@ -172,7 +172,14 @@ const { handler: externalTooltipHandler2 } = useChartTooltip({ position: 'middle', }); -function createDoughnut(chartEl, tooltip, data) { +type ChartData = { + name: string, + color: string, + value: number, + onClick?: () => void, +}[]; + +function createDoughnut(chartEl: HTMLCanvasElement, tooltip: ReturnType<typeof useChartTooltip>['handler'], data: ChartData) { const chartInstance = new Chart(chartEl, { type: 'doughnut', data: { @@ -198,8 +205,8 @@ function createDoughnut(chartEl, tooltip, data) { onClick: (ev) => { if (ev.native == null) return; const hit = chartInstance.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; - if (hit && data[hit.index].onClick) { - data[hit.index].onClick(); + if (hit != null) { + data[hit.index].onClick?.(); } }, plugins: { @@ -223,16 +230,9 @@ function createDoughnut(chartEl, tooltip, data) { onMounted(() => { misskeyApiGet('federation/stats', { limit: 30 }).then(fedStats => { - type ChartData = { - name: string, - color: string | null, - value: number, - onClick?: () => void, - }[]; - const subs: ChartData = fedStats.topSubInstances.map(x => ({ name: x.host, - color: x.themeColor, + color: x.themeColor ?? '#888888', value: x.followersCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); @@ -245,11 +245,11 @@ onMounted(() => { value: fedStats.otherFollowersCount, }); - createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); + if (subDoughnutEl.value != null) createDoughnut(subDoughnutEl.value, externalTooltipHandler1, subs); const pubs: ChartData = fedStats.topPubInstances.map(x => ({ name: x.host, - color: x.themeColor, + color: x.themeColor ?? '#888888', value: x.followingCount, onClick: () => { os.pageWindow(`/instance-info/${x.host}`); @@ -262,7 +262,7 @@ onMounted(() => { value: fedStats.otherFollowingCount, }); - createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); + if (pubDoughnutEl.value != null) createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, pubs); }); }); </script> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index d8725ade0b..0c73df4e2d 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> - <button v-if="item.action" v-click-anime class="_button item" @click="$event => { item.action($event); close(); }"> + <button v-if="item.action != null" v-click-anime class="_button item" @click="$event => { item.action!($event); close(); }"> <i class="icon" :class="item.icon"></i> <div class="text">{{ item.text }}</div> <span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span> <span v-else-if="item.indicate" class="indicator _blink"><i class="_indicatorCircle"></i></span> </button> - <MkA v-else v-click-anime :to="item.to" class="item" @click.passive="close()"> + <MkA v-else-if="item.to != null" v-click-anime :to="item.to" class="item" @click.passive="close()"> <i class="icon" :class="item.icon"></i> <div class="text">{{ item.text }}</div> <span v-if="item.indicate && item.indicateValue" class="_indicateCounter indicatorWithValue">{{ item.indicateValue }}</span> diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index e3bb39549f..efcbf26a29 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js'; const props = defineProps<{ audio: Misskey.entities.DriveFile; @@ -154,16 +155,11 @@ function hasFocus() { const playerEl = useTemplateRef('playerEl'); const audioEl = useTemplateRef('audioEl'); -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore')); +const hide = ref(shouldHideFileByDefault(props.audio)); async function reveal() { - if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.audio))) { + return; } hide.value = false; diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 7730e01a9f..fd86b61b87 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> - <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="reveal"> + <div v-else-if="hide" :class="$style.sensitive" @click="reveal"> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> @@ -27,23 +27,18 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; -import * as os from '@/os.js'; import MkMediaAudio from '@/components/MkMediaAudio.vue'; -import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; const props = defineProps<{ media: Misskey.entities.DriveFile; }>(); -const hide = ref(true); +const hide = ref(shouldHideFileByDefault(props.media)); async function reveal() { - if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.media))) { + return; } hide.value = false; diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index f59d15d9a2..4236bd943a 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal"> +<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu"> <component :is="disableImageLink ? 'div' : 'a'" v-bind="disableImageLink ? { @@ -77,6 +77,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; const props = withDefaults(defineProps<{ image: Misskey.entities.DriveFile; @@ -99,19 +100,15 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages) : props.image.thumbnailUrl!, ); -async function reveal(ev: MouseEvent) { +async function reveal(ev: PointerEvent) { if (!props.controls) { return; } if (hide.value) { ev.stopPropagation(); - if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.image))) { + return; } hide.value = false; @@ -119,14 +116,14 @@ async function reveal(ev: MouseEvent) { } // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする -watch(() => props.image, () => { - hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore'); +watch(() => props.image, (newImage) => { + hide.value = shouldHideFileByDefault(newImage); }, { deep: true, immediate: true, }); -function showMenu(ev: MouseEvent) { +function getMenu() { const menuItems: MenuItem[] = []; menuItems.push({ @@ -191,9 +188,16 @@ function showMenu(ev: MouseEvent) { }); } - os.popupMenu(menuItems, ev.currentTarget ?? ev.target); + return menuItems; } +function showMenu(ev: PointerEvent) { + os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); +} + +function onContextmenu(ev: PointerEvent) { + os.contextMenu(getMenu(), ev); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index bfc8179e13..9090e74bb6 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> +<div :class="$style.root"> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> <div ref="gallery" :class="[ $style.medias, + ...(prefer.s.showMediaListByGridInWideArea ? [$style.gridInWideArea] : []), count === 1 ? [$style.n1, { [$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9', [$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1', @@ -107,8 +108,10 @@ onMounted(() => { src: media.url, w: media.properties.width, h: media.properties.height, - alt: media.comment ?? media.name, - comment: media.comment ?? media.name, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + alt: media.comment || media.name, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + comment: media.comment || media.name, }; if (media.properties.orientation != null && media.properties.orientation >= 5) { [item.w, item.h] = [item.h, item.w]; @@ -155,8 +158,10 @@ onMounted(() => { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } itemData.msrc = file.thumbnailUrl ?? undefined; - itemData.alt = file.comment ?? file.name; - itemData.comment = file.comment ?? file.name; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + itemData.alt = file.comment || file.name; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + itemData.comment = file.comment || file.name; itemData.thumbCropped = true; return itemData; @@ -226,6 +231,10 @@ defineExpose({ </script> <style lang="scss" module> +.root { + container-type: inline-size; +} + .container { position: relative; width: 100%; @@ -309,6 +318,20 @@ defineExpose({ border-radius: 8px; } +@container (min-width: 500px) { + .medias.gridInWideArea { + display: grid; + aspect-ratio: auto; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: auto; + grid-gap: 8px; + + > .media { + aspect-ratio: 1 / 1; + } + } +} + :global(.pswp) { --pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important; --pswp-bg: var(--MI_THEME-modalBg) !important; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index b0f7a909d3..4d06e42c05 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -124,6 +124,7 @@ import hasAudio from '@/utility/media-has-audio.js'; import MkMediaRange from '@/components/MkMediaRange.vue'; import { $i, iAmModerator } from '@/i.js'; import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; @@ -176,15 +177,11 @@ function hasFocus() { } // eslint-disable-next-line vue/no-setup-props-reactivity-loss -const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore')); +const hide = ref(shouldHideFileByDefault(props.video)); async function reveal() { - if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.sensitiveMediaRevealConfirm, - }); - if (canceled) return; + if (!(await canRevealFile(props.video))) { + return; } hide.value = false; @@ -193,7 +190,7 @@ async function reveal() { // Menu const menuShowing = ref(false); -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { const menu: MenuItem[] = [ // TODO: 再生キューに追加 { @@ -708,7 +705,7 @@ onDeactivated(() => { .controlButton { padding: 6px; border-radius: calc(var(--MI-radius) / 2); - transition: background-color .2s ease-in-out; + transition: background-color .15s ease; font-size: 1.05rem; &:hover { @@ -763,4 +760,21 @@ onDeactivated(() => { } } } + +@container (max-width: 300px) { + .videoControls { + grid-template-areas: + "left . right" + "seekbar seekbar seekbar"; + grid-template-columns: auto 1fr auto; + } + + .controlsTime { + display: none; + } + + .controlsVolume { + display: none; + } +} </style> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 6c8fac934c..b618dab6b2 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span><MkEllipsis/></span> </span> - <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]"> + <div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1"> <component :is="item.component" v-bind="item.props"/> </div> @@ -316,16 +316,27 @@ function onItemMouseLeave() { if (childCloseTimer) window.clearTimeout(childCloseTimer); } -async function showRadioOptions(item: MenuRadio, ev: Event) { +async function showRadioOptions(item: MenuRadio, ev: MouseEvent | PointerEvent | KeyboardEvent) { const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => { const value = item.options[key]; return { type: 'radioOption', text: key, action: () => { - item.ref = value; + if ('value' in item.ref) { + item.ref.value = value; + } else { + // @ts-expect-error リアクティビティは保たれる + item.ref = value; + } }, - active: computed(() => item.ref === value), + active: computed(() => { + if ('value' in item.ref) { + return item.ref.value === value; + } else { + return item.ref === value; + } + }), }; }); @@ -341,7 +352,7 @@ async function showRadioOptions(item: MenuRadio, ev: Event) { } } -async function showChildren(item: MenuParent, ev: Event) { +async function showChildren(item: MenuParent, ev: MouseEvent | PointerEvent | KeyboardEvent) { ev.stopPropagation(); const children: MenuItem[] = await (async () => { @@ -371,7 +382,7 @@ async function showChildren(item: MenuParent, ev: Event) { } } -function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) { +function clicked(fn: MenuAction, ev: PointerEvent, doClose = true) { fn(ev); if (!doClose) return; diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 660d5a26be..92174d8ef7 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -184,8 +184,8 @@ const align = () => { const width = content.value!.offsetWidth; const height = content.value!.offsetHeight; - let left; - let top; + let left = 0; + let top = 0; const x = anchorRect.left + (fixed.value ? 0 : window.scrollX); const y = anchorRect.top + (fixed.value ? 0 : window.scrollY); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index a7299d2961..c78cc44425 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -468,8 +468,12 @@ if (!props.mock) { } } -function renote() { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function renote() { + if (props.mock) return; + + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); @@ -478,11 +482,12 @@ function renote() { subscribeManuallyToNoteCapture(); } -function reply(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); - if (props.mock) { - return; - } +async function reply() { + if (props.mock) return; + + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + os.post({ reply: appearNote, channel: appearNote.channel, @@ -491,8 +496,10 @@ function reply(): void { }); } -function react(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function react() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -587,7 +594,7 @@ function toggleReact() { } } -function onContextmenu(ev: MouseEvent): void { +function onContextmenu(ev: PointerEvent): void { if (props.mock) { return; } @@ -621,10 +628,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus); } -function showRenoteMenu(): void { +async function showRenoteMenu() { if (props.mock) { return; } + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; function getUnrenote(): MenuItem { return { @@ -649,7 +658,6 @@ function showRenoteMenu(): void { }; if (isMyRenote) { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); os.popupMenu([ renoteDetailsMenu, getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 47bf365877..083e3e5da0 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/> </div> - <MkNoteSub v-if="appearNote.replyId" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.replyId" :note="appearNote?.reply ?? null" :class="$style.replyTo"/> <div v-if="isRenote" :class="$style.renote"> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> @@ -143,8 +143,6 @@ SPDX-License-Identifier: AGPL-3.0-only :reactionEmojis="$appearNote.reactionEmojis" :myReaction="$appearNote.myReaction" :noteId="appearNote.id" - :maxNumber="16" - @mockUpdateMyReaction="emitUpdReaction" /> <button class="_button" :class="$style.noteFooterButton" @click="reply()"> <i class="ti ti-arrow-back-up"></i> @@ -233,7 +231,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject, markRaw, onMounted, provide, ref, useTemplateRef } from 'vue'; +import { computed, inject, markRaw, provide, ref, useTemplateRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; @@ -358,7 +356,9 @@ const keymap = { if (!prefer.s.showClipButtonInNoteFooter) return; clip(); }, - 'o': () => galleryEl.value?.openGallery(), + 'o': () => { + galleryEl.value?.openGallery(); + }, 'v|enter': () => { if (appearNote.cw != null) { showContent.value = !showContent.value; @@ -448,8 +448,10 @@ if (appearNote.reactionAcceptance === 'likeOnly') { }); } -function renote() { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function renote() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); const { menu } = getRenoteMenu({ note: note, renoteButton }); @@ -459,8 +461,10 @@ function renote() { subscribeManuallyToNoteCapture(); } -function reply(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function reply() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); os.post({ reply: appearNote, @@ -470,8 +474,10 @@ function reply(): void { }); } -function react(): void { - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); +async function react() { + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { sound.playMisskeySfx('reaction'); @@ -547,7 +553,7 @@ function toggleReact() { } } -function onContextmenu(ev: MouseEvent): void { +function onContextmenu(ev: PointerEvent): void { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; @@ -569,9 +575,12 @@ async function clip(): Promise<void> { os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus); } -function showRenoteMenu(): void { +async function showRenoteMenu() { if (!isMyRenote) return; - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; + os.popupMenu([{ text: i18n.ts.unrenote, icon: 'ti ti-trash', diff --git a/packages/frontend/src/components/MkNoteDraftsDialog.vue b/packages/frontend/src/components/MkNoteDraftsDialog.vue index 3f0a5a5247..371240ae4f 100644 --- a/packages/frontend/src/components/MkNoteDraftsDialog.vue +++ b/packages/frontend/src/components/MkNoteDraftsDialog.vue @@ -118,7 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.draftActions" class="_buttons"> <template v-if="draft.scheduledAt != null && draft.isActuallyScheduled"> <MkButton - :class="$style.itemButton" small @click="cancelSchedule(draft)" > @@ -126,7 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> <!-- TODO <MkButton - :class="$style.itemButton" small @click="reSchedule(draft)" > @@ -136,7 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <MkButton v-else - :class="$style.itemButton" small @click="restoreDraft(draft)" > @@ -147,7 +144,6 @@ SPDX-License-Identifier: AGPL-3.0-only danger small :iconOnly="true" - :class="$style.itemButton" style="margin-left: auto;" @click="deleteDraft(draft)" > diff --git a/packages/frontend/src/components/MkNoteMediaGrid.vue b/packages/frontend/src/components/MkNoteMediaGrid.vue index 7e900b28fa..e46456d614 100644 --- a/packages/frontend/src/components/MkNoteMediaGrid.vue +++ b/packages/frontend/src/components/MkNoteMediaGrid.vue @@ -6,14 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <template v-for="file in note.files"> <div - v-if="((( - (prefer.s.nsfw === 'force' || file.isSensitive) && - prefer.s.nsfw !== 'ignore' - ) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) && - !showingFiles.has(file.id) - )" + v-if="isHiding(file)" :class="[$style.filePreview, { [$style.square]: square }]" - @click="showingFiles.add(file.id)" + @click="reveal(file)" > <MkDriveFileThumbnail :file="file" @@ -49,6 +44,7 @@ import * as Misskey from 'misskey-js'; import { notePage } from '@/filters/note.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; +import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js'; import bytes from '@/filters/bytes.js'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; @@ -59,6 +55,24 @@ defineProps<{ }>(); const showingFiles = ref<Set<string>>(new Set()); + +function isHiding(file: Misskey.entities.DriveFile) { + if (shouldHideFileByDefault(file) && !showingFiles.value.has(file.id)) { + if (!file.isSensitive && !file.type.startsWith('image/')) { + return false; + } + return true; + } + return false; +} + +async function reveal(file: Misskey.entities.DriveFile) { + if (!(await canRevealFile(file))) { + return; + } + + showingFiles.value.add(file.id); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 45a74e3f02..6c70358c2c 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -121,7 +121,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ notification.invitation.room.name }} </div> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> - {{ i18n.ts._achievements._types['_' + notification.achievement].title }} + {{ i18n.ts._achievements._types[`_${notification.achievement}`].title }} </MkA> <MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`"> {{ i18n.ts.showFile }} @@ -136,15 +136,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</div> <div v-if="notification.message" :class="$style.text" style="opacity: 0.6; font-style: oblique;"> <i class="ti ti-quote" :class="$style.quote"></i> - <span>{{ notification.message }}</span> + <Mfm :text="notification.message" :author="notification.user" :plain="true" :nowrap="true"/> <i class="ti ti-quote" :class="$style.quote"></i> </div> </template> <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> diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 7205e516d2..5300abd0cf 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -42,7 +42,7 @@ import { i18n } from '@/i18n.js'; type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>; const emit = defineEmits<{ - (ev: 'done', v: { excludeTypes: string[] }): void, + (ev: 'done', v: { excludeTypes: typeof notificationTypes[number][] }): void, (ev: 'closed'): void, }>(); diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue index 7fa8c23c6c..abc4407d2a 100644 --- a/packages/frontend/src/components/MkObjectView.value.vue +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -42,7 +42,7 @@ const props = defineProps<{ value: unknown; }>(); -const collapsed = reactive({}); +const collapsed = reactive<Record<string, boolean>>({}); if (isObject(props.value)) { for (const key in props.value) { diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index a4c8ca0095..ad8fcf283c 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -88,7 +88,7 @@ const shouldEnableInfiniteScroll = computed(() => { return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll; }); -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; diff --git a/packages/frontend/src/components/MkPagingButtons.vue b/packages/frontend/src/components/MkPagingButtons.vue index fe59efd83a..10f432e50d 100644 --- a/packages/frontend/src/components/MkPagingButtons.vue +++ b/packages/frontend/src/components/MkPagingButtons.vue @@ -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> diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue index 4f1346b685..ca7bfd3a93 100644 --- a/packages/frontend/src/components/MkPolkadots.vue +++ b/packages/frontend/src/components/MkPolkadots.vue @@ -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> diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 305e9b5c4f..31567d2b84 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -90,7 +90,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ const vote = async (id: number) => { if (props.readOnly || closed.value || isVoted.value) return; - pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value }); + if (!isLoggedIn) return; const { canceled } = await os.confirm({ type: 'question', diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index b7c3d1f42d..bd36a0b97a 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -110,7 +110,7 @@ if (props.modelValue.expiresAt) { expiration.value = 'infinite'; } -function onInput(i, value) { +function onInput(i: number, value: string) { choices.value[i] = value; } @@ -122,7 +122,7 @@ function add() { // }); } -function remove(i) { +function remove(i: number) { choices.value = choices.value.filter((_, _i) => _i !== i); } diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index b3bcfcc137..d709286041 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <header :class="$style.header"> <div :class="$style.headerLeft"> <button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button> - <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu"> + <button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" class="_button" @click="openAccountMenu"> <img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/> </button> </div> @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.headerRightButtonText">{{ targetChannel.name }}</span> </button> </template> - <button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified'" @click="toggleLocalOnly"> + <button v-if="visibility !== 'specified'" v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null" @click="toggleLocalOnly"> <span v-if="!localOnly"><i class="ti ti-rocket"></i></span> <span v-else><i class="ti ti-rocket-off"></i></span> </button> @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.visibleUsers"> <span v-for="u in visibleUsers" :key="u.id" :class="$style.visibleUser"> <MkAcct :user="u"/> - <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button> + <button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u.id)"><i class="ti ti-x"></i></button> </span> <button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button> </div> @@ -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,13 +108,13 @@ 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> <script lang="ts" setup> -import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue'; +import { watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted, onBeforeUnmount } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -227,6 +227,10 @@ const targetChannel = shallowRef(props.channel); const serverDraftId = ref<string | null>(null); const postFormActions = getPluginHandlers('post_form_action'); +let textAutocomplete: Autocomplete | null = null; +let cwAutocomplete: Autocomplete | null = null; +let hashtagAutocomplete: Autocomplete | null = null; + const uploader = useUploader({ multiple: true, }); @@ -329,8 +333,8 @@ const canSaveAsServerDraft = computed((): boolean => { return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null); }); -const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags')); -const hashtags = computed(store.makeGetterSetter('postFormHashtags')); +const withHashtags = store.model('postFormWithHashtags'); +const hashtags = store.model('postFormHashtags'); watch(text, () => { checkMissingMention(); @@ -476,6 +480,7 @@ function togglePoll() { } function addTag(tag: string) { + if (textareaEl.value == null) return; insertTextAtCursor(textareaEl.value, ` #${tag} `); } @@ -486,7 +491,7 @@ function focus() { } } -function chooseFileFromPc(ev: MouseEvent) { +function chooseFileFromPc(ev: PointerEvent) { if (props.mock) return; os.chooseFileFromPc({ multiple: true }).then(files => { @@ -495,7 +500,7 @@ function chooseFileFromPc(ev: MouseEvent) { }); } -function chooseFileFromDrive(ev: MouseEvent) { +function chooseFileFromDrive(ev: PointerEvent) { if (props.mock) return; chooseDriveFile({ multiple: true }).then(driveFiles => { @@ -503,18 +508,18 @@ function chooseFileFromDrive(ev: MouseEvent) { }); } -function detachFile(id) { +function detachFile(id: Misskey.entities.DriveFile['id']) { files.value = files.value.filter(x => x.id !== id); } -function updateFileSensitive(file, sensitive) { +function updateFileSensitive(file: Misskey.entities.DriveFile, isSensitive: boolean) { if (props.mock) { - emit('fileChangeSensitive', file.id, sensitive); + emit('fileChangeSensitive', file.id, isSensitive); } - files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = sensitive; + files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = isSensitive; } -function updateFileName(file, name) { +function updateFileName(file: Misskey.entities.DriveFile, name: Misskey.entities.DriveFile['name']) { files.value[files.value.findIndex(x => x.id === file.id)].name = name; } @@ -528,7 +533,6 @@ function setVisibility() { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { currentVisibility: visibility.value, isSilenced: $i.isSilenced, - localOnly: localOnly.value, anchorElement: visibilityButton.value, ...(replyTargetNote.value ? { isReplyVisibilitySpecified: replyTargetNote.value.visibility === 'specified' } : {}), }, { @@ -704,8 +708,8 @@ function addVisibleUser() { }); } -function removeVisibleUser(user) { - visibleUsers.value = erase(user, visibleUsers.value); +function removeVisibleUser(id: string) { + visibleUsers.value = visibleUsers.value.filter(u => u.id !== id); } function clear() { @@ -742,7 +746,8 @@ const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; async function onPaste(ev: ClipboardEvent) { if (props.mock) return; - if (!ev.clipboardData) return; + if (ev.clipboardData == null) return; + if (textareaEl.value == null) return; let pastedFiles: File[] = []; for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { @@ -767,39 +772,42 @@ async function onPaste(ev: ClipboardEvent) { if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) { ev.preventDefault(); - os.confirm({ + const { canceled } = await os.confirm({ type: 'info', text: i18n.ts.quoteQuestion, - }).then(({ canceled }) => { - if (canceled) { - insertTextAtCursor(textareaEl.value, paste); - return; - } - - quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; }); + + if (canceled) { + insertTextAtCursor(textareaEl.value, paste); + return; + } + + quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null; } if (paste.length > 1000) { ev.preventDefault(); - os.confirm({ + + const { canceled } = await os.confirm({ type: 'info', text: i18n.ts.attachAsFileQuestion, - }).then(({ canceled }) => { - if (canceled) { - insertTextAtCursor(textareaEl.value, paste); - return; - } - - const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); - const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); - uploader.addFiles([file]); }); + + if (canceled) { + insertTextAtCursor(textareaEl.value, paste); + return; + } + + const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); + const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); + uploader.addFiles([file]); } } -function onDragover(ev) { - if (!ev.dataTransfer.items[0]) return; +function onDragover(ev: DragEvent) { + if (ev.dataTransfer == null) return; + if (ev.dataTransfer.items[0] == null) return; + const isFile = ev.dataTransfer.items[0].kind === 'file'; if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); @@ -852,13 +860,32 @@ function onDrop(ev: DragEvent): void { //#endregion } +type StoredDrafts = { + [key: string]: { + updatedAt: string; + data: { + text: string; + useCw: boolean; + cw: string | null; + visibility: 'public' | 'home' | 'followers' | 'specified'; + localOnly: boolean; + files: Misskey.entities.DriveFile[]; + poll: PollEditorModelValue | null; + visibleUserIds?: string[]; + quoteId: string | null; + reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; + scheduledAt: number | null; + }; + }; +}; + function saveDraft() { if (props.instant || props.mock) return; - const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); + const draftsData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as StoredDrafts; - draftData[draftKey.value] = { - updatedAt: new Date(), + draftsData[draftKey.value] = { + updatedAt: new Date().toISOString(), data: { text: text.value, useCw: useCw.value, @@ -874,15 +901,15 @@ function saveDraft() { }, }; - miLocalStorage.setItem('drafts', JSON.stringify(draftData)); + miLocalStorage.setItem('drafts', JSON.stringify(draftsData)); } function deleteDraft() { - const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); + const draftsData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}') as StoredDrafts; - delete draftData[draftKey.value]; + delete draftsData[draftKey.value]; - miLocalStorage.setItem('drafts', JSON.stringify(draftData)); + miLocalStorage.setItem('drafts', JSON.stringify(draftsData)); } async function saveServerDraft(options: { @@ -924,8 +951,8 @@ async function uploadFiles() { } } -async function post(ev?: MouseEvent) { - if (ev) { +async function post(ev?: PointerEvent) { + if (ev != null) { const el = (ev.currentTarget ?? ev.target) as HTMLElement | null; if (el && prefer.s.animation) { @@ -999,7 +1026,7 @@ async function post(ev?: MouseEvent) { channelId: targetChannel.value ? targetChannel.value.id : undefined, poll: poll.value, cw: useCw.value ? cw.value ?? '' : null, - localOnly: localOnly.value, + localOnly: visibility.value === 'specified' ? false : localOnly.value, visibility: visibility.value, visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, reactionAcceptance: reactionAcceptance.value, @@ -1138,11 +1165,12 @@ function cancel() { function insertMention() { os.selectUser({ localOnly: localOnly.value, includeSelf: true }).then(user => { + if (textareaEl.value == null) return; insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' '); }); } -async function insertEmoji(ev: MouseEvent) { +async function insertEmoji(ev: PointerEvent) { textAreaReadOnly.value = true; const target = ev.currentTarget ?? ev.target; if (target == null) return; @@ -1166,21 +1194,45 @@ async function insertEmoji(ev: MouseEvent) { }, () => { textAreaReadOnly.value = false; - nextTick(() => focus()); + nextTick(() => { + if (textareaEl.value) { + textareaEl.value.focus(); + textareaEl.value.setSelectionRange(pos, posEnd); + } + }); }, ); } -async function insertMfmFunction(ev: MouseEvent) { +async function insertMfmFunction(ev: PointerEvent) { if (textareaEl.value == null) return; + let pos = textareaEl.value.selectionStart ?? 0; + let posEnd = textareaEl.value.selectionEnd ?? text.value.length; mfmFunctionPicker( ev.currentTarget ?? ev.target, - textareaEl.value, - text, + (tag) => { + if (pos === posEnd) { + text.value = `${text.value.substring(0, pos)}$[${tag} ]${text.value.substring(pos)}`; + pos += tag.length + 3; + posEnd = pos; + } else { + text.value = `${text.value.substring(0, pos)}$[${tag} ${text.value.substring(pos, posEnd)}]${text.value.substring(posEnd)}`; + pos += tag.length + 3; + posEnd = pos; + } + }, + () => { + nextTick(() => { + if (textareaEl.value) { + textareaEl.value.focus(); + textareaEl.value.setSelectionRange(pos, posEnd); + } + }); + }, ); } -function showActions(ev: MouseEvent) { +function showActions(ev: PointerEvent) { os.popupMenu(postFormActions.map(action => ({ text: action.title, action: () => { @@ -1198,7 +1250,7 @@ function showActions(ev: MouseEvent) { const postAccount = ref<Misskey.entities.UserDetailed | null>(null); -async function openAccountMenu(ev: MouseEvent) { +async function openAccountMenu(ev: PointerEvent) { if (props.mock) return; function showDraftsDialog(scheduled: boolean) { @@ -1288,12 +1340,12 @@ async function openAccountMenu(ev: MouseEvent) { }, { type: 'divider' }, ...items], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } -function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) { +function showPerUploadItemMenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { +function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.contextMenu(menu, ev); } @@ -1360,16 +1412,15 @@ onMounted(() => { }); } - // TODO: detach when unmount - if (textareaEl.value) new Autocomplete(textareaEl.value, text); - if (cwInputEl.value) new Autocomplete(cwInputEl.value, cw); - if (hashtagsInputEl.value) new Autocomplete(hashtagsInputEl.value, hashtags); + if (textareaEl.value) textAutocomplete = new Autocomplete(textareaEl.value, text); + if (cwInputEl.value) cwAutocomplete = new Autocomplete(cwInputEl.value, cw); + if (hashtagsInputEl.value) hashtagAutocomplete = new Autocomplete(hashtagsInputEl.value, hashtags); nextTick(() => { // 書きかけの投稿を復元 if (!props.instant && !props.mention && !props.specified && !props.mock) { - const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value]; - if (draft) { + const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value] as StoredDrafts[string] | undefined; + if (draft != null) { text.value = draft.data.text; useCw.value = draft.data.useCw; cw.value = draft.data.cw; @@ -1420,6 +1471,19 @@ onMounted(() => { }); }); +onBeforeUnmount(() => { + uploader.abortAll(); + if (textAutocomplete) { + textAutocomplete.detach(); + } + if (cwAutocomplete) { + cwAutocomplete.detach(); + } + if (hashtagAutocomplete) { + hashtagAutocomplete.detach(); + } +}); + async function canClose() { if (!uploader.allItemsUploaded.value) { const { canceled } = await os.confirm({ @@ -1469,9 +1533,6 @@ defineExpose({ padding: 8px; } -.account { -} - .avatar { display: block; width: 28px; diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index f429db94df..58adb16954 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-show="props.modelValue.length != 0" :class="$style.root"> - <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{ element }"> + <MkDraggable + :modelValue="props.modelValue" + :class="$style.files" + direction="horizontal" + withGaps + @update:modelValue="v => emit('update:modelValue', v)" + > + <template #default="{ item }"> <div :class="$style.file" role="button" tabindex="0" - @click="showFileMenu(element, $event)" - @keydown.space.enter="showFileMenu(element, $event)" - @contextmenu.prevent="showFileMenu(element, $event)" + @click="showFileMenu(item, $event)" + @keydown.space.enter="showFileMenu(item, $event)" + @contextmenu.prevent.stop="showFileMenu(item, $event)" > - <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> - <div v-if="element.isSensitive" :class="$style.sensitive"> + <!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる --> + <MkDriveFileThumbnail style="pointer-events: none;" :data-id="item.id" :class="$style.thumbnail" :file="item" fit="cover"/> + <div v-if="item.isSensitive" :class="$style.sensitive" style="pointer-events: none;"> <i class="ti ti-eye-exclamation" style="margin: auto;"></i> </div> </div> </template> - </Sortable> + </MkDraggable> <p :class="[$style.remain, { [$style.exceeded]: props.modelValue.length > 16, @@ -33,11 +40,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, inject } from 'vue'; +import { inject } from 'vue'; import * as Misskey from 'misskey-js'; import type { MenuItem } from '@/types/menu'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -45,8 +53,6 @@ import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ modelValue: Misskey.entities.DriveFile[]; detachMediaFn?: (id: string) => void; @@ -91,7 +97,7 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { globalEvents.emit('driveFilesDeleted', [file]); } -function toggleSensitive(file) { +function toggleSensitive(file: Misskey.entities.DriveFile) { if (mock) { emit('changeSensitive', file, !file.isSensitive); return; @@ -105,7 +111,7 @@ function toggleSensitive(file) { }); } -async function rename(file) { +async function rename(file: Misskey.entities.DriveFile) { if (mock) return; const { canceled, result } = await os.inputText({ @@ -143,7 +149,7 @@ async function describe(file: Misskey.entities.DriveFile) { }); } -function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { +function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | KeyboardEvent): void { if (menuShowing) return; const isImage = file.type.startsWith('image/'); @@ -221,7 +227,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar position: relative; width: 64px; height: 64px; - margin-right: 4px; border-radius: 4px; overflow: hidden; cursor: move; diff --git a/packages/frontend/src/components/MkPreferenceContainer.vue b/packages/frontend/src/components/MkPreferenceContainer.vue index 70b111513c..1ce608dda9 100644 --- a/packages/frontend/src/components/MkPreferenceContainer.vue +++ b/packages/frontend/src/components/MkPreferenceContainer.vue @@ -32,7 +32,7 @@ const props = withDefaults(defineProps<{ const isAccountOverrided = ref(prefer.isAccountOverrided(props.k)); const isSyncEnabled = ref(prefer.isSyncEnabled(props.k)); -function showMenu(ev: MouseEvent, contextmenu?: boolean) { +function showMenu(ev: PointerEvent, contextmenu?: boolean) { const i = window.setInterval(() => { isAccountOverrided.value = prefer.isAccountOverrided(props.k); isSyncEnabled.value = prefer.isSyncEnabled(props.k); diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index 6c7bf6be6b..c589cd9685 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -12,11 +12,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="flag" :class="$style.preview__content1__switch_button"> <span>Switch is now {{ flag ? 'on' : 'off' }}</span> </MkSwitch> - <div :class="$style.preview__content1__input"> - <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> - <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> - <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> - </div> <div :class="$style.preview__content1__button"> <MkButton inline>This is</MkButton> <MkButton inline primary>the button</MkButton> @@ -40,15 +35,12 @@ import * as config from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; -import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; import { $i } from '@/i.js'; import { chooseDriveFile } from '@/utility/drive.js'; const text = ref(''); const flag = ref(true); -const radio = ref('misskey'); const mfm = ref(`Hello world! This is an @example mention. BTW you are @${$i ? $i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`); const openDialog = async () => { @@ -89,7 +81,7 @@ const selectUser = async () => { await os.selectUser(); }; -const openMenu = async (ev: Event) => { +const openMenu = async (ev: PointerEvent) => { os.popupMenu([{ type: 'label', text: 'Fruits', diff --git a/packages/frontend/src/components/MkPreviewWithControls.vue b/packages/frontend/src/components/MkPreviewWithControls.vue new file mode 100644 index 0000000000..ad5fd2a01d --- /dev/null +++ b/packages/frontend/src/components/MkPreviewWithControls.vue @@ -0,0 +1,93 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <div :class="$style.container"> + <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> + <div :class="$style.previewContent"> + <slot name="preview"></slot> + </div> + <div v-if="previewLoading" :class="$style.previewLoading"> + <MkLoading/> + </div> + </div> + <div :class="$style.controls"> + <slot name="controls"></slot> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + previewLoading?: boolean; +}>(), { + previewLoading: false, +}); + +defineSlots<{ + preview: () => any; + controls: () => any; +}>(); +</script> + +<style lang="scss" module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); + background-size: 20px 20px; +} + +.previewContent { + position: relative; + width: 100%; + height: 100%; + overflow: clip; +} + +.previewLoading { + position: absolute; + inset: 0; + background-color: color(from var(--MI_THEME-panel) srgb r g b / 0.7); + display: flex; + justify-content: center; + align-items: center; +} + +.animatedBg { + animation: bg 1.2s linear infinite; +} + +@keyframes bg { + 0% { background-position: 0 0; } + 100% { background-position: -20px -20px; } +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 89aca5d29b..4d936758c5 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <slot/> + <slot></slot> </div> </template> diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index 38441b0ea6..cba9b47c56 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -144,7 +144,7 @@ async function unsubscribe() { } function encode(buffer: ArrayBuffer | null) { - return btoa(String.fromCharCode.apply(null, buffer ? new Uint8Array(buffer) as any : [])); + return btoa(String.fromCharCode(...(buffer != null ? new Uint8Array(buffer) : []))); } /** diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue deleted file mode 100644 index a7d77dd118..0000000000 --- a/packages/frontend/src/components/MkRadio.vue +++ /dev/null @@ -1,136 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div - v-adaptive-border - :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]" - :aria-checked="checked" - :aria-disabled="disabled" - role="checkbox" - @click="toggle" -> - <input - type="radio" - :disabled="disabled" - :class="$style.input" - > - <span :class="$style.button"> - <span></span> - </span> - <span :class="$style.label"><slot></slot></span> -</div> -</template> - -<script lang="ts" setup generic="T extends unknown"> -import { computed } from 'vue'; - -const props = defineProps<{ - modelValue: T; - value: T; - disabled?: boolean; -}>(); - -const emit = defineEmits<{ - (ev: 'update:modelValue', value: T): void; -}>(); - -const checked = computed(() => props.modelValue === props.value); - -function toggle(): void { - if (props.disabled) return; - emit('update:modelValue', props.value); -} -</script> - -<style lang="scss" module> -.root { - position: relative; - display: inline-flex; - align-items: center; - text-align: left; - cursor: pointer; - padding: 7px 10px; - min-width: 60px; - background-color: var(--MI_THEME-panel); - background-clip: padding-box !important; - border: solid 1px var(--MI_THEME-panel); - border-radius: 6px; - font-size: 90%; - transition: all 0.2s; - user-select: none; - - &.disabled { - opacity: 0.6; - cursor: not-allowed !important; - } - - &:hover { - border-color: var(--MI_THEME-inputBorderHover) !important; - } - - &:focus-within { - outline: none; - box-shadow: 0 0 0 2px var(--MI_THEME-focus); - } - - &.checked { - background-color: var(--MI_THEME-accentedBg) !important; - border-color: var(--MI_THEME-accentedBg) !important; - color: var(--MI_THEME-accent); - cursor: default !important; - - > .button { - border-color: var(--MI_THEME-accent); - - &::after { - background-color: var(--MI_THEME-accent); - transform: scale(1); - opacity: 1; - } - } - } -} - -.input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; -} - -.button { - position: relative; - display: inline-block; - width: 14px; - height: 14px; - background: none; - border: solid 2px var(--MI_THEME-inputBorder); - border-radius: 100%; - transition: inherit; - - &::after { - content: ''; - display: block; - position: absolute; - top: 3px; - right: 3px; - bottom: 3px; - left: 3px; - border-radius: 100%; - opacity: 0; - transform: scale(0); - transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); - } -} - -.label { - margin-left: 8px; - display: block; - line-height: 20px; - cursor: pointer; -} -</style> diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 426a1d2c2b..e2210e858e 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -3,99 +3,225 @@ SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> +<template> +<div :class="{ [$style.vertical]: vertical }"> + <div :class="$style.label"> + <slot name="label"></slot> + </div> + + <div :class="$style.body"> + <div + v-for="option in options" + :key="getKey(option.value)" + v-adaptive-border + :class="[$style.optionRoot, { [$style.disabled]: option.disabled, [$style.checked]: model === option.value }]" + :aria-checked="model === option.value" + :aria-disabled="option.disabled" + role="checkbox" + @click="toggle(option)" + > + <input + type="radio" + :disabled="option.disabled" + :class="$style.optionInput" + > + <span :class="$style.optionButton"> + <span></span> + </span> + <div :class="$style.optionContent"> + <i v-if="option.icon" :class="[$style.optionIcon, option.icon]" :style="option.iconStyle"></i> + <div> + <slot v-if="option.slotId != null" :name="`option-${option.slotId as SlotNames}`"></slot> + <template v-else> + <div :style="option.labelStyle">{{ option.label ?? option.value }}</div> + <div v-if="option.caption" :class="$style.optionCaption">{{ option.caption }}</div> + </template> + </div> + </div> + </div> + </div> + + <div :class="$style.caption"> + <slot name="caption"></slot> + </div> +</div> +</template> + <script lang="ts"> -import { Comment, defineComponent, h, ref, watch } from 'vue'; -import MkRadio from './MkRadio.vue'; -import type { VNode } from 'vue'; +import type { StyleValue } from 'vue'; +import type { OptionValue } from '@/types/option-value.js'; + +export type MkRadiosOption<T = OptionValue, S = string> = { + value: T; + slotId?: S; + label?: string; + labelStyle?: StyleValue; + icon?: string; + iconStyle?: StyleValue; + caption?: string; + disabled?: boolean; +}; +</script> -export default defineComponent({ - props: { - modelValue: { - required: false, - }, - vertical: { - type: Boolean, - default: false, - }, - }, - setup(props, context) { - const value = ref(props.modelValue); - watch(value, () => { - context.emit('update:modelValue', value.value); - }); - watch(() => props.modelValue, v => { - value.value = v; - }); - if (!context.slots.default) return null; - let options = context.slots.default(); - const label = context.slots.label && context.slots.label(); - const caption = context.slots.caption && context.slots.caption(); +<script setup lang="ts" generic="const T extends MkRadiosOption"> +defineProps<{ + options: T[]; + vertical?: boolean; +}>(); - // なぜかFragmentになることがあるため - if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[]; +type SlotNames = NonNullable<T extends MkRadiosOption<any, infer U> ? U : never>; - // vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる) - options = options.filter(vnode => vnode.type !== Comment); +defineSlots<{ + label?: () => void; + caption?: () => void; +} & { + [K in `option-${SlotNames}`]: () => void; +}>(); - return () => h('div', { - class: [ - 'novjtcto', - ...(props.vertical ? ['vertical'] : []), - ], - }, [ - ...(label ? [h('div', { - class: 'label', - }, label)] : []), - h('div', { - class: 'body', - }, options.map(option => h(MkRadio, { - key: option.key as string, - value: option.props?.value, - disabled: option.props?.disabled, - modelValue: value.value, - 'onUpdate:modelValue': _v => value.value = _v, - }, () => option.children)), - ), - ...(caption ? [h('div', { - class: 'caption', - }, caption)] : []), - ]); - }, -}); +const model = defineModel<T['value']>({ required: true }); + +function getKey(value: OptionValue): PropertyKey { + if (value === null) return '___null___'; + return value; +} + +function toggle(o: MkRadiosOption): void { + if (o.disabled) return; + model.value = o.value; +} </script> -<style lang="scss"> -.novjtcto { - > .label { - font-size: 0.85em; - padding: 0 0 8px 0; - user-select: none; +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - &:empty { - display: none; - } + &:empty { + display: none; } +} + +.body { + display: flex; + gap: 10px; + flex-wrap: wrap; +} - > .body { - display: flex; - gap: 10px; - flex-wrap: wrap; +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); + + &:empty { + display: none; } +} - > .caption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); +.vertical > .body { + flex-direction: column; +} - &:empty { - display: none; - } +.optionRoot { + position: relative; + display: inline-flex; + align-items: center; + text-align: left; + cursor: pointer; + padding: 8px 10px; + min-width: 60px; + background-color: var(--MI_THEME-panel); + background-clip: padding-box !important; + border: solid 1px var(--MI_THEME-panel); + border-radius: 6px; + font-size: 90%; + transition: all 0.2s; + user-select: none; + + &.disabled { + opacity: 0.6; + cursor: not-allowed !important; + } + + &:hover { + border-color: var(--MI_THEME-inputBorderHover) !important; + } + + &:focus-within { + outline: none; + box-shadow: 0 0 0 2px var(--MI_THEME-focus); } - &.vertical { - > .body { - flex-direction: column; + &.checked { + background-color: var(--MI_THEME-accentedBg) !important; + border-color: var(--MI_THEME-accentedBg) !important; + color: var(--MI_THEME-accent); + cursor: default !important; + + .optionButton { + border-color: var(--MI_THEME-accent); + + &::after { + background-color: var(--MI_THEME-accent); + transform: scale(1); + opacity: 1; + } + } + + .optionCaption { + color: color(from var(--MI_THEME-accent) srgb r g b / 0.75); } } } + +.optionInput { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; +} + +.optionButton { + position: relative; + display: inline-block; + width: 14px; + height: 14px; + background: none; + border: solid 2px var(--MI_THEME-inputBorder); + border-radius: 100%; + transition: inherit; + + &::after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } +} + +.optionContent { + display: flex; + align-items: center; + gap: 6px; + margin-left: 8px; +} + +.optionCaption { + font-size: 0.85em; + padding: 2px 0 0 0; + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); + transition: all 0.2s; +} + +.optionIcon { + flex-shrink: 0; +} </style> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 7c60288883..a89f947fa7 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -153,7 +153,7 @@ async function toggleReaction() { } } -async function menu(ev) { +async function menu(ev: PointerEvent) { let menuItems: MenuItem[] = []; if (canGetInfo.value) { diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index bd9ef50157..67fd570b41 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -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> @@ -32,11 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only import * as Misskey from 'misskey-js'; import { inject, watch, ref } from 'vue'; import { TransitionGroup } from 'vue'; +import { isSupportedEmoji } from '@@/js/emojilist.js'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { $i } from '@/i.js'; import { prefer } from '@/preferences.js'; import { customEmojisMap } from '@/custom-emojis.js'; -import { isSupportedEmoji } from '@@/js/emojilist.js'; import { DI } from '@/di.js'; const props = withDefaults(defineProps<{ @@ -60,8 +60,8 @@ const initialReactions = new Set(Object.keys(props.reactions)); const _reactions = ref<[string, number][]>([]); const hasMoreReactions = ref(false); -if (props.myReaction && !Object.keys(_reactions.value).includes(props.myReaction)) { - _reactions.value[props.myReaction] = props.reactions[props.myReaction]; +if (props.myReaction != null && !(props.myReaction in props.reactions)) { + _reactions.value.push([props.myReaction, props.reactions[props.myReaction]]); } function onMockToggleReaction(emoji: string, count: number) { diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index a204bc3bf1..6ab7a01ce7 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -98,7 +98,7 @@ async function renderChart() { data: data as any, borderWidth: 0, borderRadius: 3, - backgroundColor(c) { + backgroundColor(c: any) { const v = c.dataset.data[c.dataIndex] as unknown as typeof data[0]; const value = v.v; const m = max(v.y); @@ -179,7 +179,7 @@ async function renderChart() { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex]; + const v = context[0].dataset.data[context[0].dataIndex] as unknown as typeof data[0]; return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000))); }, label(context) { diff --git a/packages/frontend/src/components/MkRetentionLineChart.vue b/packages/frontend/src/components/MkRetentionLineChart.vue index 21c20f944b..5b18bab8c9 100644 --- a/packages/frontend/src/components/MkRetentionLineChart.vue +++ b/packages/frontend/src/components/MkRetentionLineChart.vue @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; +import type { ScatterDataPoint } from 'chart.js'; import tinycolor from 'tinycolor2'; import { store } from '@/store.js'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; @@ -18,6 +19,12 @@ import { alpha } from '@/utility/color.js'; import { initChart } from '@/utility/init-chart.js'; import { misskeyApi } from '@/utility/misskey-api.js'; +interface RetentionPoint extends ScatterDataPoint { + x: number; + y: number; + d: string; +} + initChart(); const chartEl = useTemplateRef('chartEl'); @@ -62,14 +69,14 @@ onMounted(async () => { fill: false, tension: 0.4, data: [{ - x: '0', + x: 0, y: 100, d: getYYYYMMDD(new Date(record.createdAt)), }, ...Object.entries(record.data).sort((a, b) => getDate(a[0]) > getDate(b[0]) ? 1 : -1).map(([k, v], i) => ({ - x: (i + 1).toString(), + x: i + 1, y: (v / record.users) * 100, d: getYYYYMMDD(new Date(record.createdAt)), - }))] as any, + }))], })), }, options: { @@ -111,11 +118,11 @@ onMounted(async () => { enabled: false, callbacks: { title(context) { - const v = context[0].dataset.data[context[0].dataIndex] as unknown as { x: string, y: number, d: string }; + const v = context[0].dataset.data[context[0].dataIndex] as RetentionPoint; return `${v.x} days later`; }, label(context) { - const v = context.dataset.data[context.dataIndex] as unknown as { x: string, y: number, d: string }; + const v = context.dataset.data[context.dataIndex] as RetentionPoint; const p = Math.round(v.y) + '%'; return `${v.d} ${p}`; }, diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 937804703d..651165136a 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -55,9 +55,9 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkLoading from '@/components/global/MkLoading.vue'; const emit = defineEmits<{ - (ev: 'done', value: Misskey.entities.Role[]), - (ev: 'close'), - (ev: 'closed'), + (ev: 'done', value: Misskey.entities.Role[]): void; + (ev: 'close'): void; + (ev: 'closed'): void; }>(); const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index f130145e36..6f6957d504 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -export type OptionValue = string | number | null; +import type { OptionValue } from '@/types/option-value.js'; export type ItemOption<T extends OptionValue = OptionValue> = { type?: 'option'; diff --git a/packages/frontend/src/components/MkServerSetupWizard.vue b/packages/frontend/src/components/MkServerSetupWizard.vue index 8e3b41e754..462ded6de3 100644 --- a/packages/frontend/src/components/MkServerSetupWizard.vue +++ b/packages/frontend/src/components/MkServerSetupWizard.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root" class="_gaps_m"> +<div class="_gaps_m"> <MkInput v-model="q_name" data-cy-server-name> <template #label>{{ i18n.ts.instanceName }}</template> </MkInput> @@ -14,19 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-settings-question"></i></template> <div class="_gaps_s"> - <MkRadios v-model="q_use" :vertical="true"> - <option value="single"> - <div><i class="ti ti-user"></i> <b>{{ i18n.ts._serverSetupWizard._use.single }}</b></div> - <div>{{ i18n.ts._serverSetupWizard._use.single_description }}</div> - </option> - <option value="group"> - <div><i class="ti ti-lock"></i> <b>{{ i18n.ts._serverSetupWizard._use.group }}</b></div> - <div>{{ i18n.ts._serverSetupWizard._use.group_description }}</div> - </option> - <option value="open"> - <div><i class="ti ti-world"></i> <b>{{ i18n.ts._serverSetupWizard._use.open }}</b></div> - <div>{{ i18n.ts._serverSetupWizard._use.open_description }}</div> - </option> + <MkRadios + v-model="q_use" + :options="[ + { value: 'single', label: i18n.ts._serverSetupWizard._use.single, icon: 'ti ti-user', caption: i18n.ts._serverSetupWizard._use.single_description }, + { value: 'group', label: i18n.ts._serverSetupWizard._use.group, icon: 'ti ti-lock', caption: i18n.ts._serverSetupWizard._use.group_description }, + { value: 'open', label: i18n.ts._serverSetupWizard._use.open, icon: 'ti ti-world', caption: i18n.ts._serverSetupWizard._use.open_description }, + ]" + vertical + > </MkRadios> <MkInfo v-if="q_use === 'single'">{{ i18n.ts._serverSetupWizard._use.single_youCanCreateMultipleAccounts }}</MkInfo> @@ -40,10 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template #icon><i class="ti ti-users"></i></template> <div class="_gaps_s"> - <MkRadios v-model="q_scale" :vertical="true"> - <option value="small"><i class="ti ti-user"></i> {{ i18n.ts._serverSetupWizard._scale.small }}</option> - <option value="medium"><i class="ti ti-users"></i> {{ i18n.ts._serverSetupWizard._scale.medium }}</option> - <option value="large"><i class="ti ti-users-group"></i> {{ i18n.ts._serverSetupWizard._scale.large }}</option> + <MkRadios + v-model="q_scale" + :options="[ + { value: 'small', label: i18n.ts._serverSetupWizard._scale.small, icon: 'ti ti-user' }, + { value: 'medium', label: i18n.ts._serverSetupWizard._scale.medium, icon: 'ti ti-users' }, + { value: 'large', label: i18n.ts._serverSetupWizard._scale.large, icon: 'ti ti-users-group' }, + ]" + vertical + > </MkRadios> <MkInfo v-if="q_scale === 'large'"><b>{{ i18n.ts.advice }}:</b> {{ i18n.ts._serverSetupWizard.largeScaleServerAdvice }}</MkInfo> @@ -57,9 +58,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <div>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description1 }}<br>{{ i18n.ts._serverSetupWizard.doYouConnectToFediverse_description2 }}<br><MkLink target="_blank" url="https://wikipedia.org/wiki/Fediverse">{{ i18n.ts.learnMore }}</MkLink></div> - <MkRadios v-model="q_federation" :vertical="true"> - <option value="yes">{{ i18n.ts.yes }}</option> - <option value="no">{{ i18n.ts.no }}</option> + <MkRadios + v-model="q_federation" + :options="[ + { value: 'yes', label: i18n.ts.yes }, + { value: 'no', label: i18n.ts.no }, + ]" + vertical + > </MkRadios> <MkInfo v-if="q_federation === 'yes'">{{ i18n.ts._serverSetupWizard.youCanConfigureMoreFederationSettingsLater }}</MkInfo> @@ -212,9 +218,9 @@ const props = withDefaults(defineProps<{ }); const q_name = ref(''); -const q_use = ref('single'); -const q_scale = ref('small'); -const q_federation = ref('yes'); +const q_use = ref<'single' | 'group' | 'open'>('single'); +const q_scale = ref<'small' | 'medium' | 'large'>('small'); +const q_federation = ref<'yes' | 'no'>('no'); const q_remoteContentsCleaning = ref(true); const q_adminName = ref(''); const q_adminEmail = ref(''); @@ -239,7 +245,7 @@ const serverSettings = computed<Misskey.entities.AdminUpdateMetaRequest>(() => { enableReactionsBuffering, clientOptions: { entrancePageStyle: q_use.value === 'open' ? 'classic' : 'simple', - } as any, + }, }; }); @@ -370,8 +376,3 @@ function applySettings() { }); } </script> - -<style lang="scss" module> -.root { -} -</style> diff --git a/packages/frontend/src/components/MkServerSetupWizardDialog.vue b/packages/frontend/src/components/MkServerSetupWizardDialog.vue index ea2c5dd47f..1d03438f83 100644 --- a/packages/frontend/src/components/MkServerSetupWizardDialog.vue +++ b/packages/frontend/src/components/MkServerSetupWizardDialog.vue @@ -33,7 +33,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import MkServerSetupWizard from '@/components/MkServerSetupWizard.vue'; const emit = defineEmits<{ - (ev: 'closed'), + (ev: 'closed'): void; }>(); const windowEl = useTemplateRef('windowEl'); diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue index 4c73eab3f5..89ec6373cf 100644 --- a/packages/frontend/src/components/MkSignin.input.vue +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -78,7 +78,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'usernameSubmitted', v: string): void; - (ev: 'passkeyClick', v: MouseEvent): void; + (ev: 'passkeyClick', v: PointerEvent): void; }>(); const host = toUnicode(configHost); diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue index 27ffc724ae..3ac809cdbf 100644 --- a/packages/frontend/src/components/MkSortOrderEditor.vue +++ b/packages/frontend/src/components/MkSortOrderEditor.vue @@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts" generic="T extends string"> import { toRefs } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; import MkTagItem from '@/components/MkTagItem.vue'; import MkButton from '@/components/MkButton.vue'; -import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; const emit = defineEmits<{ (ev: 'update', sortOrders: SortOrder<T>[]): void; @@ -55,7 +55,7 @@ function onToggleSortOrderButtonClicked(order: SortOrder<T>) { emitOrder(currentOrders.value); } -function onAddSortOrderButtonClicked(ev: MouseEvent) { +function onAddSortOrderButtonClicked(ev: PointerEvent) { const menuItems: MenuItem[] = props.baseOrderKeyNames .filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey)) .map(it => { diff --git a/packages/frontend/src/components/MkSpot.vue b/packages/frontend/src/components/MkSpot.vue index 4a8ebb5f94..4bd11fe938 100644 --- a/packages/frontend/src/components/MkSpot.vue +++ b/packages/frontend/src/components/MkSpot.vue @@ -88,7 +88,7 @@ function setPosition() { bodyEl.value.style.top = data.top + 'px'; } -let loopHandler; +let loopHandler: number | null = null; onMounted(() => { nextTick(() => { @@ -104,7 +104,7 @@ onMounted(() => { }); onUnmounted(() => { - window.cancelAnimationFrame(loopHandler); + if (loopHandler != null) window.cancelAnimationFrame(loopHandler); }); </script> diff --git a/packages/frontend/src/components/MkStreamingNotesTimeline.vue b/packages/frontend/src/components/MkStreamingNotesTimeline.vue index bc6ebf0918..9784d8e017 100644 --- a/packages/frontend/src/components/MkStreamingNotesTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotesTimeline.vue @@ -350,13 +350,12 @@ function connectChannel() { connections.main = stream.useChannel('main'); connections.main.on('mention', prepend); } else if (props.src === 'directs') { - const onNote = note => { + connections.main = stream.useChannel('main'); + connections.main.on('mention', note => { if (note.visibility === 'specified') { prepend(note); } - }; - connections.main = stream.useChannel('main'); - connections.main.on('mention', onNote); + }); } else if (props.src === 'list') { if (props.list == null) return; connections.userList = stream.useChannel('userList', { diff --git a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue index 6ee2e347a5..91f071fe63 100644 --- a/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue +++ b/packages/frontend/src/components/MkStreamingNotificationsTimeline.vue @@ -137,8 +137,8 @@ watch(visibility, () => { } }); -function onNotification(notification) { - const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; +function onNotification(notification: Misskey.entities.Notification) { + const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type as typeof notificationTypes[number]) : false; if (isMuted || window.document.visibilityState === 'visible') { if (store.s.realtimeMode) { useStream().send('readNotification'); diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 236afa127c..585a628a96 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -82,7 +82,7 @@ export type SuperMenuDef = { text: string; danger?: boolean; active?: boolean; - action: (ev: MouseEvent) => Awaitable<void>; + action: (ev: PointerEvent) => Awaitable<void>; } | { type?: 'link'; to: string; diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts index 19e4eea733..f2ce55acc4 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts +++ b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts @@ -25,7 +25,7 @@ export type MkSystemWebhookResult = { }; export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> { - const { result } = await new Promise<{ result: MkSystemWebhookResult | null }>(async resolve => { + const { result } = await new Promise<{ result: MkSystemWebhookResult | null }>(resolve => { const { dispose } = os.popup( defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')), props, diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue index cd72204fce..1536b14455 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.vue +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -245,7 +245,7 @@ onMounted(async () => { secret.value = res.secret; isActive.value = res.isActive; for (const ev of Object.keys(events.value)) { - events.value[ev] = res.on.includes(ev as SystemWebhookEventType); + events.value[ev as SystemWebhookEventType] = res.on.includes(ev as SystemWebhookEventType); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (ex: any) { diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue index 9798e2c3b3..a6342ec2e1 100644 --- a/packages/frontend/src/components/MkTabs.vue +++ b/packages/frontend/src/components/MkTabs.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab<K = string> = { key: K; - onClick?: (ev: MouseEvent) => void; + onClick?: (ev: PointerEvent) => void; iconOnly?: boolean; title: string; icon?: string; @@ -74,7 +74,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'tabClick', key: string); + (ev: 'tabClick', key: string): void; }>(); const tab = defineModel<T['key']>('tab'); @@ -100,7 +100,7 @@ function onTabMousedown(selectedTab: Tab, ev: MouseEvent): void { } } -function onTabClick(t: Tab, ev: MouseEvent): void { +function onTabClick(t: Tab, ev: PointerEvent): void { emit('tabClick', t.key); if (t.onClick) { diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue index 8b7460f3a3..5cd2113e59 100644 --- a/packages/frontend/src/components/MkTagItem.vue +++ b/packages/frontend/src/components/MkTagItem.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root" @click="(ev) => emit('click', ev)"> <span v-if="iconClass" :class="[$style.icon, iconClass]"></span> - <span :class="$style.content">{{ content }}</span> + <span>{{ content }}</span> <MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)"> <span :class="[$style.exButtonIcon, exButtonIconClass]"></span> </MkButton> @@ -17,8 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only import MkButton from '@/components/MkButton.vue'; const emit = defineEmits<{ - (ev: 'click', payload: MouseEvent): void; - (ev: 'exButtonClick', payload: MouseEvent): void; + (ev: 'click', payload: PointerEvent): void; + (ev: 'exButtonClick', payload: PointerEvent): void; }>(); defineProps<{ diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 407ac33add..fe4f7b7aaf 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -63,10 +63,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'change', _ev: InputEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter'): void; (ev: 'update:modelValue', value: string): void; + (ev: 'savingStateChange', saved: boolean, invalid: boolean): void; }>(); const { modelValue, autofocus } = toRefs(props); @@ -79,12 +80,16 @@ const inputEl = useTemplateRef('inputEl'); const preview = ref(false); let autocompleteWorker: Autocomplete | null = null; -const focus = () => inputEl.value?.focus(); -const onInput = (ev) => { +function focus() { + inputEl.value?.focus(); +} + +function onInput(ev: InputEvent) { changed.value = true; emit('change', ev); -}; -const onKeydown = (ev: KeyboardEvent) => { +} + +function onKeydown(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; emit('keydown', ev); @@ -102,12 +107,12 @@ const onKeydown = (ev: KeyboardEvent) => { }); ev.preventDefault(); } -}; +} -const updated = () => { +function updated() { changed.value = false; emit('update:modelValue', v.value ?? ''); -}; +} const debouncedUpdated = debounce(1000, updated); @@ -127,6 +132,10 @@ watch(v, () => { invalid.value = inputEl.value?.validity.badInput ?? true; }); +watch([changed, invalid], ([newChanged, newInvalid]) => { + emit('savingStateChange', newChanged, newInvalid); +}, { immediate: true }); + onMounted(() => { nextTick(() => { if (autofocus.value) { diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 42cb6f1e82..8d51e1fa87 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -33,12 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> </div> <div class="_gaps_s"> - <MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch> + <MkSwitch v-for="kind in Object.keys(permissionSwitches)" :key="kind" v-model="permissionSwitches[kind as keyof typeof permissionSwitches]">{{ i18n.ts._permissions[kind as keyof typeof permissionSwitches] }}</MkSwitch> </div> <div v-if="iAmAdmin" :class="$style.adminPermissions"> <div :class="$style.adminPermissionsHeader"><b>{{ i18n.ts.adminPermission }}</b></div> <div class="_gaps_s"> - <MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind]">{{ i18n.ts._permissions[kind] }}</MkSwitch> + <MkSwitch v-for="kind in Object.keys(permissionSwitchesForAdmin)" :key="kind" v-model="permissionSwitchesForAdmin[kind as keyof typeof permissionSwitchesForAdmin]">{{ i18n.ts._permissions[kind as keyof typeof permissionSwitchesForAdmin] }}</MkSwitch> </div> </div> </div> @@ -102,8 +102,8 @@ function ok(): void { emit('done', { name: name.value, permissions: [ - ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p]), - ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p]) : []), + ...Object.keys(permissionSwitches.value).filter(p => permissionSwitches.value[p as (typeof Misskey.permissions)[number]]), + ...(iAmAdmin ? Object.keys(permissionSwitchesForAdmin.value).filter(p => permissionSwitchesForAdmin.value[p as (typeof Misskey.permissions)[number]]) : []), ], }); dialog.value?.close(); @@ -111,22 +111,22 @@ function ok(): void { function disableAll(): void { for (const p in permissionSwitches.value) { - permissionSwitches.value[p] = false; + permissionSwitches.value[p as (typeof Misskey.permissions)[number]] = false; } if (iAmAdmin) { for (const p in permissionSwitchesForAdmin.value) { - permissionSwitchesForAdmin.value[p] = false; + permissionSwitchesForAdmin.value[p as (typeof Misskey.permissions)[number]] = false; } } } function enableAll(): void { for (const p in permissionSwitches.value) { - permissionSwitches.value[p] = true; + permissionSwitches.value[p as (typeof Misskey.permissions)[number]] = true; } if (iAmAdmin) { for (const p in permissionSwitchesForAdmin.value) { - permissionSwitchesForAdmin.value[p] = true; + permissionSwitchesForAdmin.value[p as (typeof Misskey.permissions)[number]] = true; } } } diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index aa041c88e5..08a3f02f65 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -71,7 +71,7 @@ function setPosition() { el.value.style.top = data.top + 'px'; } -let loopHandler; +let loopHandler: number | null = null; onMounted(() => { nextTick(() => { @@ -87,7 +87,7 @@ onMounted(() => { }); onUnmounted(() => { - window.cancelAnimationFrame(loopHandler); + if (loopHandler != null) window.cancelAnimationFrame(loopHandler); }); </script> diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index b77e67e9c6..3ab2c5f0d4 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -74,7 +74,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ }); const onceReacted = ref<boolean>(false); -function addReaction(emoji) { +function addReaction(emoji: string) { onceReacted.value = true; emit('reacted'); doNotification(emoji); @@ -96,7 +96,7 @@ function doNotification(emoji: string): void { globalEvents.emit('clientNotification', notification); } -function removeReaction(emoji) { +function removeReaction(emoji: string) { delete exampleNote.reactions[emoji]; exampleNote.myReaction = undefined; } diff --git a/packages/frontend/src/components/MkUpdated.vue b/packages/frontend/src/components/MkUpdated.vue index eba8e5472c..09cf595eab 100644 --- a/packages/frontend/src/components/MkUpdated.vue +++ b/packages/frontend/src/components/MkUpdated.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="$emit('closed')"> +<MkModal ref="modal" preferType="dialog" :zPriority="'middle'" @click="modal?.close()" @closed="emit('closed')"> <div :class="$style.root"> <div :class="$style.title"><MkSparkle>{{ i18n.ts.misskeyUpdated }}</MkSparkle></div> <div :class="$style.version">✨{{ version }}🚀</div> @@ -26,6 +26,10 @@ import { confetti } from '@/utility/confetti.js'; const modal = useTemplateRef('modal'); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const isBeta = version.includes('-beta') || version.includes('-alpha') || version.includes('-rc'); function whatIsNew() { diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index 8849fa447d..69de56d45c 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -166,17 +166,17 @@ async function done() { dialog.value?.close(); } -async function chooseFile(ev: MouseEvent) { +async function chooseFile(ev: PointerEvent) { const newFiles = await os.chooseFileFromPc({ multiple: true }); uploader.addFiles(newFiles); } -function showPerItemMenu(item: UploaderItem, ev: MouseEvent) { +function showPerItemMenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) { +function showPerItemMenuViaContextmenu(item: UploaderItem, ev: PointerEvent) { const menu = uploader.getMenu(item); os.contextMenu(menu, ev); } diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue index f31c717ad5..51f7ac2d09 100644 --- a/packages/frontend/src/components/MkUploaderItems.vue +++ b/packages/frontend/src/components/MkUploaderItems.vue @@ -57,18 +57,18 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'showMenu', item: UploaderItem, event: MouseEvent): void; - (ev: 'showMenuViaContextmenu', item: UploaderItem, event: MouseEvent): void; + (ev: 'showMenu', item: UploaderItem, event: PointerEvent): void; + (ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void; }>(); -function onContextmenu(item: UploaderItem, ev: MouseEvent) { +function onContextmenu(item: UploaderItem, ev: PointerEvent) { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; emit('showMenuViaContextmenu', item, ev); } -function onThumbnailClick(item: UploaderItem, ev: MouseEvent) { +function onThumbnailClick(item: UploaderItem, ev: PointerEvent) { // TODO: preview when item is image } </script> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 8ec48dcc3f..5e16460104 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -22,18 +22,26 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTextarea v-model="text"> <template #label>{{ i18n.ts.text }}</template> </MkTextarea> - <MkRadios v-model="icon"> + <MkRadios + v-model="icon" + :options="[ + { value: 'info', icon: 'ti ti-info-circle' }, + { value: 'warning', icon: 'ti ti-alert-triangle', iconStyle: 'color: var(--MI_THEME-warn);' }, + { value: 'error', icon: 'ti ti-circle-x', iconStyle: 'color: var(--MI_THEME-error);' }, + { value: 'success', icon: 'ti ti-check', iconStyle: 'color: var(--MI_THEME-success);' }, + ]" + > <template #label>{{ i18n.ts.icon }}</template> - <option value="info"><i class="ti ti-info-circle"></i></option> - <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i></option> - <option value="error"><i class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i></option> - <option value="success"><i class="ti ti-check" style="color: var(--MI_THEME-success);"></i></option> </MkRadios> - <MkRadios v-model="display"> + <MkRadios + v-model="display" + :options="[ + { value: 'normal', label: i18n.ts.normal }, + { value: 'banner', label: i18n.ts.banner }, + { value: 'dialog', label: i18n.ts.dialog }, + ]" + > <template #label>{{ i18n.ts.display }}</template> - <option value="normal">{{ i18n.ts.normal }}</option> - <option value="banner">{{ i18n.ts.banner }}</option> - <option value="dialog">{{ i18n.ts.dialog }}</option> </MkRadios> <MkSwitch v-model="needConfirmationToRead"> {{ i18n.ts._announcement.needConfirmationToRead }} diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index dde2efd8ee..1fd43bd6e4 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar :class="$style.avatar" :user="user" indicator/> <div :class="$style.body"> <span :class="$style.name"><MkUserName :user="user"/></span> - <span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span> + <span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span> </div> <MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/> </div> diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index f47d9b56dc..8ce929fff3 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -27,7 +27,7 @@ const props = withDefaults(defineProps<{ noGap?: boolean; extractor?: ExtractorFunction<P, Misskey.entities.UserDetailed>; }>(), { - extractor: (item) => item, + extractor: (item: any) => item as Misskey.entities.UserDetailed, }); </script> diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index f794899281..9f196ac2c1 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -90,7 +90,7 @@ const top = ref(0); const left = ref(0); const error = ref(false); -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { if (user.value == null) return; const { menu, cleanup } = getUserMenu(user.value); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 4e96eff82e..95449dd0eb 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -66,7 +66,7 @@ watch(description, () => { }); }); -async function setAvatar(ev) { +async function setAvatar(ev: PointerEvent) { const files = await os.chooseFileFromPc({ multiple: false }); const file = files[0]; diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 88b934bb58..361fda0c24 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span :class="$style.itemDescription">{{ i18n.ts._visibility.followersDescription }}</span> </div> </button> - <button key="specified" :disabled="localOnly" class="_button" :class="[$style.item, { [$style.active]: v === 'specified' }]" data-index="4" @click="choose('specified')"> + <button key="specified" class="_button" :class="[$style.item, { [$style.active]: v === 'specified' }]" data-index="4" @click="choose('specified')"> <div :class="$style.icon"><i class="ti ti-mail"></i></div> <div :class="$style.body"> <span :class="$style.itemTitle">{{ i18n.ts._visibility.specified }}</span> @@ -52,7 +52,6 @@ const modal = useTemplateRef('modal'); const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; isSilenced: boolean; - localOnly: boolean; anchorElement?: HTMLElement | null; isReplyVisibilitySpecified?: boolean; }>(), { diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index 6aaee76565..6513ca385d 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -46,7 +46,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index 8bef225de5..2ce1912b86 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -94,7 +94,7 @@ function signup() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { openInstanceMenu(ev); } </script> diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 820cf05e1f..18f2b3e189 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -26,8 +26,8 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'done'); - (ev: 'closed'); + (ev: 'done'): void; + (ev: 'closed'): void; }>(); function done() { diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue index 154b3ffc27..8e5bb6221d 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue @@ -387,7 +387,7 @@ onMounted(async () => { } }); -function chooseFile(ev: MouseEvent) { +function chooseFile(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 6cd2111598..cadf9ba522 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -16,50 +16,49 @@ SPDX-License-Identifier: AGPL-3.0-only > <template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template> - <div :class="$style.root"> - <div :class="$style.container"> - <div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]"> - <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> - <div :class="$style.previewContainer"> - <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> - <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> - <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> - <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> - </div> + <MkPreviewWithControls> + <template #preview> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + <button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button> </div> </div> - <div :class="$style.controls"> - <div class="_spacer _gaps"> - <div class="_gaps_s"> - <MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false"> - <template #label> - <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> - <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> - <div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div> - <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> - <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> - <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> - </template> - <template #footer> - <div class="_buttons"> - <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> - <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> - <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> - </div> - </template> + </template> - <XLayer - v-model:layer="layers[i]" - ></XLayer> - </MkFolder> + <template #controls> + <div class="_spacer _gaps"> + <div class="_gaps_s"> + <MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false"> + <template #label> + <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> + <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> + <div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div> + <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> + <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> + <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> + </template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> - <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> - </div> + <XLayer + v-model:layer="layers[i]" + ></XLayer> + </MkFolder> + + <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> </div> </div> - </div> - </div> + </template> + </MkPreviewWithControls> </MkModalWindow> </template> @@ -69,6 +68,7 @@ import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/Water import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js'; import { i18n } from '@/i18n.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -350,7 +350,7 @@ async function save() { } } -function addLayer(ev: MouseEvent) { +function addLayer(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts._watermarkEditor.text, action: () => { @@ -411,33 +411,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { </script> <style module> -.root { - container-type: inline-size; - height: 100%; -} - -.container { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.preview { - position: relative; - background-color: var(--MI_THEME-bg); - background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%); - background-size: 20px 20px; -} - -.animatedBg { - animation: bg 1.2s linear infinite; -} - -@keyframes bg { - 0% { background-position: 0 0; } - 100% { background-position: -20px -20px; } -} - .previewContainer { display: flex; flex-direction: column; @@ -474,16 +447,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { } } -.previewSpinner { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - user-select: none; - -webkit-user-drag: none; -} - .previewCanvas { position: absolute; top: 0; @@ -494,15 +457,4 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) { box-sizing: border-box; object-fit: contain; } - -.controls { - overflow-y: scroll; -} - -@container (max-width: 800px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} </style> diff --git a/packages/frontend/src/components/MkWidgetSettingsDialog.vue b/packages/frontend/src/components/MkWidgetSettingsDialog.vue new file mode 100644 index 0000000000..292b4010ff --- /dev/null +++ b/packages/frontend/src/components/MkWidgetSettingsDialog.vue @@ -0,0 +1,174 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + :okButtonDisabled="!canSave" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-icons"></i> {{ i18n.ts._widgets[widgetName] ?? widgetName }}</template> + + <MkPreviewWithControls> + <template #preview> + <div :class="$style.previewWrapper"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + + <div ref="resizerRootEl" :class="$style.previewResizerRoot" inert> + <div + ref="resizerEl" + :class="$style.previewResizer" + :style="{ transform: widgetStyle }" + > + <component + :is="`widget-${widgetName}`" + :widget="{ name: widgetName, id: '__PREVIEW__', data: settings }" + ></component> + </div> + </div> + </div> + </template> + + <template #controls> + <div class="_spacer"> + <MkForm v-model="settings" :form="form" @canSaveStateChange="onCanSaveStateChanged"/> + </div> + </template> + </MkPreviewWithControls> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { useTemplateRef, ref, computed, onBeforeUnmount, onMounted } from 'vue'; +import MkPreviewWithControls from './MkPreviewWithControls.vue'; +import type { Form } from '@/utility/form.js'; +import type { WidgetName } from '@/widgets/index.js'; +import { deepClone } from '@/utility/clone.js'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkForm from '@/components/MkForm.vue'; + +const props = defineProps<{ + widgetName: WidgetName; + form: Form; + currentSettings: Record<string, any>; +}>(); + +const emit = defineEmits<{ + (ev: 'saved', settings: Record<string, any>): void; + (ev: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +const settings = ref<Record<string, any>>(deepClone(props.currentSettings)); + +const canSave = ref(true); + +function onCanSaveStateChanged(newCanSave: boolean) { + canSave.value = newCanSave; +} + +function save() { + if (!canSave.value) return; + emit('saved', deepClone(settings.value)); + dialog.value?.close(); +} + +function cancel() { + emit('canceled'); + dialog.value?.close(); +} + +//#region プレビューのリサイズ +const resizerRootEl = useTemplateRef('resizerRootEl'); +const resizerEl = useTemplateRef('resizerEl'); +const widgetHeight = ref(0); +const widgetScale = ref(1); +const widgetStyle = computed(() => { + return `translate(-50%, -50%) scale(${widgetScale.value})`; +}); +const ro1 = new ResizeObserver(() => { + widgetHeight.value = resizerEl.value!.clientHeight; + calcScale(); +}); +const ro2 = new ResizeObserver(() => { + calcScale(); +}); + +function calcScale() { + if (!resizerRootEl.value) return; + const previewWidth = resizerRootEl.value.clientWidth - 40; // 左右の余白 20pxずつ + const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下の余白 20pxずつ + const widgetWidth = 280; + const scale = Math.min(previewWidth / widgetWidth, previewHeight / widgetHeight.value, 1); // 拡大はしないので1を上限に + widgetScale.value = scale; +} + +onMounted(() => { + if (resizerEl.value) { + ro1.observe(resizerEl.value); + } + if (resizerRootEl.value) { + ro2.observe(resizerRootEl.value); + } + calcScale(); +}); + +onBeforeUnmount(() => { + ro1.disconnect(); + ro2.disconnect(); +}); +//#endregion +</script> + +<style module> +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewWrapper { + display: flex; + flex-direction: column; + height: 100%; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewResizerRoot { + position: relative; + flex: 1 0; +} + +.previewResizer { + position: absolute; + container-type: inline-size; + top: 50%; + left: 50%; + width: 280px; +} +</style> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index cf7c2cda80..a27613c24c 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div :class="$style.root" class="_gaps_s"> <template v-if="edit"> <header :class="$style.editHeader"> <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> @@ -13,27 +13,23 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> </header> - <Sortable + <MkDraggable :modelValue="props.widgets" - itemKey="id" - handle=".handle" - :animation="150" - :group="{ name: 'SortableMkWidgets' }" - :class="$style.editEditing" + direction="vertical" + withGaps + group="MkWidgets" @update:modelValue="v => emit('updateWidgets', v)" > - <template #item="{element}"> + <template #default="{ item }"> <div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container> - <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> - <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> - <div class="handle"> - <component :is="`widget-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style.customizeContainerHandleWidget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> - </div> + <button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(item.id)"><i class="ti ti-settings"></i></button> + <button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(item)"><i class="ti ti-x"></i></button> + <component :is="`widget-${item.name}`" :ref="(el: any) => widgetRefs[item.id] = el" :class="$style.customizeContainerHandleWidget" :widget="item" @updateProps="updateWidget(item.id, $event)"/> </div> </template> - </Sortable> + </MkDraggable> </template> - <component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> + <component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="(el: any) => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> </div> </template> @@ -49,19 +45,19 @@ export type DefaultStoredWidget = { </script> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { computed } from 'vue'; import { isLink } from '@@/js/is-link.js'; +import type { Component } from 'vue'; import { genId } from '@/utility/id.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ widgets: Widget[]; edit: boolean; @@ -69,13 +65,13 @@ const props = defineProps<{ const _widgetDefs = computed(() => { if (instance.federation === 'none') { - return widgetDefs.filter(x => !federationWidgets.includes(x)); + return widgetDefs.filter(x => !federationWidgets.includes(x as any)); } else { return widgetDefs; } }); -const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name))); +const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name as any))); const emit = defineEmits<{ (ev: 'updateWidgets', widgets: Widget[]): void; @@ -85,10 +81,11 @@ const emit = defineEmits<{ (ev: 'exit'): void; }>(); -const widgetRefs = {}; -const configWidget = (id: string) => { +const widgetRefs = {} as Record<string, Component & { configure: () => void }>; + +function configWidget(id: string) { widgetRefs[id].configure(); -}; +} const { model: widgetAdderSelected, @@ -98,7 +95,7 @@ const { initialValue: null, }); -const addWidget = () => { +function addWidget() { if (widgetAdderSelected.value == null) return; emit('addWidget', { @@ -108,23 +105,25 @@ const addWidget = () => { }); widgetAdderSelected.value = null; -}; -const removeWidget = (widget) => { +} + +function removeWidget(widget: Widget) { emit('removeWidget', widget); -}; -const updateWidget = (id: Widget['id'], data: Widget['data']) => { +} + +function updateWidget(id: Widget['id'], data: Widget['data']) { emit('updateWidget', { id, data }); -}; +} -function onContextmenu(widget: Widget, ev: MouseEvent) { +function onContextmenu(widget: Widget, ev: PointerEvent) { const element = ev.target as HTMLElement | null; if (element && isLink(element)) return; - if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes['contenteditable'])) return; + if (element && (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(element.tagName) || element.attributes.getNamedItem('contenteditable') != null)) return; if (window.getSelection()?.toString() !== '') return; os.contextMenu([{ type: 'label', - text: i18n.ts._widgets[widget.name], + text: i18n.ts._widgets[widget.name as typeof widgetDefs[number]], }, { icon: 'ti ti-settings', text: i18n.ts.settings, @@ -142,11 +141,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { .widget { contain: content; - margin: var(--MI-margin) 0; - - &:first-of-type { - margin-top: 0; - } } .edit { @@ -158,10 +152,6 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { padding: 4px; } } - - &Editing { - min-height: 100px; - } } .customizeContainer { diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index e5ac791d0b..c79bf44794 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button> </template> </span> - <span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> + <span :class="$style.headerTitle" @pointerdown.prevent="onHeaderPointerdown"> <slot name="header"></slot> </span> <span :class="$style.headerRight"> @@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <template v-if="canResize && !minimized"> - <div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div> - <div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div> - <div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div> - <div :class="$style.handleLeft" @mousedown.prevent="onLeftHandleMousedown"></div> - <div :class="$style.handleTopLeft" @mousedown.prevent="onTopLeftHandleMousedown"></div> - <div :class="$style.handleTopRight" @mousedown.prevent="onTopRightHandleMousedown"></div> - <div :class="$style.handleBottomRight" @mousedown.prevent="onBottomRightHandleMousedown"></div> - <div :class="$style.handleBottomLeft" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + <div :class="$style.handleTop" @pointerdown.prevent="onTopHandlePointerdown"></div> + <div :class="$style.handleRight" @pointerdown.prevent="onRightHandlePointerdown"></div> + <div :class="$style.handleBottom" @pointerdown.prevent="onBottomHandlePointerdown"></div> + <div :class="$style.handleLeft" @pointerdown.prevent="onLeftHandlePointerdown"></div> + <div :class="$style.handleTopLeft" @pointerdown.prevent="onTopLeftHandlePointerdown"></div> + <div :class="$style.handleTopRight" @pointerdown.prevent="onTopRightHandlePointerdown"></div> + <div :class="$style.handleBottomRight" @pointerdown.prevent="onBottomRightHandlePointerdown"></div> + <div :class="$style.handleBottomLeft" @pointerdown.prevent="onBottomLeftHandlePointerdown"></div> </template> </div> </Transition> @@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onBeforeUnmount, onMounted, provide, useTemplateRef, ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; -import contains from '@/utility/contains.js'; +import { elementContains } from '@/utility/element-contains.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -70,20 +70,39 @@ type WindowButton = { const minHeight = 50; const minWidth = 250; -function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) { - window.addEventListener('mousemove', fn); - window.addEventListener('touchmove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - window.addEventListener('touchend', dragClear.bind(null, fn)); +function dragListen(fn: (ev: PointerEvent) => void) { + window.addEventListener('pointermove', fn); + const clear = () => { + dragClear(fn); + }; + window.addEventListener('pointerup', clear, { once: true }); + window.addEventListener('pointercancel', clear, { once: true }); + window.addEventListener('blur', clear, { once: true }); } -function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('touchmove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - window.removeEventListener('touchend', dragClear); +function dragClear(fn: (ev: PointerEvent) => void) { + window.removeEventListener('pointermove', fn); +} + +function capturePointer(evt: PointerEvent) { + const target = evt.currentTarget; + if (!(target instanceof HTMLElement)) return; + if (!target.setPointerCapture) return; + + try { + target.setPointerCapture(evt.pointerId); + } catch { + return; + } + + const release = () => { + if (target.hasPointerCapture(evt.pointerId)) { + target.releasePointerCapture(evt.pointerId); + } + }; + + window.addEventListener('pointerup', release, { once: true }); + window.addEventListener('pointercancel', release, { once: true }); } const props = withDefaults(defineProps<{ @@ -128,7 +147,7 @@ function close() { showing.value = false; } -function onKeydown(evt) { +function onKeydown(evt: KeyboardEvent) { if (evt.which === 27) { // Esc evt.preventDefault(); evt.stopPropagation(); @@ -136,7 +155,7 @@ function onKeydown(evt) { } } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { if (props.contextmenu) { os.contextMenu(props.contextmenu, ev); } @@ -209,15 +228,17 @@ function onDblClick() { } } -function getPositionX(event: MouseEvent | TouchEvent) { - return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0; +function getPositionX(event: PointerEvent) { + return event.clientX; } -function getPositionY(event: MouseEvent | TouchEvent) { - return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0; +function getPositionY(event: PointerEvent) { + return event.clientY; } -function onHeaderMousedown(evt: MouseEvent | TouchEvent) { +function onHeaderPointerdown(evt: PointerEvent) { + capturePointer(evt); + // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 if ('button' in evt && evt.button === 2) return; @@ -240,7 +261,7 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { const main = rootEl.value; if (main == null) return; - if (!contains(main, window.document.activeElement)) main.focus(); + if (!elementContains(main, window.document.activeElement)) main.focus(); const position = main.getBoundingClientRect(); @@ -289,7 +310,9 @@ function onHeaderMousedown(evt: MouseEvent | TouchEvent) { } // 上ハンドル掴み時 -function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { +function onTopHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; // どういうわけかnullになることがある if (main == null) return; @@ -317,7 +340,9 @@ function onTopHandleMousedown(evt: MouseEvent | TouchEvent) { } // 右ハンドル掴み時 -function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { +function onRightHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; if (main == null) return; @@ -342,7 +367,9 @@ function onRightHandleMousedown(evt: MouseEvent | TouchEvent) { } // 下ハンドル掴み時 -function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { +function onBottomHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; if (main == null) return; @@ -367,7 +394,9 @@ function onBottomHandleMousedown(evt: MouseEvent | TouchEvent) { } // 左ハンドル掴み時 -function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { +function onLeftHandlePointerdown(evt: PointerEvent) { + capturePointer(evt); + const main = rootEl.value; if (main == null) return; @@ -394,48 +423,48 @@ function onLeftHandleMousedown(evt: MouseEvent | TouchEvent) { } // 左上ハンドル掴み時 -function onTopLeftHandleMousedown(evt: MouseEvent | TouchEvent) { - onTopHandleMousedown(evt); - onLeftHandleMousedown(evt); +function onTopLeftHandlePointerdown(evt: PointerEvent) { + onTopHandlePointerdown(evt); + onLeftHandlePointerdown(evt); } // 右上ハンドル掴み時 -function onTopRightHandleMousedown(evt: MouseEvent | TouchEvent) { - onTopHandleMousedown(evt); - onRightHandleMousedown(evt); +function onTopRightHandlePointerdown(evt: PointerEvent) { + onTopHandlePointerdown(evt); + onRightHandlePointerdown(evt); } // 右下ハンドル掴み時 -function onBottomRightHandleMousedown(evt: MouseEvent | TouchEvent) { - onBottomHandleMousedown(evt); - onRightHandleMousedown(evt); +function onBottomRightHandlePointerdown(evt: PointerEvent) { + onBottomHandlePointerdown(evt); + onRightHandlePointerdown(evt); } // 左下ハンドル掴み時 -function onBottomLeftHandleMousedown(evt: MouseEvent | TouchEvent) { - onBottomHandleMousedown(evt); - onLeftHandleMousedown(evt); +function onBottomLeftHandlePointerdown(evt: PointerEvent) { + onBottomHandlePointerdown(evt); + onLeftHandlePointerdown(evt); } // 高さを適用 -function applyTransformHeight(height) { +function applyTransformHeight(height: number) { if (height > window.innerHeight) height = window.innerHeight; if (rootEl.value) rootEl.value.style.height = height + 'px'; } // 幅を適用 -function applyTransformWidth(width) { +function applyTransformWidth(width: number) { if (width > window.innerWidth) width = window.innerWidth; if (rootEl.value) rootEl.value.style.width = width + 'px'; } // Y座標を適用 -function applyTransformTop(top) { +function applyTransformTop(top: number) { if (rootEl.value) rootEl.value.style.top = top + 'px'; } // X座標を適用 -function applyTransformLeft(left) { +function applyTransformLeft(left: number) { if (rootEl.value) rootEl.value.style.left = left + 'px'; } @@ -566,6 +595,7 @@ defineExpose({ overflow: hidden; text-overflow: ellipsis; cursor: move; + touch-action: none; } .content { @@ -579,6 +609,7 @@ $handleSize: 8px; .handle { position: absolute; + touch-action: none; } .handleTop { diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 2375bcc9eb..20c4475779 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkWindow :initialWidth="640" :initialHeight="402" :canResize="true" :closeButton="true"> +<MkWindow :initialWidth="640" :initialHeight="402" :canResize="true" :closeButton="true" @closed="emit('closed')"> <template #header> <i class="icon ti ti-brand-youtube" style="margin-right: 0.5em;"></i> <span>{{ title ?? 'YouTube' }}</span> @@ -34,6 +34,10 @@ const props = defineProps<{ url: string; }>(); +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue index 9866e50958..1fad1ee9e6 100644 --- a/packages/frontend/src/components/global/I18n.vue +++ b/packages/frontend/src/components/global/I18n.vue @@ -46,6 +46,6 @@ const parsed = computed(() => { }); const render = () => { - return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); + return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : (slots as any)[x.arg]())); }; </script> diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 99693a4c00..7d2908d4be 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -48,7 +48,7 @@ const active = computed(() => { return resolved.route.name === router.currentRoute.value.name; }); -function onContextmenu(ev) { +function onContextmenu(ev: PointerEvent) { const selection = window.getSelection(); if (selection && selection.toString() !== '') return; os.contextMenu([{ @@ -85,7 +85,7 @@ function openWindow() { os.pageWindow(props.to); } -function nav(ev: MouseEvent) { +function nav(ev: PointerEvent) { // 制御キーとの組み合わせは無視(shiftを除く) if (ev.metaKey || ev.altKey || ev.ctrlKey) return; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index e7208ed574..b413fef3b8 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -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> @@ -77,7 +77,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'click', v: MouseEvent): void; + (ev: 'click', v: PointerEvent): void; }>(); const showDecoration = props.forceShowDecoration || prefer.s.showAvatarDecorations; @@ -91,7 +91,7 @@ const url = computed(() => { return props.user.avatarUrl; }); -function onClick(ev: MouseEvent): void { +function onClick(ev: PointerEvent): void { if (props.link) return; emit('click', ev); } diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue index 473d444c16..baa8d783f1 100644 --- a/packages/frontend/src/components/global/MkCondensedLine.vue +++ b/packages/frontend/src/components/global/MkCondensedLine.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <span :class="$style.container"> <span ref="content" :class="$style.content" :style="{ maxWidth: `${100 / minScale}%` }"> - <slot/> + <slot></slot> </span> </span> </template> @@ -23,8 +23,8 @@ const observer = new ResizeObserver((entries) => { transform: string; }[] = []; for (const entry of entries) { - const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement; - const props: Required<Props> = content[contentSymbol]; + const content = ((entry.target as any)[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement; + const props: Required<Props> = (content as any)[contentSymbol]; const container = content.parentElement as HTMLSpanElement; const contentWidth = content.getBoundingClientRect().width; const containerWidth = container.getBoundingClientRect().width; @@ -46,15 +46,15 @@ const props = withDefaults(defineProps<Props>(), { const content = ref<HTMLSpanElement>(); watch(content, (value, oldValue) => { - if (oldValue) { - delete oldValue[contentSymbol]; + if (oldValue != null) { + delete (oldValue as any)[contentSymbol]; observer.unobserve(oldValue); if (oldValue.parentElement) { observer.unobserve(oldValue.parentElement); } } - if (value) { - value[contentSymbol] = props; + if (value != null) { + (value as any)[contentSymbol] = props; observer.observe(value); if (value.parentElement) { observer.observe(value.parentElement); diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 31c358eee7..9a171876a0 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -102,7 +102,7 @@ const url = computed(() => { const alt = computed(() => `:${customEmojiName.value}:`); const errored = ref(url.value == null); -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { if (props.menu) { const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index 792f9c7d6f..686720cec2 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -67,7 +67,7 @@ function unmute() { }); } -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { if (props.menu) { const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index 3ad2fda0ee..706ea07417 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -233,7 +233,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven if (!useAnim) { return genEl(token.children, scale); } - return h(MkSparkle, {}, genEl(token.children, scale)); + return h(MkSparkle, {}, { default: () => genEl(token.children, scale) }); } case 'rotate': { const degrees = safeParseFloat(token.props.args.deg) ?? 90; @@ -319,7 +319,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven ]); } case 'clickable': { - return h('span', { onClick(ev: MouseEvent): void { + return h('span', { onClick(ev: PointerEvent): void { ev.stopPropagation(); ev.preventDefault(); const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; @@ -363,7 +363,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven url: token.props.url, rel: 'nofollow noopener', navigationBehavior: props.linkNavigationBehavior, - }, genEl(token.children, scale, true))]; + }, { default: () => genEl(token.children, scale, true) })]; } case 'mention': { @@ -381,7 +381,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--MI_THEME-hashtag);', behavior: props.linkNavigationBehavior, - }, `#${token.props.hashtag}`)]; + }, { default: () => `#${token.props.hashtag}` })]; } case 'blockCode': { diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue index 1ef75281fd..857fd3d8b4 100644 --- a/packages/frontend/src/components/global/MkPageHeader.tabs.vue +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> export type Tab = { key: string; - onClick?: (ev: MouseEvent) => void; + onClick?: (ev: PointerEvent) => void; iconOnly?: boolean; title: string; icon?: string; @@ -70,8 +70,8 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'update:tab', key: string); - (ev: 'tabClick', key: string); + (ev: 'update:tab', key: string): void; + (ev: 'tabClick', key: string): void; }>(); const el = useTemplateRef('el'); @@ -96,7 +96,7 @@ function onTabMousedown(tab: Tab, ev: MouseEvent): void { } } -function onTabClick(t: Tab, ev: MouseEvent): void { +function onTabClick(t: Tab, ev: PointerEvent): void { emit('tabClick', t.key); if (t.onClick) { diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 2f6dfed221..e8c93b7092 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div v-if="show" ref="el" :class="[$style.root]"> <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> - <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> + <div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu"> <MkAvatar :class="$style.avatar" :user="$i"/> </div> - <div v-else-if="!thin_ && narrow && !hideTitle" :class="[$style.buttons, $style.buttonsLeft]"></div> + <div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttons"></div> <template v-if="pageMetadata"> <div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/> </template> - <div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="[$style.buttons, $style.buttonsRight]"> + <div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttons"> <template v-for="action in actions"> <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> </template> @@ -61,7 +61,6 @@ export type PageHeaderProps = { import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'vue'; import { scrollToTop } from '@@/js/scroll.js'; import XTabs from './MkPageHeader.tabs.vue'; -import { globalEvents } from '@/events.js'; import { getAccountMenu } from '@/accounts.js'; import { $i } from '@/i.js'; import { DI } from '@/di.js'; @@ -72,7 +71,7 @@ const props = withDefaults(defineProps<PageHeaderProps>(), { }); const emit = defineEmits<{ - (ev: 'update:tab', key: string); + (ev: 'update:tab', key: string): void; }>(); //const viewId = inject(DI.viewId); @@ -100,7 +99,7 @@ const top = () => { } }; -async function openAccountMenu(ev: MouseEvent) { +async function openAccountMenu(ev: PointerEvent) { const menuItems = await getAccountMenu({ withExtraOperation: true, }); diff --git a/packages/frontend/src/components/global/MkResult.vue b/packages/frontend/src/components/global/MkResult.vue index 2071859e57..0dfb23782d 100644 --- a/packages/frontend/src/components/global/MkResult.vue +++ b/packages/frontend/src/components/global/MkResult.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear> - <div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps"> + <div :class="$style.root" class="_gaps"> <img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/> <MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/> <img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/> diff --git a/packages/frontend/src/components/global/MkTip.vue b/packages/frontend/src/components/global/MkTip.vue index 231957a232..1827c16c89 100644 --- a/packages/frontend/src/components/global/MkTip.vue +++ b/packages/frontend/src/components/global/MkTip.vue @@ -32,7 +32,7 @@ function _closeTip() { closeTip(props.k); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu([{ icon: 'ti ti-bulb-off', text: i18n.ts.hideAllTips, diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index aac87b7669..a11b291418 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']"> +<div ref="rootEl" :class="reversed ? '_pageScrollableReversed' : '_pageScrollable'"> <MkStickyContainer> <template #header> <MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/> diff --git a/packages/frontend/src/components/global/StackingRouterView.vue b/packages/frontend/src/components/global/StackingRouterView.vue index d52dd9b89d..689954189d 100644 --- a/packages/frontend/src/components/global/StackingRouterView.vue +++ b/packages/frontend/src/components/global/StackingRouterView.vue @@ -171,12 +171,6 @@ router.useListener('replace', ({ fullPath }) => { width: 100%; height: 100%; } - - .tabContent { - position: relative; - width: 100%; - height: 100%; - } } &:not(:first-child) { @@ -209,13 +203,17 @@ router.useListener('replace', ({ fullPath }) => { .tabContent { flex: 1; - width: 100%; - height: 100%; - background: var(--MI_THEME-bg); } } } +.tabContent { + position: relative; + width: 100%; + height: 100%; + background: var(--MI_THEME-bg); +} + .tabMenu { position: relative; margin-left: auto; diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 6f1dae8398..8745146ccf 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -188,7 +188,7 @@ function onCellKeyDown(ev: KeyboardEvent) { } } -function onInputText(ev: Event) { +function onInputText(ev: InputEvent) { editingValue.value = (ev.target as HTMLInputElement).value; } diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 96d9e35773..097a91bad5 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -715,7 +715,7 @@ function onMouseUp(ev: MouseEvent) { } } -function onContextMenu(ev: MouseEvent) { +function onContextMenu(ev: PointerEvent) { const cellAddress = getCellAddress(ev.target as HTMLElement); if (_DEV_) { console.log(`[grid][context-menu] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`); diff --git a/packages/frontend/src/composables/use-chart-tooltip.ts b/packages/frontend/src/composables/use-chart-tooltip.ts index a42f70ba02..9816de7c14 100644 --- a/packages/frontend/src/composables/use-chart-tooltip.ts +++ b/packages/frontend/src/composables/use-chart-tooltip.ts @@ -4,6 +4,7 @@ */ import { onUnmounted, onDeactivated, ref } from 'vue'; +import type { Chart, ChartType, TooltipModel } from 'chart.js'; import * as os from '@/os.js'; import MkChartTooltip from '@/components/MkChartTooltip.vue'; @@ -40,7 +41,7 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio tooltipShowing.value = false; }); - function handler(context) { + function handler(context: { chart: Chart; tooltip: TooltipModel<ChartType> }) { if (context.tooltip.opacity === 0) { tooltipShowing.value = false; return; @@ -48,8 +49,8 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio tooltipTitle.value = context.tooltip.title[0]; tooltipSeries.value = context.tooltip.body.map((b, i) => ({ - backgroundColor: context.tooltip.labelColors[i].backgroundColor, - borderColor: context.tooltip.labelColors[i].borderColor, + backgroundColor: context.tooltip.labelColors[i].backgroundColor as string, + borderColor: context.tooltip.labelColors[i].borderColor as string, text: b.lines[0], })); diff --git a/packages/frontend/src/composables/use-form.ts b/packages/frontend/src/composables/use-form.ts index 38e9b40e20..812b66d1f0 100644 --- a/packages/frontend/src/composables/use-form.ts +++ b/packages/frontend/src/composables/use-form.ts @@ -31,7 +31,7 @@ export function useForm<T extends Record<string, any>>(initialState: T, save: (n watch([currentState, previousState], () => { for (const key in modifiedStates) { - modifiedStates[key] = !deepEqual(currentState[key], previousState[key]); + (modifiedStates as any)[key] = !deepEqual(currentState[key], previousState[key]); } }, { deep: true }); diff --git a/packages/frontend/src/composables/use-mkselect.ts b/packages/frontend/src/composables/use-mkselect.ts index 7cb470d169..97cbeffa8b 100644 --- a/packages/frontend/src/composables/use-mkselect.ts +++ b/packages/frontend/src/composables/use-mkselect.ts @@ -5,7 +5,8 @@ import { ref } from 'vue'; import type { Ref, MaybeRefOrGetter } from 'vue'; -import type { MkSelectItem, OptionValue, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; +import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; +import type { OptionValue } from '@/types/option-value.js'; type UnwrapReadonlyItems<T> = T extends readonly (infer U)[] ? U[] : T; diff --git a/packages/frontend/src/composables/use-note-capture.ts b/packages/frontend/src/composables/use-note-capture.ts index 2aeb9074e5..25a9383cd5 100644 --- a/packages/frontend/src/composables/use-note-capture.ts +++ b/packages/frontend/src/composables/use-note-capture.ts @@ -7,6 +7,7 @@ import { onUnmounted, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { EventEmitter } from 'eventemitter3'; import type { Reactive } from 'vue'; +import type { NoteUpdatedEvent } from 'misskey-js/streaming.types.js'; import { useStream } from '@/stream.js'; import { $i } from '@/i.js'; import { store } from '@/store.js'; @@ -15,9 +16,9 @@ import { prefer } from '@/preferences.js'; import { globalEvents } from '@/events.js'; export const noteEvents = new EventEmitter<{ - [ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; - [ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }) => void; - [ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: string; }) => void; + [ev: `reacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }) => void; + [ev: `unreacted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }) => void; + [ev: `pollVoted:${string}`]: (ctx: { userId: Misskey.entities.User['id']; choice: number; }) => void; }>(); const fetchEvent = new EventEmitter<{ @@ -117,7 +118,7 @@ function realtimeSubscribe(props: { const note = props.note; const connection = useStream(); - function onStreamNoteUpdated(noteData): void { + function onStreamNoteUpdated(noteData: NoteUpdatedEvent): void { const { type, id, body } = noteData; if (id !== note.id) return; @@ -136,7 +137,6 @@ function realtimeSubscribe(props: { noteEvents.emit(`unreacted:${id}`, { userId: body.userId, reaction: body.reaction, - emoji: body.emoji, }); break; } @@ -194,9 +194,9 @@ export function useNoteCapture(props: { parentNote: Misskey.entities.Note | null; mock?: boolean; }): { - $note: Reactive<ReactiveNoteData>; - subscribe: () => void; - } { + $note: Reactive<ReactiveNoteData>; + subscribe: () => void; +} { const { note, parentNote, mock } = props; const $note = reactive<ReactiveNoteData>({ @@ -224,7 +224,7 @@ export function useNoteCapture(props: { const reactionUserMap = new Map<Misskey.entities.User['id'], string | typeof noReaction>(); let latestPollVotedKey: string | null = null; - function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }): void { let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:'); normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, ''); if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return; @@ -244,7 +244,7 @@ export function useNoteCapture(props: { } } - function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; }; }): void { + function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }): void { let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:'); normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, ''); @@ -263,7 +263,7 @@ export function useNoteCapture(props: { } } - function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: string; }): void { + function onPollVoted(ctx: { userId: Misskey.entities.User['id']; choice: number; }): void { const newPollVotedKey = `${ctx.userId}:${ctx.choice}`; if (newPollVotedKey === latestPollVotedKey) return; latestPollVotedKey = newPollVotedKey; diff --git a/packages/frontend/src/composables/use-tooltip.ts b/packages/frontend/src/composables/use-tooltip.ts index af76a3a1e8..e369928208 100644 --- a/packages/frontend/src/composables/use-tooltip.ts +++ b/packages/frontend/src/composables/use-tooltip.ts @@ -22,7 +22,7 @@ export function useTooltip( let changeShowingState: (() => void) | null; - let autoHidingTimer; + let autoHidingTimer: number | null = null; const open = () => { close(); @@ -43,7 +43,7 @@ export function useTooltip( isHovering = false; window.clearTimeout(timeoutId); close(); - window.clearInterval(autoHidingTimer); + if (autoHidingTimer != null) window.clearInterval(autoHidingTimer); } }, 1000); }; @@ -66,7 +66,7 @@ export function useTooltip( if (!isHovering) return; isHovering = false; window.clearTimeout(timeoutId); - window.clearInterval(autoHidingTimer); + if (autoHidingTimer != null) window.clearInterval(autoHidingTimer); close(); }; @@ -81,7 +81,7 @@ export function useTooltip( if (!isHovering) return; isHovering = false; window.clearTimeout(timeoutId); - window.clearInterval(autoHidingTimer); + if (autoHidingTimer != null) window.clearInterval(autoHidingTimer); close(); }; diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts index 8ffb1e656b..fabc04895e 100644 --- a/packages/frontend/src/composables/use-uploader.ts +++ b/packages/frontend/src/composables/use-uploader.ts @@ -664,7 +664,7 @@ export function useUploader(options: { if (needsCompress) { const config = { - mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', + mimeType: (isWebpSupported() ? 'image/webp' : 'image/jpeg') as 'image/webp' | 'image/jpeg', maxWidth: compressionSettings.maxWidth, maxHeight: compressionSettings.maxHeight, quality: isWebpSupported() ? 0.85 : 0.8, diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 45d4b40fd7..e04d540c38 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -60,11 +60,11 @@ export async function fetchCustomEmojis(force = false) { set('lastEmojisFetchedAt', now); } -let cachedTags; +let cachedTags: string[] | null = null; export function getCustomEmojiTags() { if (cachedTags) return cachedTags; - const tags = new Set(); + const tags = new Set<string>(); for (const emoji of customEmojis.value) { for (const tag of emoji.aliases) { tags.add(tag); diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts index 208adae8fe..e85ca76ca1 100644 --- a/packages/frontend/src/deck.ts +++ b/packages/frontend/src/deck.ts @@ -316,14 +316,14 @@ export function updateColumn(id: Column['id'], column: Partial<Column>) { const currentColumn = deepClone(columns.value[columnIndex]); if (currentColumn == null) return; for (const [k, v] of Object.entries(column)) { - currentColumn[k] = v; + (currentColumn[k as keyof typeof column] as any) = v; } newColumns[columnIndex] = currentColumn; columns.value = newColumns; saveCurrentDeckProfile(); } -export function switchProfileMenu(ev: MouseEvent) { +export function switchProfileMenu(ev: PointerEvent) { const items: MenuItem[] = prefer.s['deck.profile'] ? [{ text: prefer.s['deck.profile'], active: true, diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts index 117dc397da..599f2378d1 100644 --- a/packages/frontend/src/directives/appear.ts +++ b/packages/frontend/src/directives/appear.ts @@ -16,7 +16,7 @@ export const appearDirective = { const fn = binding.value; if (fn == null) return; - const check = throttle<IntersectionObserverCallback>(1000, (entries) => { + const check = throttle<IntersectionObserverCallback>(500, (entries) => { if (entries.some(entry => entry.isIntersecting)) { fn(); } diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts index 07b756b95d..a88778efaf 100644 --- a/packages/frontend/src/directives/index.ts +++ b/packages/frontend/src/directives/index.ts @@ -39,7 +39,7 @@ export const directives = { } as Record<string, Directive>; declare module 'vue' { - export interface ComponentCustomProperties { + export interface GlobalDirectives { vUserPreview: typeof userPreviewDirective; vGetSize: typeof getSizeDirective; vRipple: typeof rippleDirective; diff --git a/packages/frontend/src/drag-and-drop.ts b/packages/frontend/src/drag-and-drop.ts index 670912241e..725e7a70b3 100644 --- a/packages/frontend/src/drag-and-drop.ts +++ b/packages/frontend/src/drag-and-drop.ts @@ -9,6 +9,7 @@ type DragDataMap = { driveFiles: Misskey.entities.DriveFile[]; driveFolders: Misskey.entities.DriveFolder[]; deckColumn: string; + MkDraggable: { item: { id: string }; instanceId: string; group: string; }; }; // NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要 diff --git a/packages/frontend/src/filters/bytes.ts b/packages/frontend/src/filters/bytes.ts index 49b44167d4..a5b6fad3b1 100644 --- a/packages/frontend/src/filters/bytes.ts +++ b/packages/frontend/src/filters/bytes.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export default (v, digits = 0) => { +export default (v: number | null, digits = 0) => { if (v == null) return '?'; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB']; if (v === 0) return '0'; diff --git a/packages/frontend/src/filters/kmg.ts b/packages/frontend/src/filters/kmg.ts index 9608e420f6..0aaa70d50d 100644 --- a/packages/frontend/src/filters/kmg.ts +++ b/packages/frontend/src/filters/kmg.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export default (v, fractionDigits = 0) => { +export default (v: number | null, fractionDigits = 0) => { if (v == null) return 'N/A'; if (v === 0) return '0'; const sizes = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'R', 'Q']; diff --git a/packages/frontend/src/filters/note.ts b/packages/frontend/src/filters/note.ts index ce31021469..3fcd50c5e3 100644 --- a/packages/frontend/src/filters/note.ts +++ b/packages/frontend/src/filters/note.ts @@ -3,6 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export const notePage = note => { +export const notePage = (note: { id: string }) => { return `/notes/${note.id}`; }; diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index c9d83a4dbe..6f15826aaa 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -43,7 +43,7 @@ export async function fetchInstance(force = false): Promise<Misskey.entities.Met }); for (const [k, v] of Object.entries(meta)) { - instance[k] = v; + (instance[k as keyof typeof meta] as any) = v; } miLocalStorage.setItem('instance', JSON.stringify(instance)); @@ -51,9 +51,3 @@ export async function fetchInstance(force = false): Promise<Misskey.entities.Met return instance; } - -export type ClientOptions = { - entrancePageStyle: 'classic' | 'simple'; - showTimelineForVisitor: boolean; - showActivitiesForVisitor: boolean; -}; diff --git a/packages/frontend/src/lib/nirax.ts b/packages/frontend/src/lib/nirax.ts index 74dda9decd..ce77a2baa9 100644 --- a/packages/frontend/src/lib/nirax.ts +++ b/packages/frontend/src/lib/nirax.ts @@ -125,7 +125,7 @@ type GetSinglePathQuery<Def extends RouteDef, Path extends FlattenAllPaths<Route ? ChildPath extends FlattenAllPaths<Children> ? GetPathQuery<Children, ChildPath> : Record<string, never> - : never + : never : never : never : Def['path'] extends Path @@ -133,9 +133,9 @@ type GetSinglePathQuery<Def extends RouteDef, Path extends FlattenAllPaths<Route ? Query extends Record<string, string> ? UnwrapReadOnly<{ [Key in keyof Query]?: string; }> : Record<string, never> + : Record<string, never> : Record<string, never> - : Record<string, never> - >; +>; type GetPathQuery<Defs extends RouteDef[], Path extends FlattenAllPaths<Defs>> = GetSinglePathQuery<Defs[number], Path>; @@ -320,11 +320,11 @@ export class Nirax<DEF extends RouteDef[]> extends EventEmitter<RouterEvents> { if (route.query != null && queryString != null) { const queryObject = [...new URLSearchParams(queryString).entries()] - .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); + .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}) as Record<string, string>; for (const q in route.query) { const as = route.query[q]; - if (queryObject[q]) { + if (queryObject[q] != null) { props.set(as, safeURIDecode(queryObject[q])); } } diff --git a/packages/frontend/src/lib/pizzax.ts b/packages/frontend/src/lib/pizzax.ts index 80543d10e4..0dd8a82957 100644 --- a/packages/frontend/src/lib/pizzax.ts +++ b/packages/frontend/src/lib/pizzax.ts @@ -7,7 +7,7 @@ // TODO: Misskeyのドメイン知識があるのでutilityなどに移動する -import { onUnmounted, ref, watch } from 'vue'; +import { customRef, ref, watch, onScopeDispose } from 'vue'; import { BroadcastChannel } from 'broadcast-channel'; import type { Ref } from 'vue'; import { $i } from '@/i.js'; @@ -223,44 +223,43 @@ export class Pizzax<T extends StateDef> { } /** - * 特定のキーの、簡易的なgetter/setterを作ります + * 特定のキーの、簡易的なcomputed refを作ります * 主にvue上で設定コントロールのmodelとして使う用 */ - // TODO: 廃止 - public makeGetterSetter<K extends keyof T, R = T[K]['default']>( + public model<K extends keyof T, R = T[K]['default']>( + key: K, + ): Ref<R>; + public model<K extends keyof T, R extends Exclude<any, T[K]['default']>>( + key: K, + getter: (v: T[K]['default']) => R, + setter: (v: R) => T[K]['default'], + ): Ref<R>; + + public model<K extends keyof T, R>( key: K, getter?: (v: T[K]['default']) => R, setter?: (v: R) => T[K]['default'], - ): { - get: () => R; - set: (value: R) => void; - } { - const valueRef = ref(this.s[key]); + ): Ref<R> { + return customRef<R>((track, trigger) => { + const watchStop = watch(this.r[key], () => { + trigger(); + }); - const stop = watch(this.r[key], val => { - valueRef.value = val; - }); + onScopeDispose(() => { + watchStop(); + }, true); - // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする - onUnmounted(() => { - stop(); + return { + get: () => { + track(); + return (getter != null ? getter(this.s[key]) : this.s[key]) as R; + }, + set: (value) => { + const val = setter != null ? setter(value) : value; + this.set(key, val as T[K]['default']); + }, + }; }); - - // TODO: VueのcustomRef使うと良い感じになるかも - return { - get: () => { - if (getter) { - return getter(valueRef.value); - } else { - return valueRef.value; - } - }, - set: (value) => { - const val = setter ? setter(value) : value; - this.set(key, val); - valueRef.value = val; - }, - }; } // localStorage => indexedDBのマイグレーション diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index a162b3aa9e..c5b77c498a 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -6,6 +6,7 @@ import { computed, reactive } from 'vue'; import { ui } from '@@/js/config.js'; import { clearCache } from './utility/clear-cache.js'; +import type { ComputedRef } from 'vue'; import { $i } from '@/i.js'; import { miLocalStorage } from '@/local-storage.js'; import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js'; @@ -14,7 +15,17 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { unisonReload } from '@/utility/unison-reload.js'; -export const navbarItemDef = reactive({ +export const navbarItemDef = reactive<{ + [key: string]: { + title: string; + icon: string; + show?: ComputedRef<boolean>; + indicated?: ComputedRef<boolean>; + indicateValue?: ComputedRef<string>; + to?: string; + action?: (ev: PointerEvent) => void; + }; +}>({ notifications: { title: i18n.ts.notifications, icon: 'ti ti-bell', @@ -137,7 +148,7 @@ export const navbarItemDef = reactive({ ui: { title: i18n.ts.switchUi, icon: 'ti ti-devices', - action: (ev: MouseEvent) => { + action: (ev) => { os.popupMenu([{ text: i18n.ts.default, active: ui === 'default' || ui === null, diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index aafa1c4b21..f7b59612c4 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -8,13 +8,16 @@ import { markRaw, ref, defineAsyncComponent, nextTick } from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as Misskey from 'misskey-js'; -import type { Component, Ref } from 'vue'; +import type { Component, MaybeRef } from 'vue'; import type { ComponentEmit, ComponentProps as CP } from 'vue-component-type-helpers'; import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; import type { UploaderFeatures } from '@/composables/use-uploader.js'; -import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { OptionValue } from '@/types/option-value.js'; +import type { MkDialogReturnType } from '@/components/MkDialog.vue'; +import type { OverloadToUnion } from '@/types/overload-to-union.js'; import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -159,12 +162,34 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number { } // props に ref を許可するようにする -type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> }; +type PropsWithRefs<P> = { [K in keyof P]: MaybeRef<P[K]> }; +type ComponentProps<T extends Component> = PropsWithRefs<CP<T>>; +// 関数の引数が any[] (もっとも広義なもの) かどうかを判定し、any[] の場合は排除 (never) するヘルパー +type FilterSpecificFunc<T> = T extends (...args: any[]) => void + ? (any[] extends Parameters<T> ? never : T) + : T; + +// オブジェクトの各プロパティに対して再帰的、あるいは単純に適用する型関数 +type CleanFunctions<T> = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? FilterSpecificFunc<T[K]> + : T[K]; +}; + +// emitの関数群をオブジェクト型に変換する(InstanceType<Component>['$emit']はFunctionalComponent = ジェネリックコンポーネントでは使用できない) +type ComponentEmitsObject<C extends Component, IE = OverloadToUnion<ComponentEmit<C>>> = CleanFunctions<{ + [K in IE extends (evName: infer U, ...args: any[]) => any ? U & PropertyKey : never]: IE extends (evName: K, ...args: infer A) => infer R + ? (...args: A) => R + : (...args: any[]) => void; +}>; + +// NOTE: ジェネリック型つきのコンポーネントでは、emitsの型推論がうまく働かない(型変数を取り出すことはできないため) +// NOTE: emitsがOverloadToUnionで対応しているオーバーロードの数を超える場合は、OverloadToUnionの個数を増やせばOK export function popup<T extends Component>( component: T, props: ComponentProps<T>, - events: Partial<ComponentEmit<T>> = {}, + events: Partial<ComponentEmitsObject<T>> = {}, ): { dispose: () => void } { markRaw(component); @@ -192,10 +217,10 @@ export function popup<T extends Component>( export async function popupAsyncWithDialog<T extends Component>( componentFetching: Promise<T>, props: ComponentProps<T>, - events: Partial<ComponentEmit<T>> = {}, + events: Partial<ComponentEmitsObject<T>> = {}, ): Promise<{ dispose: () => void }> { let component: T; - let closeWaiting = () => {}; + let closeWaiting = () => { }; const timer = window.setTimeout(() => { closeWaiting = waiting(); @@ -291,23 +316,19 @@ export function confirm(props: { }); } -// TODO: const T extends ... にしたい -// https://zenn.dev/general_link/articles/813e47b7a0eef7#const-type-parameters -export function actions<T extends { +type ActionsAction = { value: string; text: string; - primary?: boolean, - danger?: boolean, -}[]>(props: { + primary?: boolean; + danger?: boolean; +}; + +export function actions<const T extends ActionsAction[]>(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; title?: string; text?: string; actions: T; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: T[number]['value']; -}> { +}): Promise<MkDialogReturnType<T[number]['value']>> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { ...props, @@ -321,7 +342,7 @@ export function actions<T extends { })), }, { done: result => { - resolve(result ? result : { canceled: true }); + resolve(result as MkDialogReturnType<T[number]['value']>); }, closed: () => dispose(), }); @@ -338,11 +359,7 @@ export function inputText(props: { default: string; minLength?: number; maxLength?: number; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: string; -}>; +}): Promise<MkDialogReturnType<string>>; // min lengthが指定されてたら result は null になり得ないことを保証する overload function export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; @@ -353,11 +370,7 @@ export function inputText(props: { default?: string; minLength: number; maxLength?: number; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: string; -}>; +}): Promise<MkDialogReturnType<string>>; export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; title?: string; @@ -367,11 +380,7 @@ export function inputText(props: { default?: string | null; minLength?: number; maxLength?: number; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: string | null; -}>; +}): Promise<MkDialogReturnType<string | null>>; export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; title?: string; @@ -381,11 +390,7 @@ export function inputText(props: { default?: string | null; minLength?: number; maxLength?: number; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: string | null; -}> { +}): Promise<MkDialogReturnType<string | null>> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { title: props.title, @@ -400,7 +405,7 @@ export function inputText(props: { }, }, { done: result => { - resolve(result ? result : { canceled: true }); + resolve(result as MkDialogReturnType<string | null>); }, closed: () => dispose(), }); @@ -414,33 +419,21 @@ export function inputNumber(props: { placeholder?: string | null; autocomplete?: string; default: number; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: number; -}>; +}): Promise<MkDialogReturnType<number>>; export function inputNumber(props: { title?: string; text?: string; placeholder?: string | null; autocomplete?: string; default?: number | null; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: number | null; -}>; +}): Promise<MkDialogReturnType<number | null>>; export function inputNumber(props: { title?: string; text?: string; placeholder?: string | null; autocomplete?: string; default?: number | null; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: number | null; -}> { +}): Promise<MkDialogReturnType<number | null>> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { title: props.title, @@ -453,7 +446,7 @@ export function inputNumber(props: { }, }, { done: result => { - resolve(result ? result : { canceled: true }); + resolve(result as MkDialogReturnType<number | null>); }, closed: () => dispose(), }); @@ -465,11 +458,7 @@ export function inputDatetime(props: { text?: string; placeholder?: string | null; default?: string | null; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: Date; -}> { +}): Promise<MkDialogReturnType<Date>> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { title: props.title, @@ -481,7 +470,7 @@ export function inputDatetime(props: { }, }, { done: result => { - resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); + resolve(result != null && typeof result.result === 'string' ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); }, closed: () => dispose(), }); @@ -508,11 +497,7 @@ export function select<C extends OptionValue, D extends C | null = null>(props: text?: string; default?: D; items: (MkSelectItem<C> | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: Exclude<D, undefined> extends null ? C | null : C; -}> { +}): Promise<MkDialogReturnType<Exclude<D, undefined> extends null ? C | null : C>> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { title: props.title, @@ -523,7 +508,7 @@ export function select<C extends OptionValue, D extends C | null = null>(props: }, }, { done: result => { - resolve(result ? result : { canceled: true }); + resolve(result as MkDialogReturnType<Exclude<D, undefined> extends null ? C | null : C>); }, closed: () => dispose(), }); @@ -582,7 +567,7 @@ export function form<F extends Form>(title: string, f: F): Promise<{ canceled: t return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { done: result => { - resolve(result); + resolve(result as { canceled?: false, result: GetFormResultType<F> }); }, closed: () => dispose(), }); @@ -634,16 +619,16 @@ export async function pickEmoji(anchorElement: HTMLElement, opts: ComponentProps }); } -export async function cropImageFile(imageFile: File | Blob, options: { +export async function cropImageFile<F extends File | Blob>(imageFile: F, options: { aspectRatio: number | null; -}): Promise<File> { +}): Promise<F> { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { imageFile: imageFile, aspectRatio: options.aspectRatio, }, { ok: x => { - resolve(x); + resolve(x as F); }, closed: () => dispose(), }); @@ -654,6 +639,7 @@ export function popupMenu(items: (MenuItem | null)[], anchorElement?: HTMLElemen align?: string; width?: number; onClosing?: () => void; + onClosed?: () => void; }): Promise<void> { if (!(anchorElement instanceof HTMLElement)) { anchorElement = null; @@ -672,6 +658,7 @@ export function popupMenu(items: (MenuItem | null)[], anchorElement?: HTMLElemen resolve(); dispose(); returnFocusTo = null; + options?.onClosed?.(); }, closing: () => { options?.onClosing?.(); @@ -680,7 +667,7 @@ export function popupMenu(items: (MenuItem | null)[], anchorElement?: HTMLElemen })); } -export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { +export function contextMenu(items: MenuItem[], ev: PointerEvent): Promise<void> { if ( prefer.s.contextMenu === 'native' || (prefer.s.contextMenu === 'appWithShift' && !ev.shiftKey) @@ -709,8 +696,8 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { })); } -export function post(props: PostFormProps = {}): Promise<void> { - pleaseLogin({ +export async function post(props: PostFormProps = {}): Promise<void> { + const isLoggedIn = await pleaseLogin({ openOnRemote: (props.initialText || props.initialNote ? { type: 'share', params: { @@ -720,6 +707,7 @@ export function post(props: PostFormProps = {}): Promise<void> { }, } : undefined), }); + if (!isLoggedIn) return; showMovedDialog(); return new Promise(resolve => { @@ -779,7 +767,7 @@ export function chooseFileFromPc( }); } -export function launchUploader( +export async function launchUploader( files: File[], options?: { folderId?: string | null; @@ -787,9 +775,10 @@ export function launchUploader( features?: UploaderFeatures; }, ): Promise<Misskey.entities.DriveFile[]> { - return new Promise(async (res, rej) => { + return new Promise((res, rej) => { if (files.length === 0) return rej(); - const { dispose } = await popupAsyncWithDialog(import('@/components/MkUploaderDialog.vue').then(x => x.default), { + let dispose: () => void; + popupAsyncWithDialog(import('@/components/MkUploaderDialog.vue').then(x => x.default), { files: markRaw(files), folderId: options?.folderId, multiple: options?.multiple, @@ -800,7 +789,7 @@ export function launchUploader( res(driveFiles); }, closed: () => dispose(), - }); + }).then(d => dispose = d.dispose, rej); }); } diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index bbfb9a3b7c..c109000108 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -97,7 +97,7 @@ const paginator = markRaw(new Paginator('federation/instances', { })), })); -function getStatus(instance) { +function getStatus(instance: Misskey.entities.FederationInstance) { if (instance.isSuspended) return 'Suspended'; if (instance.isBlocked) return 'Blocked'; if (instance.isSilenced) return 'Silenced'; diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 22e377c75d..b084eb5ab2 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -105,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts._role.policies }}</template> <div class="_gaps"> <div v-for="policy in Object.keys(info.policies)" :key="policy"> - {{ policy }} ... {{ info.policies[policy] }} + {{ policy }} ... {{ info.policies[policy as keyof typeof info.policies] }} </div> </div> </MkFolder> @@ -209,6 +209,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, watch, ref, markRaw } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; +import type { ChartSrc } from '@/components/MkChart.vue'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -231,7 +232,6 @@ import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { Paginator } from '@/utility/paginator.js'; -import type { ChartSrc } from '@/components/MkChart.vue'; const $i = ensureSignin(); @@ -251,7 +251,7 @@ const { } = useMkSelect({ items: [ { label: i18n.ts.notes, value: 'per-user-notes' }, -], + ], initialValue: 'per-user-notes', }); const user = ref(result.user); @@ -344,7 +344,7 @@ async function resetPassword() { } } -async function toggleSuspend(v) { +async function toggleSuspend(v: boolean) { const confirm = await os.confirm({ type: 'warning', text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm, @@ -475,7 +475,7 @@ async function assignRole() { refreshUser(); } -async function unassignRole(role: typeof info.value.roles[number], ev: MouseEvent) { +async function unassignRole(role: typeof info.value.roles[number], ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.unassign, icon: 'ti ti-x', @@ -503,7 +503,7 @@ async function createAnnouncement() { }); } -async function editAnnouncement(announcement) { +async function editAnnouncement(announcement: Misskey.entities.AdminAnnouncementsListResponse[number]) { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkUserAnnouncementEditDialog.vue').then(x => x.default), { user: user.value, announcement, diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 9d9db9158d..384282262d 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div :class="$style.header"> - <MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect"> + <MkSelect v-model="typeModelForMkSelect" :items="typeDef" :class="$style.typeSelect"> </MkSelect> - <button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle"> + <button v-if="draggable" class="_button" :class="$style.dragHandle" :draggable="true" @dragstart.stop="dragStartCallback"> <i class="ti ti-menu-2"></i> </button> <button v-if="draggable" class="_button" :class="$style.remove" @click="removeSelf"> @@ -16,55 +16,69 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> - <div v-if="type === 'and' || type === 'or'" class="_gaps"> - <Sortable v-model="v.values" tag="div" class="_gaps" itemKey="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swapThreshold="0.5"> - <template #item="{element}"> + <div v-if="v.type === 'and' || v.type === 'or'" class="_gaps"> + <MkDraggable + v-model="v.values" + direction="vertical" + withGaps + canNest + manualDragStart + group="roleFormula" + > + <template #default="{ item, dragStart }"> <div :class="$style.item"> - <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <RolesEditorFormula :modelValue="element" draggable @update:modelValue="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/> + <!-- divが無いとエラーになる --> + <RolesEditorFormula + :modelValue="item" + :dragStartCallback="dragStart" + draggable + @update:modelValue="updated => childValuesItemUpdated(updated)" + @remove="removeChildItem(item.id)" + /> </div> </template> - </Sortable> - <MkButton rounded style="margin: 0 auto;" @click="addValue"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + </MkDraggable> + <MkButton rounded style="margin: 0 auto;" @click="addChildValue"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> </div> - <div v-else-if="type === 'not'" :class="$style.item"> + <div v-else-if="v.type === 'not'" :class="$style.item"> <RolesEditorFormula v-model="v.value"/> </div> - <MkInput v-else-if="type === 'createdLessThan' || type === 'createdMoreThan'" v-model="v.sec" type="number"> + <MkInput v-else-if="v.type === 'createdLessThan' || v.type === 'createdMoreThan'" v-model="v.sec" type="number"> <template #suffix>sec</template> </MkInput> - <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> + <MkInput v-else-if="v.type === 'followersLessThanOrEq' || v.type === 'followersMoreThanOrEq' || v.type === 'followingLessThanOrEq' || v.type === 'followingMoreThanOrEq' || v.type === 'notesLessThanOrEq' || v.type === 'notesMoreThanOrEq'" v-model="v.value" type="number"> </MkInput> - <MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId" :items="assignedToDef"> + <MkSelect v-else-if="v.type === 'roleAssignedTo'" v-model="v.roleId" :items="assignedToDef"> </MkSelect> </div> </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; import { genId } from '@/utility/id.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/utility/clone.js'; import { rolesCache } from '@/cache.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const emit = defineEmits<{ - (ev: 'update:modelValue', value: any): void; + (ev: 'update:modelValue', value: Misskey.entities.Role['condFormula']): void; (ev: 'remove'): void; }>(); const props = defineProps<{ - modelValue: any; + modelValue: Misskey.entities.Role['condFormula']; draggable?: boolean; + dragStartCallback?: (ev: DragEvent) => void; }>(); const v = ref(deepClone(props.modelValue)); @@ -102,38 +116,51 @@ const typeDef = [ { label: i18n.ts._role._condition.not, value: 'not' }, ] as const satisfies MkSelectItem[]; -const type = computed<GetMkSelectValueTypesFromDef<typeof typeDef>>({ +type KeyOfUnion<T> = T extends T ? keyof T : never; + +type DistributiveOmit<T, K extends KeyOfUnion<T>> = T extends T + ? Omit<T, K> + : never; + +const typeModelForMkSelect = computed<GetMkSelectValueTypesFromDef<typeof typeDef>>({ get: () => v.value.type, set: (t) => { - if (t === 'and') v.value.values = []; - if (t === 'or') v.value.values = []; - if (t === 'not') v.value.value = { id: genId(), type: 'isRemote' }; - if (t === 'roleAssignedTo') v.value.roleId = ''; - if (t === 'createdLessThan') v.value.sec = 86400; - if (t === 'createdMoreThan') v.value.sec = 86400; - if (t === 'followersLessThanOrEq') v.value.value = 10; - if (t === 'followersMoreThanOrEq') v.value.value = 10; - if (t === 'followingLessThanOrEq') v.value.value = 10; - if (t === 'followingMoreThanOrEq') v.value.value = 10; - if (t === 'notesLessThanOrEq') v.value.value = 10; - if (t === 'notesMoreThanOrEq') v.value.value = 10; - v.value.type = t; + let newValue: DistributiveOmit<Misskey.entities.Role['condFormula'], 'id'>; + switch (t) { + case 'and': newValue = { type: 'and', values: [] }; break; + case 'or': newValue = { type: 'or', values: [] }; break; + case 'not': newValue = { type: 'not', value: { id: genId(), type: 'isRemote' } }; break; + case 'roleAssignedTo': newValue = { type: 'roleAssignedTo', roleId: '' }; break; + case 'createdLessThan': newValue = { type: 'createdLessThan', sec: 86400 }; break; + case 'createdMoreThan': newValue = { type: 'createdMoreThan', sec: 86400 }; break; + case 'followersLessThanOrEq': newValue = { type: 'followersLessThanOrEq', value: 10 }; break; + case 'followersMoreThanOrEq': newValue = { type: 'followersMoreThanOrEq', value: 10 }; break; + case 'followingLessThanOrEq': newValue = { type: 'followingLessThanOrEq', value: 10 }; break; + case 'followingMoreThanOrEq': newValue = { type: 'followingMoreThanOrEq', value: 10 }; break; + case 'notesLessThanOrEq': newValue = { type: 'notesLessThanOrEq', value: 10 }; break; + case 'notesMoreThanOrEq': newValue = { type: 'notesMoreThanOrEq', value: 10 }; break; + default: newValue = { type: t }; break; + } + v.value = { id: v.value.id, ...newValue }; }, }); const assignedToDef = computed(() => roles.filter(r => r.target === 'manual').map(r => ({ label: r.name, value: r.id })) satisfies MkSelectItem[]); -function addValue() { +function addChildValue() { + if (v.value.type !== 'and' && v.value.type !== 'or') return; v.value.values.push({ id: genId(), type: 'isRemote' }); } -function valuesItemUpdated(item) { +function childValuesItemUpdated(item: Misskey.entities.Role['condFormula']) { + if (v.value.type !== 'and' && v.value.type !== 'or') return; const i = v.value.values.findIndex(_item => _item.id === item.id); v.value.values[i] = item; } -function removeItem(item) { - v.value.values = v.value.values.filter(_item => _item.id !== item.id); +function removeChildItem(itemId: string) { + if (v.value.type !== 'and' && v.value.type !== 'or') return; + v.value.values = v.value.values.filter(_item => _item.id !== itemId); } function removeSelf() { diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index 7c3f736506..591d8fa736 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -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> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue index 36d586bd23..ba5830c2e8 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue @@ -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> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue index 893bd8d6d3..a9cf372c0e 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -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"> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 76bf20b409..2d204987cb 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -105,7 +105,7 @@ const paginator = markRaw(new Paginator('admin/abuse-user-reports', { })), })); -function resolved(reportId) { +function resolved(reportId: string) { paginator.removeItem(reportId); } diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 94940a84ae..0efd1a2e28 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -22,22 +22,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.imageUrl }}</template> </MkInput> - <MkRadios v-model="ad.place"> + <MkRadios + v-model="ad.place" + :options="[ + { value: 'square' }, + { value: 'horizontal' }, + { value: 'horizontal-big' }, + ]" + > <template #label>Form</template> - <option value="square">square</option> - <option value="horizontal">horizontal</option> - <option value="horizontal-big">horizontal-big</option> </MkRadios> - <!-- - <div style="margin: 32px 0;"> - {{ i18n.ts.priority }} - <MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio> - <MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio> - <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> - </div> - --> - <FormSplit> <MkInput v-model="ad.ratio" type="number"> <template #label>{{ i18n.ts.ratio }}</template> @@ -109,7 +104,11 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; -const ads = ref<Misskey.entities.Ad[]>([]); +type Ad = Misskey.entities.Ad & { + place: 'square' | 'horizontal' | 'horizontal-big'; +}; + +const ads = ref<Ad[]>([]); // ISO形式はTZがUTCになってしまうので、TZ分ずらして時間を初期化 const localTime = new Date(); @@ -136,7 +135,7 @@ misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff); stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff); return { - ...r, + ...(r as Ad), expiresAt: exdate.toISOString().slice(0, 16), startsAt: stdate.toISOString().slice(0, 16), }; @@ -239,7 +238,7 @@ function more() { exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff); stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff); return { - ...r, + ...(r as Ad), expiresAt: exdate.toISOString().slice(0, 16), startsAt: stdate.toISOString().slice(0, 16), }; @@ -256,7 +255,7 @@ function refresh() { exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff); stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff); return { - ...r, + ...(r as Ad), expiresAt: exdate.toISOString().slice(0, 16), startsAt: stdate.toISOString().slice(0, 16), }; diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index b90a724b17..87fc6e70f4 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_gaps"> <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> - <MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> + <MkInfo v-if="announcementsStatus === 'active' && announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef"> <template #label>{{ i18n.ts.filter }}</template> @@ -45,18 +45,26 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="announcement.imageUrl" type="url"> <template #label>{{ i18n.ts.imageUrl }}</template> </MkInput> - <MkRadios v-model="announcement.icon"> + <MkRadios + v-model="announcement.icon" + :options="[ + { value: 'info', icon: 'ti ti-info-circle' }, + { value: 'warning', icon: 'ti ti-alert-triangle', iconStyle: 'color: var(--MI_THEME-warn);' }, + { value: 'error', icon: 'ti ti-circle-x', iconStyle: 'color: var(--MI_THEME-error);' }, + { value: 'success', icon: 'ti ti-check', iconStyle: 'color: var(--MI_THEME-success);' }, + ]" + > <template #label>{{ i18n.ts.icon }}</template> - <option value="info"><i class="ti ti-info-circle"></i></option> - <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i></option> - <option value="error"><i class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i></option> - <option value="success"><i class="ti ti-check" style="color: var(--MI_THEME-success);"></i></option> </MkRadios> - <MkRadios v-model="announcement.display"> + <MkRadios + v-model="announcement.display" + :options="[ + { value: 'normal', label: i18n.ts.normal }, + { value: 'banner', label: i18n.ts.banner }, + { value: 'dialog', label: i18n.ts.dialog }, + ]" + > <template #label>{{ i18n.ts.display }}</template> - <option value="normal">{{ i18n.ts.normal }}</option> - <option value="banner">{{ i18n.ts.banner }}</option> - <option value="dialog">{{ i18n.ts.dialog }}</option> </MkRadios> <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo> <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> @@ -83,6 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; @@ -112,7 +121,12 @@ const { const loading = ref(true); const loadingMore = ref(false); -const announcements = ref<any[]>([]); +const announcements = ref<(Omit<Misskey.entities.AdminAnnouncementsListResponse[number], 'id' | 'createdAt' | 'updatedAt' | 'reads' | 'isActive'> & { + id: string | null; + _id?: string; + isActive?: Misskey.entities.AdminAnnouncementsListResponse[number]['isActive']; + reads?: Misskey.entities.AdminAnnouncementsListResponse[number]['reads']; +})[]>([]); watch(announcementsStatus, (to) => { loading.value = true; @@ -136,42 +150,55 @@ function add() { forExistingUsers: false, silence: false, needConfirmationToRead: false, + userId: null, }); } -function del(announcement) { - os.confirm({ +async function del(announcement: (typeof announcements)['value'][number]) { + if (announcement.id == null) return; + const { canceled } = await os.confirm({ type: 'warning', text: i18n.tsx.deleteAreYouSure({ x: announcement.title }), - }).then(({ canceled }) => { - if (canceled) return; - announcements.value = announcements.value.filter(x => x !== announcement); - misskeyApi('admin/announcements/delete', announcement); + }); + if (canceled) return; + announcements.value = announcements.value.filter(x => x !== announcement); + misskeyApi('admin/announcements/delete', { + id: announcement.id, }); } -async function archive(announcement) { +async function archive(announcement: (typeof announcements)['value'][number]) { + if (announcement.id == null) return; + const { _id, ...data } = announcement; // _idを消す await os.apiWithDialog('admin/announcements/update', { - ...announcement, + ...data, + id: announcement.id, // TSを黙らすため isActive: false, }); refresh(); } -async function unarchive(announcement) { +async function unarchive(announcement: (typeof announcements)['value'][number]) { + if (announcement.id == null) return; + const { _id, ...data } = announcement; // _idを消す await os.apiWithDialog('admin/announcements/update', { - ...announcement, + ...data, + id: announcement.id, // TSを黙らすため isActive: true, }); refresh(); } -async function save(announcement) { +async function save(announcement: (typeof announcements)['value'][number]) { + const { _id, ...data } = announcement; // _idを消す if (announcement.id == null) { - await os.apiWithDialog('admin/announcements/create', announcement); + await os.apiWithDialog('admin/announcements/create', data); refresh(); } else { - os.apiWithDialog('admin/announcements/update', announcement); + os.apiWithDialog('admin/announcements/update', { + ...data, + id: announcement.id, // TSを黙らすため + }); } } @@ -179,7 +206,7 @@ function more() { loadingMore.value = true; misskeyApi('admin/announcements/list', { status: announcementsStatus.value, - untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id, + untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id!, }).then(announcementResponse => { announcements.value = announcements.value.concat(announcementResponse); loadingMore.value = false; diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue index 7ed280358a..481969e1a3 100644 --- a/packages/frontend/src/pages/admin/bot-protection.vue +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -19,13 +19,17 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div class="_gaps_m"> - <MkRadios v-model="botProtectionForm.state.provider"> - <option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> - <option value="hcaptcha">hCaptcha</option> - <option value="mcaptcha">mCaptcha</option> - <option value="recaptcha">reCAPTCHA</option> - <option value="turnstile">Turnstile</option> - <option value="testcaptcha">testCaptcha</option> + <MkRadios + v-model="botProtectionForm.state.provider" + :options="[ + { value: 'none', label: `${i18n.ts.none} (${i18n.ts.notRecommended})` }, + { value: 'hcaptcha', label: 'hCaptcha' }, + { value: 'mcaptcha', label: 'mCaptcha' }, + { value: 'recaptcha', label: 'reCAPTCHA' }, + { value: 'turnstile', label: 'Turnstile' }, + { value: 'testcaptcha', label: 'testCaptcha' }, + ]" + > </MkRadios> <template v-if="botProtectionForm.state.provider === 'hcaptcha'"> diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue index e5e0f087e1..016d1b6a89 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -9,10 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker path="/admin/branding" :label="i18n.ts.branding" :keywords="['branding']" icon="ti ti-paint"> <div class="_gaps_m"> <SearchMarker :keywords="['entrance', 'welcome', 'landing', 'front', 'home', 'page', 'style']"> - <MkRadios v-model="entrancePageStyle"> + <MkRadios + v-model="entrancePageStyle" + :options="[ + { value: 'classic' }, + { value: 'simple' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts._serverSettings.entrancePageStyle }}</SearchLabel></template> - <option value="classic">Classic</option> - <option value="simple">Simple</option> </MkRadios> </SearchMarker> @@ -151,8 +155,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed } from 'vue'; import JSON5 from 'json5'; +import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; -import type { ClientOptions } from '@/instance.js'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import * as os from '@/os.js'; @@ -168,11 +172,11 @@ import MkSwitch from '@/components/MkSwitch.vue'; const meta = await misskeyApi('admin/meta'); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -const entrancePageStyle = ref<ClientOptions['entrancePageStyle']>(meta.clientOptions.entrancePageStyle ?? 'classic'); +const entrancePageStyle = ref<Misskey.entities.MetaClientOptions['entrancePageStyle']>(meta.clientOptions.entrancePageStyle ?? 'classic'); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -const showTimelineForVisitor = ref<ClientOptions['showTimelineForVisitor']>(meta.clientOptions.showTimelineForVisitor ?? true); +const showTimelineForVisitor = ref<Misskey.entities.MetaClientOptions['showTimelineForVisitor']>(meta.clientOptions.showTimelineForVisitor ?? true); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -const showActivitiesForVisitor = ref<ClientOptions['showActivitiesForVisitor']>(meta.clientOptions.showActivitiesForVisitor ?? true); +const showActivitiesForVisitor = ref<Misskey.entities.MetaClientOptions['showActivitiesForVisitor']>(meta.clientOptions.showActivitiesForVisitor ?? true); const iconUrl = ref(meta.iconUrl); const app192IconUrl = ref(meta.app192IconUrl); @@ -191,11 +195,11 @@ const manifestJsonOverride = ref(meta.manifestJsonOverride === '' ? '{}' : JSON. function save() { os.apiWithDialog('admin/update-meta', { - clientOptions: ({ + clientOptions: { entrancePageStyle: entrancePageStyle.value, showTimelineForVisitor: showTimelineForVisitor.value, showActivitiesForVisitor: showActivitiesForVisitor.value, - } as ClientOptions) as any, + }, iconUrl: iconUrl.value, app192IconUrl: app192IconUrl.value, app512IconUrl: app512IconUrl.value, diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index 250abeebe2..6f58ab9857 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -51,6 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import type { SortOrder } from '@/components/MkSortOrderEditor.define.js'; import type { GridSortOrderKey } from './custom-emojis-manager.impl.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; export type EmojiSearchQuery = { name: string | null; @@ -250,7 +251,7 @@ function setupGrid(): GridSetting { icon: 'ti ti-trash', action: () => { removeDataFromGrid(context, (cell) => { - gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined; + (gridItems.value[cell.row.index] as any)[cell.column.setting.bindTo] = undefined; }); }, }, @@ -454,7 +455,7 @@ function onGridCellValidation(event: GridCellValidationEvent) { function onGridCellValueChange(event: GridCellValueChangeEvent) { const { row, column, newValue } = event; if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { - gridItems.value[row.index][column.setting.bindTo] = newValue; + (gridItems.value[row.index] as any)[column.setting.bindTo] = newValue; } } @@ -525,7 +526,7 @@ const headerPageMetadata = computed(() => ({ icon: 'ti ti-icons', })); -const headerActions = computed(() => [{ +const headerActions = computed<PageHeaderItem[]>(() => [{ icon: 'ti ti-search', text: i18n.ts.search, handler: async () => { @@ -552,7 +553,7 @@ const headerActions = computed(() => [{ }, { icon: 'ti ti-list-numbers', text: i18n.ts._customEmojisManager._gridCommon.searchLimit, - handler: (ev: MouseEvent) => { + handler: (ev) => { async function changeSearchLimit(to: number) { if (updatedItemsCount.value > 0) { const { canceled } = await os.confirm({ diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index c343d88eb1..7ccb166481 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -58,7 +58,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -/* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as Misskey from 'misskey-js'; import { computed, onMounted, ref, useCssModule } from 'vue'; import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; @@ -339,7 +338,7 @@ function onGridCellValidation(event: GridCellValidationEvent) { function onGridCellValueChange(event: GridCellValueChangeEvent) { const { row, column, newValue } = event; if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { - gridItems.value[row.index][column.setting.bindTo] = newValue; + (gridItems.value[row.index] as any)[column.setting.bindTo] = newValue; } } diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue index 6317fc0b47..d5bfdffe34 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue @@ -306,7 +306,7 @@ function onGridEvent(event: GridEvent) { function onGridCellValueChange(event: GridCellValueChangeEvent) { const { row, column, newValue } = event; if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) { - gridItems.value[row.index][column.setting.bindTo] = newValue; + (gridItems.value[row.index] as any)[column.setting.bindTo] = newValue; } } diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue index 14773d7f04..c947dc3256 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="headerTab" :tabs="headerTabs"> <XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/> - <XGridRemoteComponent v-else-if="headerTab === 'remote'" :class="$style.remote"/> - <XRegisterComponent v-else-if="headerTab === 'register'" :class="$style.register"/> + <XGridRemoteComponent v-else-if="headerTab === 'remote'"/> + <XRegisterComponent v-else-if="headerTab === 'register'"/> </PageWithHeader> </template> diff --git a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue index 420219c22c..04de781a28 100644 --- a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue @@ -28,7 +28,7 @@ const { handler: externalTooltipHandler } = useChartTooltip(); let chartInstance: Chart | null = null; -function setData(values) { +function setData(values: number[]) { if (chartInstance == null || chartInstance.data.labels == null) return; for (const value of values) { chartInstance.data.labels.push(''); @@ -41,7 +41,7 @@ function setData(values) { chartInstance.update(); } -function pushData(value) { +function pushData(value: number) { if (chartInstance == null || chartInstance.data.labels == null) return; chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 94994dc94c..b3a929faf4 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -294,7 +294,7 @@ function invite() { }); } -function adminLookup(ev: MouseEvent) { +function adminLookup(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.user, icon: 'ti ti-user', diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue index b18049cb11..97b6c2bc67 100644 --- a/packages/frontend/src/pages/admin/job-queue.vue +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header> <MkTabs v-model:tab="jobState" - :class="$style.jobsTabs" :tabs="[{ + :tabs="[{ key: 'all', title: 'All', icon: 'ti ti-code-asterisk', @@ -359,8 +359,4 @@ definePage(() => ({ font-size: 85%; margin: 6px 0; } - -.jobsTabs { - -} </style> diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 32a5a6976e..9854ca7fc6 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -47,7 +47,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 2c550bd9c3..90799647ff 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -104,7 +104,7 @@ const filesPagination = { noPaging: true, }; -function onInstanceClick(i) { +function onInstanceClick(i: Misskey.entities.FederationInstance) { os.pageWindow(`/instance-info/${i.host}`); } diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index b24b640527..e806f68162 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -21,8 +21,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { genId } from '@/utility/id.js'; import XEditor from './roles.editor.vue'; +import { genId } from '@/utility/id.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; @@ -37,8 +37,13 @@ const props = defineProps<{ id?: string; }>(); +type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & { + condFormula: any; + policies: any; +}; + const role = ref<Misskey.entities.Role | null>(null); -const data = ref<any>(null); +const data = ref<RoleLike | null>(null); if (props.id) { role.value = await misskeyApi('admin/roles/show', { @@ -61,11 +66,13 @@ if (props.id) { asBadge: false, canEditMembersByModerator: false, displayOrder: 0, + preserveAssignmentOnMoveAccount: false, policies: {}, }; } async function save() { + if (data.value === null) return; rolesCache.delete(); if (role.value) { os.apiWithDialog('admin/roles/update', { @@ -75,7 +82,7 @@ async function save() { router.push('/admin/roles/:id', { params: { id: role.value.id, - } + }, }); } else { const created = await os.apiWithDialog('admin/roles/create', { @@ -84,7 +91,7 @@ async function save() { router.push('/admin/roles/:id', { params: { id: created.id, - } + }, }); } } diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 5f8950f07e..7de973a394 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> - <MkInput v-if="readonly" :modelValue="role.id" :readonly="true"> + <MkInput v-if="readonly && role.id != null" :modelValue="role.id" :readonly="true"> <template #label>ID</template> </MkInput> @@ -866,12 +866,18 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; +type RoleLike = Pick<Misskey.entities.Role, 'name' | 'description' | 'isAdministrator' | 'isModerator' | 'color' | 'iconUrl' | 'target' | 'isPublic' | 'isExplorable' | 'asBadge' | 'canEditMembersByModerator' | 'displayOrder' | 'preserveAssignmentOnMoveAccount'> & { + id?: Misskey.entities.Role['id'] | null; + condFormula: any; + policies: any; +}; + const emit = defineEmits<{ - (ev: 'update:modelValue', v: any): void; + (ev: 'update:modelValue', v: RoleLike): void; }>(); const props = defineProps<{ - modelValue: any; + modelValue: RoleLike; readonly?: boolean; }>(); @@ -910,7 +916,7 @@ const rolePermission = computed<GetMkSelectValueTypesFromDef<typeof rolePermissi const q = ref(''); -function getPriorityIcon(option) { +function getPriorityIcon(option: { priority: number }): string { if (option.priority === 2) return 'ti ti-arrows-up'; if (option.priority === 1) return 'ti ti-arrow-narrow-up'; return 'ti ti-point'; @@ -936,6 +942,7 @@ const save = throttle(100, () => { isExplorable: role.value.isExplorable, asBadge: role.value.asBadge, canEditMembersByModerator: role.value.canEditMembersByModerator, + preserveAssignmentOnMoveAccount: role.value.preserveAssignmentOnMoveAccount, policies: role.value.policies, }; diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 2e249eee50..7fc51979af 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -28,15 +28,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items }"> <div class="_gaps_s"> - <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpened]: expandedItems.includes(item.id) }]"> + <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpened]: expandedItemIds.includes(item.id) }]"> <div :class="$style.userItemMain"> <MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`"> <MkUserCardMini :user="item.user"/> </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button> + <button class="_button" :class="$style.userToggle" @click="toggleItem(item.id)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.unassign" @click="unassign(item.user.id, $event)"><i class="ti ti-x"></i></button> </div> - <div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub"> + <div v-if="expandedItemIds.includes(item.id)" :class="$style.userItemSub"> <div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div> <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> <div v-else>Period: {{ i18n.ts.indefinitely }}</div> @@ -55,6 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, markRaw, reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import XEditor from './roles.editor.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; @@ -81,7 +82,7 @@ const usersPaginator = markRaw(new Paginator('admin/roles/users', { }) : undefined), })); -const expandedItems = ref<string[]>([]); +const expandedItemIds = ref<Misskey.entities.AdminRolesUsersResponse[number]['id'][]>([]); const role = reactive(await misskeyApi('admin/roles/show', { roleId: props.id, @@ -91,7 +92,7 @@ function edit() { router.push('/admin/roles/:id/edit', { params: { id: role.id, - } + }, }); } @@ -140,23 +141,23 @@ async function assign() { //role.users.push(user); } -async function unassign(user, ev) { +async function unassign(userId: Misskey.entities.User['id'], ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.unassign, icon: 'ti ti-x', danger: true, action: async () => { - await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.id }); - //role.users = role.users.filter(u => u.id !== user.id); + await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: userId }); + //role.users = role.users.filter(u => u.id !== userId); }, }], ev.currentTarget ?? ev.target); } -async function toggleItem(item) { - if (expandedItems.value.includes(item.id)) { - expandedItems.value = expandedItems.value.filter(x => x !== item.id); +async function toggleItem(itemId: string) { + if (expandedItemIds.value.includes(itemId)) { + expandedItemIds.value = expandedItemIds.value.filter(x => x !== itemId); } else { - expandedItems.value.push(item.id); + expandedItemIds.value.push(itemId); } } diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index fa93124daa..f310f26107 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -25,11 +25,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div><SearchText>{{ i18n.ts._sensitiveMediaDetection.description }}</SearchText></div> - <MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection"> - <option value="none">{{ i18n.ts.none }}</option> - <option value="all">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.localOnly }}</option> - <option value="remote">{{ i18n.ts.remoteOnly }}</option> + <MkRadios + v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection" + :options="[ + { value: 'none', label: i18n.ts.none }, + { value: 'all', label: i18n.ts.all }, + { value: 'local', label: i18n.ts.localOnly }, + { value: 'remote', label: i18n.ts.remoteOnly }, + ]" + > </MkRadios> <SearchMarker :keywords="['sensitivity']"> diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index d26f02b41c..02aad732f6 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -12,28 +12,25 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <div><SearchText>{{ i18n.ts._serverRules.description }}</SearchText></div> - <Sortable + <MkDraggable v-model="serverRules" - class="_gaps_m" - :itemKey="(_, i) => i" - :animation="150" - :handle="'.' + $style.itemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" + direction="vertical" + withGaps + manualDragStart > - <template #item="{element,index}"> + <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"><i class="ti ti-menu"/></span> - <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button> + <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 v-model="serverRules[index]"/> + <MkInput :modelValue="item.text" @update:modelValue="serverRules[index].text = $event"/> </div> </template> - </Sortable> + </MkDraggable> <div :class="$style.commands"> - <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton rounded @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> @@ -42,28 +39,31 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { ref } from 'vue'; import * as os from '@/os.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); +const serverRules = ref<{ text: string; id: string; }[]>(instance.serverRules.map(text => ({ text, id: Math.random().toString() }))); -const serverRules = ref<string[]>(instance.serverRules); - -const save = async () => { +async function save() { await os.apiWithDialog('admin/update-meta', { - serverRules: serverRules.value, + serverRules: serverRules.value.map(r => r.text), }); fetchInstance(true); -}; +} -const remove = (index: number): void => { - serverRules.value.splice(index, 1); -}; +function add(): void { + serverRules.value.push({ text: '', id: Math.random().toString() }); +} + +function remove(id: string): void { + serverRules.value = serverRules.value.filter(r => r.id !== id); +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 541ee7c0cd..99d4455939 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -258,11 +258,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <SearchMarker> - <MkRadios v-model="federationForm.state.federation"> + <MkRadios + v-model="federationForm.state.federation" + :options="[ + { value: 'all', label: i18n.ts.all }, + { value: 'specified', label: i18n.ts.specifyHost }, + { value: 'none', label: i18n.ts.none }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.behavior }}</SearchLabel><span v-if="federationForm.modifiedStates.federation" class="_modified">{{ i18n.ts.modified }}</span></template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="specified">{{ i18n.ts.specifyHost }}</option> - <option value="none">{{ i18n.ts.none }}</option> </MkRadios> </SearchMarker> diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue index b53667e98c..9807cbb313 100644 --- a/packages/frontend/src/pages/admin/system-webhook.item.vue +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -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"/> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 2f7ecca521..eb9806d668 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -46,6 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, markRaw, ref, watchEffect } from 'vue'; +import * as Misskey from 'misskey-js'; import { defaultMemoryStorage } from '@/memory-storage'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -146,7 +147,7 @@ async function addUser() { }); } -function show(user) { +function show(user: Misskey.entities.UserDetailed) { os.pageWindow(`/admin/user/${user.id}`); } diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 4c34c3c74b..150808fcbd 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkA> </div> - <div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer"> + <div v-if="tab !== 'past' && $i != null && !announcement.silence && !announcement.isRead" :class="$style.footer"> <MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> </div> </section> @@ -45,6 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, markRaw } from 'vue'; +import * as Misskey from 'misskey-js'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -65,7 +66,9 @@ const paginator = markRaw(new Paginator('announcements', { const tab = ref('current'); -async function read(target) { +async function read(target: Misskey.entities.Announcement) { + if ($i == null) return; + if (target.needConfirmationToRead) { const confirm = await os.confirm({ type: 'question', @@ -81,7 +84,7 @@ async function read(target) { })); misskeyApi('i/read-announcement', { announcementId: target.id }); updateCurrentAccountPartial({ - unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id), + unreadAnnouncements: $i.unreadAnnouncements.filter(a => a.id !== target.id), }); } diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue index f436fc72fa..8377dc074d 100644 --- a/packages/frontend/src/pages/api-console.vue +++ b/packages/frontend/src/pages/api-console.vue @@ -73,7 +73,7 @@ function onEndpointChange() { return; } - const endpointBody = {}; + const endpointBody = {} as Record<string, unknown>; for (const p of resp.params) { endpointBody[p.name] = p.type === 'String' ? '' : diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue index 1a0c9b36c4..bc585950b4 100644 --- a/packages/frontend/src/pages/auth.form.vue +++ b/packages/frontend/src/pages/auth.form.vue @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <section> - <div v-if="app.permission.length > 0"> + <div v-if="permissions.length > 0"> <p>{{ i18n.tsx._auth.permission({ name }) }}</p> <ul> - <li v-for="p in app.permission" :key="p">{{ i18n.ts._permissions[p] }}</li> + <li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] ?? p }}</li> </ul> </div> <div>{{ i18n.tsx._auth.shareAccess({ name: `${name} (${app.id})` }) }}</div> @@ -37,6 +37,10 @@ const emit = defineEmits<{ const app = computed(() => props.session.app); +const permissions = computed(() => { + return props.session.app.permission.filter((p): p is typeof Misskey.permissions[number] => typeof p === 'string'); +}); + const name = computed(() => { const el = window.document.createElement('div'); el.textContent = app.value.name; diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 83bf7221d0..14b13e511a 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -67,7 +67,7 @@ function accepted() { } } -function onLogin(res) { +function onLogin(res: Misskey.entities.SigninFlowResponse & { finished: true }) { login(res.i); } diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index a8ce527523..68e8d6a4d0 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -78,7 +78,7 @@ import { ensureSignin } from '@/i.js'; const $i = ensureSignin(); const props = defineProps<{ - avatarDecoration?: any, + avatarDecoration?: Misskey.entities.AdminAvatarDecorationsListResponse[number], }>(); const emit = defineEmits<{ @@ -109,7 +109,7 @@ async function addRole() { rolesThatCanBeUsedThisDecoration.value.push(roles.find(r => r.id === roleId)!); } -async function removeRole(role, ev) { +async function removeRole(role: Misskey.entities.Role, ev: PointerEvent) { rolesThatCanBeUsedThisDecoration.value = rolesThatCanBeUsedThisDecoration.value.filter(x => x.id !== role.id); } @@ -147,6 +147,8 @@ async function done() { } async function del() { + if (props.avatarDecoration == null) return; + const { canceled } = await os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: name.value }), diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue index f96c02a567..4c5457504e 100644 --- a/packages/frontend/src/pages/avatar-decorations.vue +++ b/packages/frontend/src/pages/avatar-decorations.vue @@ -45,7 +45,7 @@ function load() { load(); -async function add(ev: MouseEvent) { +async function add(ev: PointerEvent) { const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), { }, { done: result => { @@ -57,7 +57,7 @@ async function add(ev: MouseEvent) { }); } -async function edit(avatarDecoration) { +async function edit(avatarDecoration: Misskey.entities.AdminAvatarDecorationsListResponse[number]) { const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), { avatarDecoration: avatarDecoration, }, { diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 251f5d557d..4b73b6c6b3 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -41,20 +41,19 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps"> <MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton> - <Sortable - v-model="pinnedNotes" - itemKey="id" - :handle="'.' + $style.pinnedNoteHandle" - :animation="150" + <MkDraggable + :modelValue="pinnedNoteIds.map(id => ({ id }))" + direction="vertical" + @update:modelValue="v => pinnedNoteIds = v.map(x => x.id)" > - <template #item="{element,index}"> + <template #default="{ item }"> <div :class="$style.pinnedNote"> <button class="_button" :class="$style.pinnedNoteHandle"><i class="ti ti-menu"></i></button> - {{ element.id }} - <button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(index)"><i class="ti ti-x"></i></button> + {{ item.id }} + <button class="_button" :class="$style.pinnedNoteRemove" @click="removePinnedNote(item.id)"><i class="ti ti-x"></i></button> </div> </template> - </Sortable> + </MkDraggable> </div> </MkFolder> @@ -68,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch, defineAsyncComponent } from 'vue'; +import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -81,10 +80,9 @@ import { i18n } from '@/i18n.js'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { useRouter } from '@/router.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const router = useRouter(); const props = defineProps<{ @@ -99,7 +97,7 @@ const bannerId = ref<string | null>(null); const color = ref('#000'); const isSensitive = ref(false); const allowRenoteToExternal = ref(true); -const pinnedNotes = ref<{ id: Misskey.entities.Note['id'] }[]>([]); +const pinnedNoteIds = ref<Misskey.entities.Note['id'][]>([]); watch(() => bannerId.value, async () => { if (bannerId.value == null) { @@ -123,9 +121,7 @@ async function fetchChannel() { bannerId.value = result.bannerId; bannerUrl.value = result.bannerUrl; isSensitive.value = result.isSensitive; - pinnedNotes.value = result.pinnedNoteIds.map(id => ({ - id, - })); + pinnedNoteIds.value = result.pinnedNoteIds; color.value = result.color; allowRenoteToExternal.value = result.allowRenoteToExternal; @@ -143,13 +139,11 @@ async function addPinnedNote() { const note = await os.apiWithDialog('notes/show', { noteId: fromUrl ?? value, }); - pinnedNotes.value = [{ - id: note.id, - }, ...pinnedNotes.value]; + pinnedNoteIds.value.unshift(note.id); } -function removePinnedNote(index: number) { - pinnedNotes.value.splice(index, 1); +function removePinnedNote(id: string) { + pinnedNoteIds.value = pinnedNoteIds.value.filter(x => x !== id); } function save() { @@ -166,7 +160,7 @@ function save() { os.apiWithDialog('channels/update', { ...params, channelId: props.channelId, - pinnedNoteIds: pinnedNotes.value.map(x => x.id), + pinnedNoteIds: pinnedNoteIds.value, }); } else { os.apiWithDialog('channels/create', params).then(created => { @@ -197,7 +191,7 @@ async function archive() { }); } -function setBannerImage(evt) { +function setBannerImage(evt: PointerEvent) { selectFile({ anchorElement: evt.currentTarget ?? evt.target, multiple: false, diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index f77034e318..26dc5b80df 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -11,9 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkRadios v-model="searchType" @update:modelValue="search()"> - <option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option> - <option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option> + <MkRadios + v-model="searchType" + :options="[ + { value: 'nameAndDescription', label: i18n.ts._channel.nameAndDescription }, + { value: 'nameOnly', label: i18n.ts._channel.nameOnly }, + ]" + @update:modelValue="search()" + > </MkRadios> <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> </div> @@ -72,15 +77,17 @@ import { Paginator } from '@/utility/paginator.js'; const router = useRouter(); +type SearchType = 'nameAndDescription' | 'nameOnly'; + const props = defineProps<{ query: string; - type?: string; + type?: SearchType; }>(); const key = ref(''); const tab = ref('featured'); const searchQuery = ref(''); -const searchType = ref('nameAndDescription'); +const searchType = ref<SearchType>('nameAndDescription'); const channelPaginator = shallowRef(); onMounted(() => { diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index 613c4e4dcc..f759e45e48 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only :enableEmojiMenu="true" :enableEmojiMenuReaction="true" /> - <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> + <MkMediaList v-if="message.file" :mediaList="[message.file]"/> </MkFukidashi> <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> <div :class="$style.footer"> @@ -94,7 +94,7 @@ provide(DI.mfmEmojiReactCallback, (reaction) => { }); }); -function react(ev: MouseEvent) { +function react(ev: PointerEvent) { if ($i.policies.chatAvailability !== 'available') return; const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target); @@ -128,14 +128,14 @@ function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) { } } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { if (ev.target && isLink(ev.target as HTMLElement)) return; if (window.getSelection()?.toString() !== '') return; showMenu(ev, true); } -function showMenu(ev: MouseEvent, contextmenu = false) { +function showMenu(ev: PointerEvent, contextmenu = false) { const menu: MenuItem[] = []; if (!isMe.value && $i.policies.chatAvailability === 'available') { diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue index 756bf8a342..ed04253046 100644 --- a/packages/frontend/src/pages/chat/home.home.vue +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -64,7 +64,7 @@ const searchQuery = ref(''); const searched = ref(false); const searchResults = ref<Misskey.entities.ChatMessage[]>([]); -function start(ev: MouseEvent) { +function start(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts._chat.individualChat, caption: i18n.ts._chat.individualChat_description, @@ -89,7 +89,7 @@ async function startUser() { router.push('/chat/user/:userId', { params: { userId: user.id, - } + }, }); }); } @@ -108,7 +108,7 @@ async function createRoom() { router.push('/chat/room/:roomId', { params: { roomId: room.id, - } + }, }); } diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 17b68d6eb9..72aeba0a45 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -167,7 +167,7 @@ function onKeydown(ev: KeyboardEvent) { } } -function chooseFile(ev: MouseEvent) { +function chooseFile(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index ef9191b4a5..a4204435b3 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -391,7 +391,7 @@ async function leaveRoom() { router.push('/chat'); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { const menuItems: MenuItem[] = []; if (room.value) { diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 8176fb519b..8feddf70b0 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -34,6 +34,7 @@ import { computed, watch, provide, ref, markRaw } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; @@ -105,7 +106,7 @@ async function unfavorite() { }); } -const headerActions = computed(() => clip.value && isOwned.value ? [{ +const headerActions = computed<PageHeaderItem[] | null>(() => clip.value && isOwned.value ? [{ icon: 'ti ti-pencil', text: i18n.ts.edit, handler: async (): Promise<void> => { @@ -144,7 +145,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ }, ...(clip.value.isPublic ? [{ icon: 'ti ti-share', text: i18n.ts.share, - handler: (ev: MouseEvent): void => { + handler: (ev): void => { const menuItems: MenuItem[] = []; menuItems.push({ @@ -177,7 +178,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ os.popupMenu(menuItems, ev.currentTarget ?? ev.target); }, -}] : []), { +}] satisfies PageHeaderItem[] : []), { icon: 'ti ti-trash', text: i18n.ts.delete, danger: true, @@ -196,7 +197,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ clipsCache.delete(); }, -}] : null); +}] satisfies PageHeaderItem[] : null); definePage(() => ({ title: clip.value ? clip.value.name : i18n.ts.clip, diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 0f306896c9..5cb88f0b1a 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <template #default="{items}"> <div class="ldhfsamy"> - <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji as RemoteEmoji, $event)"> <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/> <div class="body"> <div class="name _monospace">{{ emoji.name }}</div> @@ -71,7 +71,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, markRaw, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { computed, markRaw, ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -93,6 +94,8 @@ const host = ref<string | null>(null); const selectMode = ref(false); const selectedEmojis = ref<string[]>([]); +type RemoteEmoji = Misskey.entities.AdminEmojiListRemoteResponse[number] & { host: string }; + const paginator = markRaw(new Paginator('admin/emoji/list', { limit: 30, computedParams: computed(() => ({ @@ -116,7 +119,7 @@ const selectAll = () => { } }; -const toggleSelect = (emoji) => { +const toggleSelect = (emoji: Misskey.entities.EmojiDetailed) => { if (selectedEmojis.value.includes(emoji.id)) { selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id); } else { @@ -124,19 +127,23 @@ const toggleSelect = (emoji) => { } }; -const add = async (ev: MouseEvent) => { +const add = async () => { const { dispose } = await os.popupAsyncWithDialog(import('./emoji-edit-dialog.vue').then(x => x.default), { }, { done: result => { if (result.created) { - paginator.prepend(result.created); + const nowIso = (new Date()).toISOString(); + paginator.prepend({ + ...result.created, + createdAt: nowIso, + }); } }, closed: () => dispose(), }); }; -const edit = async (emoji) => { +const edit = async (emoji: Misskey.entities.EmojiDetailed) => { const { dispose } = await os.popupAsyncWithDialog(import('./emoji-edit-dialog.vue').then(x => x.default), { emoji: emoji, }, { @@ -154,7 +161,13 @@ const edit = async (emoji) => { }); }; -const detailRemoteEmoji = (emoji) => { +const detailRemoteEmoji = (emoji: { + id: string, + name: string, + host: string, + license: string | null, + url: string +}) => { const { dispose } = os.popup(MkRemoteEmojiEditDialog, { emoji: emoji, }, { @@ -167,13 +180,19 @@ const detailRemoteEmoji = (emoji) => { }); }; -const importEmoji = (emoji) => { +const importEmoji = (emojiId: string) => { os.apiWithDialog('admin/emoji/copy', { - emojiId: emoji.id, + emojiId: emojiId, }); }; -const remoteMenu = (emoji, ev: MouseEvent) => { +const remoteMenu = (emoji: { + id: string, + name: string, + host: string, + license: string | null, + url: string +}, ev: PointerEvent) => { os.popupMenu([{ type: 'label', text: ':' + emoji.name + ':', @@ -184,11 +203,11 @@ const remoteMenu = (emoji, ev: MouseEvent) => { }, { text: i18n.ts.import, icon: 'ti ti-plus', - action: () => { importEmoji(emoji); }, + action: () => { importEmoji(emoji.id); }, }], ev.currentTarget ?? ev.target); }; -const menu = (ev: MouseEvent) => { +const menu = (ev: PointerEvent) => { os.popupMenu([{ icon: 'ti ti-download', text: i18n.ts.export, diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index e3cc1d988e..6b57684188 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -131,10 +131,11 @@ function move() { const f = file.value; - selectDriveFolder(null).then(folder => { + selectDriveFolder(null).then(({ canceled, folders }) => { + if (canceled) return; misskeyApi('drive/files/update', { fileId: f.id, - folderId: folder[0] ? folder[0].id : null, + folderId: folders[0] ? folders[0].id : null, }).then(async () => { await _fetch_(); }); diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue index 88300d8a74..21e4657b2c 100644 --- a/packages/frontend/src/pages/drop-and-fusion.game.vue +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -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"> @@ -729,7 +729,7 @@ async function start() { }, 1500); } -function onClick(ev: MouseEvent) { +function onClick(ev: PointerEvent) { if (!containerElRect) return; if (replaying.value) return; const x = (ev.clientX - containerElRect.left) / viewScale; diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index ea4863950d..edd3987524 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-for="role in rolesThatCanBeUsedThisEmojiAsReaction" :key="role.id" :class="$style.roleItem"> <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/> - <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role)"><i class="ti ti-x"></i></button> <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> </div> @@ -66,7 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo> </div> </MkFolder> - <MkSwitch v-model="isSensitive">isSensitive</MkSwitch> + <MkSwitch v-model="isSensitive">{{ i18n.ts.sensitive }}</MkSwitch> <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> <MkButton v-if="emoji" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> @@ -99,7 +99,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'done', v: { deleted?: boolean; updated?: Misskey.entities.AdminEmojiUpdateRequest; created?: Misskey.entities.AdminEmojiUpdateRequest }): void, + (ev: 'done', v: { deleted?: boolean; updated?: Misskey.entities.EmojiDetailed; created?: Misskey.entities.EmojiDetailed }): void, (ev: 'closed'): void }>(); @@ -120,7 +120,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => { const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null); -async function changeImage(ev: Event) { +async function changeImage(ev: PointerEvent) { file.value = await selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, @@ -143,7 +143,7 @@ async function addRole() { rolesThatCanBeUsedThisEmojiAsReaction.value.push(roles.find(r => r.id === roleId)!); } -async function removeRole(role: Misskey.entities.RoleLite, ev: Event) { +async function removeRole(role: Misskey.entities.RoleLite) { rolesThatCanBeUsedThisEmojiAsReaction.value = rolesThatCanBeUsedThisEmojiAsReaction.value.filter(x => x.id !== role.id); } @@ -157,19 +157,29 @@ async function done() { localOnly: localOnly.value, roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id), fileId: file.value ? file.value.id : undefined, - }; + } satisfies Misskey.entities.AdminEmojiUpdateRequest; if (props.emoji) { + const emojiDetailed = { + id: props.emoji.id, + aliases: params.aliases, + name: params.name, + category: params.category, + host: props.emoji.host, + url: file.value ? file.value.url : props.emoji.url, + license: params.license, + isSensitive: params.isSensitive, + localOnly: params.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: params.roleIdsThatCanBeUsedThisEmojiAsReaction, + } satisfies Misskey.entities.EmojiDetailed; + await os.apiWithDialog('admin/emoji/update', { id: props.emoji.id, ...params, }); emit('done', { - updated: { - id: props.emoji.id, - ...params, - }, + updated: emojiDetailed, }); windowEl.value?.close(); diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index aaf433e78e..bed7f2166a 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -15,7 +15,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { defineAsyncComponent } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { misskeyApiGet } from '@/utility/misskey-api.js'; @@ -28,7 +27,7 @@ const props = defineProps<{ emoji: Misskey.entities.EmojiSimple; }>(); -function menu(ev) { +function menu(ev: PointerEvent) { const menuItems: MenuItem[] = []; menuItems.push({ type: 'label', @@ -57,22 +56,21 @@ function menu(ev) { menuItems.push({ text: i18n.ts.edit, icon: 'ti ti-pencil', - action: () => { - edit(props.emoji); + action: async () => { + const detailedEmoji = await misskeyApiGet('emoji', { + name: props.emoji.name, + }); + const { dispose } = await os.popupAsyncWithDialog(import('@/pages/emoji-edit-dialog.vue').then(x => x.default), { + emoji: detailedEmoji, + }, { + closed: () => dispose(), + }); }, }); } os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } - -const edit = async (emoji) => { - const { dispose } = await os.popupAsyncWithDialog(import('@/pages/emoji-edit-dialog.vue').then(x => x.default), { - emoji: emoji, - }, { - closed: () => dispose(), - }); -}; </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index b3e8e88c23..3d9de0584a 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -395,7 +395,7 @@ const { }); const script = ref(flash.value?.script ?? PRESET_DEFAULT); -function selectPreset(ev: MouseEvent) { +function selectPreset(ev: PointerEvent) { os.popupMenu([{ text: 'Omikuji', action: () => { diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index efc9ee014f..449f1af60a 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -104,7 +104,7 @@ function fetchFlash() { }); } -function share(ev: MouseEvent) { +function share(ev: PointerEvent) { if (!flash.value) return; const menuItems: MenuItem[] = []; @@ -151,9 +151,11 @@ function shareWithNote() { }); } -function like() { +async function like() { if (!flash.value) return; - pleaseLogin(); + + const isLoggedIn = await pleaseLogin(); + if (!isLoggedIn) return; os.apiWithDialog('flash/like', { flashId: flash.value.id, @@ -165,7 +167,9 @@ function like() { async function unlike() { if (!flash.value) return; - pleaseLogin(); + + const isLoggedIn = await pleaseLogin(); + if (!isLoggedIn) return; const confirm = await os.confirm({ type: 'warning', @@ -208,7 +212,7 @@ async function run() { const version = utils.getLangVersion(flash.value.script); const isLegacy = getIsLegacy(version); - const { Interpreter, Parser, values } = isLegacy ? (await import('@syuilo/aiscript-0-19-0') as any) : await import('@syuilo/aiscript'); + const { Interpreter, Parser, values } = (isLegacy ? (await import('@syuilo/aiscript-0-19-0')) : await import('@syuilo/aiscript')) as typeof import('@syuilo/aiscript'); const parser = new Parser(); @@ -225,10 +229,10 @@ async function run() { THIS_URL: values.STR(`${url}/play/${flash.value.id}`), }, { in: aiScriptReadline, - out: (value) => { + out: () => { // nop }, - log: (type, params) => { + log: () => { // nop }, }); @@ -269,7 +273,7 @@ async function reportAbuse() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { if (!flash.value) return; const menu: MenuItem[] = [ diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index ba24d7abc6..2404fd9744 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -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> @@ -89,7 +89,7 @@ async function cancel(user: Misskey.entities.UserLite) { }); } -function displayUser(req) { +function displayUser(req: Misskey.entities.FollowingRequestsListResponse[number]) { return tab.value === 'list' ? req.follower : req.followee; } diff --git a/packages/frontend/src/pages/gallery/edit.root.vue b/packages/frontend/src/pages/gallery/edit.root.vue index 45493ab561..ec0a293494 100644 --- a/packages/frontend/src/pages/gallery/edit.root.vue +++ b/packages/frontend/src/pages/gallery/edit.root.vue @@ -58,7 +58,7 @@ const description = ref(props.post?.description ?? null); const title = ref(props.post?.title ?? ''); const isSensitive = ref(props.post?.isSensitive ?? false); -function chooseFile(evt) { +function chooseFile(evt: MouseEvent) { selectFile({ anchorElement: evt.currentTarget ?? evt.target, multiple: true, @@ -67,7 +67,7 @@ function chooseFile(evt) { }); } -function remove(file) { +function remove(file: NonNullable<Misskey.entities.GalleryPost['files']>[number]) { files.value = files.value.filter(f => f.id !== file.id); } diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index f60bbc0b74..92cb663ee1 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> - <button v-if="$i && $i.id !== post.user.id" v-click-anime class="_button" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button> + <button v-if="$i && $i.id !== post.user.id" v-click-anime class="_button" @click="showMenu"><i class="ti ti-dots ti-fw"></i></button> </div> </div> <div class="user"> @@ -175,7 +175,7 @@ async function reportAbuse() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { if (!post.value) return; const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 132c55571a..92a5d25983 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -110,7 +110,7 @@ function addUser() { }); } -async function removeUser(item, ev) { +async function removeUser(item: Misskey.entities.UsersListsGetMembershipsResponse[number], ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.remove, icon: 'ti ti-x', @@ -127,7 +127,7 @@ async function removeUser(item, ev) { }], ev.currentTarget ?? ev.target); } -async function showMembershipMenu(item, ev) { +async function showMembershipMenu(item: Misskey.entities.UsersListsGetMembershipsResponse[number], ev: PointerEvent) { const withRepliesRef = ref(item.withReplies); os.popupMenu([{ diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 5d308e6b29..37ec6284a3 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, markRaw, ref } from 'vue'; import { notificationTypes } from 'misskey-js'; +import type { PageHeaderItem } from '@/types/page-header.js'; import MkStreamingNotificationsTimeline from '@/components/MkStreamingNotificationsTimeline.vue'; import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import * as os from '@/os.js'; @@ -44,7 +45,7 @@ const directNotesPaginator = markRaw(new Paginator('notes/mentions', { }, })); -function setFilter(ev) { +function setFilter(ev: PointerEvent) { const typeItems = notificationTypes.map(t => ({ text: i18n.ts._notification._types[t], active: (includeTypes.value && includeTypes.value.includes(t)) ?? false, @@ -62,7 +63,7 @@ function setFilter(ev) { os.popupMenu(items, ev.currentTarget ?? ev.target); } -const headerActions = computed(() => [tab.value === 'all' ? { +const headerActions = computed<PageHeaderItem[]>(() => ([tab.value === 'all' ? { text: i18n.ts.filter, icon: 'ti ti-filter', highlighted: includeTypes.value != null, @@ -73,7 +74,7 @@ const headerActions = computed(() => [tab.value === 'all' ? { handler: () => { os.apiWithDialog('notifications/mark-all-as-read', {}); }, -} : undefined].filter(x => x !== undefined)); +} : undefined] as (PageHeaderItem | undefined)[]).filter(x => x !== undefined)); const headerTabs = computed(() => [{ key: 'all', diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue index f191320180..18f6c40013 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -4,36 +4,41 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<Sortable :modelValue="modelValue" tag="div" itemKey="id" handle=".drag-handle" :group="{ name: 'blocks' }" :animation="150" :swapThreshold="0.5" @update:modelValue="v => emit('update:modelValue', v)"> - <template #item="{element}"> - <div :class="$style.item"> - <!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 --> - <component :is="getComponent(element.type)" :modelValue="element" @update:modelValue="updateItem" @remove="() => removeItem(element)"/> +<MkDraggable + :modelValue="modelValue" + direction="vertical" + withGaps + canNest + group="pageBlocks" + @update:modelValue="v => emit('update:modelValue', v)" +> + <template #default="{ item }"> + <div> + <!-- divが無いとエラーになる --> + <component :is="getComponent(item.type) as any" :modelValue="item" @update:modelValue="updateItem" @remove="() => removeItem(item)"/> </div> </template> -</Sortable> +</MkDraggable> </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import XSection from './els/page-editor.el.section.vue'; import XText from './els/page-editor.el.text.vue'; import XImage from './els/page-editor.el.image.vue'; import XNote from './els/page-editor.el.note.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; -function getComponent(type: string) { +function getComponent(type: Misskey.entities.Page['content'][number]['type']) { switch (type) { case 'section': return XSection; case 'text': return XText; case 'image': return XImage; case 'note': return XNote; - default: return null; + default: return XText; } } -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const props = defineProps<{ modelValue: Misskey.entities.Page['content']; }>(); @@ -42,7 +47,7 @@ const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.Page['content']): void; }>(); -function updateItem(v) { +function updateItem(v: Misskey.entities.PageBlock) { const i = props.modelValue.findIndex(x => x.id === v.id); const newValue = [ ...props.modelValue.slice(0, i), @@ -52,8 +57,8 @@ function updateItem(v) { emit('update:modelValue', newValue); } -function removeItem(el) { - const i = props.modelValue.findIndex(x => x.id === el.id); +function removeItem(v: Misskey.entities.PageBlock) { + const i = props.modelValue.findIndex(x => x.id === v.id); const newValue = [ ...props.modelValue.slice(0, i), ...props.modelValue.slice(i + 1), @@ -61,11 +66,3 @@ function removeItem(el) { emit('update:modelValue', newValue); } </script> - -<style lang="scss" module> -.item { - & + .item { - margin-top: 16px; - } -} -</style> diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 3b36f7fa2d..85871c993c 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -247,9 +247,9 @@ async function add() { } } -function setEyeCatchingImage(img: Event) { +function setEyeCatchingImage(ev: PointerEvent) { selectFile({ - anchorElement: img.currentTarget ?? img.target, + anchorElement: ev.currentTarget ?? ev.target, multiple: false, }).then(file => { eyeCatchingImageId.value = file.id; diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index c3b52a24fd..212c8140c8 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -64,7 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA> <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button> - <button v-if="$i" v-click-anime class="_button" :class="$style.generalActionButton" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button> + <button v-if="$i" v-click-anime class="_button" :class="$style.generalActionButton" @click="showMenu"><i class="ti ti-dots ti-fw"></i></button> </div> </div> <div :class="$style.pageUser"> @@ -163,7 +163,7 @@ function fetchPage() { }); } -function share(ev: MouseEvent) { +function share(ev: PointerEvent) { if (!page.value) return; const menuItems: MenuItem[] = []; @@ -237,7 +237,7 @@ async function unlike() { }); } -function pin(pin) { +function pin(pin: boolean) { if (!page.value) return; os.apiWithDialog('i/update', { @@ -258,7 +258,7 @@ async function reportAbuse() { }); } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { if (!page.value) return; const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/pages/qr.read.vue b/packages/frontend/src/pages/qr.read.vue index 251dccd0f0..5e3633c052 100644 --- a/packages/frontend/src/pages/qr.read.vue +++ b/packages/frontend/src/pages/qr.read.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header> <div :class="$style.view"> <video ref="videoEl" :class="$style.video" autoplay muted playsinline></video> - <div ref="overlayEl" :class="$style.overlay"></div> + <div ref="overlayEl"></div> <div :class="$style.controls"> <MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton> diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index aae638641a..5988604652 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -151,7 +151,7 @@ import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { deepClone } from '@/utility/clone.js'; -import { ensureSignin } from '@/i.js'; +import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { userPage } from '@/filters/user.js'; @@ -160,8 +160,6 @@ import * as os from '@/os.js'; import { confetti } from '@/utility/confetti.js'; import { genId } from '@/utility/id.js'; -const $i = ensureSignin(); - const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; connection?: Misskey.IChannelConnection<Misskey.Channels['reversiGame']> | null; @@ -182,13 +180,13 @@ const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({ })); const iAmPlayer = computed(() => { - return game.value.user1Id === $i.id || game.value.user2Id === $i.id; + return game.value.user1Id === $i?.id || game.value.user2Id === $i?.id; }); const myColor = computed(() => { if (!iAmPlayer.value) return null; - if (game.value.user1Id === $i.id && game.value.black === 1) return true; - if (game.value.user2Id === $i.id && game.value.black === 2) return true; + if (game.value.user1Id === $i?.id && game.value.black === 1) return true; + if (game.value.user2Id === $i?.id && game.value.black === 2) return true; return false; }); @@ -219,7 +217,7 @@ const isMyTurn = computed(() => { if (!iAmPlayer.value) return false; const u = turnUser.value; if (u == null) return false; - return u.id === $i.id; + return u.id === $i?.id; }); const cellsStyle = computed(() => { @@ -308,7 +306,7 @@ if (!props.game.isEnded) { }, TIMER_INTERVAL_SEC * 1000, { immediate: false, afterMounted: true }); } -async function onStreamLog(log) { +async function onStreamLog(log: Reversi.Serializer.Log & { id: string | null }) { game.value.logs = Reversi.Serializer.serializeLogs([ ...Reversi.Serializer.deserializeLogs(game.value.logs), log, @@ -348,10 +346,13 @@ async function onStreamLog(log) { } } -function onStreamEnded(x) { +function onStreamEnded(x: { + winnerId: Misskey.entities.User['id'] | null; + game: Misskey.entities.ReversiGameDetailed; +}) { game.value = deepClone(x.game); - if (game.value.winnerId === $i.id) { + if (game.value.winnerId === $i?.id) { confetti({ duration: 1000 * 3, }); @@ -384,7 +385,7 @@ function checkEnd() { } } -function restoreGame(_game) { +function restoreGame(_game: Misskey.entities.ReversiGameDetailed) { game.value = deepClone(_game); engine.value = Reversi.Serializer.restoreGame({ diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 1e01496bbb..f3f89d163b 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -35,22 +35,28 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts._reversi.blackOrWhite }}</template> - <MkRadios v-model="game.bw"> - <option value="random">{{ i18n.ts.random }}</option> - <option :value="'1'"> + <MkRadios + v-model="game.bw" + :options="[ + { value: 'random', label: i18n.ts.random }, + { value: '1', slotId: 'user1' }, + { value: '2', slotId: 'user2' }, + ]" + > + <template #option-user1> <I18n :src="i18n.ts._reversi.blackIs" tag="span"> <template #name> <b><MkUserName :user="game.user1"/></b> </template> </I18n> - </option> - <option :value="'2'"> + </template> + <template #option-user2> <I18n :src="i18n.ts._reversi.blackIs" tag="span"> <template #name> <b><MkUserName :user="game.user2"/></b> </template> </I18n> - </option> + </template> </MkRadios> </MkFolder> @@ -58,15 +64,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts._reversi.timeLimitForEachTurn }}</template> <template #suffix>{{ game.timeLimitForEachTurn }}{{ i18n.ts._time.second }}</template> - <MkRadios v-model="game.timeLimitForEachTurn"> - <option :value="5">5{{ i18n.ts._time.second }}</option> - <option :value="10">10{{ i18n.ts._time.second }}</option> - <option :value="30">30{{ i18n.ts._time.second }}</option> - <option :value="60">60{{ i18n.ts._time.second }}</option> - <option :value="90">90{{ i18n.ts._time.second }}</option> - <option :value="120">120{{ i18n.ts._time.second }}</option> - <option :value="180">180{{ i18n.ts._time.second }}</option> - <option :value="3600">3600{{ i18n.ts._time.second }}</option> + <MkRadios + v-model="game.timeLimitForEachTurn" + :options="gameTurnOptionsDef" + > </MkRadios> </MkFolder> @@ -110,22 +111,21 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; +import { computed, watch, ref, onUnmounted } from 'vue'; import * as Misskey from 'misskey-js'; import * as Reversi from 'misskey-reversi'; +import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; -import { ensureSignin } from '@/i.js'; +import { $i } from '@/i.js'; import { deepClone } from '@/utility/clone.js'; import MkButton from '@/components/MkButton.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import type { MenuItem } from '@/types/menu.js'; +import type { MkRadiosOption } from '@/components/MkRadios.vue'; import { useRouter } from '@/router.js'; -const $i = ensureSignin(); - const router = useRouter(); const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x.category))); @@ -139,19 +139,30 @@ const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false } const game = ref<Misskey.entities.ReversiGameDetailed>(deepClone(props.game)); +const gameTurnOptionsDef = [ + { value: 5, label: '5' + i18n.ts._time.second }, + { value: 10, label: '10' + i18n.ts._time.second }, + { value: 30, label: '30' + i18n.ts._time.second }, + { value: 60, label: '60' + i18n.ts._time.second }, + { value: 90, label: '90' + i18n.ts._time.second }, + { value: 120, label: '120' + i18n.ts._time.second }, + { value: 180, label: '180' + i18n.ts._time.second }, + { value: 3600, label: '3600' + i18n.ts._time.second }, +] as MkRadiosOption<number>[]; + const mapName = computed(() => { if (game.value.map == null) return 'Random'; const found = Object.values(Reversi.maps).find(x => x.data.join('') === game.value.map.join('')); return found ? found.name! : '-Custom-'; }); const isReady = computed(() => { - if (game.value.user1Id === $i.id && game.value.user1Ready) return true; - if (game.value.user2Id === $i.id && game.value.user2Ready) return true; + if (game.value.user1Id === $i?.id && game.value.user1Ready) return true; + if (game.value.user2Id === $i?.id && game.value.user2Ready) return true; return false; }); const isOpReady = computed(() => { - if (game.value.user1Id !== $i.id && game.value.user1Ready) return true; - if (game.value.user2Id !== $i.id && game.value.user2Ready) return true; + if (game.value.user1Id !== $i?.id && game.value.user1Ready) return true; + if (game.value.user2Id !== $i?.id && game.value.user2Ready) return true; return false; }); @@ -165,7 +176,7 @@ watch(() => game.value.timeLimitForEachTurn, () => { updateSettings('timeLimitForEachTurn'); }); -function chooseMap(ev: MouseEvent) { +function chooseMap(ev: PointerEvent) { const menu: MenuItem[] = []; for (const c of mapCategories) { @@ -212,7 +223,10 @@ function unready() { props.connection.send('ready', false); } -function onChangeReadyStates(states) { +function onChangeReadyStates(states: { + user1: boolean; + user2: boolean; +}) { game.value.user1Ready = states.user1; game.value.user2Ready = states.user2; } @@ -225,7 +239,7 @@ function updateSettings(key: typeof Misskey.reversiUpdateKeys[number]) { } function onUpdateSettings<K extends typeof Misskey.reversiUpdateKeys[number]>({ userId, key, value }: { userId: string; key: K; value: Misskey.entities.ReversiGameDetailed[K]; }) { - if (userId === $i.id) return; + if (userId === $i?.id) return; if (game.value[key] === value) return; game.value[key] = value; if (isReady.value) { diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index b1ba4da247..926d825b66 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -17,15 +17,13 @@ import GameBoard from './game.board.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; import { useStream } from '@/stream.js'; -import { ensureSignin } from '@/i.js'; +import { $i } from '@/i.js'; import { useRouter } from '@/router.js'; import * as os from '@/os.js'; import { url } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@@/js/use-interval.js'; -const $i = ensureSignin(); - const router = useRouter(); const props = defineProps<{ @@ -74,7 +72,7 @@ async function fetchGame() { connection.value.on('canceled', x => { connection.value?.dispose(); - if (x.userId !== $i.id) { + if (x.userId !== $i?.id) { os.alert({ type: 'warning', text: i18n.ts._reversi.gameCanceled, diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index 0ae374649d..9a737e93ac 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -197,7 +197,8 @@ async function matchHeatbeat() { } async function matchUser() { - pleaseLogin(); + const isLoggedIn = await pleaseLogin(); + if (!isLoggedIn) return; const user = await os.selectUser({ includeSelf: false, localOnly: true }); if (user == null) return; @@ -207,8 +208,9 @@ async function matchUser() { matchHeatbeat(); } -function matchAny(ev: MouseEvent) { - pleaseLogin(); +async function matchAny(ev: PointerEvent) { + const isLoggedIn = await pleaseLogin(); + if (!isLoggedIn) return; os.popupMenu([{ text: i18n.ts._reversi.allowIrregularRules, @@ -237,11 +239,11 @@ function cancelMatching() { } } -async function accept(user) { +async function accept(user: Misskey.entities.UserLite) { const game = await misskeyApi('reversi/match', { userId: user.id, }); - if (game) { + if (game != null) { startGame(game); } } diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index 4e02556c83..b3b899517e 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -97,7 +97,7 @@ watch(code, () => { miLocalStorage.setItem('scratchpad', code.value); }); -function stringifyUiProps(uiProps) { +function stringifyUiProps(uiProps: AsUiComponent) { return JSON.stringify( { ...uiProps, type: undefined, id: undefined }, (k, v) => typeof v === 'function' ? '<function>' : v, diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index fb34d592a6..ab36f2e6c5 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -19,11 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>{{ i18n.ts.options }}</template> <div class="_gaps_m"> - <MkRadios v-model="searchScope"> - <option v-if="instance.federation !== 'none' && noteSearchableScope === 'global'" value="all">{{ i18n.ts._search.searchScopeAll }}</option> - <option value="local">{{ instance.federation === 'none' ? i18n.ts._search.searchScopeAll : i18n.ts._search.searchScopeLocal }}</option> - <option v-if="instance.federation !== 'none' && noteSearchableScope === 'global'" value="server">{{ i18n.ts._search.searchScopeServer }}</option> - <option value="user">{{ i18n.ts._search.searchScopeUser }}</option> + <MkRadios + v-model="searchScope" + :options="searchScopeDef" + > </MkRadios> <div v-if="instance.federation !== 'none' && searchScope === 'server'" :class="$style.subOptionRoot"> @@ -71,7 +70,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkUserCardMini :user="user" :withChart="false" - :class="$style.userSelectedCard" /> </div> <div> @@ -128,6 +126,7 @@ import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { Paginator } from '@/utility/paginator.js'; +import type { MkRadiosOption } from '@/components/MkRadios.vue'; const props = withDefaults(defineProps<{ query?: string; @@ -184,6 +183,24 @@ const searchScope = ref<'all' | 'local' | 'server' | 'user'>((() => { return 'all'; })()); +const searchScopeDef = computed<MkRadiosOption[]>(() => { + const options: MkRadiosOption[] = []; + + if (instance.federation !== 'none' && noteSearchableScope === 'global') { + options.push({ value: 'all', label: i18n.ts._search.searchScopeAll }); + } + + options.push({ value: 'local', label: instance.federation === 'none' ? i18n.ts._search.searchScopeAll : i18n.ts._search.searchScopeLocal }); + + if (instance.federation !== 'none' && noteSearchableScope === 'global') { + options.push({ value: 'server', label: i18n.ts._search.searchScopeServer }); + } + + options.push({ value: 'user', label: i18n.ts._search.searchScopeUser }); + + return options; +}); + type SearchParams = { readonly query: string; readonly host?: string; diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 5110fca10c..cc91adb63d 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -9,10 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkRadios v-if="instance.federation !== 'none'" v-model="searchOrigin" @update:modelValue="search()"> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> + <MkRadios + v-if="instance.federation !== 'none'" + v-model="searchOrigin" + :options="[ + { value: 'combined', label: i18n.ts.all }, + { value: 'local', label: i18n.ts.local }, + { value: 'remote', label: i18n.ts.remote }, + ]" + @update:modelValue="search()" + > </MkRadios> <MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton> </div> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index 2cc13744b1..bf71845a38 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -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> @@ -85,6 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, computed } from 'vue'; import { supported as webAuthnSupported, create as webAuthnCreate, parseCreationOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; +import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -156,7 +157,7 @@ function renewTOTP(): void { }); } -async function unregisterKey(key) { +async function unregisterKey(key: NonNullable<Misskey.entities.MeDetailedOnly['securityKeysList']>[number]) { const confirm = await os.confirm({ type: 'question', title: i18n.ts._2fa.removeKey, @@ -175,7 +176,7 @@ async function unregisterKey(key) { os.success(); } -async function renameKey(key) { +async function renameKey(key: NonNullable<Misskey.entities.MeDetailedOnly['securityKeysList']>[number]) { const name = await os.inputText({ title: i18n.ts.rename, default: key.name, diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue index c75667b06b..b07515a49a 100644 --- a/packages/frontend/src/pages/settings/account-data.vue +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -189,7 +189,7 @@ const onImportSuccess = () => { }); }; -const onError = (ev) => { +const onError = (ev: Error) => { os.alert({ type: 'error', text: ev.message, @@ -232,7 +232,7 @@ const exportAntennas = () => { misskeyApi('i/export-antennas', {}).then(onExportSuccess).catch(onError); }; -const importFollowing = async (ev) => { +const importFollowing = async (ev: PointerEvent) => { const file = await selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, @@ -243,7 +243,7 @@ const importFollowing = async (ev) => { }).then(onImportSuccess).catch(onError); }; -const importUserLists = async (ev) => { +const importUserLists = async (ev: PointerEvent) => { const file = await selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, @@ -251,7 +251,7 @@ const importUserLists = async (ev) => { misskeyApi('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); }; -const importMuting = async (ev) => { +const importMuting = async (ev: PointerEvent) => { const file = await selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, @@ -259,7 +259,7 @@ const importMuting = async (ev) => { misskeyApi('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); }; -const importBlocking = async (ev) => { +const importBlocking = async (ev: PointerEvent) => { const file = await selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, @@ -267,7 +267,7 @@ const importBlocking = async (ev) => { misskeyApi('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); }; -const importAntennas = async (ev) => { +const importAntennas = async (ev: PointerEvent) => { const file = await selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 764ec72652..55a81bbf38 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -38,7 +38,7 @@ function refreshAllAccounts() { // TODO } -function showMenu(host: string, id: string, ev: MouseEvent) { +function showMenu(host: string, id: string, ev: PointerEvent) { let menu: MenuItem[]; menu = [{ @@ -54,7 +54,7 @@ function showMenu(host: string, id: string, ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target); } -function addAccount(ev: MouseEvent) { +function addAccount(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.existingAccount, action: () => { addExistingAccount(); }, diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 10901f737b..e9857b1e0b 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.permission }}</template> <template #suffix>{{ Object.keys(token.permission).length === 0 ? i18n.ts.none : Object.keys(token.permission).length }}</template> <ul> - <li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li> + <li v-for="p in token.permission" :key="p">{{ (i18n.ts._permissions as any)[p] ?? p }}</li> </ul> </MkFolder> </div> @@ -68,7 +68,7 @@ const paginator = markRaw(new Paginator('i/apps', { }, })); -function revoke(token) { +function revoke(token: Misskey.entities.IAppsResponse[number]) { misskeyApi('i/revoke-token', { tokenId: token.id }).then(() => { paginator.reload(); }); diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index 7a19b0495b..40fee6caaf 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -40,31 +40,43 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['column', 'align']"> <MkPreferenceContainer k="deck.columnAlign"> - <MkRadios v-model="columnAlign"> + <MkRadios + v-model="columnAlign" + :options="[ + { value: 'left', label: i18n.ts.left }, + { value: 'center', label: i18n.ts.center }, + ]" + > <template #label><SearchLabel>{{ i18n.ts._deck.columnAlign }}</SearchLabel></template> - <option value="left">{{ i18n.ts.left }}</option> - <option value="center">{{ i18n.ts.center }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['menu', 'position']"> <MkPreferenceContainer k="deck.menuPosition"> - <MkRadios v-model="menuPosition"> + <MkRadios + v-model="menuPosition" + :options="[ + { value: 'right', label: i18n.ts.right }, + { value: 'bottom', label: i18n.ts.bottom }, + ]" + > <template #label><SearchLabel>{{ i18n.ts._deck.deckMenuPosition }}</SearchLabel></template> - <option value="right">{{ i18n.ts.right }}</option> - <option value="bottom">{{ i18n.ts.bottom }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['navbar', 'position']"> <MkPreferenceContainer k="deck.navbarPosition"> - <MkRadios v-model="navbarPosition"> + <MkRadios + v-model="navbarPosition" + :options="[ + { value: 'left', label: i18n.ts.left }, + { value: 'top', label: i18n.ts.top }, + { value: 'bottom', label: i18n.ts.bottom }, + ]" + > <template #label><SearchLabel>{{ i18n.ts._deck.navbarPosition }}</SearchLabel></template> - <option value="left">{{ i18n.ts.left }}</option> - <option value="top">{{ i18n.ts.top }}</option> - <option value="bottom">{{ i18n.ts.bottom }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> @@ -113,7 +125,7 @@ watch(wallpaper, () => { suggestReload(); }); -function setWallpaper(ev: MouseEvent) { +function setWallpaper(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 57192c0fb7..7189e19780 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -60,6 +60,7 @@ import bytes from '@/filters/bytes.js'; import { definePage } from '@/page.js'; import MkSelect from '@/components/MkSelect.vue'; import { useMkSelect } from '@/composables/use-mkselect.js'; +import { useGlobalEvent } from '@/events.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; @@ -115,14 +116,20 @@ function genUsageBar(fsize: number): StyleValue { }; } -function onClick(ev: MouseEvent, file) { +function onClick(ev: PointerEvent, file: Misskey.entities.DriveFile) { os.popupMenu(getDriveFileMenu(file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } -function onContextMenu(ev: MouseEvent, file): void { +function onContextMenu(ev: PointerEvent, file: Misskey.entities.DriveFile): void { os.contextMenu(getDriveFileMenu(file), ev); } +useGlobalEvent('driveFilesDeleted', (files) => { + for (const f of files) { + paginator.removeItem(f.id); + } +}); + definePage(() => ({ title: i18n.ts.drivecleaner, icon: 'ti ti-trash', diff --git a/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue b/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue index 62922fc964..f92e87375f 100644 --- a/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue +++ b/packages/frontend/src/pages/settings/drive.ImageFrameItem.vue @@ -52,7 +52,7 @@ async function edit() { }); } -function del(ev: MouseEvent) { +function del(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.delete, action: () => { diff --git a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue index 0c03a4493a..9e80d719de 100644 --- a/packages/frontend/src/pages/settings/drive.WatermarkItem.vue +++ b/packages/frontend/src/pages/settings/drive.WatermarkItem.vue @@ -52,7 +52,7 @@ async function edit() { }); } -function del(ev: MouseEvent) { +function del(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.delete, action: () => { diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 8d443921a9..b170d17a5a 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -296,8 +296,9 @@ if (prefer.s.uploadFolder) { } function chooseUploadFolder() { - selectDriveFolder(null).then(async folder => { - prefer.commit('uploadFolder', folder[0] ? folder[0].id : null); + selectDriveFolder(null).then(async ({ canceled, folders }) => { + if (canceled) return; + prefer.commit('uploadFolder', folders[0] ? folders[0].id : null); os.success(); if (prefer.s.uploadFolder) { uploadFolder.value = await misskeyApi('drive/folders/show', { diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index 469a3c2f1c..85fea7ae66 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -76,11 +76,11 @@ const $i = ensureSignin(); const emailAddress = ref($i.email ?? ''); -const onChangeReceiveAnnouncementEmail = (v) => { +function onChangeReceiveAnnouncementEmail(v: boolean) { misskeyApi('i/update', { receiveAnnouncementEmail: v, }); -}; +} async function saveEmailAddress() { const auth = await os.authenticateDialog(); diff --git a/packages/frontend/src/pages/settings/emoji-palette.palette.vue b/packages/frontend/src/pages/settings/emoji-palette.palette.vue index b624d424f3..d8a5f16b7d 100644 --- a/packages/frontend/src/pages/settings/emoji-palette.palette.vue +++ b/packages/frontend/src/pages/settings/emoji-palette.palette.vue @@ -18,19 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <div v-panel style="border-radius: 6px;"> - <Sortable - v-model="emojis" + <MkDraggable + :modelValue="emojis.map(emoji => ({ id: emoji, emoji }))" + direction="horizontal" :class="$style.emojis" - :itemKey="item => item" - :animation="150" - :delay="100" - :delayOnTouchOnly="true" - :group="{ name: 'SortableEmojiPalettes' }" + group="emojiPalettes" + @update:modelValue="v => emojis = v.map(x => x.emoji)" > - <template #item="{element}"> - <button class="_button" :class="$style.emojisItem" @click="remove(element, $event)"> - <MkCustomEmoji v-if="element[0] === ':'" :name="element" :normal="true" :fallbackToImage="true"/> - <MkEmoji v-else :emoji="element" :normal="true"/> + <template #default="{ item }"> + <button class="_button" :class="$style.emojisItem" @click="remove(item.emoji, $event)"> + <!-- pointer-eventsをnoneにしておかないとiOSなどでドラッグしたときに画像の方に判定が持ってかれる --> + <MkCustomEmoji v-if="item.emoji[0] === ':'" style="pointer-events: none;" :name="item.emoji" :normal="true" :fallbackToImage="true"/> + <MkEmoji v-else style="pointer-events: none;" :emoji="item.emoji" :normal="true"/> </button> </template> <template #footer> @@ -38,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-plus"></i> </button> </template> - </Sortable> + </MkDraggable> </div> <div :class="$style.editorCaption">{{ i18n.ts.reactionSettingDescription2 }}</div> </div> @@ -47,7 +46,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; -import Sortable from 'vuedraggable'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -55,6 +53,7 @@ import { deepClone } from '@/utility/clone.js'; import MkCustomEmoji from '@/components/global/MkCustomEmoji.vue'; import MkEmoji from '@/components/global/MkEmoji.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; const props = defineProps<{ @@ -77,7 +76,7 @@ watch(emojis, () => { emit('updateEmojis', emojis.value); }, { deep: true }); -function remove(reaction: string, ev: MouseEvent) { +function remove(reaction: string, ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.remove, action: () => { @@ -86,7 +85,7 @@ function remove(reaction: string, ev: MouseEvent) { }], getHTMLElement(ev)); } -function pick(ev: MouseEvent) { +function pick(ev: PointerEvent) { os.pickEmoji(getHTMLElement(ev), { showPinned: false, }).then(it => { @@ -97,7 +96,7 @@ function pick(ev: MouseEvent) { }); } -function getHTMLElement(ev: MouseEvent): HTMLElement { +function getHTMLElement(ev: PointerEvent): HTMLElement { const target = ev.currentTarget ?? ev.target; return target as HTMLElement; } @@ -125,7 +124,7 @@ function paste() { }); } -function del(ev: MouseEvent) { +function del(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.delete, action: () => { diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue index 7f31699ed1..cb665554cd 100644 --- a/packages/frontend/src/pages/settings/emoji-palette.vue +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -63,38 +63,33 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <SearchMarker :keywords="['emoji', 'picker', 'scale', 'size']"> <MkPreferenceContainer k="emojiPickerScale"> - <MkRadios v-model="emojiPickerScale"> + <MkRadios + v-model="emojiPickerScale" + :options="emojiPickerScaleDef" + > <template #label><SearchLabel>{{ i18n.ts.size }}</SearchLabel></template> - <option :value="1">{{ i18n.ts.small }}</option> - <option :value="2">{{ i18n.ts.medium }}</option> - <option :value="3">{{ i18n.ts.large }}</option> - <option :value="4">{{ i18n.ts.large }}+</option> - <option :value="5">{{ i18n.ts.large }}++</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['emoji', 'picker', 'width', 'column', 'size']"> <MkPreferenceContainer k="emojiPickerWidth"> - <MkRadios v-model="emojiPickerWidth"> + <MkRadios + v-model="emojiPickerWidth" + :options="emojiPickerWidthDef" + > <template #label><SearchLabel>{{ i18n.ts.numberOfColumn }}</SearchLabel></template> - <option :value="1">5</option> - <option :value="2">6</option> - <option :value="3">7</option> - <option :value="4">8</option> - <option :value="5">9</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['emoji', 'picker', 'height', 'size']"> <MkPreferenceContainer k="emojiPickerHeight"> - <MkRadios v-model="emojiPickerHeight"> + <MkRadios + v-model="emojiPickerHeight" + :options="emojiPickerHeightDef" + > <template #label><SearchLabel>{{ i18n.ts.height }}</SearchLabel></template> - <option :value="1">{{ i18n.ts.small }}</option> - <option :value="2">{{ i18n.ts.medium }}</option> - <option :value="3">{{ i18n.ts.large }}</option> - <option :value="4">{{ i18n.ts.large }}+</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> @@ -126,6 +121,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, watch } from 'vue'; import XPalette from './emoji-palette.palette.vue'; import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { MkRadiosOption } from '@/components/MkRadios.vue'; import { genId } from '@/utility/id.js'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -158,8 +154,31 @@ const emojiPaletteForMainDef = computed<MkSelectItem[]>(() => [ })), ]); const emojiPickerScale = prefer.model('emojiPickerScale'); +const emojiPickerScaleDef = [ + { label: i18n.ts.small, value: 1 }, + { label: i18n.ts.medium, value: 2 }, + { label: i18n.ts.large, value: 3 }, + { label: i18n.ts.large + '+', value: 4 }, + { label: i18n.ts.large + '++', value: 5 }, +] as MkRadiosOption<number>[]; + const emojiPickerWidth = prefer.model('emojiPickerWidth'); +const emojiPickerWidthDef = [ + { label: '5', value: 1 }, + { label: '6', value: 2 }, + { label: '7', value: 3 }, + { label: '8', value: 4 }, + { label: '9', value: 5 }, +] as MkRadiosOption<number>[]; + const emojiPickerHeight = prefer.model('emojiPickerHeight'); +const emojiPickerHeightDef = [ + { label: i18n.ts.small, value: 1 }, + { label: i18n.ts.medium, value: 2 }, + { label: i18n.ts.large, value: 3 }, + { label: i18n.ts.large + '+', value: 4 }, +] as MkRadiosOption<number>[]; + const emojiPickerStyle = prefer.model('emojiPickerStyle'); const palettesSyncEnabled = ref(prefer.isSyncEnabled('emojiPalettes')); @@ -226,12 +245,12 @@ function delPalette(id: string) { } } -function getHTMLElement(ev: MouseEvent): HTMLElement { +function getHTMLElement(ev: PointerEvent): HTMLElement { const target = ev.currentTarget ?? ev.target; return target as HTMLElement; } -function previewPicker(ev: MouseEvent) { +function previewPicker(ev: PointerEvent) { emojiPicker.show(getHTMLElement(ev)); } diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 39c32d347f..abfac37275 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="!narrow || currentPage?.route.name == null" class="nav"> <div class="_gaps_s"> <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> - <MkInfo v-if="!storagePersisted && store.r.showStoragePersistenceSuggestion.value" class="info"> + <MkInfo v-if="storagePersistenceSupported && !storagePersisted && store.r.showStoragePersistenceSuggestion.value" class="info"> <div>{{ i18n.ts._settings.settingsPersistence_description1 }}</div> <div>{{ i18n.ts._settings.settingsPersistence_description2 }}</div> <div><button class="_textButton" @click="enableStoragePersistence">{{ i18n.ts.enable }}</button> | <button class="_textButton" @click="skipStoragePersistence">{{ i18n.ts.skip }}</button></div> @@ -51,10 +51,12 @@ import { enableAutoBackup, getPreferencesProfileMenu } from '@/preferences/utili import { store } from '@/store.js'; import { signout } from '@/signout.js'; import { genSearchIndexes } from '@/utility/inapp-search.js'; -import { enableStoragePersistence, storagePersisted, skipStoragePersistence } from '@/utility/storage.js'; +import { enableStoragePersistence, getStoragePersistenceStatusRef, storagePersistenceSupported, skipStoragePersistence } from '@/utility/storage.js'; const searchIndex = await import('search-index:settings').then(({ searchIndexes }) => genSearchIndexes(searchIndexes)); +const storagePersisted = await getStoragePersistenceStatusRef(); + const indexInfo = { title: i18n.ts.settings, icon: 'ti ti-settings', @@ -166,7 +168,7 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ type: 'button', icon: 'ti ti-settings-2', text: i18n.ts.preferencesProfile, - action: async (ev: MouseEvent) => { + action: async (ev) => { os.popupMenu(getPreferencesProfileMenu(), ev.currentTarget ?? ev.target); }, }, { diff --git a/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue b/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue index ea131381a1..37cd9fa67d 100644 --- a/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue @@ -55,12 +55,12 @@ import { const emojis = prefer.model('mutingEmojis'); -function getHTMLElement(ev: MouseEvent): HTMLElement { +function getHTMLElement(ev: PointerEvent): HTMLElement { const target = ev.currentTarget ?? ev.target; return target as HTMLElement; } -function add(ev: MouseEvent) { +function add(ev: PointerEvent) { os.pickEmoji(getHTMLElement(ev), { showPinned: false }).then((emoji) => { if (emoji) { muteEmoji(emoji); @@ -68,7 +68,7 @@ function add(ev: MouseEvent) { }); } -function onEmojiClick(ev: MouseEvent, emoji: string) { +function onEmojiClick(ev: PointerEvent, emoji: string) { const menuItems : MenuItem[] = [{ type: 'label', text: emoji, diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 6fd9f07a47..433969f474 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -173,6 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch, markRaw } from 'vue'; +import * as Misskey from 'misskey-js'; import XEmojiMute from './mute-block.emoji-mute.vue'; import XInstanceMute from './mute-block.instance-mute.vue'; import XWordMute from './mute-block.word-mute.vue'; @@ -218,7 +219,7 @@ watch([ suggestReload(); }); -async function unrenoteMute(user, ev) { +async function unrenoteMute(user: Misskey.entities.UserDetailed, ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.renoteUnmute, icon: 'ti ti-x', @@ -229,7 +230,7 @@ async function unrenoteMute(user, ev) { }], ev.currentTarget ?? ev.target); } -async function unmute(user, ev) { +async function unmute(user: Misskey.entities.UserDetailed, ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.unmute, icon: 'ti ti-x', @@ -240,7 +241,7 @@ async function unmute(user, ev) { }], ev.currentTarget ?? ev.target); } -async function unblock(user, ev) { +async function unblock(user: Misskey.entities.UserDetailed, ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.unblock, icon: 'ti ti-x', diff --git a/packages/frontend/src/pages/settings/mute-block.word-mute.vue b/packages/frontend/src/pages/settings/mute-block.word-mute.vue index f5837abe98..49d8ecd92d 100644 --- a/packages/frontend/src/pages/settings/mute-block.word-mute.vue +++ b/packages/frontend/src/pages/settings/mute-block.word-mute.vue @@ -30,7 +30,7 @@ const emit = defineEmits<{ (ev: 'save', value: (string[] | string)[]): void; }>(); -const render = (mutedWords) => mutedWords.map(x => { +const render = (mutedWords: (string | string[])[]) => mutedWords.map(x => { if (Array.isArray(x)) { return x.join(' '); } else { @@ -46,13 +46,13 @@ watch(mutedWords, () => { }); async function save() { - const parseMutes = (mutes) => { + const parseMutes = (mutes: string) => { // split into lines, remove empty lines and unnecessary whitespace - let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); + let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== '') as (string | string[])[]; // check each line if it is a RegExp or not for (let i = 0; i < lines.length; i++) { - const line = lines[i]; + const line = lines[i] as string; const regexp = line.match(/^\/(.+)\/(.*)$/); if (regexp) { // check that the RegExp is valid diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index d25708dcb4..997a9f00c2 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -9,25 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSlot> <template #label>{{ i18n.ts.navbar }}</template> <MkContainer :showHeader="false"> - <Sortable + <MkDraggable v-model="items" - itemKey="id" - :animation="150" - :handle="'.' + $style.itemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" + direction="vertical" > - <template #item="{element,index}"> + <template #default="{ item }"> <div - v-if="element.type === '-' || navbarItemDef[element.type]" + v-if="item.type === '-' || navbarItemDef[item.type]" :class="$style.item" > <button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button> - <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span> - <button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button> + <i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[item.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[item.type]?.title ?? i18n.ts.divider }}</span> + <button class="_button" :class="$style.itemRemove" @click="removeItem(item.id)"><i class="ti ti-x"></i></button> </div> </template> - </Sortable> + </MkDraggable> </MkContainer> </FormSlot> <div class="_buttons"> @@ -36,10 +32,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary class="save" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> - <MkRadios v-model="menuDisplay"> + <MkRadios + v-model="menuDisplay" + :options="[ + { value: 'sideFull', label: i18n.ts._menuDisplay.sideFull }, + { value: 'sideIcon', label: i18n.ts._menuDisplay.sideIcon }, + ]" + > <template #label>{{ i18n.ts.display }}</template> - <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option> - <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option> </MkRadios> <SearchMarker :keywords="['navbar', 'sidebar', 'toggle', 'button', 'sub']"> @@ -54,13 +54,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import { computed, ref } from 'vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSlot from '@/components/form/slot.vue'; import MkContainer from '@/components/MkContainer.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; import { store } from '@/store.js'; @@ -70,15 +71,13 @@ import { prefer } from '@/preferences.js'; import { getInitialPrefValue } from '@/preferences/manager.js'; import { genId } from '@/utility/id.js'; -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - const items = ref(prefer.s.menu.map(x => ({ id: genId(), type: x, }))); const itemTypeValues = computed(() => items.value.map(x => x.type)); -const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); +const menuDisplay = store.model('menuDisplay'); const showNavbarSubButtons = prefer.model('showNavbarSubButtons'); async function addItem() { @@ -98,8 +97,8 @@ async function addItem() { }]; } -function removeItem(index: number) { - items.value.splice(index, 1); +function removeItem(itemId: string) { + items.value = items.value.filter(i => i.id !== itemId); } function save() { diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 2802d3263e..3787e07626 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -13,16 +13,16 @@ SPDX-License-Identifier: AGPL-3.0-only <FormSection first> <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> <div class="_gaps_s"> - <MkFolder v-for="type in notificationTypes.filter(x => !nonConfigurableNotificationTypes.includes(x))" :key="type"> + <MkFolder v-for="type in configurableNotificationTypes" :key="type"> <template #label>{{ i18n.ts._notification._types[type] }}</template> <template #suffix> {{ - $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : - $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : - $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : - $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : - $i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : - $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : + $i.notificationRecieveConfig[type as (typeof configurableNotificationTypes)[number]]?.type === 'never' ? i18n.ts.none : + $i.notificationRecieveConfig[type as (typeof configurableNotificationTypes)[number]]?.type === 'following' ? i18n.ts.following : + $i.notificationRecieveConfig[type as (typeof configurableNotificationTypes)[number]]?.type === 'follower' ? i18n.ts.followers : + $i.notificationRecieveConfig[type as (typeof configurableNotificationTypes)[number]]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : + $i.notificationRecieveConfig[type as (typeof configurableNotificationTypes)[number]]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower : + $i.notificationRecieveConfig[type as (typeof configurableNotificationTypes)[number]]?.type === 'list' ? i18n.ts.userList : i18n.ts.all }} </template> @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" - :configurableTypes="onlyOnOrOffNotificationTypes.includes(type) ? ['all', 'never'] : undefined" + :configurableTypes="(onlyOnOrOffNotificationTypes as string[]).includes(type) ? ['all', 'never'] : undefined" @update="(res) => updateReceiveConfig(type, res)" /> </MkFolder> @@ -83,9 +83,11 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; const $i = ensureSignin(); -const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; +const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] as const satisfies (typeof notificationTypes[number])[]; -const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'] satisfies (typeof notificationTypes[number])[] as string[]; +const configurableNotificationTypes = notificationTypes.filter(type => !nonConfigurableNotificationTypes.includes(type as any)) as Exclude<typeof notificationTypes[number], typeof nonConfigurableNotificationTypes[number]>[]; + +const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'] as const satisfies (typeof notificationTypes[number])[]; const allowButton = useTemplateRef('allowButton'); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index d4097bde94..4facc696a4 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <div v-for="policy in Object.keys($i.policies)" :key="policy"> - {{ policy }} ... {{ $i.policies[policy] }} + {{ policy }} ... {{ $i.policies[policy as keyof typeof $i.policies] }} </div> </div> </MkFolder> @@ -142,7 +142,7 @@ SPDX-License-Identifier: AGPL-3.0-only <hr> </template> - <MkButton v-if="!storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton> + <MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton> <MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton> @@ -165,7 +165,7 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os.js'; -import { enableStoragePersistence, storagePersisted, skipStoragePersistence } from '@/utility/storage.js'; +import { enableStoragePersistence, getStoragePersistenceStatusRef, storagePersistenceSupported } from '@/utility/storage.js'; import { ensureSignin } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; @@ -180,6 +180,8 @@ import { cloudBackup } from '@/preferences/utility.js'; const $i = ensureSignin(); +const storagePersisted = await getStoragePersistenceStatusRef(); + const reportError = prefer.model('reportError'); const enableCondensedLine = prefer.model('enableCondensedLine'); const skipNoteRender = prefer.model('skipNoteRender'); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 7c6ce90e7e..89f457cf69 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -58,7 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #key>{{ i18n.ts.permission }}</template> <template #value> <ul style="margin-top: 0; margin-bottom: 0;"> - <li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + <li v-for="permission in plugin.permissions" :key="permission">{{ i18n.ts._permissions[permission] ?? permission }}</li> <li v-if="!plugin.permissions || plugin.permissions.length === 0">{{ i18n.ts.none }}</li> </ul> </template> @@ -96,6 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { nextTick, ref, computed } from 'vue'; +import { isSafeMode } from '@@/js/config.js'; import type { Plugin } from '@/plugin.js'; import FormLink from '@/components/form/link.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -110,7 +111,6 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { changePluginActive, configPlugin, pluginLogs, uninstallPlugin, reloadPlugin } from '@/plugin.js'; import { prefer } from '@/preferences.js'; -import { isSafeMode } from '@@/js/config.js'; import * as os from '@/os.js'; const plugins = prefer.r.plugins; diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 972b50f8cd..1a613466db 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -31,12 +31,16 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop']"> - <MkRadios v-model="overridedDeviceKind"> + <MkRadios + v-model="overridedDeviceKind" + :options="[ + { value: null, label: i18n.ts.auto }, + { value: 'smartphone', label: i18n.ts.smartphone, icon: 'ti ti-device-mobile' }, + { value: 'tablet', label: i18n.ts.tablet, icon: 'ti ti-device-tablet' }, + { value: 'desktop', label: i18n.ts.desktop, icon: 'ti ti-device-desktop' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.overridedDeviceKind }}</SearchLabel></template> - <option :value="null">{{ i18n.ts.auto }}</option> - <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> - <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> - <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> </MkRadios> </SearchMarker> @@ -121,11 +125,15 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['emoji', 'style', 'native', 'system', 'fluent', 'twemoji']"> <MkPreferenceContainer k="emojiStyle"> <div> - <MkRadios v-model="emojiStyle"> + <MkRadios + v-model="emojiStyle" + :options="[ + { value: 'native', label: i18n.ts.native }, + { value: 'fluentEmoji', label: 'Fluent Emoji' }, + { value: 'twemoji', label: 'Twemoji' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.emojiStyle }}</SearchLabel></template> - <option value="native">{{ i18n.ts.native }}</option> - <option value="fluentEmoji">Fluent Emoji</option> - <option value="twemoji">Twemoji</option> </MkRadios> <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> </div> @@ -240,11 +248,15 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['reaction', 'size', 'scale', 'display']"> <MkPreferenceContainer k="reactionsDisplaySize"> - <MkRadios v-model="reactionsDisplaySize"> + <MkRadios + v-model="reactionsDisplaySize" + :options="[ + { value: 'small', label: i18n.ts.small }, + { value: 'medium', label: i18n.ts.medium }, + { value: 'large', label: i18n.ts.large }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.reactionsDisplaySize }}</SearchLabel></template> - <option value="small">{{ i18n.ts.small }}</option> - <option value="medium">{{ i18n.ts.medium }}</option> - <option value="large">{{ i18n.ts.large }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> @@ -259,16 +271,28 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height']"> <MkPreferenceContainer k="mediaListWithOneImageAppearance"> - <MkRadios v-model="mediaListWithOneImageAppearance"> + <MkRadios + v-model="mediaListWithOneImageAppearance" + :options="[ + { value: 'expand', label: i18n.ts.default }, + { value: '16_9', label: i18n.tsx.limitTo({ x: '16:9' }) }, + { value: '1_1', label: i18n.tsx.limitTo({ x: '1:1' }) }, + { value: '2_3', label: i18n.tsx.limitTo({ x: '2:3' }) }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.mediaListWithOneImageAppearance }}</SearchLabel></template> - <option value="expand">{{ i18n.ts.default }}</option> - <option value="16_9">{{ i18n.tsx.limitTo({ x: '16:9' }) }}</option> - <option value="1_1">{{ i18n.tsx.limitTo({ x: '1:1' }) }}</option> - <option value="2_3">{{ i18n.tsx.limitTo({ x: '2:3' }) }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> + <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'grid', 'wide', 'area']"> + <MkPreferenceContainer k="showMediaListByGridInWideArea"> + <MkSwitch v-model="showMediaListByGridInWideArea"> + <template #label><SearchLabel>{{ i18n.ts.showMediaListByGridInWideArea }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']"> <MkPreferenceContainer k="instanceTicker"> <MkSelect @@ -386,22 +410,30 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['position']"> <MkPreferenceContainer k="notificationPosition"> - <MkRadios v-model="notificationPosition"> + <MkRadios + v-model="notificationPosition" + :options="[ + { value: 'leftTop', label: i18n.ts.leftTop, icon: 'ti ti-align-box-left-top' }, + { value: 'rightTop', label: i18n.ts.rightTop, icon: 'ti ti-align-box-right-top' }, + { value: 'leftBottom', label: i18n.ts.leftBottom, icon: 'ti ti-align-box-left-bottom' }, + { value: 'rightBottom', label: i18n.ts.rightBottom, icon: 'ti ti-align-box-right-bottom' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.position }}</SearchLabel></template> - <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> - <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option> - <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option> - <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['stack', 'axis', 'direction']"> <MkPreferenceContainer k="notificationStackAxis"> - <MkRadios v-model="notificationStackAxis"> + <MkRadios + v-model="notificationStackAxis" + :options="[ + { value: 'vertical', label: i18n.ts.vertical, icon: 'ti ti-carousel-vertical' }, + { value: 'horizontal', label: i18n.ts.horizontal, icon: 'ti ti-carousel-horizontal' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.stackAxis }}</SearchLabel></template> - <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option> - <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> @@ -570,12 +602,16 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['font', 'size']"> - <MkRadios v-model="fontSize"> + <MkRadios + v-model="fontSize" + :options="[ + { value: null, label: 'Aa', labelStyle: 'font-size: 14px;' }, + { value: '1', label: 'Aa', labelStyle: 'font-size: 15px;' }, + { value: '2', label: 'Aa', labelStyle: 'font-size: 16px;' }, + { value: '3', label: 'Aa', labelStyle: 'font-size: 17px;' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.fontSize }}</SearchLabel></template> - <option :value="null"><span style="font-size: 14px;">Aa</span></option> - <option value="1"><span style="font-size: 15px;">Aa</span></option> - <option value="2"><span style="font-size: 16px;">Aa</span></option> - <option value="3"><span style="font-size: 17px;">Aa</span></option> </MkRadios> </SearchMarker> @@ -784,10 +820,14 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker> <MkPreferenceContainer k="hemisphere"> - <MkRadios v-model="hemisphere"> + <MkRadios + v-model="hemisphere" + :options="[ + { value: 'N', label: i18n.ts._hemisphere.N }, + { value: 'S', label: i18n.ts._hemisphere.S }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.hemisphere }}</SearchLabel></template> - <option value="N">{{ i18n.ts._hemisphere.N }}</option> - <option value="S">{{ i18n.ts._hemisphere.S }}</option> <template #caption>{{ i18n.ts._hemisphere.caption }}</template> </MkRadios> </MkPreferenceContainer> @@ -855,7 +895,7 @@ const $i = ensureSignin(); const lang = ref(miLocalStorage.getItem('lang')); const dataSaver = ref(prefer.s.dataSaver); -const realtimeMode = computed(store.makeGetterSetter('realtimeMode')); +const realtimeMode = store.model('realtimeMode'); const overridedDeviceKind = prefer.model('overridedDeviceKind'); const pollingInterval = prefer.model('pollingInterval'); @@ -890,6 +930,7 @@ const notificationStackAxis = prefer.model('notificationStackAxis'); const instanceTicker = prefer.model('instanceTicker'); const highlightSensitiveMedia = prefer.model('highlightSensitiveMedia'); const mediaListWithOneImageAppearance = prefer.model('mediaListWithOneImageAppearance'); +const showMediaListByGridInWideArea = prefer.model('showMediaListByGridInWideArea'); const reactionsDisplaySize = prefer.model('reactionsDisplaySize'); const limitWidthOfReaction = prefer.model('limitWidthOfReaction'); const squareAvatars = prefer.model('squareAvatars'); @@ -916,7 +957,7 @@ const contextMenu = prefer.model('contextMenu'); const menuStyle = prefer.model('menuStyle'); const makeEveryTextElementsSelectable = prefer.model('makeEveryTextElementsSelectable'); -const fontSize = ref(miLocalStorage.getItem('fontSize')); +const fontSize = ref(miLocalStorage.getItem('fontSize') as '1' | '2' | '3' | null); const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); watch(lang, () => { @@ -1042,7 +1083,7 @@ function removePinnedList() { function enableAllDataSaver() { const g = { ...prefer.s.dataSaver }; - Object.keys(g).forEach((key) => { g[key] = true; }); + (Object.keys(g) as (keyof typeof g)[]).forEach((key) => { g[key] = true; }); dataSaver.value = g; } @@ -1050,7 +1091,7 @@ function enableAllDataSaver() { function disableAllDataSaver() { const g = { ...prefer.s.dataSaver }; - Object.keys(g).forEach((key) => { g[key] = false; }); + (Object.keys(g) as (keyof typeof g)[]).forEach((key) => { g[key] = false; }); dataSaver.value = g; } diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 7d3da470d6..a7aea9bde4 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -75,30 +75,27 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.metadataRoot" class="_gaps_s"> <MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo> - <Sortable + <MkDraggable v-model="fields" - class="_gaps_s" - itemKey="id" - :animation="150" - :handle="'.' + $style.dragItemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" + direction="vertical" + withGaps + manualDragStart > - <template #item="{element, index}"> + <template #default="{ item, dragStart }"> <div v-panel :class="$style.fieldDragItem"> - <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> - <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> + <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1" :draggable="true" @dragstart.stop="dragStart"><i class="ti ti-menu"></i></button> + <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(item.id)"><i class="ti ti-x"></i></button> <div :class="$style.dragItemForm"> <FormSplit :minWidth="200"> - <MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel"> + <MkInput v-model="item.name" small :placeholder="i18n.ts._profile.metadataLabel"> </MkInput> - <MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent"> + <MkInput v-model="item.value" small :placeholder="i18n.ts._profile.metadataContent"> </MkInput> </FormSplit> </div> </div> </template> - </Sortable> + </MkDraggable> </div> </MkFolder> <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> @@ -165,7 +162,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, reactive, ref, watch, defineAsyncComponent } from 'vue'; +import { computed, reactive, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; @@ -174,6 +172,7 @@ import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; import FormLink from '@/components/form/link.vue'; +import MkDraggable from '@/components/MkDraggable.vue'; import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -188,9 +187,7 @@ import { genId } from '@/utility/id.js'; const $i = ensureSignin(); -const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); - -const reactionAcceptance = computed(store.makeGetterSetter('reactionAcceptance')); +const reactionAcceptance = store.model('reactionAcceptance'); function assertVaildLang(lang: string | null): lang is keyof typeof langmap { return lang != null && lang in langmap; @@ -228,8 +225,8 @@ while (fields.value.length < 4) { addField(); } -function deleteField(index: number) { - fields.value.splice(index, 1); +function deleteField(itemId: string) { + fields.value = fields.value.filter(f => f.id !== itemId); } function saveFields() { @@ -270,8 +267,8 @@ function save() { } } -function changeAvatar(ev) { - async function done(driveFile) { +function changeAvatar(ev: PointerEvent) { + async function done(driveFile: Misskey.entities.DriveFile) { const i = await os.apiWithDialog('i/update', { avatarId: driveFile.id, }); @@ -319,8 +316,8 @@ function changeAvatar(ev) { }], ev.currentTarget ?? ev.target); } -function changeBanner(ev) { - async function done(driveFile) { +function changeBanner(ev: PointerEvent) { + async function done(driveFile: Misskey.entities.DriveFile) { const i = await os.apiWithDialog('i/update', { bannerId: driveFile.id, }); diff --git a/packages/frontend/src/pages/settings/profiles.vue b/packages/frontend/src/pages/settings/profiles.vue index 4804c11f7a..b3d02ba3fe 100644 --- a/packages/frontend/src/pages/settings/profiles.vue +++ b/packages/frontend/src/pages/settings/profiles.vue @@ -15,21 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; -import type { MenuItem } from '@/types/menu.js'; +import { computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/utility/misskey-api.js'; -import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import { prefer } from '@/preferences.js'; import { deleteCloudBackup, listCloudBackups } from '@/preferences/utility.js'; const backups = await listCloudBackups(); -function del(backup) { +function del(backup: { name: string }): void { deleteCloudBackup(backup.name); } diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 31fe9a64db..050586c2e1 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, watch } from 'vue'; import type { SoundType } from '@/utility/sound.js'; +import type { SoundStore } from '@/preferences/def.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; @@ -41,7 +42,6 @@ import { useMkSelect } from '@/composables/use-mkselect.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; import { selectFile } from '@/utility/drive.js'; -import type { SoundStore } from '@/preferences/def.js'; const props = defineProps<{ def: SoundStore; @@ -100,7 +100,7 @@ const friendlyFileName = computed<string>(() => { return i18n.ts._soundSettings.driveFileWarn; }); -function selectSound(ev) { +function selectSound(ev: PointerEvent) { selectFile({ anchorElement: ev.currentTarget ?? ev.target, multiple: false, diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 1b851825d6..0d0623f11f 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -100,11 +100,14 @@ function getSoundTypeName(f: SoundType): string { } } -async function updated(type: keyof typeof sounds.value, sound) { - const v: SoundStore = { +async function updated(type: keyof typeof sounds.value, sound: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }) { + const v: SoundStore = sound.type === '_driveFile_' ? { + type: sound.type, + fileId: sound.fileId!, + fileUrl: sound.fileUrl!, + volume: sound.volume, + } : { type: sound.type, - fileId: sound.fileId, - fileUrl: sound.fileUrl, volume: sound.volume, }; diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index b69fd2596d..83c8a7b9a7 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -17,13 +17,17 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>Black</template> </MkSwitch> - <MkRadios v-model="statusbar.size"> + <MkRadios + v-model="statusbar.size" + :options="[ + { value: 'verySmall', label: i18n.ts.small + '+' }, + { value: 'small', label: i18n.ts.small }, + { value: 'medium', label: i18n.ts.medium }, + { value: 'large', label: i18n.ts.large }, + { value: 'veryLarge', label: i18n.ts.large + '+' }, + ]" + > <template #label>{{ i18n.ts.size }}</template> - <option value="verySmall">{{ i18n.ts.small }}+</option> - <option value="small">{{ i18n.ts.small }}</option> - <option value="medium">{{ i18n.ts.medium }}</option> - <option value="large">{{ i18n.ts.large }}</option> - <option value="veryLarge">{{ i18n.ts.large }}+</option> </MkRadios> <template v-if="statusbar.type === 'rss'"> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 0129aebe94..46b537f866 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -306,7 +306,7 @@ function changeThemesSyncEnabled(value: boolean) { } } -function onThemeContextmenu(theme: Theme, ev: MouseEvent) { +function onThemeContextmenu(theme: Theme, ev: PointerEvent) { os.contextMenu([{ type: 'label', text: theme.name, diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index 047e68f583..1e268e64d2 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -20,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, markRaw, ref } from 'vue'; +import type { PageHeaderItem } from '@/types/page-header.js'; import MkNotesTimeline from '@/components/MkNotesTimeline.vue'; import MkButton from '@/components/MkButton.vue'; import { definePage } from '@/page.js'; @@ -50,10 +51,10 @@ async function post() { paginator.reload(); } -const headerActions = computed(() => [{ +const headerActions = computed<PageHeaderItem[]>(() => [{ icon: 'ti ti-dots', text: i18n.ts.more, - handler: (ev: MouseEvent) => { + handler: (ev) => { os.popupMenu([{ text: i18n.ts.embed, icon: 'ti ti-code', diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index af3891ac8e..2d2b8ed292 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -160,11 +160,11 @@ function setBgColor(color: typeof bgColors[number]) { } } -function setAccentColor(color) { +function setAccentColor(color: string) { theme.value.props.accent = color; } -function setFgColor(color) { +function setFgColor(color: typeof fgColors[number]) { theme.value.props.fg = theme.value.base === 'light' ? color.forLight : color.forDark; } diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 89d0991bc0..64c2b2eee3 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -31,6 +31,7 @@ import { computed, watch, provide, useTemplateRef, ref, onMounted, onActivated } import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { MenuItem } from '@/types/menu.js'; import type { BasicTimelineType } from '@/timelines.js'; +import type { PageHeaderItem } from '@/types/page-header.js'; import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import * as os from '@/os.js'; @@ -105,7 +106,7 @@ const withSensitive = computed<boolean>({ const showFixedPostForm = prefer.model('showFixedPostForm'); -async function chooseList(ev: MouseEvent): Promise<void> { +async function chooseList(ev: PointerEvent): Promise<void> { const lists = await userListsCache.fetch(); const items: (MenuItem | undefined)[] = [ ...lists.map(list => ({ @@ -124,7 +125,7 @@ async function chooseList(ev: MouseEvent): Promise<void> { os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target); } -async function chooseAntenna(ev: MouseEvent): Promise<void> { +async function chooseAntenna(ev: PointerEvent): Promise<void> { const antennas = await antennasCache.fetch(); const items: (MenuItem | undefined)[] = [ ...antennas.map(antenna => ({ @@ -144,7 +145,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> { os.popupMenu(items.filter(i => i != null), ev.currentTarget ?? ev.target); } -async function chooseChannel(ev: MouseEvent): Promise<void> { +async function chooseChannel(ev: PointerEvent): Promise<void> { const channels = await favoritedChannelsCache.fetch(); const items: (MenuItem | undefined)[] = [ ...channels.map(channel => { @@ -203,8 +204,8 @@ onActivated(() => { switchTlIfNeeded(); }); -const headerActions = computed(() => { - const items = [{ +const headerActions = computed<PageHeaderItem[]>(() => { + const items: PageHeaderItem[] = [{ icon: 'ti ti-dots', text: i18n.ts.options, handler: (ev) => { @@ -254,7 +255,7 @@ const headerActions = computed(() => { items.unshift({ icon: 'ti ti-refresh', text: i18n.ts.reload, - handler: (ev: Event) => { + handler: () => { tlComponent.value?.reloadTimeline(); }, }); diff --git a/packages/frontend/src/pages/user-tag.vue b/packages/frontend/src/pages/user-tag.vue index ec4c854381..75519f2850 100644 --- a/packages/frontend/src/pages/user-tag.vue +++ b/packages/frontend/src/pages/user-tag.vue @@ -25,6 +25,7 @@ const props = defineProps<{ const paginator = markRaw(new Paginator('hashtags/users', { limit: 30, + offsetMode: true, computedParams: computed(() => ({ tag: props.tag, origin: 'combined', diff --git a/packages/frontend/src/pages/user/activity.following.vue b/packages/frontend/src/pages/user/activity.following.vue index 4310c7ad85..f9a2eed6b9 100644 --- a/packages/frontend/src/pages/user/activity.following.vue +++ b/packages/frontend/src/pages/user/activity.following.vue @@ -57,7 +57,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, diff --git a/packages/frontend/src/pages/user/activity.notes.vue b/packages/frontend/src/pages/user/activity.notes.vue index 6d9c1bedd9..00bfe25430 100644 --- a/packages/frontend/src/pages/user/activity.notes.vue +++ b/packages/frontend/src/pages/user/activity.notes.vue @@ -57,7 +57,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index 76df53becd..451f8ba0f7 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -57,7 +57,7 @@ async function renderChart() { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index b61c84cbbc..64b03bc4bc 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -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> @@ -186,6 +186,7 @@ import { getStaticImageUrl } from '@/utility/media-proxy.js'; import MkSparkle from '@/components/MkSparkle.vue'; import { prefer } from '@/preferences.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; +import { isBirthday } from '@/utility/is-birthday.js'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -251,7 +252,7 @@ const age = computed(() => { return props.user.birthday ? calcAge(props.user.birthday) : NaN; }); -function menu(ev: MouseEvent) { +function menu(ev: PointerEvent) { const { menu, cleanup } = getUserMenu(user.value, router); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } @@ -319,16 +320,10 @@ function disposeBannerParallaxResizeObserver() { onMounted(() => { narrow.value = rootEl.value!.clientWidth < 1000; - if (props.user.birthday) { - const m = new Date().getMonth() + 1; - const d = new Date().getDate(); - const bm = parseInt(props.user.birthday.split('-')[1]); - const bd = parseInt(props.user.birthday.split('-')[2]); - if (m === bm && d === bd) { - confetti({ - duration: 1000 * 4, - }); - } + if (isBirthday(user.value)) { + confetti({ + duration: 1000 * 4, + }); } nextTick(() => { diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index 210021618e..10b0582143 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -36,7 +36,7 @@ const props = withDefaults(defineProps<{ const chartSrc = ref<'per-user-notes' | 'per-user-pv'>('per-user-notes'); -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu([{ text: i18n.ts.notes, active: chartSrc.value === 'per-user-notes', diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue index 58f6b0ca45..1523e99453 100644 --- a/packages/frontend/src/pages/user/index.files.vue +++ b/packages/frontend/src/pages/user/index.files.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkButton rounded full @click="emit('showMore')">{{ i18n.ts.showMore }} <i class="ti ti-arrow-right"></i></MkButton> </div> - <p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p> + <p v-if="!fetching && notes.length == 0">{{ i18n.ts.nothing }}</p> </div> </MkContainer> </template> diff --git a/packages/frontend/src/pages/user/notes.vue b/packages/frontend/src/pages/user/notes.vue index 1e6dba73bd..137c6cb872 100644 --- a/packages/frontend/src/pages/user/notes.vue +++ b/packages/frontend/src/pages/user/notes.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <div :class="$style.root"> + <div> <MkStickyContainer> <template #header> <MkTab diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 393ba98d30..3a4a558605 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only <Suspense> <template #default> - <MkServerSetupWizard :token="token" @finished="onWizardFinished"/> + <MkServerSetupWizard :token="token!" @finished="onWizardFinished"/> </template> <template #fallback> <MkLoading/> @@ -124,8 +124,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref } from 'vue'; -import * as Misskey from 'misskey-js'; +import { ref } from 'vue'; import { host, version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -143,7 +142,7 @@ const accountCreating = ref(false); const accountCreated = ref(false); const step = ref(0); -let token; +let token: string | null = null; function createAccount() { if (accountCreating.value) return; @@ -191,6 +190,7 @@ function skipSettings() { } function finish() { + if (token == null) return; login(token); } </script> diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts index f32c991828..a41d0bc217 100644 --- a/packages/frontend/src/plugin.ts +++ b/packages/frontend/src/plugin.ts @@ -26,7 +26,7 @@ export type Plugin = { version: string; author?: string; description?: string; - permissions?: string[]; + permissions?: (typeof Misskey.permissions)[number][]; }; export type AiScriptPluginMeta = { @@ -34,7 +34,7 @@ export type AiScriptPluginMeta = { version: string; author: string; description?: string; - permissions?: string[]; + permissions?: (typeof Misskey.permissions)[number][]; config?: Record<string, any>; }; @@ -49,7 +49,7 @@ async function getParser(): Promise<Parser> { export function isSupportedAiScriptVersion(version: string): boolean { try { return (compareVersions(version, '0.12.0') >= 0); - } catch (err) { + } catch (_) { return false; } } @@ -72,7 +72,7 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> try { const parser = await getParser(); ast = parser.parse(code); - } catch (err) { + } catch (_) { throw new Error('Aiscript syntax error'); } @@ -97,7 +97,7 @@ export async function parsePluginMeta(code: string): Promise<AiScriptPluginMeta> version: version as string, author: author as string, description: description as string | undefined, - permissions: permissions as string[] | undefined, + permissions: permissions as (typeof Misskey.permissions)[number][] | undefined, config: config as Record<string, any> | undefined, }; } @@ -106,8 +106,9 @@ export async function authorizePlugin(plugin: Plugin) { if (plugin.permissions == null || plugin.permissions.length === 0) return; if (Object.hasOwn(store.s.pluginTokens, plugin.installId)) return; - const token = await new Promise<string>(async (res, rej) => { - const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkTokenGenerateWindow.vue').then(x => x.default), { + const token = await new Promise<string>((res, rej) => { + let dispose: () => void; + os.popupAsyncWithDialog(import('@/components/MkTokenGenerateWindow.vue').then(x => x.default), { title: i18n.ts.tokenRequested, information: i18n.ts.pluginTokenRequestedDescription, initialName: plugin.name, @@ -123,7 +124,7 @@ export async function authorizePlugin(plugin: Plugin) { res(token); }, closed: () => dispose(), - }); + }).then(d => dispose = d.dispose, err => rej(err)); }); store.set('pluginTokens', { diff --git a/packages/frontend/src/pref-migrate.ts b/packages/frontend/src/pref-migrate.ts index 8258bbb846..bf86f6954e 100644 --- a/packages/frontend/src/pref-migrate.ts +++ b/packages/frontend/src/pref-migrate.ts @@ -5,13 +5,14 @@ import type { DeckProfile } from '@/deck.js'; import { genId } from '@/utility/id.js'; -import { ColdDeviceStorage, store } from '@/store.js'; +import { store } from '@/store.js'; import { prefer } from '@/preferences.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { unisonReload } from '@/utility/unison-reload.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import type { SoundStore } from '@/preferences/def.js'; // TODO: そのうち消す export function migrateOldSettings() { @@ -24,16 +25,6 @@ export function migrateOldSettings() { } }); - const plugins = ColdDeviceStorage.get('plugins'); - prefer.commit('plugins', plugins.map(p => { - const { id, ...rest } = p; - return { - ...rest, - config: rest.config ?? {}, - installId: id, - }; - })); - prefer.commit('deck.profile', deckStore.s.profile); misskeyApi('i/registry/keys', { scope: ['client', 'deck', 'profiles'], @@ -54,9 +45,6 @@ export function migrateOldSettings() { prefer.commit('deck.profiles', profiles); }); - prefer.commit('lightTheme', ColdDeviceStorage.get('lightTheme')); - prefer.commit('darkTheme', ColdDeviceStorage.get('darkTheme')); - prefer.commit('syncDeviceDarkMode', ColdDeviceStorage.get('syncDeviceDarkMode')); prefer.commit('emojiPalettes', [{ id: 'reactions', name: '', @@ -139,10 +127,10 @@ export function migrateOldSettings() { prefer.commit('sound.masterVolume', store.s.sound_masterVolume); prefer.commit('sound.notUseSound', store.s.sound_notUseSound); prefer.commit('sound.useSoundOnlyWhenActive', store.s.sound_useSoundOnlyWhenActive); - prefer.commit('sound.on.note', store.s.sound_note as any); - prefer.commit('sound.on.noteMy', store.s.sound_noteMy as any); - prefer.commit('sound.on.notification', store.s.sound_notification as any); - prefer.commit('sound.on.reaction', store.s.sound_reaction as any); + prefer.commit('sound.on.note', store.s.sound_note as SoundStore); + prefer.commit('sound.on.noteMy', store.s.sound_noteMy as SoundStore); + prefer.commit('sound.on.notification', store.s.sound_notification as SoundStore); + prefer.commit('sound.on.reaction', store.s.sound_reaction as SoundStore); prefer.commit('defaultNoteVisibility', store.s.defaultNoteVisibility); prefer.commit('defaultNoteLocalOnly', store.s.defaultNoteLocalOnly); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 8d1d33977d..b33b53b83c 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -237,7 +237,7 @@ export const PREF_DEF = definePreferences({ default: false, }, emojiStyle: { - default: 'twemoji', // twemoji / fluentEmoji / native + default: 'twemoji' as 'native' | 'fluentEmoji' | 'twemoji', }, menuStyle: { default: 'auto' as 'auto' | 'popup' | 'drawer', @@ -320,6 +320,9 @@ export const PREF_DEF = definePreferences({ mediaListWithOneImageAppearance: { default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', }, + showMediaListByGridInWideArea: { + default: false, + }, notificationPosition: { default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom', }, @@ -500,7 +503,7 @@ export const PREF_DEF = definePreferences({ default: true, }, 'deck.columnAlign': { - default: 'center' as 'left' | 'right' | 'center', + default: 'center' as 'left' | 'center', }, 'deck.columnGap': { default: 6, diff --git a/packages/frontend/src/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index 13ba0000e4..7f3949f81b 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, onUnmounted, ref, watch } from 'vue'; +import { customRef, ref, watch, onScopeDispose } from 'vue'; import { EventEmitter } from 'eventemitter3'; import { host, version } from '@@/js/config.js'; import { PREF_DEF } from './def.js'; -import type { Ref, WritableComputedRef } from 'vue'; +import type { Ref } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import { genId } from '@/utility/id.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; @@ -81,7 +81,7 @@ export type PreferencesProfile = { }; export type PossiblyNonNormalizedPreferencesProfile = Omit<PreferencesProfile, 'preferences'> & { - preferences: Record<string, any>; + preferences: Record<string, [scope: Scope, value: any, meta: ValueMeta][]>; }; export type StorageProvider = { @@ -112,17 +112,17 @@ type PreferencesManagerEvents = { export function definePreferences<T extends Record<string, unknown>>(x: { [K in keyof T]: PreferencesDefinitionRecord<T[K]> }): { - [K in keyof T]: PreferencesDefinitionRecord<T[K]> - } { + [K in keyof T]: PreferencesDefinitionRecord<T[K]> +} { return x; } export function getInitialPrefValue<K extends keyof PREF>(k: K): ValueOf<K> { - const _default = PREF_DEF[k as string].default; + const _default = PREF_DEF[k].default; if (typeof _default === 'function') { // factory - return _default(); + return _default() as ValueOf<K>; } else { - return _default; + return _default as unknown as ValueOf<K>; } } @@ -146,7 +146,7 @@ function createEmptyProfile(): PossiblyNonNormalizedPreferencesProfile { } function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfile['preferences'], account: { id: string } | null): PreferencesProfile['preferences'] { - const data = {} as PreferencesProfile['preferences']; + const data = {} as Record<string, [scope: Scope, value: any, meta: ValueMeta][]>; for (const key in PREF_DEF) { const records = preferences[key]; if (records == null || records.length === 0) { @@ -183,7 +183,7 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi } } - return data; + return data as PreferencesProfile['preferences']; } // TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない @@ -223,9 +223,10 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> { const states = this.genStates(); + // apply states for (const key in states) { - this.s[key] = states[key]; - this.r[key] = ref(this.s[key]); + (this.s[key as keyof PREF] as any) = states[key as keyof PREF]; + (this.r[key as keyof PREF] as Ref<any>) = ref(this.s[key as keyof PREF]); } // normalizeの結果変わっていたら保存 @@ -299,36 +300,39 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> { * 特定のキーの、簡易的なcomputed refを作ります * 主にvue上で設定コントロールのmodelとして使う用 */ - public model<K extends keyof PREF, V extends ValueOf<K> = ValueOf<K>>( + public model<K extends keyof PREF, V = ValueOf<K>>( + key: K, + ): Ref<V>; + public model<K extends keyof PREF, V extends Exclude<any, ValueOf<K>>>( + key: K, + getter: (v: ValueOf<K>) => V, + setter: (v: V) => ValueOf<K>, + ): Ref<V>; + + public model<K extends keyof PREF, V>( key: K, getter?: (v: ValueOf<K>) => V, setter?: (v: V) => ValueOf<K>, - ): WritableComputedRef<V> { - const valueRef = ref(this.s[key]); - - const stop = watch(this.r[key], val => { - valueRef.value = val; - }); + ): Ref<V> { + return customRef<V>((track, trigger) => { + const watchStop = watch(this.r[key], () => { + trigger(); + }); - // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする - onUnmounted(() => { - stop(); - }); + onScopeDispose(() => { + watchStop(); + }, true); - // TODO: VueのcustomRef使うと良い感じになるかも - return computed({ - get: () => { - if (getter) { - return getter(valueRef.value); - } else { - return valueRef.value; - } - }, - set: (value) => { - const val = setter ? setter(value) : value; - this.commit(key, val); - valueRef.value = val; - }, + return { + get: () => { + track(); + return (getter != null ? getter(this.s[key]) : this.s[key]) as V; + }, + set: (value) => { + const val = setter != null ? setter(value) : value; + this.commit(key, val as ValueOf<K>); + }, + }; }); } @@ -460,7 +464,7 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> { let mergedValue: ValueOf<K> | undefined = undefined; // null と区別したいため try { if (merge != null) mergedValue = merge(local, remote); - } catch (err) { + } catch (_) { // nop } const { canceled, result: choice } = await os.select({ diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index fb9349c42f..e7cdc98415 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -3,16 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { markRaw, ref } from 'vue'; +import { markRaw } from 'vue'; import * as Misskey from 'misskey-js'; -import lightTheme from '@@/themes/l-light.json5'; -import darkTheme from '@@/themes/d-green-lime.json5'; import { prefersReducedMotion } from '@@/js/config.js'; import { hemisphere } from '@@/js/intl-const.js'; import type { DeviceKind } from '@/utility/device-kind.js'; -import type { Plugin } from '@/plugin.js'; import type { TIPS } from '@/tips.js'; -import { miLocalStorage } from '@/local-storage.js'; import { Pizzax } from '@/lib/pizzax.js'; import { DEFAULT_DEVICE_KIND } from '@/utility/device-kind.js'; @@ -83,7 +79,7 @@ export const store = markRaw(new Pizzax('base', { }, menuDisplay: { where: 'device', - default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', + default: 'sideFull' as 'sideFull' | 'sideIcon'/* | 'top' */, }, postFormWithHashtags: { where: 'device', @@ -257,7 +253,7 @@ export const store = markRaw(new Pizzax('base', { }, emojiStyle: { where: 'device', - default: 'twemoji', // twemoji / fluentEmoji / native + default: 'twemoji' as 'twemoji' | 'fluentEmoji' | 'native', }, menuStyle: { where: 'device', @@ -478,89 +474,3 @@ interface Watcher { key: string; callback: (value: unknown) => void; } - -// TODO: 消す(preferに移行済みのため) -/** - * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) - */ -export class ColdDeviceStorage { - public static default = { - lightTheme, // TODO: 消す(preferに移行済みのため) - darkTheme, // TODO: 消す(preferに移行済みのため) - syncDeviceDarkMode: true, // TODO: 消す(preferに移行済みのため) - plugins: [] as (Omit<Plugin, 'installId'> & { id: string })[], // TODO: 消す(preferに移行済みのため) - }; - - public static watchers: Watcher[] = []; - - public static get<T extends keyof typeof ColdDeviceStorage.default>(key: T): typeof ColdDeviceStorage.default[T] { - // TODO: indexedDBにする - // ただしその際はnullチェックではなくキー存在チェックにしないとダメ - // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) - const value = miLocalStorage.getItem(`${PREFIX}${key}`); - if (value == null) { - return ColdDeviceStorage.default[key]; - } else { - return JSON.parse(value); - } - } - - public static getAll(): Partial<typeof this.default> { - return (Object.keys(this.default) as (keyof typeof this.default)[]).reduce<Partial<typeof this.default>>((acc, key) => { - const value = localStorage.getItem(PREFIX + key); - if (value != null) { - acc[key] = JSON.parse(value); - } - return acc; - }, {}); - } - - public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { - // 呼び出し側のバグ等で undefined が来ることがある - // undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 - - if (value === undefined) { - console.error(`attempt to store undefined value for key '${key}'`); - return; - } - - miLocalStorage.setItem(`${PREFIX}${key}`, JSON.stringify(value)); - - for (const watcher of this.watchers) { - if (watcher.key === key) watcher.callback(value); - } - } - - public static watch(key, callback) { - this.watchers.push({ key, callback }); - } - - // TODO: VueのcustomRef使うと良い感じになるかも - public static ref<T extends keyof typeof ColdDeviceStorage.default>(key: T) { - const v = ColdDeviceStorage.get(key); - const r = ref(v); - // TODO: このままではwatcherがリークするので開放する方法を考える - this.watch(key, v => { - r.value = v; - }); - return r; - } - - /** - * 特定のキーの、簡易的なgetter/setterを作ります - * 主にvue場で設定コントロールのmodelとして使う用 - */ - public static makeGetterSetter<K extends keyof typeof ColdDeviceStorage.default>(key: K) { - // TODO: VueのcustomRef使うと良い感じになるかも - const valueRef = ColdDeviceStorage.ref(key); - return { - get: () => { - return valueRef.value; - }, - set: (value: typeof ColdDeviceStorage.default[K]) => { - const val = value; - ColdDeviceStorage.set(key, val); - }, - }; - } -} diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index adbde3fee2..4e6917e0af 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -18,7 +18,6 @@ let lastHeartbeatCall = 0; export function useStream(): Misskey.IStream { if (stream) return stream; - // TODO: No Websocketモードもここで判定 stream = markRaw(new Misskey.Stream(wsOrigin, $i ? { token: $i.token, } : null)); diff --git a/packages/frontend/src/theme.ts b/packages/frontend/src/theme.ts index e001bed8f3..af47402bd6 100644 --- a/packages/frontend/src/theme.ts +++ b/packages/frontend/src/theme.ts @@ -204,7 +204,7 @@ export function compile(theme: Theme): Record<string, string> { return tinycolor(val); } - const props = {}; + const props = {} as Record<string, string>; for (const [k, v] of Object.entries(theme.props)) { if (k.startsWith('$')) continue; // ignore const @@ -232,7 +232,7 @@ export function parseThemeCode(code: string): Theme { try { theme = JSON5.parse(code); - } catch (err) { + } catch (_) { throw new Error('Failed to parse theme json'); } if (!validateTheme(theme)) { @@ -247,12 +247,12 @@ export function parseThemeCode(code: string): Theme { export function previewTheme(code: string): void { const theme = parseThemeCode(code); - if (theme) applyTheme(theme, false); + if (theme != null) applyTheme(theme, false); } export async function installTheme(code: string): Promise<void> { const theme = parseThemeCode(code); - if (!theme) return; + if (theme == null) return; await addTheme(theme); } diff --git a/packages/frontend/src/tips.ts b/packages/frontend/src/tips.ts index 6ee7130ee9..d5fc047c2b 100644 --- a/packages/frontend/src/tips.ts +++ b/packages/frontend/src/tips.ts @@ -32,7 +32,7 @@ export function resetAllTips() { } export function hideAllTips() { - const v = {}; + const v = {} as Record<typeof TIPS[number], boolean>; for (const k of TIPS) { v[k] = true; } diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index e0bf135ef8..05fb3034f0 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -6,14 +6,15 @@ import * as Misskey from 'misskey-js'; import type { Component, ComputedRef, Ref, MaybeRef } from 'vue'; import type { ComponentProps as CP } from 'vue-component-type-helpers'; +import type { OptionValue } from '@/types/option-value.js'; type ComponentProps<T extends Component> = { [K in keyof CP<T>]: MaybeRef<CP<T>[K]> }; -type MenuRadioOptionsDef = Record<string, any>; +type MenuRadioOptionsDef = Record<string, OptionValue>; type Text = string | ComputedRef<string>; -export type MenuAction = (ev: MouseEvent) => void; +export type MenuAction = (ev: PointerEvent) => void; export interface MenuButton { type?: 'button'; diff --git a/packages/frontend/src/types/option-value.ts b/packages/frontend/src/types/option-value.ts new file mode 100644 index 0000000000..98d0fb5faa --- /dev/null +++ b/packages/frontend/src/types/option-value.ts @@ -0,0 +1,6 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type OptionValue = string | number | null; diff --git a/packages/frontend/src/types/overload-to-union.ts b/packages/frontend/src/types/overload-to-union.ts new file mode 100644 index 0000000000..3cf16a5f3c --- /dev/null +++ b/packages/frontend/src/types/overload-to-union.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type FlattenAndDedup<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : never; + +// 10個で足りなかった場合は増やす +export type OverloadToUnion<T> = FlattenAndDedup<T extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + (...args: infer A6): infer R6; + (...args: infer A7): infer R7; + (...args: infer A8): infer R8; + (...args: infer A9): infer R9; + (...args: infer A10): infer R10; +} ? ( + ((...args: A1) => R1) | + ((...args: A2) => R2) | + ((...args: A3) => R3) | + ((...args: A4) => R4) | + ((...args: A5) => R5) | + ((...args: A6) => R6) | + ((...args: A7) => R7) | + ((...args: A8) => R8) | + ((...args: A9) => R9) | + ((...args: A10) => R10) +) : never>; diff --git a/packages/frontend/src/types/page-header.ts b/packages/frontend/src/types/page-header.ts index 7232f17d47..fcfe9d284c 100644 --- a/packages/frontend/src/types/page-header.ts +++ b/packages/frontend/src/types/page-header.ts @@ -7,5 +7,6 @@ export type PageHeaderItem = { text?: string; icon: string; highlighted?: boolean; - handler: (ev: MouseEvent) => void; + danger?: boolean; + handler: (ev: PointerEvent) => void; }; diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index a9ad36c97a..7ad18fc2a8 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -50,7 +50,7 @@ function toolsMenuItems(): MenuItem[] { return items; } -export function openInstanceMenu(ev: MouseEvent) { +export function openInstanceMenu(ev: PointerEvent) { const menuItems: MenuItem[] = []; menuItems.push({ @@ -175,7 +175,7 @@ export function openInstanceMenu(ev: MouseEvent) { }); } -export function openToolsMenu(ev: MouseEvent) { +export function openToolsMenu(ev: PointerEvent) { os.popupMenu(toolsMenuItems(), ev.currentTarget ?? ev.target, { align: 'left', }); diff --git a/packages/frontend/src/ui/_common_/navbar-h.vue b/packages/frontend/src/ui/_common_/navbar-h.vue index 64da4647b6..eb1d99f0aa 100644 --- a/packages/frontend/src/ui/_common_/navbar-h.vue +++ b/packages/frontend/src/ui/_common_/navbar-h.vue @@ -6,22 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, acrylic ? $style.acrylic : null]"> <div :class="$style.body"> - <div :class="$style.left"> + <div> <button v-click-anime :class="[$style.item, $style.instance]" class="_button" @click="openInstanceMenu"> <img :class="$style.instanceIcon" :src="instance.iconUrl ?? '/favicon.ico'" draggable="false"/> </button> - <MkA v-click-anime v-tooltip="i18n.ts.timeline" :class="$style.item" activeClass="active" to="/" exact> + <MkA v-click-anime v-tooltip="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> <i :class="$style.itemIcon" class="ti ti-home ti-fw"></i> </MkA> <template v-for="item in menu"> <div v-if="item === '-'" :class="$style.divider"></div> - <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="_button" :class="$style.item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show == null || navbarItemDef[item].show.value !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="_button" :class="$style.item" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> <i :class="[$style.itemIcon, navbarItemDef[item].icon]" class="ti-fw"></i> <span v-if="navbarItemDef[item].indicated" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span> </component> </template> <div :class="$style.divider"></div> - <MkA v-if="$i && ($i.isAdmin || $i.isModerator)" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> + <MkA v-if="$i && ($i.isAdmin || $i.isModerator)" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" :activeClass="$style.active" to="/admin" :behavior="settingsWindowed ? 'window' : null"> <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw"></i> </MkA> <button v-click-anime :class="$style.item" class="_button" @click="more"> @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> <div :class="$style.right"> - <MkA v-click-anime v-tooltip="i18n.ts.settings" :class="$style.item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null"> + <MkA v-click-anime v-tooltip="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings" :behavior="settingsWindowed ? 'window' : null"> <i :class="$style.itemIcon" class="ti ti-settings ti-fw"></i> </MkA> <button v-if="$i" v-click-anime :class="[$style.item, $style.account]" class="_button" @click="openAccountMenu"> @@ -67,7 +67,7 @@ const props = defineProps<{ const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD); const menu = ref(prefer.s.menu); -// const menuDisplay = computed(store.makeGetterSetter('menuDisplay')); +// const menuDisplay = store.model('menuDisplay'); const otherNavItemIndicated = computed<boolean>(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; @@ -76,7 +76,7 @@ const otherNavItemIndicated = computed<boolean>(() => { return false; }); -async function more(ev: MouseEvent) { +async function more(ev: PointerEvent) { const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target); if (!target) return; @@ -88,7 +88,7 @@ async function more(ev: MouseEvent) { }); } -async function openAccountMenu(ev: MouseEvent) { +async function openAccountMenu(ev: PointerEvent) { const menuItems = await getAccountMenu({ withExtraOperation: true, }); diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index c679ee7a92..d0c183763a 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.body"> <div :class="$style.top"> <button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu"> - <img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/> + <img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="view-transition-name: navbar-serverIcon;"/> </button> <button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode"> <i v-if="store.r.realtimeMode.value" class="ti ti-bolt ti-fw"></i> @@ -20,16 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.middle"> <MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact> - <i :class="$style.itemIcon" class="ti ti-home ti-fw" style="viewTransitionName: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> + <i :class="$style.itemIcon" class="ti ti-home ti-fw" style="view-transition-name: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span> </MkA> <template v-for="item in prefer.r.menu.value"> <div v-if="item === '-'" :class="$style.divider"></div> <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" - v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" + v-else-if="navbarItemDef[item] && (navbarItemDef[item].show == null || navbarItemDef[item].show.value !== false)" v-tooltip.noDelay.right="navbarItemDef[item].title" class="_button" - :class="[$style.item, { [$style.active]: navbarItemDef[item].active }]" + :class="[$style.item]" :activeClass="$style.active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" @@ -43,14 +43,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div :class="$style.divider"></div> <MkA v-if="$i != null && ($i.isAdmin || $i.isModerator)" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin"> - <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw" style="viewTransitionName: navbar-controlPanel;"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> + <i :class="$style.itemIcon" class="ti ti-dashboard ti-fw" style="view-transition-name: navbar-controlPanel;"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span> </MkA> <button class="_button" :class="$style.item" @click="more"> - <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw" style="viewTransitionName: navbar-more;"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> + <i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw" style="view-transition-name: navbar-more;"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span> <span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span> </button> <MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings"> - <i :class="$style.itemIcon" class="ti ti-settings ti-fw" style="viewTransitionName: navbar-settings;"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> + <i :class="$style.itemIcon" class="ti ti-settings ti-fw" style="view-transition-name: navbar-settings;"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span> </MkA> </div> <div :class="$style.bottom"> @@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span> </button> <button v-if="$i != null" v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu"> - <MkAvatar :user="$i" :class="$style.avatar" style="viewTransitionName: navbar-avatar;"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> + <MkAvatar :user="$i" :class="$style.avatar" style="view-transition-name: navbar-avatar;"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/> </button> </div> </div> @@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <div v-if="!forceIconOnly && prefer.r.showNavbarSubButtons.value" :class="$style.subButtons"> - <div :class="[$style.subButton, $style.menuEditButton]"> + <div :class="$style.subButton"> <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> @@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="!props.asDrawer"> <div :class="$style.subButtonGapFill"></div> <div :class="$style.subButtonGapFillDivider"></div> - <div :class="[$style.subButton, $style.toggleButton]"> + <div :class="$style.subButton"> <svg viewBox="0 0 16 64" :class="$style.subButtonShape"> <g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)"> <path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/> @@ -161,7 +161,7 @@ function toggleIconOnly() { } } -function toggleRealtimeMode(ev: MouseEvent) { +function toggleRealtimeMode(ev: PointerEvent) { os.popupMenu([{ type: 'label', text: i18n.ts.realtimeMode, @@ -175,7 +175,7 @@ function toggleRealtimeMode(ev: MouseEvent) { }], ev.currentTarget ?? ev.target); } -async function openAccountMenu(ev: MouseEvent) { +async function openAccountMenu(ev: PointerEvent) { const menuItems = await getAccountMenu({ withExtraOperation: true, }); @@ -183,7 +183,7 @@ async function openAccountMenu(ev: MouseEvent) { os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } -async function more(ev: MouseEvent) { +async function more(ev: PointerEvent) { const target = getHTMLElementOrNull(ev.currentTarget ?? ev.target); if (!target) return; const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLaunchPad.vue').then(x => x.default), { diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index 079f1f92bb..23093e60d7 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -67,7 +67,7 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { afterMounted: true, }); -function getInstanceIcon(instance): string { +function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; } </script> diff --git a/packages/frontend/src/ui/_common_/widgets.vue b/packages/frontend/src/ui/_common_/widgets.vue index 1a6d62e19b..4087c4d517 100644 --- a/packages/frontend/src/ui/_common_/widgets.vue +++ b/packages/frontend/src/ui/_common_/widgets.vue @@ -5,10 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <XWidgets :edit="editMode" :widgets="widgets" @addWidget="addWidget" @removeWidget="removeWidget" @updateWidget="updateWidget" @updateWidgets="updateWidgets" @exit="editMode = false"/> + <XWidgets + :edit="editMode" + :widgets="widgets" + @addWidget="addWidget" + @removeWidget="removeWidget" + @updateWidget="updateWidget" + @updateWidgets="updateWidgets" + @exit="editMode = false" + /> <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> - <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> + <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em; margin-top: 16px;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> </div> </template> @@ -16,7 +24,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref } from 'vue'; const editMode = ref(false); </script> + <script lang="ts" setup> +import type { DefaultStoredWidget, Widget } from '@/components/MkWidgets.vue'; import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; @@ -36,30 +46,31 @@ const widgets = computed(() => { return prefer.r.widgets.value.filter(w => w.place !== 'left'); }); -function addWidget(widget) { +function addWidget(widget: Widget) { prefer.commit('widgets', [{ ...widget, place: props.place, }, ...prefer.s.widgets]); } -function removeWidget(widget) { +function removeWidget(widget: Widget) { prefer.commit('widgets', prefer.s.widgets.filter(w => w.id !== widget.id)); } -function updateWidget({ id, data }) { - prefer.commit('widgets', prefer.s.widgets.map(w => w.id === id ? { +function updateWidget(widget: { id: Widget['id']; data: Widget['data']; }) { + prefer.commit('widgets', prefer.s.widgets.map(w => w.id === widget.id ? { ...w, - data, + data: widget.data, place: props.place, } : w)); } -function updateWidgets(thisWidgets) { +function updateWidgets(thisWidgets: Widget[]) { if (props.place === null) { - prefer.commit('widgets', thisWidgets); + prefer.commit('widgets', thisWidgets as DefaultStoredWidget[]); return; } + if (props.place === 'left') { prefer.commit('widgets', [ ...thisWidgets.map(w => ({ ...w, place: 'left' })), @@ -67,6 +78,7 @@ function updateWidgets(thisWidgets) { ]); return; } + prefer.commit('widgets', [ ...prefer.s.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)), ...thisWidgets.map(w => ({ ...w, place: 'right' })), diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 484b7f277a..0bafa1074c 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="id in ids" :ref="id" :key="id" - :class="[$style.column, { '_shadow': withWallpaper }]" + :class="{ '_shadow': withWallpaper }" :column="columns.find(c => c.id === id)!" :isStacked="ids.length > 1" @headerWheel="onWheel" @@ -174,7 +174,7 @@ const addColumnButtonEl = useTemplateRef('addColumnButtonEl'); const settingsButtonEl = useTemplateRef('settingsButtonEl'); const swicthProfileButtonEl = useTemplateRef('swicthProfileButtonEl'); -const addColumn = async (ev) => { +async function addColumn(ev: PointerEvent) { const { canceled, result: column } = await os.select({ title: i18n.ts._deck.addColumn, items: columnTypes.filter(column => column !== 'chat' || $i == null || $i.policies.chatAvailability !== 'unavailable').map(column => ({ @@ -190,14 +190,14 @@ const addColumn = async (ev) => { width: 330, soundSetting: { type: null, volume: 1 }, }); -}; +} -const onContextmenu = (ev) => { +function onContextmenu(ev: PointerEvent) { os.contextMenu([{ text: i18n.ts._deck.addColumn, action: addColumn, }], ev); -}; +} // タッチでスクロールしてるときはスナップスクロールを有効にする function pointerEvent(ev: PointerEvent) { diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 312ca51c83..410faf11f9 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -247,15 +247,15 @@ function getMenu() { return menuItems; } -function showSettingsMenu(ev: MouseEvent) { +function showSettingsMenu(ev: PointerEvent) { os.popupMenu(getMenu(), ev.currentTarget ?? ev.target); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getMenu(), ev); } -function goTop(ev: MouseEvent) { +function goTop(ev: PointerEvent) { emit('headerClick', ev); if (!props.handleScrollToTop) return; @@ -267,7 +267,9 @@ function goTop(ev: MouseEvent) { } } -function onDragstart(ev) { +function onDragstart(ev: DragEvent) { + if (ev.dataTransfer == null) return; + ev.dataTransfer.effectAllowed = 'move'; setDragData(ev, 'deckColumn', props.column.id); @@ -278,11 +280,13 @@ function onDragstart(ev) { }, 10); } -function onDragend(ev) { +function onDragend(ev: DragEvent) { dragging.value = false; } -function onDragover(ev) { +function onDragover(ev: DragEvent) { + if (ev.dataTransfer == null) return; + // 自分自身がドラッグされている場合 if (dragging.value) { // 自分自身にはドロップさせない @@ -300,7 +304,7 @@ function onDragleave() { draghover.value = false; } -function onDrop(ev) { +function onDrop(ev: DragEvent) { draghover.value = false; os.deckGlobalEvents.emit('column.dragEnd'); diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index 1388cbdc18..8757ec0941 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -58,11 +58,11 @@ function back() { history.back(); } */ -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { if (!ev.target) return; if (isLink(ev.target as HTMLElement)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes.getNamedItem('contenteditable') != null) return; if (window.getSelection()?.toString() !== '') return; const path = mainRouter.currentRoute.value.path; os.contextMenu([{ diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 0e59913c4c..aab0cde1c8 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -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> diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue index 4e84ef0ba0..0985e95653 100644 --- a/packages/frontend/src/ui/deck/widgets-column.vue +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -17,8 +17,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import XColumn from './column.vue'; -import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from '@/deck.js'; import type { Column } from '@/deck.js'; +import type { Widget } from '@/components/MkWidgets.vue'; +import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from '@/deck.js'; import XWidgets from '@/components/MkWidgets.vue'; import { i18n } from '@/i18n.js'; @@ -29,19 +30,19 @@ const props = defineProps<{ const edit = ref(false); -function addWidget(widget) { +function addWidget(widget: Widget) { addColumnWidget(props.column.id, widget); } -function removeWidget(widget) { +function removeWidget(widget: Widget) { removeColumnWidget(props.column.id, widget); } -function updateWidget({ id, data }) { - updateColumnWidget(props.column.id, id, data); +function updateWidget(widget: { id: Widget['id']; data: Widget['data']; }) { + updateColumnWidget(props.column.id, widget.id, widget.data); } -function updateWidgets(widgets) { +function updateWidgets(widgets: Widget[]) { setColumnWidgets(props.column.id, widgets); } diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 497ef72d04..95582edea1 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -103,9 +103,9 @@ if (window.innerWidth > 1024) { } } -const onContextmenu = (ev) => { - if (isLink(ev.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; +function onContextmenu(ev: PointerEvent) { + if (isLink(ev.target as HTMLElement)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes.getNamedItem('contenteditable') != null) return; if (window.getSelection()?.toString() !== '') return; const path = mainRouter.getCurrentFullPath(); os.contextMenu([{ @@ -118,7 +118,7 @@ const onContextmenu = (ev) => { os.pageWindow(path); }, }], ev); -}; +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue index 800aef8696..b56ed4eb6a 100644 --- a/packages/frontend/src/ui/zen.vue +++ b/packages/frontend/src/ui/zen.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> +<div> <div :class="$style.contents"> <!-- デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない) @@ -57,9 +57,6 @@ function goToDeck() { </script> <style lang="scss" module> -.root { -} - .contents { display: flex; flex-direction: column; diff --git a/packages/frontend/src/utility/admin-lookup.ts b/packages/frontend/src/utility/admin-lookup.ts index 18eebaa8f8..74485a11d7 100644 --- a/packages/frontend/src/utility/admin-lookup.ts +++ b/packages/frontend/src/utility/admin-lookup.ts @@ -14,7 +14,7 @@ export async function lookupUser() { }); if (canceled || result == null) return; - const show = (user) => { + const show = (user: Misskey.entities.UserDetailed) => { os.pageWindow(`/admin/user/${user.id}`); }; @@ -36,7 +36,7 @@ export async function lookupUser() { notFound(); } }); - idPromise.then(show).catch(err => { + idPromise.then(show).catch(_ => { notFound(); }); } @@ -71,12 +71,8 @@ export async function lookupFile() { }); if (canceled) return; - const show = (file) => { - os.pageWindow(`/admin/file/${file.id}`); - }; - misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { - show(file); + os.pageWindow(`/admin/file/${file.id}`); }).catch(err => { if (err.code === 'NO_SUCH_FILE') { os.alert({ diff --git a/packages/frontend/src/utility/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts index 82109af1a0..a44bf7c1ae 100644 --- a/packages/frontend/src/utility/autocomplete.ts +++ b/packages/frontend/src/utility/autocomplete.ts @@ -12,6 +12,15 @@ import { popup } from '@/os.js'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; +type CompleteProps<T extends keyof CompleteInfo> = { + type: T; + value: CompleteInfo[T]['payload']; +}; + +function isCompleteType<T extends keyof CompleteInfo>(expectedType: T, props: CompleteProps<keyof CompleteInfo>): props is CompleteProps<T> { + return props.type === expectedType; +} + export class Autocomplete { private suggestion: { x: Ref<number>; @@ -194,7 +203,7 @@ export class Autocomplete { this.currentType = type; //#region サジェストを表示すべき位置を計算 - const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart ?? 0); const rect = this.textarea.getBoundingClientRect(); @@ -213,10 +222,11 @@ export class Autocomplete { const _y = ref(y); const _q = ref(q); - const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), { textarea: this.textarea, close: this.close, type: type, + //@ts-expect-error popupは今のところジェネリック型のコンポーネントに対応していない q: _q, x: _x, y: _y, @@ -252,19 +262,19 @@ export class Autocomplete { /** * オートコンプリートする */ - private complete<T extends keyof CompleteInfo>({ type, value }: { type: T; value: CompleteInfo[T]['payload'] }) { + private complete<T extends keyof CompleteInfo>(props: CompleteProps<T>) { this.close(); const caret = Number(this.textarea.selectionStart); - if (type === 'user') { + if (isCompleteType('user', props)) { const source = this.text; const before = source.substring(0, caret); const trimmedBefore = before.substring(0, before.lastIndexOf('@')); const after = source.substring(caret); - const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; + const acct = props.value.host === null ? props.value.username : `${props.value.username}@${toASCII(props.value.host)}`; // 挿入 this.text = `${trimmedBefore}@${acct} ${after}`; @@ -275,7 +285,7 @@ export class Autocomplete { const pos = trimmedBefore.length + (acct.length + 2); this.textarea.setSelectionRange(pos, pos); }); - } else if (type === 'hashtag') { + } else if (isCompleteType('hashtag', props)) { const source = this.text; const before = source.substring(0, caret); @@ -283,15 +293,15 @@ export class Autocomplete { const after = source.substring(caret); // 挿入 - this.text = `${trimmedBefore}#${value} ${after}`; + this.text = `${trimmedBefore}#${props.value} ${after}`; // キャレットを戻す nextTick(() => { this.textarea.focus(); - const pos = trimmedBefore.length + (value.length + 2); + const pos = trimmedBefore.length + (props.value.length + 2); this.textarea.setSelectionRange(pos, pos); }); - } else if (type === 'emoji') { + } else if (isCompleteType('emoji', props)) { const source = this.text; const before = source.substring(0, caret); @@ -299,15 +309,15 @@ export class Autocomplete { const after = source.substring(caret); // 挿入 - this.text = trimmedBefore + value + after; + this.text = trimmedBefore + props.value + after; // キャレットを戻す nextTick(() => { this.textarea.focus(); - const pos = trimmedBefore.length + value.length; + const pos = trimmedBefore.length + props.value.length; this.textarea.setSelectionRange(pos, pos); }); - } else if (type === 'emojiComplete') { + } else if (isCompleteType('emojiComplete', props)) { const source = this.text; const before = source.substring(0, caret); @@ -315,15 +325,15 @@ export class Autocomplete { const after = source.substring(caret); // 挿入 - this.text = trimmedBefore + value + after; + this.text = trimmedBefore + props.value + after; // キャレットを戻す nextTick(() => { this.textarea.focus(); - const pos = trimmedBefore.length + value.length; + const pos = trimmedBefore.length + props.value.length; this.textarea.setSelectionRange(pos, pos); }); - } else if (type === 'mfmTag') { + } else if (isCompleteType('mfmTag', props)) { const source = this.text; const before = source.substring(0, caret); @@ -331,15 +341,15 @@ export class Autocomplete { const after = source.substring(caret); // 挿入 - this.text = `${trimmedBefore}$[${value} ]${after}`; + this.text = `${trimmedBefore}$[${props.value} ]${after}`; // キャレットを戻す nextTick(() => { this.textarea.focus(); - const pos = trimmedBefore.length + (value.length + 3); + const pos = trimmedBefore.length + (props.value.length + 3); this.textarea.setSelectionRange(pos, pos); }); - } else if (type === 'mfmParam') { + } else if (isCompleteType('mfmParam', props)) { const source = this.text; const before = source.substring(0, caret); @@ -347,12 +357,12 @@ export class Autocomplete { const after = source.substring(caret); // 挿入 - this.text = `${trimmedBefore}.${value}${after}`; + this.text = `${trimmedBefore}.${props.value}${after}`; // キャレットを戻す nextTick(() => { this.textarea.focus(); - const pos = trimmedBefore.length + (value.length + 1); + const pos = trimmedBefore.length + (props.value.length + 1); this.textarea.setSelectionRange(pos, pos); }); } diff --git a/packages/frontend/src/utility/chart-vline.ts b/packages/frontend/src/utility/chart-vline.ts index 2fe4bdb83b..1097c66d0e 100644 --- a/packages/frontend/src/utility/chart-vline.ts +++ b/packages/frontend/src/utility/chart-vline.ts @@ -11,7 +11,7 @@ export const chartVLine = (vLineColor: string) => ({ const tooltip = chart.tooltip as any; if (tooltip?._active?.length) { const ctx = chart.ctx; - const xs = tooltip._active.map(a => a.element.x); + const xs = tooltip._active.map((a: any) => a.element.x) as number[]; const x = xs.reduce((a, b) => a + b, 0) / xs.length; const topY = chart.scales.y.top; const bottomY = chart.scales.y.bottom; diff --git a/packages/frontend/src/utility/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts index 98fea1bced..eafc939c80 100644 --- a/packages/frontend/src/utility/check-word-mute.ts +++ b/packages/frontend/src/utility/check-word-mute.ts @@ -29,7 +29,7 @@ export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities. try { return new RegExp(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } diff --git a/packages/frontend/src/utility/collect-page-vars.ts b/packages/frontend/src/utility/collect-page-vars.ts deleted file mode 100644 index 5096c0669e..0000000000 --- a/packages/frontend/src/utility/collect-page-vars.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -interface StringPageVar { - name: string, - type: 'string', - value: string -} - -interface NumberPageVar { - name: string, - type: 'number', - value: number -} - -interface BooleanPageVar { - name: string, - type: 'boolean', - value: boolean -} - -type PageVar = StringPageVar | NumberPageVar | BooleanPageVar; - -export function collectPageVars(content): PageVar[] { - const pageVars: PageVar[] = []; - const collect = (xs: any[]): void => { - for (const x of xs) { - if (x.type === 'textInput') { - pageVars.push({ - name: x.name, - type: 'string', - value: x.default || '', - }); - } else if (x.type === 'textareaInput') { - pageVars.push({ - name: x.name, - type: 'string', - value: x.default || '', - }); - } else if (x.type === 'numberInput') { - pageVars.push({ - name: x.name, - type: 'number', - value: x.default || 0, - }); - } else if (x.type === 'switch') { - pageVars.push({ - name: x.name, - type: 'boolean', - value: x.default || false, - }); - } else if (x.type === 'counter') { - pageVars.push({ - name: x.name, - type: 'number', - value: 0, - }); - } else if (x.type === 'radioButton') { - pageVars.push({ - name: x.name, - type: 'string', - value: x.default || '', - }); - } else if (x.children) { - collect(x.children); - } - } - }; - collect(content); - return pageVars; -} diff --git a/packages/frontend/src/utility/deep-equal.ts b/packages/frontend/src/utility/deep-equal.ts index 2859641dc7..ac2c2e68da 100644 --- a/packages/frontend/src/utility/deep-equal.ts +++ b/packages/frontend/src/utility/deep-equal.ts @@ -31,7 +31,7 @@ export function deepEqual(a: JsonLike, b: JsonLike): boolean { if (aks.length !== bks.length) return false; for (let i = 0; i < aks.length; i++) { const k = aks[i]; - if (!deepEqual(a[k], (b as { [key: string]: JsonLike })[k])) return false; + if (!deepEqual((a as { [key: string]: JsonLike })[k], (b as { [key: string]: JsonLike })[k])) return false; } return true; } diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts index 64079d125a..7578ed36e6 100644 --- a/packages/frontend/src/utility/drive.ts +++ b/packages/frontend/src/utility/drive.ts @@ -180,8 +180,9 @@ export function chooseFileFromPcAndUpload( export function chooseDriveFile(options: { multiple?: boolean; } = {}): Promise<Misskey.entities.DriveFile[]> { - return new Promise(async resolve => { - const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFileSelectDialog.vue').then(x => x.default), { + return new Promise((resolve, rej) => { + let dispose: () => void; + os.popupAsyncWithDialog(import('@/components/MkDriveFileSelectDialog.vue').then(x => x.default), { multiple: options.multiple ?? false, }, { done: files => { @@ -190,7 +191,7 @@ export function chooseDriveFile(options: { } }, closed: () => dispose(), - }); + }).then((d) => dispose = d.dispose, rej); }); } @@ -300,15 +301,28 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi }); } -export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<(Misskey.entities.DriveFolder | null)[]> { - return new Promise(async resolve => { - const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFolderSelectDialog.vue').then(x => x.default), { +export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<{ + canceled: false; + folders: (Misskey.entities.DriveFolder | null)[]; +} | { + canceled: true; + folders: undefined; +}> { + return new Promise((resolve, reject) => { + let dispose: () => void; + os.popupAsyncWithDialog(import('@/components/MkDriveFolderSelectDialog.vue').then(x => x.default), { initialFolder, }, { done: folders => { - resolve(folders); + resolve(folders == null ? { + canceled: true, + folders: undefined, + } : { + canceled: false, + folders, + }); }, closed: () => dispose(), - }); + }).then(d => dispose = d.dispose, reject); }); } diff --git a/packages/frontend/src/utility/contains.ts b/packages/frontend/src/utility/element-contains.ts index 6137c06e85..8389d49278 100644 --- a/packages/frontend/src/utility/contains.ts +++ b/packages/frontend/src/utility/element-contains.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export default (parent, child, checkSame = true) => { +export function elementContains(parent: Element | null, child: Element | null, checkSame = true) { + if (parent === null || child === null) return false; if (checkSame && parent === child) return true; let node = child.parentNode; while (node) { @@ -11,4 +12,4 @@ export default (parent, child, checkSame = true) => { node = node.parentNode; } return false; -}; +} diff --git a/packages/frontend/src/utility/file-drop.ts b/packages/frontend/src/utility/file-drop.ts index 4259fe25e9..ffc024e8f3 100644 --- a/packages/frontend/src/utility/file-drop.ts +++ b/packages/frontend/src/utility/file-drop.ts @@ -75,20 +75,18 @@ export async function readDataTransferItems(itemList: DataTransferItemList): Pro }); } - function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> { - return new Promise(async (resolve) => { - const allEntries = Array.of<FileSystemEntry>(); - const reader = fileSystemDirectoryEntry.createReader(); - while (true) { - const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej)); - if (entries.length === 0) { - break; - } - allEntries.push(...entries); + async function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> { + const allEntries = Array.of<FileSystemEntry>(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; } + allEntries.push(...entries); + } - resolve(await Promise.all(allEntries.map(readEntry))); - }); + return await Promise.all(allEntries.map(readEntry)); } // 扱いにくいので配列に変換 diff --git a/packages/frontend/src/utility/form.ts b/packages/frontend/src/utility/form.ts index cb4a227f67..43dee37a0e 100644 --- a/packages/frontend/src/utility/form.ts +++ b/packages/frontend/src/utility/form.ts @@ -4,7 +4,7 @@ */ import * as Misskey from 'misskey-js'; -import type { OptionValue } from '@/components/MkSelect.vue'; +import type { OptionValue } from '@/types/option-value.js'; export type EnumItem = string | { label: string; @@ -25,6 +25,7 @@ export interface StringFormItem extends FormItemBase { required?: boolean; multiline?: boolean; treatAsMfm?: boolean; + manualSave?: boolean; } export interface NumberFormItem extends FormItemBase { @@ -33,6 +34,7 @@ export interface NumberFormItem extends FormItemBase { description?: string; required?: boolean; step?: number; + manualSave?: boolean; } export interface BooleanFormItem extends FormItemBase { @@ -43,18 +45,18 @@ export interface BooleanFormItem extends FormItemBase { export interface EnumFormItem extends FormItemBase { type: 'enum'; - default?: string | null; + default?: OptionValue | null; required?: boolean; enum: EnumItem[]; } export interface RadioFormItem extends FormItemBase { type: 'radio'; - default?: unknown | null; + default?: OptionValue | null; required?: boolean; options: { label: string; - value: unknown; + value: OptionValue; }[]; } @@ -82,7 +84,7 @@ export interface ArrayFormItem extends FormItemBase { export interface ButtonFormItem extends FormItemBase { type: 'button'; content?: string; - action: (ev: MouseEvent, v: any) => void; + action: (ev: PointerEvent, v: any) => void; } export interface DriveFileFormItem extends FormItemBase { @@ -124,24 +126,32 @@ type NonNullableIfRequired<T, Item extends FormItem> = type GetItemType<Item extends FormItem> = Item extends StringFormItem ? NonNullableIfRequired<InferDefault<Item, string>, Item> - : Item extends NumberFormItem - ? NonNullableIfRequired<InferDefault<Item, number>, Item> - : Item extends BooleanFormItem - ? boolean - : Item extends RadioFormItem - ? GetRadioItemType<Item> - : Item extends RangeFormItem - ? NonNullableIfRequired<InferDefault<Item, number>, Item> - : Item extends EnumFormItem - ? GetEnumItemType<Item> - : Item extends ArrayFormItem - ? NonNullableIfRequired<InferDefault<Item, unknown[]>, Item> - : Item extends ObjectFormItem - ? NonNullableIfRequired<InferDefault<Item, Record<string, unknown>>, Item> - : Item extends DriveFileFormItem - ? Misskey.entities.DriveFile | undefined - : never; + : Item extends NumberFormItem + ? NonNullableIfRequired<InferDefault<Item, number>, Item> + : Item extends BooleanFormItem + ? boolean + : Item extends RadioFormItem + ? GetRadioItemType<Item> + : Item extends RangeFormItem + ? NonNullableIfRequired<InferDefault<Item, number>, Item> + : Item extends EnumFormItem + ? GetEnumItemType<Item> + : Item extends ArrayFormItem + ? NonNullableIfRequired<InferDefault<Item, unknown[]>, Item> + : Item extends ObjectFormItem + ? NonNullableIfRequired<InferDefault<Item, Record<string, unknown>>, Item> + : Item extends DriveFileFormItem + ? Misskey.entities.DriveFile | undefined + : never; export type GetFormResultType<F extends Form> = { [P in keyof F]: GetItemType<F[P]>; }; + +export function getDefaultFormValues<F extends FormWithDefault>(form: F): GetFormResultType<F> { + const result = {} as GetFormResultType<F>; + for (const key of Object.keys(form) as (keyof F)[]) { + result[key] = form[key].default as GetItemType<F[typeof key]>; + } + return result; +} diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts index 040cf8f976..fea7f8f1d3 100644 --- a/packages/frontend/src/utility/get-drive-file-menu.ts +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -44,10 +44,11 @@ async function describe(file: Misskey.entities.DriveFile) { } function move(file: Misskey.entities.DriveFile) { - selectDriveFolder(null).then(folder => { + selectDriveFolder(null).then(({ canceled, folders }) => { + if (canceled) return; misskeyApi('drive/files/update', { fileId: file.id, - folderId: folder[0] ? folder[0].id : null, + folderId: folders[0] ? folders[0].id : null, }); }); } @@ -89,7 +90,7 @@ async function deleteFile(file: Misskey.entities.DriveFile) { } export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { - const isImage = file.type.startsWith('image/'); + const _isImage = file.type.startsWith('image/'); const menuItems: MenuItem[] = []; diff --git a/packages/frontend/src/utility/get-embed-code.ts b/packages/frontend/src/utility/get-embed-code.ts index 5ccd46cfe2..5817d7ece8 100644 --- a/packages/frontend/src/utility/get-embed-code.ts +++ b/packages/frontend/src/utility/get-embed-code.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { defineAsyncComponent } from 'vue'; -import { genId } from '@/utility/id.js'; import { url } from '@@/js/config.js'; import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js'; +import { genId } from '@/utility/id.js'; import * as os from '@/os.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; @@ -21,19 +21,20 @@ export function normalizeEmbedParams(params: EmbedParams): Record<string, string // paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す const normalizedParams: Record<string, string> = {}; for (const key in params) { + const k = key as keyof EmbedParams; // デフォルトの値と同じならparamsに含めない - if (params[key] == null || params[key] === defaultEmbedParams[key]) { + if (params[k] == null || params[k] === defaultEmbedParams[k]) { continue; } - switch (typeof params[key]) { + switch (typeof params[k]) { case 'number': - normalizedParams[key] = params[key].toString(); + normalizedParams[k] = params[k].toString(); break; case 'boolean': - normalizedParams[key] = params[key] ? 'true' : 'false'; + normalizedParams[k] = params[k] ? 'true' : 'false'; break; default: - normalizedParams[key] = params[key]; + normalizedParams[k] = params[k]; break; } } diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index fc165ea898..78176970f1 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -262,7 +262,7 @@ export function getNoteMenu(props: { os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); } - async function promote(): Promise<void> { + async function _promote(): Promise<void> { const { canceled, result: days } = await os.inputNumber({ title: i18n.ts.numberOfDays, }); diff --git a/packages/frontend/src/utility/get-user-environment.ts b/packages/frontend/src/utility/get-user-environment.ts index 3b8d43fb2c..ebae0492b1 100644 --- a/packages/frontend/src/utility/get-user-environment.ts +++ b/packages/frontend/src/utility/get-user-environment.ts @@ -39,7 +39,7 @@ export async function getUserEnvironment(): Promise<UserEnvironment> { } } - const browserData = uaData.fullVersionList.find((item) => !/^\s*not.+a.+brand\s*$/i.test(item.brand)); + const browserData = uaData.fullVersionList.find((item: any) => !/^\s*not.+a.+brand\s*$/i.test(item.brand)); return { os: `${uaData.platform} ${osVersion}`, browser: browserData ? `${browserData.brand} v${browserData.version}` : 'Unknown', diff --git a/packages/frontend/src/utility/image-compositor-functions/blur.glsl b/packages/frontend/src/utility/image-compositor-functions/blur.glsl index e591267887..dc48c2ae94 100644 --- a/packages/frontend/src/utility/image-compositor-functions/blur.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/blur.glsl @@ -21,13 +21,20 @@ uniform float u_radius; uniform int u_samples; out vec4 out_color; +float rand(vec2 value) { + return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453); +} + void main() { float angle = -(u_angle * PI); + float aspect = in_resolution.x / max(in_resolution.y, 1.0); vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ) + u_offset; + vec2 scaledUv = vec2(centeredUv.x * aspect, centeredUv.y); + vec2 rotatedScaledUv = vec2( + scaledUv.x * cos(angle) - scaledUv.y * sin(angle), + scaledUv.x * sin(angle) + scaledUv.y * cos(angle) + ); + vec2 rotatedUV = vec2(rotatedScaledUv.x / aspect, rotatedScaledUv.y) + u_offset; bool isInside = false; if (u_ellipse) { @@ -46,31 +53,29 @@ void main() { float totalSamples = 0.0; // Make blur radius resolution-independent by using a percentage of image size - // This ensures consistent visual blur regardless of image resolution float referenceSize = min(in_resolution.x, in_resolution.y); - float normalizedRadius = u_radius / 100.0; // Convert radius to percentage (0-15 -> 0-0.15) - vec2 blurOffset = vec2(normalizedRadius) / in_resolution * referenceSize; - - // Calculate how many samples to take in each direction - // This determines the grid density, not the blur extent - int sampleRadius = int(sqrt(float(u_samples)) / 2.0); + float normalizedRadius = u_radius / 100.0; + float radiusPx = normalizedRadius * referenceSize; + vec2 texelSize = 1.0 / in_resolution; - // Sample in a grid pattern within the specified radius - for (int x = -sampleRadius; x <= sampleRadius; x++) { - for (int y = -sampleRadius; y <= sampleRadius; y++) { - // Normalize the grid position to [-1, 1] range - float normalizedX = float(x) / float(sampleRadius); - float normalizedY = float(y) / float(sampleRadius); + int sampleCount = max(u_samples, 1); + float sampleCountF = float(sampleCount); + float jitter = rand(in_uv * in_resolution); + float goldenAngle = 2.39996323; - // Scale by radius to get the actual sampling offset - vec2 offset = vec2(normalizedX, normalizedY) * blurOffset; - vec2 sampleUV = in_uv + offset; + // Sample in a circular pattern to avoid axis-aligned artifacts + for (int i = 0; i < sampleCount; i++) { + float fi = float(i); + float radius = sqrt((fi + 0.5) / sampleCountF); + float theta = (fi + jitter) * goldenAngle; + vec2 direction = vec2(cos(theta), sin(theta)); + vec2 offset = direction * (radiusPx * radius) * texelSize; + vec2 sampleUV = in_uv + offset; - // Only sample if within texture bounds - if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) { - result += texture(in_texture, sampleUV); - totalSamples += 1.0; - } + if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) { + float weight = exp(-radius * radius * 4.0); + result += texture(in_texture, sampleUV) * weight; + totalSamples += weight; } } diff --git a/packages/frontend/src/utility/image-compositor-functions/blur.ts b/packages/frontend/src/utility/image-compositor-functions/blur.ts index 1ab8eee6ba..72711445cc 100644 --- a/packages/frontend/src/utility/image-compositor-functions/blur.ts +++ b/packages/frontend/src/utility/image-compositor-functions/blur.ts @@ -84,9 +84,9 @@ export const uiDefinition = { radius: { label: i18n.ts._imageEffector._fxProps.strength, type: 'number', - default: 3.0, + default: 10.0, min: 0.0, - max: 10.0, + max: 20.0, step: 0.5, }, }, diff --git a/packages/frontend/src/utility/image-compositor-functions/fill.glsl b/packages/frontend/src/utility/image-compositor-functions/fill.glsl index f04dc5545a..02e5e3a071 100644 --- a/packages/frontend/src/utility/image-compositor-functions/fill.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/fill.glsl @@ -27,11 +27,14 @@ void main() { //float y_ratio = max(in_resolution.y / in_resolution.x, 1.0); float angle = -(u_angle * PI); + float aspect = in_resolution.x / max(in_resolution.y, 1.0); vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ) + u_offset; + vec2 scaledUv = vec2(centeredUv.x * aspect, centeredUv.y); + vec2 rotatedScaledUv = vec2( + scaledUv.x * cos(angle) - scaledUv.y * sin(angle), + scaledUv.x * sin(angle) + scaledUv.y * cos(angle) + ); + vec2 rotatedUV = vec2(rotatedScaledUv.x / aspect, rotatedScaledUv.y) + u_offset; bool isInside = false; if (u_ellipse) { diff --git a/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl index 4de3f27397..b08a3d798f 100644 --- a/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl +++ b/packages/frontend/src/utility/image-compositor-functions/pixelate.glsl @@ -21,8 +21,6 @@ uniform int u_samples; uniform float u_strength; out vec4 out_color; -// TODO: pixelateの中心を画像中心ではなく範囲の中心にする -// TODO: 画像のアスペクト比に関わらず各画素は正方形にする void main() { if (u_strength <= 0.0) { @@ -31,11 +29,14 @@ void main() { } float angle = -(u_angle * PI); + float aspect = in_resolution.x / max(in_resolution.y, 1.0); vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset; - vec2 rotatedUV = vec2( - centeredUv.x * cos(angle) - centeredUv.y * sin(angle), - centeredUv.x * sin(angle) + centeredUv.y * cos(angle) - ) + u_offset; + vec2 scaledUv = vec2(centeredUv.x * aspect, centeredUv.y); + vec2 rotatedScaledUv = vec2( + scaledUv.x * cos(angle) - scaledUv.y * sin(angle), + scaledUv.x * sin(angle) + scaledUv.y * cos(angle) + ); + vec2 rotatedUV = vec2(rotatedScaledUv.x / aspect, rotatedScaledUv.y) + u_offset; bool isInside = false; if (u_ellipse) { @@ -50,19 +51,24 @@ void main() { return; } - float dx = u_strength / 1.0; - float dy = u_strength / 1.0; + float baseResolution = (in_resolution.x + in_resolution.y) * 0.5; + float dx = (u_strength * baseResolution) / max(in_resolution.x, 1.0); + float dy = (u_strength * baseResolution) / max(in_resolution.y, 1.0); + vec2 centerUv = vec2(0.5, 0.5) + u_offset; vec2 new_uv = vec2( - (dx * (floor((in_uv.x - 0.5 - (dx / 2.0)) / dx) + 0.5)), - (dy * (floor((in_uv.y - 0.5 - (dy / 2.0)) / dy) + 0.5)) - ) + vec2(0.5 + (dx / 2.0), 0.5 + (dy / 2.0)); + (dx * (floor((in_uv.x - centerUv.x - (dx / 2.0)) / dx) + 0.5)), + (dy * (floor((in_uv.y - centerUv.y - (dy / 2.0)) / dy) + 0.5)) + ) + vec2(centerUv.x + (dx / 2.0), centerUv.y + (dy / 2.0)); vec4 result = vec4(0.0); float totalSamples = 0.0; - // TODO: より多くのサンプリング - result += texture(in_texture, new_uv); - totalSamples += 1.0; + vec2 halfStep = vec2(dx, dy) * 0.25; + result += texture(in_texture, new_uv + vec2(-halfStep.x, -halfStep.y)); + result += texture(in_texture, new_uv + vec2(halfStep.x, -halfStep.y)); + result += texture(in_texture, new_uv + vec2(-halfStep.x, halfStep.y)); + result += texture(in_texture, new_uv + vec2(halfStep.x, halfStep.y)); + totalSamples += 4.0; out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv); } diff --git a/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts b/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts index 9e97728785..591a94b855 100644 --- a/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts +++ b/packages/frontend/src/utility/image-frame-renderer/ImageFrameRenderer.ts @@ -201,7 +201,7 @@ export class ImageFrameRenderer { qrSize, ); qrImageBitmap.close(); - } catch (err) { + } catch (_) { // nop } } diff --git a/packages/frontend/src/utility/is-birthday.ts b/packages/frontend/src/utility/is-birthday.ts new file mode 100644 index 0000000000..ff875281a2 --- /dev/null +++ b/packages/frontend/src/utility/is-birthday.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +export function isBirthday(user: Misskey.entities.UserDetailed, now = new Date()): boolean { + if (user.birthday == null) return false; + + const [_, bm, bd] = user.birthday.split('-').map((v) => parseInt(v, 10)); + if (isNaN(bm) || isNaN(bd)) return false; + + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const d = now.getDate(); + + // 閏日生まれで平年の場合は3月1日を誕生日として扱う + if (bm === 2 && bd === 29 && m === 3 && d === 1 && !isLeapYear(y)) { + return true; + } + + return m === bm && d === bd; +} + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); +} diff --git a/packages/frontend/src/utility/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts index 09802d580b..5580435db1 100644 --- a/packages/frontend/src/utility/mfm-function-picker.ts +++ b/packages/frontend/src/utility/mfm-function-picker.ts @@ -3,55 +3,27 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { nextTick } from 'vue'; import { MFM_TAGS } from '@@/js/const.js'; -import type { Ref } from 'vue'; -import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; /** * MFMの装飾のリストを表示する */ -export function mfmFunctionPicker(anchorElement: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { +export function mfmFunctionPicker(anchorElement: HTMLElement | EventTarget | null, onChosen: (tag: string) => void, onClosed?: () => void) { os.popupMenu([{ text: i18n.ts.addMfmFunction, type: 'label', - }, ...getFunctionList(textArea, textRef)], anchorElement); -} - -function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>): MenuItem[] { - return MFM_TAGS.map(tag => ({ + }, ...MFM_TAGS.map(tag => ({ text: tag, icon: 'ti ti-icons', - action: () => add(textArea, textRef, tag), - })); -} - -function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) { - const caretStart: number = textArea.selectionStart as number; - const caretEnd: number = textArea.selectionEnd as number; - - MFM_TAGS.forEach(tag => { - if (type === tag) { - if (caretStart === caretEnd) { - // 単純にFunctionを追加 - const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`; - textRef.value = trimmedText; - } else { - // 選択範囲を囲むようにFunctionを追加 - const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`; - textRef.value = trimmedText; - } - } - }); - - const nextCaretStart: number = caretStart + 3 + type.length; - const nextCaretEnd: number = caretEnd + 3 + type.length; - - // キャレットを戻す - nextTick(() => { - textArea.focus(); - textArea.setSelectionRange(nextCaretStart, nextCaretEnd); + action: () => { + onChosen(tag); + }, + }))], anchorElement, { + onClosed: () => { + if (onClosed) onClosed(); + }, }); } + diff --git a/packages/frontend/src/utility/paginator.ts b/packages/frontend/src/utility/paginator.ts index 59ae1e431a..45054acfd0 100644 --- a/packages/frontend/src/utility/paginator.ts +++ b/packages/frontend/src/utility/paginator.ts @@ -213,7 +213,7 @@ export class Paginator< } : {}), }; - const apiRes = (await misskeyApi(this.endpoint, data).catch(err => { + const apiRes = (await misskeyApi(this.endpoint, data).catch(_ => { this.error.value = true; this.fetching.value = false; return null; @@ -273,7 +273,7 @@ export class Paginator< }), }; - const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => { + const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(_ => { return null; })) as T[] | null; @@ -326,7 +326,7 @@ export class Paginator< }), }; - const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(err => { + const apiRes = (await misskeyApi<T[]>(this.endpoint, data).catch(_ => { return null; })) as T[] | null; diff --git a/packages/frontend/src/utility/please-login.ts b/packages/frontend/src/utility/please-login.ts index 737e7d7c6e..8120a8d1af 100644 --- a/packages/frontend/src/utility/please-login.ts +++ b/packages/frontend/src/utility/please-login.ts @@ -48,8 +48,8 @@ export async function pleaseLogin(opts: { path?: string; message?: string; openOnRemote?: OpenOnRemoteOptions; -} = {}) { - if ($i) return; +} = {}): Promise<boolean> { + if ($i != null) return true; let _openOnRemote: OpenOnRemoteOptions | undefined = undefined; @@ -71,5 +71,5 @@ export async function pleaseLogin(opts: { closed: () => dispose(), }); - throw new Error('signin required'); + return false; } diff --git a/packages/frontend/src/utility/sensitive-file.ts b/packages/frontend/src/utility/sensitive-file.ts new file mode 100644 index 0000000000..f1fc909e4a --- /dev/null +++ b/packages/frontend/src/utility/sensitive-file.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import * as os from '@/os.js'; +import { prefer } from '@/preferences.js'; +import { i18n } from '@/i18n.js'; + +export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean { + if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) { + return true; + } + + if (file.isSensitive && prefer.s.nsfw !== 'ignore') { + return true; + } + + return false; +} + +export async function canRevealFile(file: Misskey.entities.DriveFile): Promise<boolean> { + if (file.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.sensitiveMediaRevealConfirm, + }); + if (canceled) return false; + } + + return true; +} diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts index cefa720ebf..aa86db6bd1 100644 --- a/packages/frontend/src/utility/snowfall-effect.ts +++ b/packages/frontend/src/utility/snowfall-effect.ts @@ -21,7 +21,7 @@ export class SnowfallEffect { }>; private uniforms: Record<string, { type: string; - value: number[] | Float32Array; + value: number | number[] | Float32Array; location: WebGLUniformLocation; }>; private texture: WebGLTexture; @@ -44,9 +44,9 @@ export class SnowfallEffect { start: number; previous: number; } = { - start: 0, - previous: 0, - }; + start: 0, + previous: 0, + }; private raf = 0; private density: number = 1 / 90; @@ -90,7 +90,7 @@ export class SnowfallEffect { mat2: 'uniformMatrix2fv', mat3: 'uniformMatrix3fv', mat4: 'uniformMatrix4fv', - }; + } as const; private CAMERA = { fov: 60, @@ -167,7 +167,7 @@ export class SnowfallEffect { return { ...this.WIND }; } - private initShader(type, source): WebGLShader { + private initShader(type: number, source: string): WebGLShader { const { gl } = this; const shader = gl.createShader(type); if (shader == null) throw new Error('Failed to create shader'); @@ -224,7 +224,7 @@ export class SnowfallEffect { } } - private setBuffer(name: string, value?) { + private setBuffer(name: string, value?: number[] | undefined) { const { gl, buffers } = this; const buffer = buffers[name]; @@ -253,18 +253,18 @@ export class SnowfallEffect { } } - private setUniform(name: string, value?) { + private setUniform(name: string, value?: number | number[] | Float32Array<ArrayBufferLike> | undefined) { const { gl, uniforms } = this; const uniform = uniforms[name]; - const setter = this.UNIFORM_SETTERS[uniform.type]; + const setter = this.UNIFORM_SETTERS[uniform.type as keyof typeof this.UNIFORM_SETTERS]; const isMatrix = /^mat[2-4]$/i.test(uniform.type); uniform.value = value ?? uniform.value; if (isMatrix) { - gl[setter](uniform.location, false, uniform.value); + (gl as any)[setter](uniform.location, false, uniform.value); } else { - gl[setter](uniform.location, uniform.value); + (gl as any)[setter](uniform.location, uniform.value); } } diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts index 8e79841647..303244d126 100644 --- a/packages/frontend/src/utility/sound.ts +++ b/packages/frontend/src/utility/sound.ts @@ -111,7 +111,7 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; }) try { response = await window.fetch(url); - } catch (err) { + } catch (_) { return; } diff --git a/packages/frontend/src/utility/storage.ts b/packages/frontend/src/utility/storage.ts index 9df3a251e6..42743f78ea 100644 --- a/packages/frontend/src/utility/storage.ts +++ b/packages/frontend/src/utility/storage.ts @@ -3,14 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { computed, ref, shallowRef, watch, defineAsyncComponent } from 'vue'; +import { readonly, ref } from 'vue'; import * as os from '@/os.js'; import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; -export const storagePersisted = ref(await navigator.storage.persisted()); +export const storagePersistenceSupported = window.isSecureContext && 'storage' in navigator; +const storagePersisted = ref(false); + +export async function getStoragePersistenceStatusRef() { + if (storagePersistenceSupported) { + storagePersisted.value = await navigator.storage.persisted().catch(() => false); + } + + return readonly(storagePersisted); +} export async function enableStoragePersistence() { + if (!storagePersistenceSupported) return; try { const persisted = await navigator.storage.persist(); if (persisted) { diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index 33ddea048b..de71b8ce11 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -104,7 +104,7 @@ export function makeDateGroupedTimelineComputedRef<T extends { id: string; creat for (let i = 0; i < items.value.length; i++) { const item = items.value[i]; const date = new Date(item.createdAt); - const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; + const _nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; if (tl.length === 0 || ( span === 'day' && tl[tl.length - 1].date.getTime() !== date.getTime() diff --git a/packages/frontend/src/utility/tour.ts b/packages/frontend/src/utility/tour.ts index c6bfa35a66..b14486e953 100644 --- a/packages/frontend/src/utility/tour.ts +++ b/packages/frontend/src/utility/tour.ts @@ -13,7 +13,7 @@ type TourStep = { }; export function startTour(steps: TourStep[]) { - return new Promise<void>(async (resolve) => { + return new Promise<void>((resolve) => { const currentStepIndex = ref(0); const titleRef = ref(steps[0].title); const descriptionRef = ref(steps[0].description); diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue index e708343b3a..bab688f851 100644 --- a/packages/frontend/src/widgets/WidgetActivity.chart.vue +++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue @@ -53,19 +53,27 @@ const pointsReply = ref<string>(); const pointsRenote = ref<string>(); const pointsTotal = ref<string>(); -function dragListen(fn) { +function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) { window.addEventListener('mousemove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); window.addEventListener('mouseup', dragClear.bind(null, fn)); } -function dragClear(fn) { +function dragClear(fn: (ev: MouseEvent | TouchEvent) => void) { window.removeEventListener('mousemove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); + window.removeEventListener('mouseleave', dragClear as any); + window.removeEventListener('mouseup', dragClear as any); } -function onMousedown(ev) { +function getPositionX(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0; +} + +function getPositionY(event: MouseEvent | TouchEvent) { + return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0; +} + +function onMousedown(ev: MouseEvent) { const clickX = ev.clientX; const clickY = ev.clientY; const baseZoom = zoom.value; @@ -73,8 +81,11 @@ function onMousedown(ev) { // 動かした時 dragListen(me => { - let moveLeft = me.clientX - clickX; - let moveTop = me.clientY - clickY; + const x = getPositionX(me); + const y = getPositionY(me); + + let moveLeft = x - clickX; + let moveTop = y - clickY; zoom.value = Math.max(1, baseZoom + (-moveTop / 20)); pos.value = Math.min(0, basePos + moveLeft); diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue index 9625abb4d1..3d0f4657b1 100644 --- a/packages/frontend/src/widgets/WidgetActivity.vue +++ b/packages/frontend/src/widgets/WidgetActivity.vue @@ -38,10 +38,12 @@ const name = 'activity'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, view: { diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue index 3951de1d84..c2a41b6257 100644 --- a/packages/frontend/src/widgets/WidgetAichan.vue +++ b/packages/frontend/src/widgets/WidgetAichan.vue @@ -12,14 +12,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import { useWidgetPropsManager } from './widget.js'; +import { i18n } from '@/i18n.js'; import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; -const name = 'ai'; +const name = 'aichan'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue index 795c5a2cfa..c6cafb270f 100644 --- a/packages/frontend/src/widgets/WidgetAiscript.vue +++ b/packages/frontend/src/widgets/WidgetAiscript.vue @@ -37,10 +37,12 @@ const name = 'aiscript'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, script: { type: 'string', + label: i18n.ts.script, multiline: true, default: '(1 + 1)', hidden: true, diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue index 18acd966fd..9ed441b77c 100644 --- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue +++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue @@ -24,6 +24,7 @@ import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js'; import * as os from '@/os.js'; import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import { $i } from '@/i.js'; +import { i18n } from '@/i18n.js'; import MkAsUi from '@/components/MkAsUi.vue'; import MkContainer from '@/components/MkContainer.vue'; import { registerAsUiLib } from '@/aiscript/ui.js'; @@ -33,11 +34,14 @@ const name = 'aiscriptApp'; const widgetPropsDef = { script: { type: 'string', + label: i18n.ts.script, multiline: true, + manualSave: true, default: '', }, showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue new file mode 100644 index 0000000000..2b714c2f6c --- /dev/null +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue @@ -0,0 +1,86 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <MkA :to="userPage(item.user)" style="overflow: clip;"> + <MkUserCardMini :user="item.user" :withChart="false" style="text-overflow: ellipsis; background: inherit; border-radius: unset;"> + <template #sub> + <span>{{ countdownDate }}</span> + <span> / </span> + <span class="_monospace">@{{ acct(item.user) }}</span> + </template> + </MkUserCardMini> + </MkA> + <button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})"> + <i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i> + </button> +</div> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { useLowresTime } from '@/composables/use-lowres-time.js'; +import { userPage, acct } from '@/filters/user.js'; + +const props = defineProps<{ + item: Misskey.entities.UsersGetFollowingUsersByBirthdayResponse[number]; +}>(); + +const now = useLowresTime(); +const nowDate = computed(() => { + const date = new Date(now.value); + date.setHours(0, 0, 0, 0); + return date; +}); +const birthdayDate = computed(() => { + const [year, month, day] = props.item.birthday.split('-').map((v) => parseInt(v, 10)); + return new Date(year, month - 1, day, 0, 0, 0, 0); +}); + +const countdownDate = computed(() => { + const days = Math.floor((birthdayDate.value.getTime() - nowDate.value.getTime()) / (1000 * 60 * 60 * 24)); + if (days === 0) { + return i18n.ts.today; + } else if (days > 0) { + return i18n.tsx._timeIn.days({ n: days }); + } else { + return i18n.tsx._ago.daysAgo({ n: Math.abs(days) }); + } +}); +</script> + +<style lang="scss" module> +.root { + box-sizing: border-box; + display: grid; + align-items: center; + grid-template-columns: auto 56px; +} + +.post { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + width: 40px; + margin-right: 16px; + aspect-ratio: 1/1; + border-radius: 100%; + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); + + &:hover { + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); + } +} + +.postIcon { + color: var(--MI_THEME-fgOnAccent); +} +</style> diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index d1991cd70a..cf9c5a3d35 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -4,42 +4,75 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings"> +<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" class="mkw-bdayfollowings"> <template #icon><i class="ti ti-cake"></i></template> <template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template> - <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ti ti-refresh"></i></button></template> + <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="fetch"><i class="ti ti-refresh"></i></button></template> - <div :class="$style.bdayFRoot"> - <MkLoading v-if="fetching"/> - <div v-else-if="users.length > 0" :class="$style.bdayFGrid"> - <MkAvatar v-for="user in users" :key="user.id" :user="user.followee!" link preview></MkAvatar> + <MkPagination v-slot="{ items }" :paginator="birthdayUsersPaginator"> + <div> + <template v-for="(user, i) in items" :key="user.id"> + <div + v-if="i > 0 && isSeparatorNeeded(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)" + > + <div :class="$style.date"> + <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.prevText }}</span> + <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span> + <span>{{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.nextText }} <i class="ti ti-chevron-down"></i></span> + </div> + <XUser :class="$style.user" :item="user" /> + </div> + <XUser v-else :class="$style.user" :item="user" /> + </template> </div> - <div v-else :class="$style.bdayFFallback"> - <MkResult type="empty"/> - </div> - </div> + </MkPagination> </MkContainer> </template> <script lang="ts" setup> -import { ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { useInterval } from '@@/js/use-interval.js'; +import { computed, markRaw, ref, watch } from 'vue'; +import { useLowresTime } from '@/composables/use-lowres-time.js'; +import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { misskeyApi } from '@/utility/misskey-api.js'; +import MkPagination from '@/components/MkPagination.vue'; +import XUser from './WidgetBirthdayFollowings.user.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/i.js'; +import { Paginator } from '@/utility/paginator.js'; -const name = i18n.ts._widgets.birthdayFollowings; +const name = 'birthdayFollowings'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, + height: { + type: 'number' as const, + label: i18n.ts._widgetOptions.height, + default: 300, + }, + period: { + type: 'radio' as const, + label: i18n.ts._widgetOptions._birthdayFollowings.period, + default: '3day', + options: [{ + value: 'today' as const, + label: i18n.ts.today, + }, { + value: '3day' as const, + label: i18n.tsx.dayX({ day: 3 }), + }, { + value: 'week' as const, + label: i18n.ts.oneWeek, + }, { + value: 'month' as const, + label: i18n.ts.oneMonth, + }], + }, } satisfies FormWithDefault; type WidgetProps = GetFormResultType<typeof widgetPropsDef>; @@ -47,62 +80,84 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>; const props = defineProps<WidgetComponentProps<WidgetProps>>(); const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); -const { widgetProps, configure } = useWidgetPropsManager(name, +const { widgetProps, configure } = useWidgetPropsManager( + name, widgetPropsDef, props, emit, ); -const users = ref<Misskey.Endpoints['users/following']['res']>([]); -const fetching = ref(true); -let lastFetchedAt = '1970-01-01'; +const now = useLowresTime(); +const nextDay = new Date(); +nextDay.setHours(24, 0, 0, 0); +let nextDayMidnightTime = nextDay.getTime(); -const fetch = () => { - if (!$i) { - users.value = []; - fetching.value = false; - return; +const begin = ref<Date>(new Date()); +const end = computed(() => { + switch (widgetProps.period) { + case '3day': + return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 3); + case 'week': + return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 7); + case 'month': + return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 30); + default: + return begin.value; } +}); + +const birthdayUsersPaginator = markRaw(new Paginator('users/get-following-users-by-birthday', { + limit: 18, + offsetMode: true, + computedParams: computed(() => { + if (widgetProps.period === 'today') { + return { + birthday: { + month: begin.value.getMonth() + 1, + day: begin.value.getDate(), + }, + }; + } else { + return { + birthday: { + begin: { + month: begin.value.getMonth() + 1, + day: begin.value.getDate(), + }, + end: { + month: end.value.getMonth() + 1, + day: end.value.getDate(), + }, + }, + }; + } + }), +})); - const lfAtD = new Date(lastFetchedAt); - lfAtD.setHours(0, 0, 0, 0); +function fetch() { const now = new Date(); - now.setHours(0, 0, 0, 0); + begin.value = now; +} - if (now > lfAtD) { - actualFetch(); +const UPDATE_INTERVAL = 1000 * 60; +let nextDayTimer: number | null = null; - lastFetchedAt = now.toISOString(); - } -}; +watch(now, (to) => { + // 次回更新までに日付が変わる場合、日付が変わった直後に強制的に更新するタイマーをセットする + if (nextDayMidnightTime - to <= UPDATE_INTERVAL) { + if (nextDayTimer != null) { + window.clearTimeout(nextDayTimer); + nextDayTimer = null; + } -function actualFetch() { - if ($i == null) { - users.value = []; - fetching.value = false; - return; + nextDayTimer = window.setTimeout(() => { + fetch(); + nextDay.setHours(24, 0, 0, 0); + nextDayMidnightTime = nextDay.getTime(); + nextDayTimer = null; + }, nextDayMidnightTime - to); } - - const now = new Date(); - now.setHours(0, 0, 0, 0); - fetching.value = true; - misskeyApi('users/following', { - limit: 18, - birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`, - userId: $i.id, - }).then(res => { - users.value = res; - window.setTimeout(() => { - // 早すぎるとチカチカする - fetching.value = false; - }, 100); - }); -} - -useInterval(fetch, 1000 * 60, { - immediate: true, - afterMounted: true, -}); +}, { immediate: true }); defineExpose<WidgetComponentExpose>({ name, @@ -112,24 +167,24 @@ defineExpose<WidgetComponentExpose>({ </script> <style lang="scss" module> -.bdayFRoot { - overflow: hidden; - min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--MI-margin) * 2)); +.root { + container-type: inline-size; + background: var(--MI_THEME-panel); } -.bdayFGrid { - display: grid; - grid-template-columns: repeat(6, 42px); - grid-template-rows: repeat(3, 42px); - place-content: center; - gap: 8px; - margin: var(--MI-margin) auto; + +.user { + border-bottom: solid 0.5px var(--MI_THEME-divider); } -.bdayFFallback { - height: 100%; +.date { display: flex; - flex-direction: column; - justify-content: center; + font-size: 85%; align-items: center; + justify-content: center; + gap: 1em; + opacity: 0.75; + padding: 8px 8px; + margin: 0 auto; + border-bottom: solid 0.5px var(--MI_THEME-divider); } </style> diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue index f8ae03c5fd..223901390e 100644 --- a/packages/frontend/src/widgets/WidgetButton.vue +++ b/packages/frontend/src/widgets/WidgetButton.vue @@ -20,22 +20,26 @@ import * as os from '@/os.js'; import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js'; import { $i } from '@/i.js'; import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; const name = 'button'; const widgetPropsDef = { label: { type: 'string', + label: i18n.ts.label, default: 'BUTTON', }, colored: { type: 'boolean', + label: i18n.ts._widgetOptions._button.colored, default: true, }, script: { type: 'string', + label: i18n.ts.script, multiline: true, - default: 'Mk:dialog("hello" "world")', + default: 'Mk:dialog("hello", "world")', }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index f2321ca9fa..28336cbe09 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -50,6 +50,7 @@ const name = 'calendar'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetChat.vue b/packages/frontend/src/widgets/WidgetChat.vue index 8fee7f00f6..06d8f741f4 100644 --- a/packages/frontend/src/widgets/WidgetChat.vue +++ b/packages/frontend/src/widgets/WidgetChat.vue @@ -29,6 +29,7 @@ const name = 'chat'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue index 282a1a6d93..614e7c7fe5 100644 --- a/packages/frontend/src/widgets/WidgetClicker.vue +++ b/packages/frontend/src/widgets/WidgetClicker.vue @@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; +import { i18n } from '@/i18n.js'; import MkContainer from '@/components/MkContainer.vue'; import MkClickerGame from '@/components/MkClickerGame.vue'; @@ -23,6 +24,7 @@ const name = 'clicker'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: false, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue index 7aa69a39b5..80f312e7c4 100644 --- a/packages/frontend/src/widgets/WidgetClock.vue +++ b/packages/frontend/src/widgets/WidgetClock.vue @@ -44,10 +44,12 @@ const name = 'clock'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, size: { type: 'radio', + label: i18n.ts._widgetOptions._clock.size, default: 'medium', options: [{ value: 'small' as const, @@ -62,79 +64,89 @@ const widgetPropsDef = { }, thickness: { type: 'radio', + label: i18n.ts._widgetOptions._clock.thickness, default: 0.2, options: [{ value: 0.1 as const, - label: 'thin', + label: i18n.ts._widgetOptions._clock.thicknessThin, }, { value: 0.2 as const, - label: 'medium', + label: i18n.ts._widgetOptions._clock.thicknessMedium, }, { value: 0.3 as const, - label: 'thick', + label: i18n.ts._widgetOptions._clock.thicknessThick, }], }, graduations: { type: 'radio', + label: i18n.ts._widgetOptions._clock.graduations, default: 'numbers', options: [{ value: 'none' as const, - label: 'None', + label: i18n.ts.none, }, { value: 'dots' as const, - label: 'Dots', + label: i18n.ts._widgetOptions._clock.graduationDots, }, { value: 'numbers' as const, - label: 'Numbers', - }], + label: i18n.ts._widgetOptions._clock.graduationArabic, + }, /*, { + value: 'roman' as const, + label: i18n.ts._widgetOptions._clock.graduationRoman, + }*/], }, fadeGraduations: { type: 'boolean', + label: i18n.ts._widgetOptions._clock.fadeGraduations, default: true, }, sAnimation: { type: 'radio', + label: i18n.ts._widgetOptions._clock.sAnimation, default: 'elastic', options: [{ value: 'none' as const, - label: 'None', + label: i18n.ts.none, }, { value: 'elastic' as const, - label: 'Elastic', + label: i18n.ts._widgetOptions._clock.sAnimationElastic, }, { value: 'easeOut' as const, - label: 'Ease out', + label: i18n.ts._widgetOptions._clock.sAnimationEaseOut, }], }, twentyFour: { type: 'boolean', + label: i18n.ts._widgetOptions._clock.twentyFour, default: false, }, label: { type: 'radio', + label: i18n.ts.label, default: 'none', options: [{ value: 'none' as const, - label: 'None', + label: i18n.ts.none, }, { value: 'time' as const, - label: 'Time', + label: i18n.ts._widgetOptions._clock.labelTime, }, { value: 'tz' as const, - label: 'TZ', + label: i18n.ts._widgetOptions._clock.labelTz, }, { value: 'timeAndTz' as const, - label: 'Time + TZ', + label: i18n.ts._widgetOptions._clock.labelTimeAndTz, }], }, timezone: { type: 'enum', + label: i18n.ts._widgetOptions._clock.timezone, default: null, enum: [...timezones.map((tz) => ({ label: tz.name, value: tz.name.toLowerCase(), })), { - label: '(auto)', + label: i18n.ts.auto, value: null, }], }, diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue index b8cbc6429c..d50d4aef62 100644 --- a/packages/frontend/src/widgets/WidgetDigitalClock.vue +++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue @@ -19,6 +19,7 @@ import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import { timezones } from '@/utility/timezones.js'; +import { i18n } from '@/i18n.js'; import MkDigitalClock from '@/components/MkDigitalClock.vue'; const name = 'digitalClock'; @@ -26,29 +27,34 @@ const name = 'digitalClock'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, fontSize: { type: 'number', + label: i18n.ts.fontSize, default: 1.5, step: 0.1, }, showMs: { type: 'boolean', + label: i18n.ts._widgetOptions._clock.showMs, default: true, }, showLabel: { type: 'boolean', + label: i18n.ts._widgetOptions._clock.showLabel, default: true, }, timezone: { type: 'enum', + label: i18n.ts._widgetOptions._clock.timezone, default: null, enum: [...timezones.map((tz) => ({ label: tz.name, value: tz.name.toLowerCase(), })), { - label: '(auto)', + label: i18n.ts.auto, value: null, }], }, diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 3e880af03b..cf2c2f0ab7 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -43,6 +43,7 @@ const name = 'federation'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, } satisfies FormWithDefault; @@ -62,7 +63,7 @@ const instances = ref<Misskey.entities.FederationInstance[]>([]); const charts = ref<Misskey.entities.ChartsInstanceResponse[]>([]); const fetching = ref(true); -const fetch = async () => { +async function fetchInstances() { const fetchedInstances = await misskeyApi('federation/instances', { sort: '+latestRequestReceivedAt', limit: 5, @@ -71,14 +72,14 @@ const fetch = async () => { instances.value = fetchedInstances; charts.value = fetchedCharts; fetching.value = false; -}; +} -useInterval(fetch, 1000 * 60, { +useInterval(fetchInstances, 1000 * 60, { immediate: true, afterMounted: true, }); -function getInstanceIcon(instance): string { +function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; } diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue index 8053dd43cf..c1e864bdb3 100644 --- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue +++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue @@ -29,12 +29,14 @@ import MkTagCloud from '@/components/MkTagCloud.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; +import { i18n } from '@/i18n.js'; const name = 'instanceCloud'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, } satisfies FormWithDefault; @@ -53,7 +55,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, const cloud = useTemplateRef('cloud'); const activeInstances = shallowRef<Misskey.entities.FederationInstance[] | null>(null); -function onInstanceClick(i) { +function onInstanceClick(i: Misskey.entities.FederationInstance) { os.pageWindow(`/instance-info/${i.host}`); } @@ -70,7 +72,7 @@ useInterval(() => { afterMounted: true, }); -function getInstanceIcon(instance): string { +function getInstanceIcon(instance: Misskey.entities.FederationInstance): string { return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; } diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue index fba7d82062..1727ea9b74 100644 --- a/packages/frontend/src/widgets/WidgetJobQueue.vue +++ b/packages/frontend/src/widgets/WidgetJobQueue.vue @@ -52,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; @@ -61,16 +62,19 @@ import * as sound from '@/utility/sound.js'; import { deepClone } from '@/utility/clone.js'; import { prefer } from '@/preferences.js'; import { genId } from '@/utility/id.js'; +import { i18n } from '@/i18n.js'; const name = 'jobQueue'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, sound: { type: 'boolean', + label: i18n.ts._widgetOptions._jobQueue.sound, default: false, }, } satisfies FormWithDefault; @@ -113,20 +117,22 @@ if (prefer.s['sound.masterVolume']) { } for (const domain of ['inbox', 'deliver']) { - prev[domain] = deepClone(current[domain]); + const d = domain as 'inbox' | 'deliver'; + prev[d] = deepClone(current[d]); } -const onStats = (stats) => { +const onStats = (stats: Misskey.entities.QueueStats) => { for (const domain of ['inbox', 'deliver']) { - prev[domain] = deepClone(current[domain]); - current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick; - current[domain].active = stats[domain].active; - current[domain].waiting = stats[domain].waiting; - current[domain].delayed = stats[domain].delayed; + const d = domain as 'inbox' | 'deliver'; + prev[d] = deepClone(current[d]); + current[d].activeSincePrevTick = stats[d].activeSincePrevTick; + current[d].active = stats[d].active; + current[d].waiting = stats[d].waiting; + current[d].delayed = stats[d].delayed; - if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) { + if (current[d].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) { const soundNode = sound.createSourceNode(jammedAudioBuffer.value, {}).soundSource; - if (soundNode) { + if (soundNode != null) { jammedSoundNodePlaying.value = true; soundNode.onended = () => jammedSoundNodePlaying.value = false; soundNode.start(); @@ -135,7 +141,7 @@ const onStats = (stats) => { } }; -const onStatsLog = (statsLog) => { +const onStatsLog = (statsLog: Misskey.entities.QueueStatsLog) => { for (const stats of [...statsLog].reverse()) { onStats(stats); } diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index 2beca8c43a..fd5b56991e 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -29,10 +29,12 @@ const name = 'memo'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, height: { type: 'number', + label: i18n.ts.height, default: 100, }, } satisfies FormWithDefault; @@ -50,7 +52,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name, const text = ref<string | null>(store.s.memo); const changed = ref(false); -let timeoutId; +let timeoutId: number | null = null; const saveMemo = () => { store.set('memo', text.value); @@ -59,7 +61,7 @@ const saveMemo = () => { const onChange = () => { changed.value = true; - window.clearTimeout(timeoutId); + if (timeoutId != null) window.clearTimeout(timeoutId); timeoutId = window.setTimeout(saveMemo, 1000); }; diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index b588bcb029..48a29d6145 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -31,10 +31,12 @@ const name = 'notifications'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, height: { type: 'number', + label: i18n.ts.height, default: 300, }, excludeTypes: { diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index 9fd8c013d1..b0bb4b47b1 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -28,6 +28,7 @@ const name = 'onlineUsers'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: true, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue index e89a642b99..670e764c8c 100644 --- a/packages/frontend/src/widgets/WidgetPhotos.vue +++ b/packages/frontend/src/widgets/WidgetPhotos.vue @@ -39,10 +39,12 @@ const name = 'photos'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, } satisfies FormWithDefault; @@ -62,12 +64,12 @@ const connection = useStream().useChannel('main'); const images = ref<Misskey.entities.DriveFile[]>([]); const fetching = ref(true); -const onDriveFileCreated = (file) => { +function onDriveFileCreated(file: Misskey.entities.DriveFile) { if (/^image\/.+$/.test(file.type)) { images.value.unshift(file); if (images.value.length > 9) images.value.pop(); } -}; +} const thumbnail = (image: Misskey.entities.DriveFile): string => { return prefer.s.disableShowingAnimatedImages diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index e5499aa0da..2495c5a6e9 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -25,28 +25,33 @@ import * as Misskey from 'misskey-js'; import { url as base } from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager } from './widget.js'; +import { i18n } from '@/i18n.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; -import { i18n } from '@/i18n.js'; const name = 'rss'; const widgetPropsDef = { url: { type: 'string', + label: i18n.ts._widgetOptions._rss.url, default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + manualSave: true, }, refreshIntervalSec: { type: 'number', + label: i18n.ts._widgetOptions._rss.refreshIntervalSec, default: 60, }, maxEntries: { type: 'number', + label: i18n.ts._widgetOptions._rss.maxEntries, default: 15, }, showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, } satisfies FormWithDefault; @@ -68,7 +73,7 @@ const fetching = ref(true); const fetchEndpoint = computed(() => { const url = new URL('/api/fetch-rss', base); url.searchParams.set('url', widgetProps.url); - return url; + return url.toString(); }); const intervalClear = ref<(() => void) | undefined>(); @@ -83,7 +88,7 @@ const tick = () => { }); }; -watch(() => fetchEndpoint, tick); +watch(fetchEndpoint, tick); watch(() => widgetProps.refreshIntervalSec, () => { if (intervalClear.value) { intervalClear.value(); diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 95f82f7d7b..9ed21e6d00 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -35,6 +35,7 @@ import MkMarqueeText from '@/components/MkMarqueeText.vue'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { shuffle } from '@/utility/shuffle.js'; +import { i18n } from '@/i18n.js'; import { url as base } from '@@/js/config.js'; import { useInterval } from '@@/js/use-interval.js'; @@ -43,22 +44,28 @@ const name = 'rssTicker'; const widgetPropsDef = { url: { type: 'string', + label: i18n.ts._widgetOptions._rss.url, default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + manualSave: true, }, shuffle: { type: 'boolean', + label: i18n.ts._widgetOptions._rssTicker.shuffle, default: true, }, refreshIntervalSec: { type: 'number', + label: i18n.ts._widgetOptions._rss.refreshIntervalSec, default: 60, }, maxEntries: { type: 'number', + label: i18n.ts._widgetOptions._rss.maxEntries, default: 15, }, duration: { type: 'range', + label: i18n.ts._widgetOptions._rssTicker.duration, default: 70, step: 1, min: 5, @@ -66,14 +73,17 @@ const widgetPropsDef = { }, reverse: { type: 'boolean', + label: i18n.ts._widgetOptions._rssTicker.reverse, default: false, }, showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: false, }, transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, } satisfies FormWithDefault; @@ -119,7 +129,7 @@ const tick = () => { }); }; -watch(() => fetchEndpoint, tick); +watch(fetchEndpoint, tick); watch(() => widgetProps.refreshIntervalSec, () => { if (intervalClear.value) { intervalClear.value(); diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 240210c1fb..b812c89e08 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -33,6 +33,7 @@ const name = 'slideshow'; const widgetPropsDef = { height: { type: 'number', + label: i18n.ts._widgetOptions.height, default: 300, }, folderId: { @@ -95,11 +96,11 @@ const fetch = () => { }; const choose = () => { - selectDriveFolder(null).then(folder => { - if (folder[0] == null) { + selectDriveFolder(null).then(({ folders, canceled }) => { + if (canceled || folders[0] == null) { return; } - widgetProps.folderId = folder[0].id; + widgetProps.folderId = folders[0].id; save(); fetch(); }); diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue index 6c775fd98c..83b8e7ccbc 100644 --- a/packages/frontend/src/widgets/WidgetTimeline.vue +++ b/packages/frontend/src/widgets/WidgetTimeline.vue @@ -41,13 +41,13 @@ import * as Misskey from 'misskey-js'; import { useWidgetPropsManager } from './widget.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkContainer from '@/components/MkContainer.vue'; import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue'; import { i18n } from '@/i18n.js'; import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass, basicTimelineTypes } from '@/timelines.js'; -import type { MenuItem } from '@/types/menu.js'; const name = 'timeline'; @@ -93,12 +93,12 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, const menuOpened = ref(false); const headerTitle = computed<string>(() => { - if (widgetProps.src === 'list' && widgetProps.list != null) { - return widgetProps.list.name; - } else if (widgetProps.src === 'antenna' && widgetProps.antenna != null) { - return widgetProps.antenna.name; + if (widgetProps.src === 'list') { + return widgetProps.list != null ? widgetProps.list.name : '?'; + } else if (widgetProps.src === 'antenna') { + return widgetProps.antenna != null ? widgetProps.antenna.name : '?'; } else { - return i18n.ts._timelines[widgetProps.src]; + return i18n.ts._timelines[widgetProps.src] ?? '?'; } }); @@ -107,7 +107,7 @@ const setSrc = (src: TlSrc) => { save(); }; -const choose = async (ev: MouseEvent) => { +const choose = async (ev: PointerEvent) => { menuOpened.value = true; const [antennas, lists] = await Promise.all([ misskeyApi('antennas/list'), diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index dcb900b0c9..498129305b 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -36,11 +36,12 @@ import { misskeyApiGet } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; -const name = 'hashtags'; +const name = 'trends'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue index 226a4c73aa..1bb361664f 100644 --- a/packages/frontend/src/widgets/WidgetUnixClock.vue +++ b/packages/frontend/src/widgets/WidgetUnixClock.vue @@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, ref, watch } from 'vue'; import { useWidgetPropsManager } from './widget.js'; +import { i18n } from '@/i18n.js'; import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; @@ -26,19 +27,23 @@ const name = 'unixClock'; const widgetPropsDef = { transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, fontSize: { type: 'number', + label: i18n.ts.fontSize, default: 1.5, step: 0.1, }, showMs: { type: 'boolean', + label: i18n.ts._widgetOptions._clock.showMs, default: true, }, showLabel: { type: 'boolean', + label: i18n.ts._widgetOptions._clock.showLabel, default: true, }, } satisfies FormWithDefault; diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index 9e914fa648..3fc46f303f 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -41,6 +41,7 @@ const name = 'userList'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, listId: { diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index aea810d1ea..79bae68d71 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -42,7 +42,7 @@ export default function(app: App) { export const federationWidgets = [ 'federation', 'instanceCloud', -]; +] as const; export const widgets = [ 'profile', @@ -74,4 +74,6 @@ export const widgets = [ 'chat', ...federationWidgets, -]; +] as const; + +export type WidgetName = typeof widgets[number]; diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue index f52b6fd12e..5d93c6a982 100644 --- a/packages/frontend/src/widgets/server-metric/index.vue +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -40,10 +40,12 @@ const name = 'serverMetric'; const widgetPropsDef = { showHeader: { type: 'boolean', + label: i18n.ts._widgetOptions.showHeader, default: true, }, transparent: { type: 'boolean', + label: i18n.ts._widgetOptions.transparent, default: false, }, view: { diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts index c5ca7ac26c..bfb724ff72 100644 --- a/packages/frontend/src/widgets/widget.ts +++ b/packages/frontend/src/widgets/widget.ts @@ -3,12 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { reactive, watch } from 'vue'; -import type { Reactive } from 'vue'; +import { defineAsyncComponent, reactive, watch } from 'vue'; import { throttle } from 'throttle-debounce'; +import type { Reactive } from 'vue'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; +import { getDefaultFormValues } from '@/utility/form.js'; import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; +import type { WidgetName } from './index.js'; export type Widget<P extends Record<string, unknown>> = { id: string; @@ -20,7 +22,7 @@ export type WidgetComponentProps<P extends Record<string, unknown>> = { }; export type WidgetComponentEmits<P extends Record<string, unknown>> = { - (ev: 'updateProps', props: P); + (ev: 'updateProps', props: P): void; }; export type WidgetComponentExpose = { @@ -30,7 +32,7 @@ export type WidgetComponentExpose = { }; export const useWidgetPropsManager = <F extends FormWithDefault>( - name: string, + name: WidgetName, propsDef: F, props: Readonly<WidgetComponentProps<GetFormResultType<F>>>, emit: WidgetComponentEmits<GetFormResultType<F>>, @@ -39,19 +41,23 @@ export const useWidgetPropsManager = <F extends FormWithDefault>( save: () => void; configure: () => void; } => { - const widgetProps = reactive<GetFormResultType<F>>((props.widget ? deepClone(props.widget.data) : {}) as GetFormResultType<F>); - - const mergeProps = () => { - for (const prop of Object.keys(propsDef)) { - if (typeof widgetProps[prop] === 'undefined') { - widgetProps[prop] = propsDef[prop].default; + const widgetProps = reactive((() => { + const np = getDefaultFormValues(propsDef); + if (props.widget?.data != null) { + for (const key of Object.keys(props.widget.data) as (keyof F)[]) { + np[key] = props.widget.data[key] as GetFormResultType<F>[typeof key]; } } - }; + return np; + })()); - watch(widgetProps, () => { - mergeProps(); - }, { deep: true, immediate: true }); + watch(() => props.widget?.data, (to) => { + if (to != null) { + for (const key of Object.keys(propsDef)) { + (widgetProps as any)[key] = to[key]; + } + } + }, { deep: true }); const save = throttle(3000, () => { emit('updateProps', widgetProps as GetFormResultType<F>); @@ -60,13 +66,38 @@ export const useWidgetPropsManager = <F extends FormWithDefault>( const configure = async () => { const form = deepClone(propsDef); for (const item of Object.keys(form)) { - form[item].default = widgetProps[item]; + form[item].default = (widgetProps as any)[item]; + } + + const res = await new Promise<{ + canceled: false; + result: GetFormResultType<F>; + } | { + canceled: true; + }>((resolve) => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWidgetSettingsDialog.vue')), { + widgetName: name, + form: form, + currentSettings: widgetProps, + }, { + saved: (newProps) => { + resolve({ canceled: false, result: newProps as GetFormResultType<F> }); + }, + canceled: () => { + resolve({ canceled: true }); + }, + closed: () => { + dispose(); + }, + }); + }); + + if (res.canceled) { + return; } - const { canceled, result } = await os.form(name, form); - if (canceled) return; - for (const key of Object.keys(result)) { - widgetProps[key] = result[key]; + for (const key of Object.keys(res.result)) { + (widgetProps as any)[key] = res.result[key]; } save(); diff --git a/packages/frontend/test/is-birthday.test.ts b/packages/frontend/test/is-birthday.test.ts new file mode 100644 index 0000000000..a072db4416 --- /dev/null +++ b/packages/frontend/test/is-birthday.test.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { describe, test, expect } from 'vitest'; +import { isBirthday } from '@/utility/is-birthday.js'; + +describe('isBirthday', () => { + test('通常の誕生日', () => { + const currentDate = new Date('2024-05-15'); + const result = isBirthday({ + birthday: '2000-05-15', + } as Misskey.entities.UserDetailed, currentDate); + + expect(result).toBe(true); + }); + + test('誕生日ではない場合', () => { + const currentDate = new Date('2024-05-15'); + const result = isBirthday({ + birthday: '2000-06-20', + } as Misskey.entities.UserDetailed, currentDate); + + expect(result).toBe(false); + }); + + test('平年に閏日生まれを見た際に3月1日を誕生日とする', () => { + const currentDate = new Date('2023-03-01'); + const result = isBirthday({ + birthday: '2000-02-29', + } as Misskey.entities.UserDetailed, currentDate); + + expect(result).toBe(true); + }); + + test('閏年に閏日生まれを見た際に2月29日を誕生日とする', () => { + const currentDate = new Date('2024-02-29'); + const result = isBirthday({ + birthday: '2000-02-29', + } as Misskey.entities.UserDetailed, currentDate); + + expect(result).toBe(true); + }); + + test('閏年に閏日生まれを見た際に3月1日を誕生日としない', () => { + const currentDate = new Date('2024-03-01'); + const result = isBirthday({ + birthday: '2000-02-29', + } as Misskey.entities.UserDetailed, currentDate); + + expect(result).toBe(false); + }); +}); diff --git a/packages/frontend/test/scroll.test.ts b/packages/frontend/test/scroll.test.ts index 34e7e64313..11029c71ee 100644 --- a/packages/frontend/test/scroll.test.ts +++ b/packages/frontend/test/scroll.test.ts @@ -25,7 +25,7 @@ describe('Scroll', () => { */ test('No onScrollTop callback for disconnected elements', () => { - const { document } = new Window(); + const { document: _ } = new Window(); const div = window.document.createElement('div'); assert.strictEqual(div.scrollTop, 0); @@ -53,7 +53,7 @@ describe('Scroll', () => { */ test('No onScrollBottom callback for disconnected elements', () => { - const { document } = new Window(); + const { document: _ } = new Window(); const div = window.document.createElement('div'); assert.strictEqual(div.scrollTop, 0); diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 125a393417..e80d981bd2 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "allowJs": true, "noEmitOnError": false, - "noImplicitAny": false, + "noImplicitAny": true, "noImplicitReturns": true, "noUnusedParameters": false, "noUnusedLocals": false, @@ -23,7 +23,6 @@ "useDefineForClassFields": true, "verbatimModuleSyntax": true, "skipLibCheck": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@@/*": ["../frontend-shared/*"] diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index c9c20b23ea..260d1215df 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -167,7 +167,7 @@ export function getConfig(): UserConfig { _ENV_: JSON.stringify(process.env.NODE_ENV), _DEV_: process.env.NODE_ENV !== 'production', _PERF_PREFIX_: JSON.stringify('Misskey:'), - __VUE_OPTIONS_API__: true, + __VUE_OPTIONS_API__: false, __VUE_PROD_DEVTOOLS__: false, }, diff --git a/packages/i18n/build.ts b/packages/i18n/build.ts index 21bf2996b4..3caddd0054 100644 --- a/packages/i18n/build.ts +++ b/packages/i18n/build.ts @@ -100,7 +100,7 @@ async function buildSrc(): Promise<void> { function buildDts(): Promise<unknown> { return execa( - 'tsc', + 'tsgo', [ '--project', 'tsconfig.json', '--rootDir', 'src', diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ac6c386995..20abb21cbc 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -20,7 +20,7 @@ "build": "tsx ./build.ts", "watch": "nodemon -w package.json -e json --exec \"tsx ./build.ts --watch\"", "tsd": "tsd", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint", "lint:fix": "pnpm eslint --fix" }, @@ -29,15 +29,14 @@ ], "devDependencies": { "@types/js-yaml": "4.0.9", - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", + "@types/node": "24.10.13", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", "chokidar": "5.0.0", - "esbuild": "0.27.1", + "esbuild": "0.27.3", "execa": "9.6.1", - "nodemon": "3.1.11", - "tsx": "4.21.0", - "typescript": "5.9.3" + "nodemon": "3.1.14", + "tsx": "4.21.0" }, "dependencies": { "js-yaml": "4.1.1" diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 96a728da63..f814f3ecc2 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -2185,6 +2185,10 @@ export interface Locale extends ILocale { */ "limitTo": ParameterizedString<"x">; /** + * 画面幅が広いときはメディアリストを横並びで表示する + */ + "showMediaListByGridInWideArea": string; + /** * フォロー申請はありません */ "noFollowRequests": string; @@ -5639,6 +5643,10 @@ export interface Locale extends ILocale { * ゼロ埋め */ "zeroPadding": string; + /** + * 設定項目はありません + */ + "nothingToConfigure": string; "_imageEditing": { "_vars": { /** @@ -9881,7 +9889,7 @@ export interface Locale extends ILocale { */ "clicker": string; /** - * 今日誕生日のユーザー + * もうすぐ誕生日のユーザー */ "birthdayFollowings": string; /** @@ -9889,6 +9897,144 @@ export interface Locale extends ILocale { */ "chat": string; }; + "_widgetOptions": { + /** + * ヘッダーを表示 + */ + "showHeader": string; + /** + * 背景を透明にする + */ + "transparent": string; + /** + * 高さ + */ + "height": string; + "_button": { + /** + * 色付き + */ + "colored": string; + }; + "_clock": { + /** + * サイズ + */ + "size": string; + /** + * 針の太さ + */ + "thickness": string; + /** + * 細い + */ + "thicknessThin": string; + /** + * 普通 + */ + "thicknessMedium": string; + /** + * 太い + */ + "thicknessThick": string; + /** + * 文字盤の目盛り + */ + "graduations": string; + /** + * ドット + */ + "graduationDots": string; + /** + * アラビア数字 + */ + "graduationArabic": string; + /** + * 目盛りをフェード + */ + "fadeGraduations": string; + /** + * 秒針のアニメーション + */ + "sAnimation": string; + /** + * リアル + */ + "sAnimationElastic": string; + /** + * 滑らか + */ + "sAnimationEaseOut": string; + /** + * 24時間表示 + */ + "twentyFour": string; + /** + * 時刻 + */ + "labelTime": string; + /** + * タイムゾーン + */ + "labelTz": string; + /** + * 時刻とタイムゾーン + */ + "labelTimeAndTz": string; + /** + * タイムゾーン + */ + "timezone": string; + /** + * ミリ秒を表示 + */ + "showMs": string; + /** + * ラベルを表示 + */ + "showLabel": string; + }; + "_jobQueue": { + /** + * 音を鳴らす + */ + "sound": string; + }; + "_rss": { + /** + * RSSフィードのURL + */ + "url": string; + /** + * 更新間隔(秒) + */ + "refreshIntervalSec": string; + /** + * 最大表示件数 + */ + "maxEntries": string; + }; + "_rssTicker": { + /** + * 表示順をシャッフル + */ + "shuffle": string; + /** + * ティッカーのスクロール速度(秒) + */ + "duration": string; + /** + * 逆方向にスクロール + */ + "reverse": string; + }; + "_birthdayFollowings": { + /** + * 期間 + */ + "period": string; + }; + }; "_cw": { /** * 隠す @@ -12764,10 +12910,6 @@ export interface Locale extends ILocale { */ "discardChangesConfirm": string; /** - * 設定項目はありません - */ - "nothingToConfigure": string; - /** * 画像の読み込みに失敗しました */ "failedToLoadImage": string; diff --git a/packages/icons-subsetter/package.json b/packages/icons-subsetter/package.json index 8d52555288..cbad48c26e 100644 --- a/packages/icons-subsetter/package.json +++ b/packages/icons-subsetter/package.json @@ -7,20 +7,19 @@ "scripts": { "build": "tsx src/generator.ts", "eslint": "eslint src/**/*.ts", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.2", + "@types/node": "24.10.13", "@types/wawoff2": "1.0.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0" + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0" }, "dependencies": { "@tabler/icons-webfont": "3.35.0", - "harfbuzzjs": "0.4.13", + "harfbuzzjs": "0.8.0", "tsx": "4.21.0", - "typescript": "5.9.3", "wawoff2": "2.0.1" }, "files": [ diff --git a/packages/misskey-bubble-game/build.js b/packages/misskey-bubble-game/build.js index 1a6f87a8e8..2e606f276f 100644 --- a/packages/misskey-bubble-game/build.js +++ b/packages/misskey-bubble-game/build.js @@ -58,7 +58,7 @@ async function buildSrc() { function buildDts() { return execa( - 'tsc', + 'tsgo', [ '--project', 'tsconfig.json', '--outDir', 'built', diff --git a/packages/misskey-bubble-game/package.json b/packages/misskey-bubble-game/package.json index 3844740bf2..f707beaee8 100644 --- a/packages/misskey-bubble-game/package.json +++ b/packages/misskey-bubble-game/package.json @@ -20,25 +20,24 @@ "build": "node ./build.js", "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"", "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { "@types/matter-js": "0.20.2", - "@types/node": "24.10.2", + "@types/node": "24.10.13", "@types/seedrandom": "3.0.8", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "esbuild": "0.27.1", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "esbuild": "0.27.3", "execa": "9.6.1", - "nodemon": "3.1.11", - "typescript": "5.9.3" + "nodemon": "3.1.14" }, "files": [ "built" ], "dependencies": { - "eventemitter3": "5.0.1", + "eventemitter3": "5.0.4", "matter-js": "0.20.0", "seedrandom": "3.0.5" } diff --git a/packages/misskey-js/LICENSE b/packages/misskey-js/LICENSE index 16352625db..d666d5ef7f 100644 --- a/packages/misskey-js/LICENSE +++ b/packages/misskey-js/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 syuilo and other contributors +Copyright (c) 2021-2026 syuilo and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/misskey-js/build.js b/packages/misskey-js/build.js index 68535556d3..befc605a08 100644 --- a/packages/misskey-js/build.js +++ b/packages/misskey-js/build.js @@ -59,7 +59,7 @@ async function buildSrc() { function buildDts() { return execa( - 'tsc', + 'tsgo', [ '--project', 'tsconfig.json', '--outDir', 'built', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index fe19c00a80..760d5c319b 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -854,7 +854,14 @@ export type Channels = { key: K; value: ReversiGameDetailed[K]; }) => void; - log: (payload: Record<string, unknown>) => void; + log: (payload: { + time: number; + player: boolean; + operation: 'put'; + pos: number; + } & { + id: string | null; + }) => void; }; receives: { putStone: { @@ -2121,6 +2128,8 @@ declare namespace entities { UsersFollowingResponse, UsersGalleryPostsRequest, UsersGalleryPostsResponse, + UsersGetFollowingUsersByBirthdayRequest, + UsersGetFollowingUsersByBirthdayResponse, UsersGetFrequentlyRepliedUsersRequest, UsersGetFrequentlyRepliedUsersResponse, UsersListsCreateRequest, @@ -2221,6 +2230,7 @@ declare namespace entities { MetaLite, MetaDetailedOnly, MetaDetailed, + MetaClientOptions, UserWebhook, SystemWebhook, AbuseReportNotificationRecipient, @@ -2812,6 +2822,9 @@ type MeDetailed = components['schemas']['MeDetailed']; type MeDetailedOnly = components['schemas']['MeDetailedOnly']; // @public (undocumented) +type MetaClientOptions = components['schemas']['MetaClientOptions']; + +// @public (undocumented) type MetaDetailed = components['schemas']['MetaDetailed']; // @public (undocumented) @@ -3728,6 +3741,12 @@ type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBo type UsersGalleryPostsResponse = operations['users___gallery___posts']['responses']['200']['content']['application/json']; // @public (undocumented) +type UsersGetFollowingUsersByBirthdayRequest = operations['users___get-following-users-by-birthday']['requestBody']['content']['application/json']; + +// @public (undocumented) +type UsersGetFollowingUsersByBirthdayResponse = operations['users___get-following-users-by-birthday']['responses']['200']['content']['application/json']; + +// @public (undocumented) type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json']; // @public (undocumented) @@ -3864,7 +3883,7 @@ type VerifyEmailRequest = operations['verify-email']['requestBody']['content'][' // src/entities.ts:55:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:226:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:236:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:241:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/generator/package.json b/packages/misskey-js/generator/package.json index e9721911cc..bdce22942f 100644 --- a/packages/misskey-js/generator/package.json +++ b/packages/misskey-js/generator/package.json @@ -7,16 +7,15 @@ "generate": "tsx src/generator.ts && eslint ./built/**/*.ts --fix" }, "devDependencies": { - "@readme/openapi-parser": "5.2.1", - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", + "@readme/openapi-parser": "5.5.0", + "@types/node": "24.10.13", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", "openapi-types": "12.1.3", - "openapi-typescript": "7.10.1", + "openapi-typescript": "7.13.0", "ts-case-convert": "2.1.0", "tsx": "4.21.0", - "typescript": "5.9.3", - "eslint": "9.39.1" + "eslint": "9.39.3" }, "files": [ "built" diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 226428af13..b031e2ef61 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2025.12.2", + "version": "2026.3.0", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", @@ -25,7 +25,7 @@ "api": "pnpm api-extractor run --local --verbose", "api-prod": "pnpm api-extractor run --verbose", "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint", "vitest": "vitest run --coverage", "test": "pnpm vitest && pnpm tsd", @@ -37,18 +37,17 @@ "directory": "packages/misskey-js" }, "devDependencies": { - "@microsoft/api-extractor": "7.55.2", - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@vitest/coverage-v8": "4.0.15", - "esbuild": "0.27.1", + "@microsoft/api-extractor": "7.57.2", + "@types/node": "24.10.13", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@vitest/coverage-v8": "4.0.18", + "esbuild": "0.27.3", "execa": "9.6.1", "ncp": "2.0.0", - "nodemon": "3.1.11", + "nodemon": "3.1.14", "tsd": "0.33.0", - "typescript": "5.9.3", - "vitest": "4.0.15", + "vitest": "4.0.18", "vitest-websocket-mock": "0.5.0" }, "files": [ @@ -56,7 +55,7 @@ ], "dependencies": { "@simplewebauthn/types": "12.0.0", - "eventemitter3": "5.0.1", + "eventemitter3": "5.0.4", "reconnecting-websocket": "4.4.0" } } diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index af3a09a16e..c9cb9147f1 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -4533,6 +4533,17 @@ declare module '../api.js' { ): Promise<SwitchCaseResponseType<E, P>>; /** + * Retrieve users who have a birthday on the specified range. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request<E extends 'users/get-following-users-by-birthday', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** * Get a list of other users that the specified user frequently replies to. * * **Credential required**: *No* diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index c3ef3de4e6..2d4e0fe35e 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -616,6 +616,8 @@ import type { UsersFollowingResponse, UsersGalleryPostsRequest, UsersGalleryPostsResponse, + UsersGetFollowingUsersByBirthdayRequest, + UsersGetFollowingUsersByBirthdayResponse, UsersGetFrequentlyRepliedUsersRequest, UsersGetFrequentlyRepliedUsersResponse, UsersListsCreateRequest, @@ -1067,6 +1069,7 @@ export type Endpoints = { 'users/followers': { req: UsersFollowersRequest; res: UsersFollowersResponse }; 'users/following': { req: UsersFollowingRequest; res: UsersFollowingResponse }; 'users/gallery/posts': { req: UsersGalleryPostsRequest; res: UsersGalleryPostsResponse }; + 'users/get-following-users-by-birthday': { req: UsersGetFollowingUsersByBirthdayRequest; res: UsersGetFollowingUsersByBirthdayResponse }; 'users/get-frequently-replied-users': { req: UsersGetFrequentlyRepliedUsersRequest; res: UsersGetFrequentlyRepliedUsersResponse }; 'users/lists/create': { req: UsersListsCreateRequest; res: UsersListsCreateResponse }; 'users/lists/create-from-public': { req: UsersListsCreateFromPublicRequest; res: UsersListsCreateFromPublicResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 0d57b065dc..a49dd729e6 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -619,6 +619,8 @@ export type UsersFollowingRequest = operations['users___following']['requestBody export type UsersFollowingResponse = operations['users___following']['responses']['200']['content']['application/json']; export type UsersGalleryPostsRequest = operations['users___gallery___posts']['requestBody']['content']['application/json']; export type UsersGalleryPostsResponse = operations['users___gallery___posts']['responses']['200']['content']['application/json']; +export type UsersGetFollowingUsersByBirthdayRequest = operations['users___get-following-users-by-birthday']['requestBody']['content']['application/json']; +export type UsersGetFollowingUsersByBirthdayResponse = operations['users___get-following-users-by-birthday']['responses']['200']['content']['application/json']; export type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-replied-users']['requestBody']['content']['application/json']; export type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json']; export type UsersListsCreateRequest = operations['users___lists___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 01b48442d6..f45ae39da4 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -58,6 +58,7 @@ export type ReversiGameDetailed = components['schemas']['ReversiGameDetailed']; export type MetaLite = components['schemas']['MetaLite']; export type MetaDetailedOnly = components['schemas']['MetaDetailedOnly']; export type MetaDetailed = components['schemas']['MetaDetailed']; +export type MetaClientOptions = components['schemas']['MetaClientOptions']; export type UserWebhook = components['schemas']['UserWebhook']; export type SystemWebhook = components['schemas']['SystemWebhook']; export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 2650869590..46d04ac2dc 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3717,6 +3717,15 @@ export type paths = { */ post: operations['users___gallery___posts']; }; + '/users/get-following-users-by-birthday': { + /** + * users/get-following-users-by-birthday + * @description Retrieve users who have a birthday on the specified range. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['users___get-following-users-by-birthday']; + }; '/users/get-frequently-replied-users': { /** * users/get-frequently-replied-users @@ -4267,6 +4276,33 @@ export type components = { /** Format: misskey:id */ userListId: string; }; + login?: { + /** @enum {string} */ + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; + } | { + /** @enum {string} */ + type: 'list'; + /** Format: misskey:id */ + userListId: string; + }; + createToken?: { + /** @enum {string} */ + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; + } | { + /** @enum {string} */ + type: 'list'; + /** Format: misskey:id */ + userListId: string; + }; + exportCompleted?: { + /** @enum {string} */ + type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never'; + } | { + /** @enum {string} */ + type: 'list'; + /** Format: misskey:id */ + userListId: string; + }; }; emailNotificationTypes: string[]; achievements: components['schemas']['Achievement'][]; @@ -5337,7 +5373,8 @@ export type components = { /** Format: id */ timeoutUserId: string | null; black: number | null; - bw: string; + /** @enum {string} */ + bw: 'random' | '1' | '2'; noIrregularRules: boolean; isLlotheo: boolean; canPutEverywhere: boolean; @@ -5373,7 +5410,8 @@ export type components = { /** Format: id */ timeoutUserId: string | null; black: number | null; - bw: string; + /** @enum {string} */ + bw: 'random' | '1' | '2'; noIrregularRules: boolean; isLlotheo: boolean; canPutEverywhere: boolean; @@ -5403,7 +5441,7 @@ export type components = { feedbackUrl: string | null; defaultDarkTheme: string | null; defaultLightTheme: string | null; - clientOptions: Record<string, never>; + clientOptions: components['schemas']['MetaClientOptions']; disableRegistration: boolean; emailRequiredForSignup: boolean; enableHcaptcha: boolean; @@ -5502,6 +5540,12 @@ export type components = { cacheRemoteSensitiveFiles: boolean; }; MetaDetailed: components['schemas']['MetaLite'] & components['schemas']['MetaDetailedOnly']; + MetaClientOptions: { + /** @enum {string} */ + entrancePageStyle: 'classic' | 'simple'; + showTimelineForVisitor: boolean; + showActivitiesForVisitor: boolean; + }; UserWebhook: { /** Format: id */ id: string; @@ -6777,8 +6821,10 @@ export interface operations { updatedAt: string | null; text: string; title: string; - icon: string | null; - display: string; + /** @enum {string} */ + icon: 'info' | 'warning' | 'error' | 'success'; + /** @enum {string} */ + display: 'normal' | 'banner' | 'dialog'; isActive: boolean; forExistingUsers: boolean; silence: boolean; @@ -8217,16 +8263,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - /** Format: id */ - id: string; - aliases: string[]; - name: string; - category: string | null; - /** @description The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files. */ - host: string | null; - url: string; - }[]; + 'application/json': components['schemas']['EmojiDetailed'][]; }; }; /** @description Client error */ @@ -8305,16 +8342,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': { - /** Format: id */ - id: string; - aliases: string[]; - name: string; - category: string | null; - /** @description The local host is represented with `null`. */ - host: string | null; - url: string; - }[]; + 'application/json': components['schemas']['EmojiDetailed'][]; }; }; /** @description Client error */ @@ -9446,7 +9474,7 @@ export interface operations { deeplIsPro: boolean; defaultDarkTheme: string | null; defaultLightTheme: string | null; - clientOptions: Record<string, never>; + clientOptions: components['schemas']['MetaClientOptions']; description: string | null; disableRegistration: boolean; impressumUrl: string | null; @@ -12702,7 +12730,12 @@ export interface operations { description?: string | null; defaultLightTheme?: string | null; defaultDarkTheme?: string | null; - clientOptions?: Record<string, never>; + clientOptions?: { + /** @enum {string} */ + entrancePageStyle?: 'classic' | 'simple'; + showTimelineForVisitor?: boolean; + showActivitiesForVisitor?: boolean; + }; cacheRemoteFiles?: boolean; cacheRemoteSensitiveFiles?: boolean; emailRequiredForSignup?: boolean; @@ -23987,6 +24020,8 @@ export interface operations { tag: string; /** @default 10 */ limit?: number; + /** @default 0 */ + offset?: number; /** @enum {string} */ sort: '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt'; /** @@ -34847,6 +34882,7 @@ export interface operations { untilDate?: number; /** @default 10 */ limit?: number; + /** @description @deprecated use get-following-users-by-birthday instead. */ birthday?: string | null; }; }; @@ -34982,6 +35018,92 @@ export interface operations { }; }; }; + 'users___get-following-users-by-birthday': { + requestBody: { + content: { + 'application/json': { + /** @default 10 */ + limit?: number; + /** @default 0 */ + offset?: number; + birthday: { + month: number; + day: number; + } | { + begin: { + month: number; + day: number; + }; + end: { + month: number; + day: number; + }; + }; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** Format: misskey:id */ + id: string; + birthday: string; + user: components['schemas']['UserLite']; + }[]; + }; + }; + /** @description Client error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; 'users___get-frequently-replied-users': { requestBody: { content: { diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index eb6c5cd1b0..8789ee056d 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -13,7 +13,6 @@ import type { Role, ReversiGameDetailed, SystemWebhook, - UserLite, ChatRoom, } from './autogen/models.js'; diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts index 93838ceed5..63e1f947a5 100644 --- a/packages/misskey-js/src/streaming.types.ts +++ b/packages/misskey-js/src/streaming.types.ts @@ -224,7 +224,12 @@ export type Channels = { canceled: (payload: { userId: User['id']; }) => void; changeReadyStates: (payload: { user1: boolean; user2: boolean; }) => void; updateSettings: <K extends ReversiUpdateKey>(payload: { userId: User['id']; key: K; value: ReversiGameDetailed[K]; }) => void; - log: (payload: Record<string, unknown>) => void; + log: (payload: { + time: number; + player: boolean; + operation: 'put'; + pos: number; + } & { id: string | null }) => void; }; receives: { putStone: { @@ -291,7 +296,10 @@ export type NoteUpdatedEvent = { id: Note['id'] } & ({ type: 'reacted'; body: { reaction: string; - emoji: string | null; + emoji?: { + name: string; + url: string; + } | null; userId: User['id']; }; } | { diff --git a/packages/misskey-reversi/build.js b/packages/misskey-reversi/build.js index 1a6f87a8e8..2e606f276f 100644 --- a/packages/misskey-reversi/build.js +++ b/packages/misskey-reversi/build.js @@ -58,7 +58,7 @@ async function buildSrc() { function buildDts() { return execa( - 'tsc', + 'tsgo', [ '--project', 'tsconfig.json', '--outDir', 'built', diff --git a/packages/misskey-reversi/package.json b/packages/misskey-reversi/package.json index e22ccd1e02..d1913bc9dc 100644 --- a/packages/misskey-reversi/package.json +++ b/packages/misskey-reversi/package.json @@ -20,17 +20,16 @@ "build": "node ./build.js", "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"", "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "24.10.2", - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "esbuild": "0.27.1", + "@types/node": "24.10.13", + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "esbuild": "0.27.3", "execa": "9.6.1", - "nodemon": "3.1.11", - "typescript": "5.9.3" + "nodemon": "3.1.14" }, "files": [ "built" diff --git a/packages/shared/eslint.config.js b/packages/shared/eslint.config.js index ae9fb3cd37..afd14001d9 100644 --- a/packages/shared/eslint.config.js +++ b/packages/shared/eslint.config.js @@ -40,6 +40,22 @@ export default [ // 型の情報を利用してlintする必要があるため無効化 // TODO: 有効化検討 '@typescript-eslint/no-misused-promises': 'off', + 'no-async-promise-executor': 'error', + }, + }, + { + // typescript + files: ['**/*.ts', '**/*.tsx'], + rules: { + '@typescript-eslint/no-unused-vars': ['warn', { + 'args': 'all', + 'argsIgnorePattern': '^_', + 'caughtErrors': 'all', + 'caughtErrorsIgnorePattern': '^_', + 'destructuredArrayIgnorePattern': '^_', + 'varsIgnorePattern': '^_', + 'ignoreRestSiblings': true, + }], }, }, ]; diff --git a/packages/sw/package.json b/packages/sw/package.json index 1911524b8f..28d7c30f7b 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -4,22 +4,21 @@ "scripts": { "watch": "nodemon -w ../../package.json -e json --exec \"node build.js watch\"", "build": "node build.js", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "eslint": "eslint --quiet src/**/*.ts", "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { "i18n": "workspace:*", - "esbuild": "0.27.1", + "esbuild": "0.27.3", "idb-keyval": "6.2.2", "misskey-js": "workspace:*" }, "devDependencies": { - "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/parser": "8.56.0", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74", "eslint-plugin-import": "2.32.0", - "nodemon": "3.1.11", - "typescript": "5.9.3" + "nodemon": "3.1.14" }, "type": "module" } diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index 783c78f7dc..6dfea12aa8 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -292,30 +292,28 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif } export async function createEmptyNotification(): Promise<void> { - return new Promise<void>(async res => { - const i18n = await (swLang.i18n ?? swLang.fetchLocale()); - - await globalThis.registration.showNotification( - (new URL(origin)).host, - { - body: `Misskey v${_VERSION_}`, - silent: true, - badge: iconUrl('null'), - tag: 'read_notification', - actions: [ - { - action: 'markAllAsRead', - title: i18n.ts.markAllAsRead, - }, - { - action: 'settings', - title: i18n.ts.notificationSettings, - }, - ], - data: {}, - }, - ); - + const i18n = await (swLang.i18n ?? swLang.fetchLocale()); + await globalThis.registration.showNotification( + (new URL(origin)).host, + { + body: `Misskey v${_VERSION_}`, + silent: true, + badge: iconUrl('null'), + tag: 'read_notification', + actions: [ + { + action: 'markAllAsRead', + title: i18n.ts.markAllAsRead, + }, + { + action: 'settings', + title: i18n.ts.notificationSettings, + }, + ], + data: {}, + }, + ); + return new Promise<void>(res => { setTimeout(async () => { try { await closeNotificationsByTags(['user_visible_auto_notification']); diff --git a/packages/sw/tsconfig.json b/packages/sw/tsconfig.json index 2712475a37..9732a438ce 100644 --- a/packages/sw/tsconfig.json +++ b/packages/sw/tsconfig.json @@ -19,7 +19,6 @@ "experimentalDecorators": true, "resolveJsonModule": true, "isolatedModules": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@@/*": ["../frontend-shared/*"] @@ -28,6 +27,7 @@ "./node_modules/@types", "./src/@types" ], + "libReplacement": true, "lib": [ "esnext", "webworker" |